Danh sách bài viết

Bài 81: UTF-8 Boundary Panic — Pitfall Của String Slice

Bài 81 của series Rust Cơ Bản — đào sâu pitfall mà hầu hết người mới học Rust đều dính ít nhất một lần khi viết code xử lý chuỗi tiếng Việt, tiếng Trung hoặc emoji: let bad = &s[0..1]; compile OK nhưng panic runtime nếu ký tự đầu là multi-byte. Bài học giải thích vì sao Rust phải panic (invariant &str luôn valid UTF-8), giới thiệu is_char_boundary(i) để check trước khi slice, chars().nth(i) safe access trả Option<char>, char_indices() iterator trả (byte_index, char) giúp tìm boundary chính xác, method get(range) trả Option<&str> như alternative an toàn cho &s[a..b], và idiom chars().take(n).collect::<String>() lấy n char đầu. Kết thúc với pattern thực tế khi xử lý chuỗi Unicode và tổng kết toàn bộ Nhóm 11.

09/06/2026
12 phút đọc
1 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Nhận diện được pitfall &s[a..b] trên dữ liệu Unicode — biết chính xác khi nào nó panic và khi nào nó an toàn.
  • Hiểu vì sao Rust buộc phải panic khi slice rơi giữa multi-byte char: invariant &str luôn valid UTF-8 là nền tảng để mọi method trên &str không cần kiểm tra lại tính hợp lệ.
  • Dùng được str::is_char_boundary(i) để check trước, tránh panic.
  • Dùng chars().nth(i) để truy cập char an toàn (trả Option<char>, không bao giờ panic), và hiểu chi phí O(n) nên không lạm dụng trong loop.
  • Dùng char_indices() để vừa biết char vừa biết byte position — công cụ chính xác nhất để tìm boundary an toàn.
  • Dùng method str::get(range) như alternative an toàn cho &s[a..b], trả Option<&str> thay vì panic.
  • Viết được idiom s.chars().take(n).collect::<String>() để lấy n ký tự đầu của chuỗi Unicode.
  • Tóm tắt được toàn bộ Nhóm 11 - Slices và sẵn sàng bước sang Nhóm 12 - Structs.

Bài 55 (UTF-8 Encoding) đã đề cập rằng slice có thể panic, nhưng dừng ở mức cảnh báo. Bài này tập trung đào sâu pitfall đó kèm bộ công cụ thay thế hoàn chỉnh — vì xử lý chuỗi tiếng Việt là việc bạn sẽ làm hàng ngày.

2

Vấn Đề: Slice Index Trên UTF-8 Có Thể Panic

Code dưới trông hoàn toàn bình thường với người đến từ Python hoặc JavaScript, compile sạch không warning, nhưng panic ngay dòng đầu tiên khi chạy:

fn main() {
    let s = "héllo";          // h + é(2 byte) + l + l + o
    let bad = &s[0..1];       // compile OK
    println!("{bad}");        // PANIC runtime
}

Thông báo panic từ rustc rất rõ ràng — và rất dài. Đây là output thật:

thread 'main' panicked at src/main.rs:3:18:
byte index 1 is not a char boundary; it is inside 'é' (bytes 1..3) of `héllo`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Đọc kỹ message: byte index 1 is not a char boundary; it is inside 'é' (bytes 1..3). Rust biết chính xác bạn đang cố cắt vào giữa ký tự é (chiếm byte 1 đến 2 — vì é trong UTF-8 là 0xC3 0xA9, 2 byte). Slice 0..1 sẽ lấy đúng byte 0xC3 — đứng một mình thì không phải UTF-8 hợp lệ.

Đáng chú ý: code này không lỗi compile. Borrow checker, type checker đều cho qua vì syntax đúng, type đúng. Lỗi chỉ phát hiện được khi chạy — và panic đồng nghĩa thread chết. Trong web server, panic 1 request có thể chấp nhận được (handler khác vẫn chạy), nhưng trong batch job xử lý 10 triệu chuỗi, panic = mất toàn bộ tiến trình.

