Mục lục
- Mục Tiêu Bài Học
- Vấn Đề: Slice Index Trên UTF-8 Có Thể Panic
- Vì Sao Panic — Invariant Của &str
- is_char_boundary(i) — Check Trước Khi Slice
- chars().nth(i) — Safe Char Access
- char_indices() — Iterator (byte_index, char)
- Method get(range) — Slice Trả Option<&str>
- Pattern Idiomatic Cho Substring
- Pitfall Thực Tế Với Tiếng Việt / Trung / Emoji
- Tổng Kết Nhóm 11
- Bài Tập Củng Cố
- Bài Tiếp Theo
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
&strkhô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.
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.
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[]runehoặcutf8.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ờ.
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í
ivà check 2 bit cao xem có phải UTF-8 continuation byte (10xxxxxx) hay không. Cực rẻ. - Cả
0vàs.len()luôn là boundary — không cần check trường hợp biên. i > s.len()trảfalsechứ không panic — an toàn dùng.
is_char_boundary là building 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.
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ứimớ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ớiităng dần → O(n²). Lúc đó dùng.chars()trực tiếp (iterate 1 lần O(n)) hoặcchar_indices(). - Trả về
Option<char>, không phảiOption<&str>. Nếu cần substring chứ không chỉ 1 char → dùnggethoặctake().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.
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_indices là câ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.
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
ahoặcbđế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ùnggetđể 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
avàbđã đượ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.
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à
String→chars().take(n).collect(). - Lấy n char đầu, kết quả là
&strmượn buffer gốc →char_indices().nth(n)rồisplit_at. - Input chắc chắn ASCII →
&s[..n]. - Input không kiểm soát, không muốn panic →
s.get(..n).
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 sangs.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 crateunicode-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.
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ếuahoặcbkhô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ặcchar_indices().nth(n)+split_atnếu cần&strmượ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
- Bài 76: String Slice — &str Từ String — view (fat pointer) trỏ vào một phần String, deref coercion.
- Bài 77: Array Slice — &[T] — slice cho mảng / Vec, fat pointer ptr + len.
- Bài 78: Range Syntax —
&s[0..5],&s[..],&s[2..]và các biến thể. - Bài 79: Mutable Slice — &mut [T] — slice có quyền sửa, vẫn tuân quy tắc borrow.
- Bài 80: Slice Trong Function Signature — nhận
&[T]/&strtổng quát hơn nhận&Vec/&String. - Bài 81 (bài này): UTF-8 boundary panic — pitfall slice index trên Unicode + bộ công cụ an toàn.
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.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- 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). - Viết function
fn safe_prefix(s: &str, n: usize) -> &strtrả về n ký tự (theo char) đầu củas, không panic dù với chuỗi rỗng haynlớn hơn độ dài chuỗi. Hint: dùngchar_indices. - Vì sao
"Xin chào".len()trả 9 chứ không phải 8? Liệt kê byte của từng ký tự. - Giải thích sự khác biệt giữa
&s[0..1]vàs.get(0..1)khis = "héllo". Tình huống nào nên ưu tiên cái nào? - Code
let bad = "🦀".chars().count();trả về bao nhiêu? Còn"🦀".len()? Lý giải.
Đáp án
- (a)
false— byte 2 nằm giữa ký tựé(byte 1-2). (b)None— slice0..2cắ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 (sauh=1 byte +é=2 byte). 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ợpsrỗng:char_indices().nth(n)trảNone→ returns(chuỗi rỗng). Trường hợpn = 0:nth(0)trả char đầu tiên ở byte 0 →&s[..0]= chuỗi rỗng. OK."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ònchars().count()trả 8 vì đếm scalar value, không đếm byte.&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êngetkhi: (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ùnggetphải.unwrap()thừa."🦀".chars().count()= 1 — emoji 🦀 (U+1F980) là 1 Unicode Scalar Value."🦀".len()= 4 — trong UTF-8, code point này encode thành 4 byte0xF0 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.
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.
