Hình dung một chuỗi trong Go như một dây hạt cườm. Mỗi byte là một hạt. Chữ ASCII như T là đúng một hạt — nhưng chữ ế tiếng Việt là ba hạt xâu liền nhau thành một ký tự. Từ hình ảnh đó, mọi cái bẫy của bài hôm nay tự hiện ra: len đếm hạt chứ không đếm ký tự; s[i] đưa cho bạn một hạt lẻ, có thể là hạt giữa của một chữ; và cắt dây ở giữa ba hạt đó thì bạn làm gãy đôi một ký tự, nhận về byte rác. 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 — mười ký tự nhưng mười bốn hạt cườm.

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ự — một hạt lẻ giữa chuỗi ba hạ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) — nó đi theo từng ký tự, không theo từng hạt. Chú ý chỉ số nhảy: sau ế ở vị trí 2, vị trí tiếp theo sẽ là 5 chứ không phải 3, vì ế chiếm ba hạt.

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ị. Cắt s[:4] là kéo kéo ngang qua giữa chùm ba hạt của chữ ế, và chữ gãy làm đôi.

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.

Nếu muốn thấy cả hai cái bẫy trong một cái chớp mắt, thử đúng 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" — số hạt khác số ký tự, và nhát cắt s[:4] lại rơi đúng giữa một chữ. 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.

Mẫu số chung

Câu hỏi "một ký tự là gì" tưởng đơn giản nhưng là một trong những chỗ rối nhất của lập trình, và mọi ngôn ngữ đều phải chọn chỗ đứng trên một cái thang ba bậc: byte (cách lưu UTF-8) → điểm mã (ký tự Unicode đơn) → grapheme (thứ con người gọi là "một chữ"). Gần như mọi lỗi chuỗi đều đến từ việc nhầm bậc này với bậc kia.

  • Go và Rust lưu UTF-8 byte. Khác biệt thú vị: Rust thẳng tay cấm s[i] — truy cập byte bằng chỉ số vào chuỗi là lỗi biên dịch, đúng để chặn chính cái bẫy cắt-gãy-chữ-tiếng-Việt này; muốn đi thì phải nói rõ .chars() (theo điểm mã) hay .bytes() (theo byte). Go thì cho bạn s[i] và tin bạn biết mình làm gì — tự do hơn, nhưng cái footgun nằm sẵn đó.
  • Java, JavaScript, C# dùng UTF-16: charAt(i) trả một đơn vị mã, và emoji hay ký tự ngoài mặt phẳng cơ bản là một cặp thay thế (surrogate pair) — bị chặt đôi y hệt chữ ế của ta, chỉ khác chỗ người Âu Mỹ ít gặp nên ít ai cảnh báo.
  • Python 3 đánh chỉ số theo điểm mã: len cho số điểm mã, s[i] O(1) và không bao giờ cắt giữa một điểm mã — giải được cái bẫy byte, đổi lại trả giá ở cách lưu trữ.

Và đây là tầng sâu nhất, đúng ở mọi ngôn ngữ: ngay cả "điểm mã" cũng chưa phải "ký tự người nhìn thấy". Một emoji kèm tông da, một lá cờ, hay chữ é viết dạng tổ hợp (e cộng dấu rời) đều là nhiều điểm mã ghép lại — nên len() của không ngôn ngữ nào cho ra số grapheme nếu thiếu một thư viện phân tách Unicode (ICU, unicode-segmentation). Sợi chỉ chung đáng mang theo: câu hỏi "chuỗi này dài bao nhiêu" không có một câu trả lời, mà có ba — và phần lớn lỗi chuỗi là trả lời nhầm bậc. Trước khi gọi len hay cắt s[:n] ở bất kỳ ngôn ngữ nào, hãy hỏi: mình đang đếm hạt cườm, đếm ký tự Unicode, hay đếm thứ người đọc gọi là chữ?

Ngày mai: error là một giá trị — và toàn bộ triết lý xử lý lỗi của Go.

Bài tập làm thử

Bài 1 (đọc hiểu). Cho chuỗi s := "Việt Nam", hai lệnh sau in ra gì, và vì sao hai con số khác nhau?

fmt.Println(len(s))
fmt.Println(utf8.RuneCountInString(s))
Đáp án

