ใน 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 | หมายเหตุ |
|---|---|---|---|
bool | 1 byte | false | |
int, uint | 32 หรือ 64 bit | 0 | ขึ้นกับแพลตฟอร์ม บน server ปัจจุบันเป็น 64 bit |
int8…int64, uint8…uint64 | คงที่ | 0 | ใช้เมื่อขนาดมีผล (binary format, คอลัมน์ใน DB) |
byte | 8 bit | 0 | Alias ของ uint8 |
rune | 32 bit | 0 | Alias ของ int32 คือ Unicode code point |
float32, float64 | 32 / 64 bit | 0 | ใช้ float64 เป็นค่าเริ่มต้น |
string | header + data | "" | Byte ที่แก้ไขไม่ได้ |
การประกาศและแปลงชนิดข้อมูล:
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 0Go ไม่แปลงชนิดตัวเลขให้อัตโนมัติ int + int64 จะ compile ไม่ผ่าน ต้องเขียนการแปลงเอง การแปลง float เป็นจำนวนเต็มจะตัดเศษทิ้งเข้าหาศูนย์ และการแปลงเป็นจำนวนเต็มที่เล็กลงจะเก็บไว้เฉพาะ bit ล่าง จึงควรเช็กช่วงค่าก่อนแปลงให้แคบลง
Integer overflow ตอน runtime จะวนกลับแบบเงียบ ๆ เช่น uint8 ที่มีค่า 255 จะกลายเป็น 0 หลัง ++ แต่ถ้า overflow ในนิพจน์ที่เป็น constant จะเป็น compile error
Constant และ iota
Constant ถูกคำนวณตอน compile และเปลี่ยนค่าไม่ได้:
const Pi = 3.14159
const MaxRetries = 3Constant แบบ untyped แบบนี้มีความแม่นยำไม่จำกัดจนกว่าจะถูกนำไปใช้ const huge = 1 << 100 เขียนได้ และ huge >> 98 คือ constant ค่า 4 จะ error ก็ต่อเมื่อพยายามเก็บ huge ลง int ตรง ๆ
ทำ enum ด้วย iota
ภายในบล็อก const ค่า iota เริ่มที่ 0 และเพิ่มขึ้นทีละหนึ่งในแต่ละบรรทัด บรรทัดที่ไม่มีนิพจน์จะใช้นิพจน์เดียวกับบรรทัดก่อนหน้า:
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 ใช้ในนิพจน์ได้ด้วย:
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 ไม่ได้:
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:
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 characters[0] ให้ byte ที่อยู่กลางลำดับ multi-byte ซึ่งแทบไม่เคยเป็นสิ่งที่ต้องการเวลาทำงานกับข้อความ ถ้าต้องการ code point ให้ใช้ rune:
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
byte | rune | |
|---|---|---|
| Alias ของ | uint8 | int32 |
| แทนค่า | 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:
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
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
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ต่างจากTrimPrefixstrings.Trim(s, "abc")จะตัดตัวอักษรa,b,cตัวใดก็ได้ออกจากทั้งสองฝั่ง ถ้าต้องการตัด prefix หรือ suffix แบบตรงตัว ให้ใช้TrimPrefixหรือTrimSuffixstrings.Titleถูก deprecate แล้ว เพราะจัดการขอบเขตคำของ Unicode ได้ไม่ถูกต้อง ให้ใช้golang.org/x/text/casesแทน
Parse และ format ด้วย strconv
strconv แปลงระหว่าง string กับตัวเลขหรือ boolean และคืน error แทนการเดา:
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 truefmt.Sprintf("%d", n) ก็ใช้ได้ แต่ strconv เร็วกว่าและสื่อความตั้งใจชัดกว่า และต้องจัดการ error จากฟังก์ชัน parse ทุกครั้ง เพราะสักวัน input จากผู้ใช้จะมี "12abc" หรือ string ว่างเข้ามาแน่นอน
สร้าง string ด้วย strings.Builder
เมื่อ string แก้ไขไม่ได้ การเขียน s += x ใน loop จึงต้องสร้าง string ใหม่และ copy ทุกอย่างที่มีมาก่อนหน้าทุกรอบ ถ้า loop ใหญ่ งานจะโตแบบกำลังสอง
strings.Builder เขียนต่อท้ายลง buffer ภายใน แล้วสร้าง string สุดท้ายเพียงครั้งเดียว:
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และเพิ่ม methodString() - ใช้
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 ช่วยได้
