กลับไปยังบทความ

Backend

String, Byte และ Rune ใน Go: จัดการข้อความ UTF-8 และภาษาไทยให้ถูกต้อง

เข้าใจความต่างของ rune, byte และ string ใน Go ว่าทำไม len นับเป็น byte, UTF-8 กับข้อความภาษาไทยทำงานอย่างไร การวน loop บน string, strconv, strings.Builder รวมถึง data type, const และ iota

24 กันยายน 256910 นาทีในการอ่าน

go strings bytes and runes

ใน Go, string คือลำดับของ byte ที่แก้ไขไม่ได้ (immutable) และโดยทั่วไปเก็บข้อความแบบ UTF-8 ส่วน byte คือ alias ของ uint8 แทนข้อมูลดิบหนึ่ง byte และ rune คือ alias ของ int32 แทน Unicode code point หนึ่งตัว นี่คือเหตุผลที่ len("สวัสดี") ได้ 18 ไม่ใช่ 6 เพราะ len นับเป็น byte และอักษรไทยแต่ละตัวใช้ 3 byte ใน UTF-8 พอเข้าใจความต่างของ rune, byte และ string แล้ว bug เรื่องข้อความส่วนใหญ่ใน Go จะมองออกทันที

บทความนี้เริ่มจาก data type พื้นฐานของ Go, constant และ iota จากนั้นอธิบายว่า string, byte และ rune สัมพันธ์กันอย่างไร วน loop ข้อความอย่างไรให้ถูก และใช้ strings, strconv กับ strings.Builder ที่ต้องหยิบใช้แทบทุกวัน

Data type พื้นฐานของ Go

Go เป็นภาษา static typing ตัวแปรทุกตัวมี type ที่ตายตัวตั้งแต่ตอน compile:

TypeขนาดZero valueหมายเหตุ
bool1 bytefalse
int, uint32 หรือ 64 bit0ขึ้นกับแพลตฟอร์ม บน server ปัจจุบันเป็น 64 bit
int8…int64, uint8…uint64คงที่0ใช้เมื่อขนาดมีผล (binary format, คอลัมน์ใน DB)
byte8 bit0Alias ของ uint8
rune32 bit0Alias ของ int32 คือ Unicode code point
float32, float6432 / 64 bit0ใช้ float64 เป็นค่าเริ่มต้น
stringheader + data""Byte ที่แก้ไขไม่ได้

การประกาศและแปลงชนิดข้อมูล:

go
var count int            // 0
name := "Vectorkub"      // type inferred as string
ratio := 0.75            // float64

total := count + int(ratio*100) // explicit conversion required
var big int64 = 1 << 40
small := int32(big)             // compiles, but silently truncates to 0

fmt.Println(name, total, small) // Vectorkub 75 0

Go ไม่แปลงชนิดตัวเลขให้อัตโนมัติ int + int64 จะ compile ไม่ผ่าน ต้องเขียนการแปลงเอง การแปลง float เป็นจำนวนเต็มจะตัดเศษทิ้งเข้าหาศูนย์ และการแปลงเป็นจำนวนเต็มที่เล็กลงจะเก็บไว้เฉพาะ bit ล่าง จึงควรเช็กช่วงค่าก่อนแปลงให้แคบลง

Integer overflow ตอน runtime จะวนกลับแบบเงียบ ๆ เช่น uint8 ที่มีค่า 255 จะกลายเป็น 0 หลัง ++ แต่ถ้า overflow ในนิพจน์ที่เป็น constant จะเป็น compile error

Constant และ iota

Constant ถูกคำนวณตอน compile และเปลี่ยนค่าไม่ได้:

go
const Pi = 3.14159
const MaxRetries = 3

Constant แบบ untyped แบบนี้มีความแม่นยำไม่จำกัดจนกว่าจะถูกนำไปใช้ const huge = 1 << 100 เขียนได้ และ huge >> 98 คือ constant ค่า 4 จะ error ก็ต่อเมื่อพยายามเก็บ huge ลง int ตรง ๆ

ทำ enum ด้วย iota

ภายในบล็อก const ค่า iota เริ่มที่ 0 และเพิ่มขึ้นทีละหนึ่งในแต่ละบรรทัด บรรทัดที่ไม่มีนิพจน์จะใช้นิพจน์เดียวกับบรรทัดก่อนหน้า:

go
type Status int

const (
	StatusPending Status = iota // 0
	StatusPaid                  // 1
	StatusShipped               // 2
	StatusCancelled             // 3
)

