Đi sâu vào chuỗi trong Rust: String vs &str và UTF-8
Làm chủ xử lý chuỗi trong Rust: phân biệt String và &str, mã hóa UTF-8 nội tại, an toàn khi slice chuỗi, format và tối ưu bộ nhớ.
Kiểu chuỗi (Strings) trong Rust là một trong những chủ đề gây bối rối nhất cho người mới bắt đầu. Tại sao Rust lại có cả String lẫn &str? Tại sao không thể truy cập ký tự đầu tiên bằng cú pháp s[0]? Hiểu rõ kiến trúc chuỗi trong Rust sẽ giúp bạn nắm vững cách ngôn ngữ này đảm bảo an toàn bộ nhớ và tính chính xác tuyệt đối với chuẩn quốc tế UTF-8.
Explain Like I’m 10: Cuốn Sổ Ghi Chép vs Dải Đánh Dấu Sách#
Hãy hình dung hai cách làm việc với văn bản:
String(Cuốn sổ tay bìa còng): Một cuốn sổ vật lý mà bạn sở hữu trên bàn làm việc (bộ nhớ Heap). Bạn có thể viết thêm câu mới, xé bớt đoạn văn, chèn thêm trang trắng (mut) hoặc chuyển quyền sở hữu cuốn sổ cho người khác.&str(Dải thước trong suốt nhìn xuyên qua): Một dải đánh dấu sách trong suốt đặt lên một dòng chữ cụ thể trong một cuốn sách in sẵn ở thư viện hoặc trong sổ tay của người khác. Bạn đọc được nội dung bên dưới mà không cần photo hay sở hữu tờ giấy đó.
graph TD
subgraph HeapMemory ["Phân Bổ Vùng Nhớ Heap"]
StringBuf["String Buffer (Chuỗi Byte UTF-8 trên Heap)"]
end
subgraph StackVariables ["Biến Trên Ngăn Xếp Stack"]
StringObj["Struct String<br/>[ ptr | len | capacity ]"] -->|Trỏ tới dữ liệu sở hữu| StringBuf
StrSlice["Slice &str<br/>[ ptr | len ]"] -->|Mượn góc nhìn vào| StringBuf
LiteralStr["Chuoi static str<br/>[ ptr | len ]"] -->|Trỏ tới chuỗi nhị phân cố định| ReadOnlyData["Phân đoạn nhị phân (.rodata)"]
end
String vs &str: Những Khác Biệt Cốt Lõi#
| Đặc tính | String | &str (String Slice) |
|---|---|---|
| Quyền sở hữu | Sở hữu vùng đệm trên heap | Mượn góc nhìn dữ liệu (borrowed view) |
| Khả năng mở rộng | Co giãn, thay đổi kích thước với mut | Kích thước cố định, chỉ đọc |
| Vị trí lưu trữ | Dữ liệu trên heap (stack giữ ptr, len, cap) | Con trỏ béo trên stack (ptr + len) |
| Cách khởi tạo | String::new(), String::from("..."), s.to_string() | "hello" (literal), &s[0..4], &s |
Khởi Tạo Và Cập Nhật Chuỗi#
fn main() {
// 1. Tạo một String
let mut greeting = String::from("Hello");
// 2. Thêm ký tự và chuỗi con
greeting.push(' '); // Thêm một ký tự char đơn lẻ
greeting.push_str("world!"); // Thêm một string slice
println!("{greeting}");
// 3. Ghép chuỗi bằng toán tử +
let s1 = String::from("Hello, ");
let s2 = String::from("Rust!");
let s3 = s1 + &s2; // s1 bị chuyển quyền sở hữu (move) và không dùng được nữa!
println!("{s3}");
// 4. Ghép chuỗi an toàn không mất ownership bằng macro format!
let part1 = "tic";
let part2 = "tac";
let part3 = "toe";
let game = format!("{part1}-{part2}-{part3}");
println!("{game}");
}rust[!NOTE] Toán tử
+trongs1 + &s2thực chất gọi methodfn add(self, s: &str) -> String. Nó lấy quyền sở hữu củas1, nối bản sao củas2vào cuối và trả về vùng đệm mới.
Tại Sao Rust Không Cho Phép Truy Cập Chỉ Số Trực Tiếp (s[0])?#
Trong nhiều ngôn ngữ khác, s[0] sẽ trả về ký tự đầu tiên. Nhưng trong Rust, câu lệnh này sẽ bị compiler chặn đứng ngay lập tức:
let s = String::from("hello");
// let h = s[0]; // ❌ Lỗi biên dịch: the type `String` cannot be indexed by `{integer}`rustNguyên Nhân Từ Chuẩn Mã Hóa UTF-8#
Chuỗi trong Rust luôn luôn là chuỗi UTF-8 hợp lệ. Trong UTF-8, các ký tự Unicode có độ dài biến thiên (từ 1 đến 4 byte):
graph LR
subgraph English ["Tiếng Anh: 'Hello' (1 byte / ký tự)"]
H["'H' [0x48]"] --- E["'e' [0x65]"] --- L1["'l' [0x6C]"] --- L2["'l' [0x6C]"] --- O["'o' [0x6F]"]
end
subgraph Vietnamese ["Tiếng Việt: 'Chào' (Đa byte)"]
C["'C' [0x43]"] --- H2["'h' [0x68]"] --- AU["'à' [0xC3, 0xA0] (2 byte)"] --- O2["'o' [0x6F]"]
end
subgraph Emoji ["Emoji: '🦀' (4 byte)"]
Crab["'🦀' [0xF0, 0x9F, 0xA6, 0x80] (4 byte)"]
end
Nếu Rust cho phép s[0], việc lấy ra 1 byte đơn lẻ ở vị trí đầu tiên của một ký tự đa byte (như ký tự 'à' gồm 2 byte 0xC3 0xA0) sẽ chỉ lấy được nửa ký tự vô nghĩa, gây lỗi hỏng dữ liệu ngầm. Hơn nữa, việc tìm ký tự thứ sẽ tốn thời gian để quét qua các byte, vi phạm nguyên tắc hiệu năng zero-cost của Rust.
Duyệt Qua Chuỗi: Byte vs Chars#
Để xử lý chuỗi an toàn, bạn cần chọn rõ đơn vị muốn duyệt:
fn main() {
let word = "Chào 🦀";
// 1. Duyệt theo ký tự Unicode (chars)
println!("--- Ký tự (Chars) ---");
for c in word.chars() {
println!("{c}");
}
// 2. Duyệt theo từng byte thô UTF-8
println!("--- Từng Byte ---");
for b in word.bytes() {
print!("{b:02X} ");
}
println!();
}rustLưu Ý Quan Trọng Khi Cắt Chuỗi (String Slicing)#
Bạn có thể cắt chuỗi bằng cú pháp khoảng chỉ số (range), nhưng chỉ số bắt đầu và kết thúc bắt buộc phải nằm trên ranh giới hợp lệ của ký tự UTF-8:
fn main() {
let hello = "Здравствуйте";
// Mỗi ký tự Cyrillic chiếm 2 byte
let s = &hello[0..4]; // Lấy 2 ký tự đầu tiên (4 byte) -> "Зд"
println!("{s}");
// ❌ Cắt vào giữa một ký tự đa byte sẽ gây sập app:
// let invalid = &hello[0..1]; // Panics: byte index 1 is not a char boundary
}rust[!WARNING] Khi xử lý chuỗi đa ngôn ngữ, hãy ưu tiên dùng iterator như
.chars().take(n)thay vì cắt chuỗi theo byte range thủ công để tránh rủi ro panic runtime.
Truyền Chuỗi Vào Hàm Theo Phong Cách Chuẩn#
Khi viết hàm chỉ cần đọc nội dung chuỗi mà không cần sở hữu hay thay đổi nó, hãy luôn nhận tham số kiểu &str thay vì &String:
// Hàm chuẩn mực: chấp nhận cả &String lẫn &str nhờ cơ chế Deref Coercion
fn print_length(text: &str) {
println!("Độ dài: {} bytes", text.len());
}
fn main() {
let owned = String::from("Rustacean");
let literal = "Rustacean";
print_length(&owned); // Tự động chuyển &String thành &str
print_length(literal); // Truyền trực tiếp &str
}rustTổng kết#
Stringlà kiểu dữ liệu sở hữu, lưu trên heap và có thể co giãn kích thước.&strlà góc nhìn tham chiếu bất biến (slice) trỏ vào dữ liệu chuỗi UTF-8.- Rust không hỗ trợ đánh chỉ số số nguyên
s[0]vì ký tự UTF-8 có kích thước biến thiên từ 1 đến 4 byte. - Dùng
.chars()để duyệt theo ký tự và.bytes()để duyệt theo từng byte thô. - Luôn ưu tiên dùng
&strlàm kiểu dữ liệu tham số hàm để đạt tính tương thích và hiệu năng cao nhất.