Trớ trêu: thay "héllo" bằng "hello" (không dấu) thì code chạy ngon lành. Bug chỉ lộ ra khi dữ liệu thật chứa ký tự non-ASCII — kịch bản phổ biến nhất khi xử lý input người dùng Việt Nam.

3

Vì Sao Panic — Invariant Của &str

Rust quy ước cứng: mọi giá trị &str trong runtime đều là valid UTF-8. Đây không phải khuyến nghị — đây là invariant được tận dụng khắp std và toàn bộ ecosystem. Vì có invariant này, các method như .chars(), .lines(), .split(), format!()... không cần kiểm tra lại từng byte — chúng giả định buffer đã hợp lệ và decode trực tiếp. Đó là lý do &str nhanh và an toàn.

Nếu Rust cho phép &s[0..1] trả về &str chứa byte 0xC3 đứng một mình → invariant vỡ → mọi method downstream gọi trên slice đó sẽ misbehave (đọc lỗi, hash sai, so sánh sai, hoặc undefined behavior với unsafe code). Để giữ invariant, Rust chọn giải pháp fail fast: panic ngay tại điểm slice, không cho giá trị invalid lọt vào hệ thống.

So sánh với các ngôn ngữ khác để thấy trade-off:

  • Python 3: chuỗi là sequence of code points, không phải byte → s[0] trả char đúng. Trade-off: indexing O(1) bằng chỉ số byte (qua biểu diễn nội bộ UCS-1/2/4 tuỳ chuỗi) nhưng tốn RAM hơn UTF-8 thuần.
  • JavaScript: chuỗi là UTF-16 → s[0] trả 1 code unit (16 bit). Emoji chiếm 2 code unit → s[0] trả nửa surrogate pair → in ra ký tự rác (nhưng không crash). Bug silent — tệ hơn panic.
  • Go: s[0] trả 1 byte (uint8), không phải char. Lập trình viên buộc phải biết khi nào cần dùng []rune hoặc utf8.DecodeRuneInString. Tương đương dùng .bytes() trong Rust.
  • Rust: s[0] compile error (đã học ở Bài 55), &s[0..1] panic nếu không ở boundary. Chọn an toàn hơn tốc độ — bug không thể silent.

Triết lý ở đây: thà panic to và rõ ngay khi sai còn hơn để bug âm thầm trôi xuống production. Một panic message gắn line number, hex byte, ký tự cụ thể → debug 5 giây. Một string corruption silent → debug 5 giờ.

4

is_char_boundary(i) — Check Trước Khi Slice

Công cụ thấp nhất, mỏng nhất: method str::is_char_boundary(&self, index: usize) -> bool. Trả true nếu byte index i đúng tại biên giữa 2 char (hoặc tại đầu/cuối chuỗi), false nếu rơi giữa multi-byte char.

fn main() {
    let s = "héllo";                          // bytes: h=1, é=2, l=1, l=1, o=1

    assert!(s.is_char_boundary(0));           // OK: đầu chuỗi
    assert!(!s.is_char_boundary(1));          // SAI: giữa 'é'
    assert!(s.is_char_boundary(3));           // OK: sau 'é', trước 'l'
    assert!(s.is_char_boundary(s.len()));     // OK: cuối chuỗi luôn là boundary

    // Idiom: check trước khi slice
    let end = 1;
    if s.is_char_boundary(0) && s.is_char_boundary(end) {
        println!("{}", &s[0..end]);
    } else {
        println!("byte {end} không phải char boundary");
    }
}

Đặc điểm cần nhớ:

  • Method O(1) — chỉ đọc 1 byte tại vị trí i và check 2 bit cao xem có phải UTF-8 continuation byte (10xxxxxx) hay không. Cực rẻ.
  • Cả 0s.len() luôn là boundary — không cần check trường hợp biên.
  • i > s.len() trả false chứ không panic — an toàn dùng.