func (s Status) String() string {
	switch s {
	case StatusPending:
		return "pending"
	case StatusPaid:
		return "paid"
	case StatusShipped:
		return "shipped"
	case StatusCancelled:
		return "cancelled"
	}
	return fmt.Sprintf("Status(%d)", int(s))
}

การให้ enum มี type ของตัวเอง (Status) ป้องกันไม่ให้ผู้เรียกส่ง int อะไรก็ได้เข้ามา ส่วน method String() ทำให้ fmt.Println(StatusPaid) พิมพ์คำว่า paid ถ้า enum มีหลายค่า ใช้เครื่องมือ stringer จาก golang.org/x/tools generate method นี้ให้ได้

iota ใช้ในนิพจน์ได้ด้วย:

go
const (
	_  = iota             // skip 0
	KB = 1 << (10 * iota) // 1 << 10
	MB                    // 1 << 20
	GB                    // 1 << 30
)

type Perm uint8

const (
	Read Perm = 1 << iota // 1
	Write                 // 2
	Exec                  // 4
)

รูปแบบ bit flag แบบนี้ช่วยให้รวมค่าด้วย | และตรวจด้วย & ได้ เช่น p&Write != 0

String ใน Go เก็บ byte อย่างไร

String คือมุมมองแบบอ่านอย่างเดียวบนชุดของ byte การ index จะได้ byte กลับมา และจะ assign ผ่าน index ไม่ได้:

go
s := "Go"
fmt.Println(s[0])   // 71, the byte for 'G'
// s[0] = 'g'       // compile error: cannot assign to s[0]

s = "go"            // fine: the variable now points to a different string

การที่ string แก้ไขไม่ได้ทำให้แชร์กันได้อย่างปลอดภัย การ slice (s[1:4]) และการ assign ไม่ต้อง copy ข้อมูล และไม่มี goroutine ไหนมาแก้ค่าลับหลังเราได้ ข้อแลกเปลี่ยนคือการแก้ไขทุกครั้งต้องสร้าง string ใหม่

Byte กับ rune ใน Go

ไฟล์ source ของ Go เป็น UTF-8 และ string literal ก็ถูกเก็บเป็น UTF-8 ตัวอักษร ASCII ใช้ 1 byte ตัวอักษรละตินที่มีเครื่องหมายส่วนใหญ่ใช้ 2 byte ส่วนภาษาไทย จีน และญี่ปุ่นใช้ 3 byte:

go
s := "สวัสดี"

fmt.Println(len(s))                    // 18 bytes
fmt.Println(utf8.RuneCountInString(s)) // 6 runes
fmt.Println(s[0])                      // 224: the first byte of 'ส', not a character

s[0] ให้ byte ที่อยู่กลางลำดับ multi-byte ซึ่งแทบไม่เคยเป็นสิ่งที่ต้องการเวลาทำงานกับข้อความ ถ้าต้องการ code point ให้ใช้ rune:

go
r := []rune(s)
fmt.Println(len(r))          // 6
fmt.Println(string(r[0]))    // ส
fmt.Printf("%U\n", r[0])     // U+0E2A

มีอีกเรื่องที่สำคัญมากสำหรับภาษาไทย rune คือ code point ไม่จำเป็นต้องเป็นตัวอักษรที่มองเห็นหนึ่งตัว ในคำว่า "สวัสดี" สระ ั และ ี เป็น rune แยกที่ไปประกอบกับพยัญชนะข้างหน้า 6 rune จึงแสดงผลเป็นตัวที่มองเห็นแค่ 4 ตัว ถ้าต้องนับหรือตัดข้อความตามที่ผู้ใช้เห็น เช่น ตัดชื่อที่แสดงให้สั้นลง ให้ใช้ library ที่รู้จัก grapheme cluster อย่าง github.com/rivo/uniseg

byterune
Alias ของuint8int32
แทนค่าByte ดิบหนึ่งตัวUnicode code point หนึ่งตัว
Literal'a' ที่ assign ให้ byte'ก' (type เริ่มต้นของ char literal)
ใช้กับข้อมูล binary, protocol แบบ ASCII, I/Oประมวลผลข้อความ, logic ที่ต้องรองรับ Unicode

วน loop บน string

for range บน string จะ decode UTF-8 ให้อัตโนมัติ index ที่ได้คือ byte offset และค่าที่ได้คือ rune:

go
for i, r := range "héllo" {
	fmt.Printf("%d:%c ", i, r)
}
// 0:h 1:é 3:l 4:l 5:o