In ra 10 rồi 8. len(s) luôn trả về số byte của chuỗi — "Việt Nam" có 8 ký tự nhưng các chữ có dấu như ệ chiếm 3 byte trong UTF-8 (thay vì 1 byte như ASCII), nên tổng số byte là 10. utf8.RuneCountInString mới đếm đúng số ký tự (rune) là 8. Đây là quyết định thiết kế của Go: len() là phép toán O(1) trên số byte, còn đếm ký tự phải duyệt toàn bộ chuỗi để giải mã UTF-8.

Bài 2 (sửa lỗi). Hàm sau dùng để tóm tắt chuỗi hiển thị trên giao diện nhưng làm hỏng chữ có dấu tiếng Việt. Sửa lại cho đúng.

func TomTat(s string, n int) string {
	if len(s) <= n {
		return s
	}
	return s[:n] + "…"
}
Đáp án

Lỗi: s[:n] cắt theo chỉ số byte, có thể rơi vào giữa một ký tự nhiều byte (ví dụ giữa 3 byte của chữ ế), sinh ra byte rác không hợp lệ UTF-8. Sửa bằng cách chuyển sang []rune trước khi cắt:

func TomTat(s string, n int) string {
	r := []rune(s)
	if len(r) <= n {
		return s
	}
	return string(r[:n]) + "…"
}

Cách này tốn thêm một lần cấp phát và duyệt để chuyển đổi, nhưng đó là cái giá chấp nhận được để đảm bảo tính đúng đắn với chuỗi tiếng Việt.

Bài 3 (đọc hiểu). Vòng lặp range sau in ra chỉ số byte nào cho ký tự 'ế', và tại sao chỉ số của ký tự tiếp theo (nếu có) không phải là số liền kề?

for i, r := range "Tiế" {
	fmt.Printf("[byte %d = %q] ", i, r)
}
Đáp án

In ra [byte 0 = 'T'] [byte 1 = 'i'] [byte 2 = 'ế']. range trên chuỗi giải mã UTF-8 và trả về cặp (chỉ số byte bắt đầu, rune), đi theo từng ký tự chứ không theo từng byte. Vì ế chiếm 3 byte (byte 2, 3, 4), nếu chuỗi có ký tự tiếp theo, chỉ số của nó sẽ là 5 chứ không phải 3 — chỉ số byte "nhảy" một khoảng bằng đúng số byte mà ký tự trước đó chiếm dụng.

Bài 4 (vận dụng thực tế). Bạn cần viết một hàm nối 10.000 chuỗi ngắn lại với nhau trong một vòng lặp, sao cho hiệu năng tốt (tránh độ phức tạp bậc hai). Viết mã theo đúng công cụ mà bài viết khuyến nghị.

Đáp án
var b strings.Builder
b.Grow(10000 * 8) // ước lượng trước để cấp phát một lần, nếu biết trước kích thước
for i := 0; i < 10000; i++ {
	b.WriteString(cacChuoi[i])
}
ketQua := b.String()

Dùng + để nối chuỗi trong vòng lặp tạo ra chuỗi mới và chép lại toàn bộ nội dung cũ mỗi lần (vì chuỗi Go bất biến), dẫn tới độ phức tạp bậc hai. strings.Builder tránh được việc này bằng cách ghi vào một bộ đệm có thể lớn dần; gọi thêm b.Grow(n) giúp cấp phát một lần thay vì tự nhân đôi nhiều lần.

Bài 5 (bẫy/đánh đổi). Bài viết nói Go không có gói chuẩn hoá Unicode trong thư viện chuẩn. Vấn đề gì xảy ra nếu bạn so sánh trực tiếp (==) hai chuỗi tiếng Việt trông giống hệt nhau trên màn hình, và cách khắc phục là gì?

Đáp án

Hai chuỗi trông giống hệt nhau có thể được biểu diễn bằng hai chuỗi byte khác nhau: một bên dùng dạng tổ hợp (chữ cái cộng dấu là ký tự rời, ví dụ e + dấu huyền tổ hợp), bên kia dùng dạng dựng sẵn (một điểm mã Unicode đã có sẵn dấu). So sánh == (so byte) giữa hai dạng này sẽ trả về false dù người dùng nhìn thấy chúng giống hệt nhau — đây là vấn đề thường gặp với dữ liệu người dùng nhập hoặc tên tệp từ macOS. Cách khắc phục: chuẩn hoá chuỗi về một dạng thống nhất (thường là NFC) ngay ở cửa vào, dùng thư viện ngoài golang.org/x/text/unicode/norm vì thư viện chuẩn Go không có sẵn chức năng này.