is_char_boundarybuilding block. Với hầu hết use case bạn sẽ dùng get hoặc char_indices ở mục sau — tiện hơn nhiều. Nhưng khi viết logic parsing thủ công, có lúc cần check trực tiếp như trên.

5

chars().nth(i) — Safe Char Access

Nếu thứ bạn cần là "ký tự thứ i" (theo char, không theo byte) → chars().nth(i) là cách an toàn và rõ ý nhất. Trả Option<char>: Some(c) nếu có, None nếu chuỗi không đủ dài. Không bao giờ panic.

fn main() {
    let s = "héllo";

    let c0 = s.chars().nth(0);       // Some('h')
    let c1 = s.chars().nth(1);       // Some('é')  ← chỉ số 1 theo CHAR, không phải byte
    let c4 = s.chars().nth(4);       // Some('o')
    let c5 = s.chars().nth(5);       // None       ← vượt độ dài

    println!("{:?} {:?} {:?} {:?}", c0, c1, c4, c5);

    // Pattern match thường gặp
    match s.chars().nth(1) {
        Some(c) => println!("ký tự thứ 1: {c}"),
        None    => println!("chuỗi rỗng hoặc quá ngắn"),
    }
}

Điểm cần lưu ý — đặc biệt với người đến từ Python:

  • O(n): chars().nth(i) phải decode UTF-8 từ đầu chuỗi tới char thứ i mới biết được — không có cách nào nhảy thẳng O(1) vì char có độ dài 1-4 byte. Truy cập trong loop với i tăng dần → O(n²). Lúc đó dùng .chars() trực tiếp (iterate 1 lần O(n)) hoặc char_indices().
  • Trả về Option<char>, không phải Option<&str>. Nếu cần substring chứ không chỉ 1 char → dùng get hoặc take().collect() bên dưới.
  • Idiomatic Rust ưu tiên iteration hơn indexing. Nếu bạn gọi .chars().nth(i) nhiều lần → khả năng cao có cách viết lại bằng .chars().for_each, .chars().collect::<Vec<char>>(), hoặc match pattern trên iterator gọn hơn.
6

char_indices() — Iterator (byte_index, char)

Công cụ mạnh nhất khi bạn cần vừa biết char vừa biết vị trí byte: str::char_indices(). Trả iterator (usize, char) — usize là byte index nơi char bắt đầu, char là chính ký tự đó.

fn main() {
    let s = "héllo";

    for (i, c) in s.char_indices() {
        println!("byte {i:>2}: {c}");
    }
    // byte  0: h
    // byte  1: é      ← chiếm 2 byte (1, 2)
    // byte  3: l
    // byte  4: l
    // byte  5: o

    // Tìm byte position của ký tự thứ 3 (theo char, 0-indexed)
    let third = s.char_indices().nth(3).map(|(i, _)| i);
    println!("char thứ 3 bắt đầu ở byte: {third:?}");   // Some(4)

    // Slice an toàn từ char thứ 2 đến hết
    if let Some((start, _)) = s.char_indices().nth(2) {
        println!("{}", &s[start..]);                    // "llo"
    }
}

char_indicescây cầu giữa hai thế giới char và byte. Nó cho bạn cả 2 thông tin trong cùng một pass O(n), không phải gọi chars() rồi lại tự tính byte offset. Khi cần slice một đoạn dựa trên vị trí char (ví dụ: lấy từ ký tự thứ 5 đến thứ 10) → char_indices là lựa chọn chuẩn vì byte index lấy ra luôn là char boundary hợp lệ, slice với index đó không bao giờ panic.

So với chars() đơn thuần: thêm chi phí không đáng kể (chỉ đếm thêm byte position trong quá trình decode), nhưng mở ra khả năng slice an toàn. Khi nghi ngờ → dùng char_indices.

7

Method get(range) — Slice Trả Option<&str>

Alternative an toàn cho &s[a..b]: method str::get(range). Cùng input là một range, nhưng thay vì panic khi boundary invalid, get trả Option<&str>Some(&str) nếu hợp lệ, None nếu range out-of-bounds hoặc không ở char boundary.