สังเกตว่า index กระโดดจาก 1 ไป 3 เพราะ é ใช้ 2 byte ส่วน loop แบบ for i := 0; i < len(s); i++ จะเดินทีละ byte ซึ่งทำให้ข้อความที่ไม่ใช่ ASCII พัง byte ที่ไม่ใช่ UTF-8 ที่ถูกต้องจะออกมาจาก range เป็น utf8.RuneError (U+FFFD) ถ้าประมวลผล input ที่เชื่อถือไม่ได้ ให้เช็กด้วย utf8.ValidString ก่อน

แปลงระหว่าง string, []byte และ []rune

go
s := "hello"
b := []byte(s)   // copies the bytes
b[0] = 'H'
s2 := string(b)  // copies again: "Hello"

r := []rune("กขค")
r[0] = 'ง'
fmt.Println(string(r)) // งขค

การแปลงทุกครั้งมีการ copy เพราะ string แก้ไขไม่ได้แต่ slice แก้ไขได้ สำหรับค่าเล็ก ๆ ต้นทุนต่ำ แต่ใน loop ที่ถูกเรียกถี่ ๆ จะเริ่มเห็นใน profile API หลายตัวรับ []byte ได้โดยตรง (bytes, io.Writer, json.Unmarshal) จึงควรอยู่ในรูปแบบเดียวให้นานที่สุดเท่าที่ทำได้

ข้อผิดพลาดที่เจอบ่อยคือ string(65) ซึ่งได้ "A" คือตัวอักษรของ code point 65 ไม่ใช่ "65" และ go vet จะเตือนเรื่องนี้ ถ้าต้องการแปลงตัวเลขเป็นข้อความทศนิยม ให้ใช้ strconv

Package strings

Package strings ครอบคลุมงานข้อความในชีวิตประจำวันเกือบทั้งหมด และ package bytes ก็มีฟังก์ชันชุดเดียวกันสำหรับ []byte

go
s := "  Order-1042,paid,Bangkok  "

strings.TrimSpace(s)                   // "Order-1042,paid,Bangkok"
strings.Split("a,b,c", ",")            // ["a" "b" "c"]
strings.Fields(" a  b\tc ")            // ["a" "b" "c"], splits on any whitespace
strings.Join([]string{"a", "b"}, "-")  // "a-b"
strings.Contains(s, "paid")            // true
strings.HasPrefix("Order-1042", "Order-") // true
strings.ReplaceAll("a-b-c", "-", "/")  // "a/b/c"
strings.ToUpper("go")                  // "GO"
strings.EqualFold("Go", "GO")          // true, case-insensitive compare
strings.Index("chicken", "ken")        // 4 (byte offset), -1 if not found
strings.Repeat("=", 10)                // "=========="

fmt.Println(strings.Cut("key=value", "=")) // key value true

กับดักสองข้อที่ควรรู้:

  • Trim ต่างจาก TrimPrefix strings.Trim(s, "abc") จะตัดตัวอักษร a, b, c ตัวใดก็ได้ออกจากทั้งสองฝั่ง ถ้าต้องการตัด prefix หรือ suffix แบบตรงตัว ให้ใช้ TrimPrefix หรือ TrimSuffix
  • strings.Title ถูก deprecate แล้ว เพราะจัดการขอบเขตคำของ Unicode ได้ไม่ถูกต้อง ให้ใช้ golang.org/x/text/cases แทน

Parse และ format ด้วย strconv

strconv แปลงระหว่าง string กับตัวเลขหรือ boolean และคืน error แทนการเดา:

go
n, err := strconv.Atoi("42")          // int 42
if err != nil {
	return fmt.Errorf("invalid quantity: %w", err)
}

id, err := strconv.ParseInt("9000000000", 10, 64) // base 10, int64
price, err := strconv.ParseFloat("19.95", 64)
ok, err := strconv.ParseBool("true")  // accepts 1, t, T, TRUE, true, True, 0, f...

strconv.Itoa(42)                       // "42"
strconv.FormatInt(255, 16)             // "ff"
strconv.FormatFloat(19.95, 'f', 2, 64) // "19.95"

fmt.Println(n, id, price, ok) // 42 9000000000 19.95 true

fmt.Sprintf("%d", n) ก็ใช้ได้ แต่ strconv เร็วกว่าและสื่อความตั้งใจชัดกว่า และต้องจัดการ error จากฟังก์ชัน parse ทุกครั้ง เพราะสักวัน input จากผู้ใช้จะมี "12abc" หรือ string ว่างเข้ามาแน่นอน

สร้าง string ด้วย strings.Builder

