Bài này đặc biệt quan trọng với người viết tiếng Việt, vì mọi cái bẫy đều lộ ra với chữ có dấu.
Chuỗi là byte
s := "Tiếng Việt"
len(s) = 14 <- SỐ BYTE
utf8.RuneCountInString = 10 <- số ký tự
Mười ký tự, mười bốn byte. Chữ ế và ệ mỗi cái chiếm 3 byte trong UTF-8, chữ ASCII chiếm 1.
len() trong Go luôn trả về số byte với chuỗi. Đây là quyết định thiết kế, không phải thiếu sót: nó là phép toán O(1), còn đếm ký tự phải duyệt cả chuỗi.
Chuỗi trong Go bất biến và luôn là UTF-8 — bài 4 đã đo, unsafe.Sizeof(string) là 16 byte: một con trỏ tới mảng byte cộng một độ dài.
s[i] trả về byte, không phải ký tự
s[0] = 84 ('T') — kiểu byte
s[1] = 105 — vẫn là byte
Kiểu của s[i] là byte (bí danh của uint8), không phải rune. Với chuỗi ASCII thì trùng nhau nên bạn không thấy vấn đề; với tiếng Việt thì s[i] có thể là nửa của một ký tự.
Đây là khác biệt căn bản với Java, nơi charAt(i) trả về char (một đơn vị mã UTF-16). Cả hai đều không phải "ký tự" theo nghĩa người dùng, nhưng chúng sai theo hai kiểu khác nhau.
range duyệt theo rune
for i, r := range "Tiế" {
fmt.Printf("[byte %d = %q] ", i, r)
}
[byte 0 = 'T'] [byte 1 = 'i'] [byte 2 = 'ế']
range trên chuỗi giải mã UTF-8 và trả về (chỉ số byte, rune). Chú ý chỉ số nhảy — sau ế ở vị trí 2, vị trí tiếp theo sẽ là 5 chứ không phải 3.
Nên hai vòng lặp này hoàn toàn khác nhau:
for i := 0; i < len(s); i++ { _ = s[i] } // duyệt BYTE
for _, r := range s { _ = r } // duyệt RUNE
rune là bí danh của int32 và chứa một điểm mã Unicode.
Cắt chuỗi có thể làm hỏng ký tự
s[:4] = "Ti\xe1\xba" <- cắt giữa ký tự, byte rác
string(r[:4]) = "Tiến" <- cắt theo rune thì đúng
Đây là cái bẫy nguy hiểm nhất trong bài, vì nó xuất hiện ở chỗ rất đời thường: cắt ngắn chuỗi để hiển thị.
func TomTat(s string, n int) string {
if len(s) <= n { return s }
return s[:n] + "…" // SAI với tiếng Việt
}
Hàm này sinh ra byte rác cho mọi chuỗi có dấu. Cách đúng:
func TomTat(s string, n int) string {
r := []rune(s)
if len(r) <= n { return s }
return string(r[:n]) + "…"
}
Chuyển sang []rune tốn một lần cấp phát và duyệt, nhưng đó là cái giá cho tính đúng đắn.
Với chuỗi rất lớn mà chỉ cần cắt, utf8.DecodeRuneInString cho phép đi từng rune mà không cấp phát.
Chuyển đổi
[]byte(s) // sao chép sang slice byte
[]rune(s) // giải mã UTF-8, sao chép sang slice rune
string(b) // từ []byte
string(r) // từ []rune
Mọi phép chuyển đều sao chép vì chuỗi bất biến. Trong vòng lặp nóng, đây là chi phí thật.
Và nhớ cái bẫy ở bài 5: string(65) cho "A" chứ không phải "65".
Nối chuỗi
Chuỗi bất biến, nên mỗi phép + tạo chuỗi mới và chép lại toàn bộ. Trong vòng lặp, đó là độ phức tạp bậc hai — đúng vấn đề của Java ở bài 83 sê-ri trước.
Dùng strings.Builder:
var b strings.Builder
for i := 0; i < n; i++ {
b.WriteString("x")
}
kq := b.String()
Builder là ví dụ mẫu của zero value dùng được ngay ở bài 18 — không cần New.
Với vài chuỗi thì + hoặc fmt.Sprintf vẫn tốt hơn về độ đọc. strings.Join cho slice.
Gói strings và unicode
strings.ToUpper, ToLower, TrimSpace, Split, Join, Contains, ReplaceAll
strings.EqualFold // so sánh không phân biệt hoa thường
unicode.IsLetter, IsDigit, IsSpace
utf8.RuneCountInString, utf8.ValidString
strings.ToUpper xử lý đúng tiếng Việt vì nó theo bảng Unicode. Nhưng cẩn thận với so sánh: hai chuỗi tiếng Việt trông giống hệt vẫn có thể khác byte nếu một bên dùng dạng tổ hợp (chữ cộng dấu rời) còn bên kia dùng dạng dựng sẵn.
Go không có gói chuẩn hoá Unicode trong thư viện chuẩn — phải dùng golang.org/x/text/unicode/norm. Với dữ liệu người dùng nhập hoặc tên tệp từ macOS, hãy chuẩn hoá về NFC ở cửa vào.
Thử ba mươi giây
s := "Việt Nam"
fmt.Println(len(s), len([]rune(s)))
fmt.Printf("%q\n", s[:4])
In ra 10 8 và "Vi\xe1\xbb". Ba mươi giây đó giải thích vì sao mọi hàm cắt chuỗi trong dự án tiếng Việt của bạn cần được xem lại.
Ngày mai: error là một giá trị — và toàn bộ triết lý xử lý lỗi của Go.