fn main() {
    let s = "héllo";

    let ok   = s.get(0..3);        // Some("hé")
    let bad  = s.get(0..1);        // None  ← không panic
    let oob  = s.get(0..100);      // None  ← out of bounds, vẫn không panic

    println!("{:?} {:?} {:?}", ok, bad, oob);

    // Pattern thực tế: parse input người dùng
    fn first_n_bytes(s: &str, n: usize) -> Option<&str> {
        s.get(..n)
    }

    match first_n_bytes("héllo", 1) {
        Some(prefix) => println!("prefix: {prefix}"),
        None         => println!("không cắt được tại byte 1"),
    }
}

Khi nên ưu tiên get thay cho &s[a..b]:

  • Khi a hoặc b đến từ input bên ngoài: người dùng nhập, parse config, deserialize từ JSON. Không kiểm soát được dữ liệu → dùng get để fail an toàn.
  • Khi muốn xử lý "không cắt được" như một case bình thường trong logic, không phải lỗi cần dừng chương trình.
  • Khi viết library — không bao giờ panic dựa trên input là good citizenship.

Khi &s[a..b] vẫn ổn:

  • Khi ab đã được tính từ char_indices() hoặc từ method khác đảm bảo boundary (find, split, splitn...) — không thể sai.
  • Khi prototype nhanh — panic chỉ ra bug sớm.

Lưu ý cú pháp: get(..n), get(n..), get(a..b), get(..) đều hợp lệ — nhận được range type qua trait, không cần convert.

8

Pattern Idiomatic Cho Substring

Cùng một bài toán "lấy n ký tự đầu" có nhiều cách viết. Lựa chọn đúng tuỳ thuộc bạn đếm theo char hay theo byte, có sẵn boundary hay phải tính.

Cách 1 — Đếm theo char, không cần biết boundary trước:

fn main() {
    let s = "héllo";
    let first3: String = s.chars().take(3).collect();
    println!("{first3}");        // "hél"

    // Hoạt động với mọi chuỗi Unicode
    let viet: String = "Xin chào".chars().take(4).collect();
    println!("{viet}");          // "Xin "
}

Pattern chars().take(n).collect::<String>() luôn an toàn, không cần biết byte boundary, trả String mới (cấp phát heap). Phù hợp khi cần kết quả owned hoặc khi n nhỏ.

Cách 2 — Biết byte index hợp lệ (lấy từ char_indices):

fn main() {
    let s = "héllo";

    // Tìm byte index của char thứ 3 → slice không panic
    let split_at = s
        .char_indices()
        .nth(3)
        .map(|(i, _)| i)
        .unwrap_or(s.len());

    let (left, right) = s.split_at(split_at);
    println!("trái: {left}, phải: {right}");   // trái: hél, phải: lo
}

str::split_at(byte_index) chia chuỗi thành 2 slice tại byte index — panic nếu không ở char boundary, nhưng ở đây index lấy từ char_indices nên chắc chắn an toàn. Ưu điểm: trả 2 &str mượn lại buffer gốc, không cấp phát mới — nhanh hơn cách 1.

Cách 3 — Đã biết chắc input là ASCII:

fn main() {
    let s = "blogcode.vn";       // chắc chắn ASCII
    let prefix = &s[0..4];       // OK vì mọi byte đều là 1 char
    println!("{prefix}");        // "blog"
}

Khi biết chắc chuỗi chỉ chứa ASCII (ví dụ URL slug, hex string, base64), slice trực tiếp là idiom phổ biến và đúng. Đừng overengineer cho trường hợp này.

Quy tắc chọn nhanh:

  • Lấy n char đầu, kết quả là Stringchars().take(n).collect().
  • Lấy n char đầu, kết quả là &str mượn buffer gốc → char_indices().nth(n) rồi split_at.
  • Input chắc chắn ASCII → &s[..n].
  • Input không kiểm soát, không muốn panic → s.get(..n).