เมื่อ string แก้ไขไม่ได้ การเขียน s += x ใน loop จึงต้องสร้าง string ใหม่และ copy ทุกอย่างที่มีมาก่อนหน้าทุกรอบ ถ้า loop ใหญ่ งานจะโตแบบกำลังสอง

strings.Builder เขียนต่อท้ายลง buffer ภายใน แล้วสร้าง string สุดท้ายเพียงครั้งเดียว:

go
func CSVLine(fields []string) string {
	var b strings.Builder
	b.Grow(64) // optional: pre-allocate if you can estimate the size

	for i, f := range fields {
		if i > 0 {
			b.WriteByte(',')
		}
		b.WriteString(f)
	}
	return b.String()
}

Builder implement io.Writer จึงใช้ fmt.Fprintf(&b, "%d items", n) ได้ด้วย อย่า copy Builder หลังจากเขียนข้อมูลลงไปแล้ว ให้ส่งเป็น pointer แทน ถ้ามี slice อยู่แล้วและแค่ต้องการต่อกัน strings.Join ง่ายกว่าและเร็วพอกัน ถ้าสงสัยว่าการสร้าง string เป็นคอขวด ให้ยืนยันด้วย CPU และ allocation profile ก่อน วิธีทำอยู่ใน การ profile Go ด้วย pprof

คำถามที่พบบ่อย

อักษรไทยหนึ่งตัวใช้กี่ byte ใน Go?

3 byte code point ภาษาไทย (U+0E00 ถึง U+0E7F) ถูก encode เป็น 3 byte ใน UTF-8 ดังนั้น len จะนับ 3 ต่อหนึ่งตัว รวมถึงสระและวรรณยุกต์ด้วย

byte กับ rune ใน Go ต่างกันอย่างไร?

byte (uint8) คือ byte ดิบหนึ่งตัว ส่วน rune (int32) คือ Unicode code point หนึ่งตัว ซึ่งใช้ 1 ถึง 4 byte ใน UTF-8

แปลง int เป็น string ใน Go อย่างไร?

ใช้ strconv.Itoa(n) หรือ strconv.FormatInt(n, 10) อย่าใช้ string(n) เพราะจะได้ตัวอักษรของ code point นั้นแทน

กลับด้าน string ใน Go อย่างไร?

แปลงเป็น []rune กลับลำดับ slice แล้วแปลงกลับ แต่ถ้าข้อความมีเครื่องหมายที่ประกอบกับตัวอื่น เช่น สระไทย ต้องกลับด้านตาม grapheme cluster ไม่อย่างนั้นสระจะไปเกาะผิดพยัญชนะ

ทำไมแก้ตัวอักษรใน string ของ Go ไม่ได้?

เพราะ string เป็น immutable ให้แปลงเป็น []byte หรือ []rune แก้ใน slice แล้วแปลงกลับเป็น string ใหม่

Checklist สำหรับจัดการข้อความใน Go

  • จำไว้ว่า len(s) นับ byte ถ้าต้องการจำนวน code point ให้ใช้ utf8.RuneCountInString
  • ใช้ for range เมื่อสนใจตัวอักษร ไม่ใช่ byte
  • ให้ enum มี type ของตัวเอง ใช้ iota และเพิ่ม method String()
  • ใช้ strconv แปลงตัวเลข และจัดการ error จากการ parse ทุกครั้ง
  • เปลี่ยน += ใน loop เป็น strings.Builder หรือ strings.Join
  • ตรวจ UTF-8 ของ input ที่เชื่อถือไม่ได้ และนับตาม grapheme เมื่อต้องตัดข้อความที่ผู้ใช้เห็น

String เป็นหนึ่งใน value type หลักของ Go ส่วนที่เหลืออ่านต่อได้ใน slice, map และ pointer ใน Go และดูวิธีเพิ่มพฤติกรรมให้ type ของตัวเองได้ใน struct, method และ interface ใน Go ถ้าคุณกำลังสร้าง backend ด้วย Go ที่ต้องรองรับทั้งข้อความไทยและอังกฤษ และอยากได้คนช่วยวางให้ถูกตั้งแต่ต้น Vectorkub ช่วยได้

พร้อมสร้างสิ่งที่ยิ่งใหญ่หรือยัง?

เล่าโปรเจคของคุณให้เราฟัง — เราจะพาคุณไปถึง

เลือกคุกกี้ที่เราใช้ได้ คุกกี้ที่จำเป็นช่วยให้เว็บไซต์ทำงานได้และเปิดใช้งานเสมอ