9

Pitfall Thực Tế Với Tiếng Việt / Trung / Emoji

Pitfall không phải chuyện hiếm gặp — nó chắc chắn xảy ra với mọi dev Việt Nam viết code xử lý chuỗi. Ví dụ:

fn main() {
    // Tiếng Việt: mỗi ký tự có dấu chiếm 2-3 byte
    let s = "Xin chào";              // X-i-n- -c-h-à-o, "à" = 2 byte
    println!("len byte: {}", s.len());                  // 9
    println!("len char: {}", s.chars().count());        // 8

    // NGUY HIỂM: lấy "8 ký tự đầu" bằng byte slice
    // let first8 = &s[..8];      // PANIC nếu byte 8 rơi giữa "à"

    // ĐÚNG: dùng chars().take
    let first4: String = s.chars().take(4).collect();
    println!("{first4}");                               // "Xin "

    // Tiếng Trung: mỗi char 3 byte
    let cn = "你好";                  // 6 byte, 2 char
    println!("byte = {}, char = {}", cn.len(), cn.chars().count());  // 6, 2

    // Emoji: thường 4 byte, có loại còn dùng grapheme nhiều char
    let emoji = "🦀Rust";             // 🦀 = 4 byte
    // let bad = &emoji[0..2];       // PANIC ngay
    let safe = emoji.get(0..4);       // Some("🦀")
    println!("{safe:?}");
}

Vài quy tắc đúc kết qua đau thương:

  • Không bao giờ slice byte index trên string đến từ input. Username, comment, tên file người dùng nhập — đều có thể chứa Unicode. Dùng get, chars, char_indices.
  • Truncate hiển thị (UI/log) → dùng chars, không dùng byte. Code kiểu format!("{}…", &s[..30]) để hiển thị 30 ký tự đầu là bug đợi nổ. Đổi sang s.chars().take(30).collect::<String>().
  • Cẩn thận khi nhận byte length từ database / API. PostgreSQL varchar(50) đếm theo char nhưng nhiều API cũ (Twitter cũ, Facebook OG meta) đếm theo byte UTF-8 hoặc UTF-16. Đếm sai → field bị truncate giữa char → invalid UTF-8 → khi load lại Rust panic.
  • Grapheme cluster vẫn là chuyện riêng. chars() trả Unicode Scalar Value, không phải "ký tự người dùng nhìn thấy". Một emoji có skin tone (👋🏻) gồm 2 scalar value (👋 + skin modifier) — chars().count() trả 2. Nếu cần đếm grapheme đúng → dùng crate unicode-segmentation (đã đề cập ở Bài 55).
  • Unit test với dữ liệu Unicode thật. Đừng chỉ test "hello" — thêm "héllo", "Xin chào", "你好", "🦀" vào test suite. Phần lớn bug UTF-8 chỉ lộ khi test với dữ liệu thật.
10

Tổng Kết Nhóm 11

Tóm tắt bài này

  • &s[a..b] compile OK nhưng panic runtime nếu a hoặc b không phải char boundary — Rust giữ invariant &str luôn valid UTF-8, không cho slice invalid lọt ra.
  • Panic message của Rust rất chi tiết: chỉ rõ byte index, ký tự đang cắt giữa, range byte của ký tự đó → debug 5 giây.
  • is_char_boundary(i): O(1) check trước khi slice. chars().nth(i): safe access, O(n), trả Option<char>. char_indices(): iterator (byte_index, char), cây cầu giữa char-world và byte-world.
  • str::get(range): alternative an toàn cho &s[a..b], trả Option<&str>, không panic. Ưu tiên dùng khi index đến từ input ngoài.
  • Idiom lấy n char đầu: chars().take(n).collect::<String>() nếu cần owned, hoặc char_indices().nth(n) + split_at nếu cần &str mượn.
  • Quy tắc vàng khi xử lý chuỗi Unicode: không slice byte index, đặc biệt với input người dùng — dùng iterator-based API.

Tổng Kết Nhóm 11 — Slices

Sau Nhóm 11, bạn đã có đủ công cụ làm việc với view của collection — vừa rẻ (không clone), vừa an toàn (borrow checker giữ), vừa tổng quát (function nhận slice dùng được với cả array, Vec, String). Đây là cơ sở để hiểu signature của hầu hết function trong std và crate phổ biến.

11

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. Cho let s = "héllo";, dự đoán giá trị trả về của: (a) s.is_char_boundary(2), (b) s.get(0..2), (c) s.chars().nth(2), (d) s.char_indices().nth(2).
  2. Viết function fn safe_prefix(s: &str, n: usize) -> &str trả về n ký tự (theo char) đầu của s, không panic dù với chuỗi rỗng hay n lớn hơn độ dài chuỗi. Hint: dùng char_indices.
  3. Vì sao "Xin chào".len() trả 9 chứ không phải 8? Liệt kê byte của từng ký tự.
  4. Giải thích sự khác biệt giữa &s[0..1]s.get(0..1) khi s = "héllo". Tình huống nào nên ưu tiên cái nào?
  5. Code let bad = "🦀".chars().count(); trả về bao nhiêu? Còn "🦀".len()? Lý giải.
Đáp án
  1. (a) false — byte 2 nằm giữa ký tự é (byte 1-2). (b) None — slice 0..2 cắt giữa é, không hợp lệ. (c) Some('l') — chars là ['h','é','l','l','o'], index 2 là 'l'. (d) Some((3, 'l')) — char thứ 2 (zero-indexed) bắt đầu ở byte 3 (sau h=1 byte + é=2 byte).
  2. fn safe_prefix(s: &str, n: usize) -> &str { match s.char_indices().nth(n) { Some((i, _)) => &s[..i], None => s, // n ≥ chars.count() → trả hết chuỗi } } — không bao giờ panic. Trường hợp s rỗng: char_indices().nth(n) trả None → return s (chuỗi rỗng). Trường hợp n = 0: nth(0) trả char đầu tiên ở byte 0 → &s[..0] = chuỗi rỗng. OK.
  3. "Xin chào" = X(1) + i(1) + n(1) + space(1) + c(1) + h(1) + à(2) + o(1) = 9 byte. Char à trong UTF-8 là 0xC3 0xA0 — 2 byte. Còn chars().count() trả 8 vì đếm scalar value, không đếm byte.
  4. &s[0..1] panic runtime vì byte 1 không phải char boundary (rơi giữa é). s.get(0..1) trả None, không panic. Ưu tiên get khi: (a) index đến từ input ngoài không kiểm soát, (b) viết library — không panic là lịch sự với caller, (c) muốn xử lý "không cắt được" như case bình thường trong control flow. Ưu tiên &s[a..b] khi: index lấy từ find/split/char_indices — chắc chắn đúng boundary, dùng get phải .unwrap() thừa.
  5. "🦀".chars().count() = 1 — emoji 🦀 (U+1F980) là 1 Unicode Scalar Value. "🦀".len() = 4 — trong UTF-8, code point này encode thành 4 byte 0xF0 0x9F 0xA6 0x80. Đây chính là ví dụ rõ nhất về tại sao "ký tự thứ i" theo byte và theo char là 2 thứ khác nhau.
12

Bài Tiếp Theo

Bài cuối Nhóm 11 - Slices. Tiếp theo sang Nhóm 12 - Structs.

Bài 82: Định Nghĩa Struct Trong Rust — bắt đầu Nhóm 12 với khái niệm struct, công cụ chính của Rust để mô hình hoá dữ liệu. Học cách khai báo struct User { name: String, age: u32 }, quy tắc field public / private (default private), cách dùng struct trong module, derive Debug để print, và áp dụng vào ví dụ thực tế như User, Point. Sau Nhóm 12 bạn sẽ viết được type domain-specific cho project thật, không còn dừng ở primitive type.