blog.dopana

Back

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:

  1. 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.
  2. &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ínhString&str (String Slice)
Quyền sở hữuSở hữu vùng đệm trên heapMượn góc nhìn dữ liệu (borrowed view)
Khả năng mở rộngCo giãn, thay đổi kích thước với mutKí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ạoString::new(), String::from("..."), s.to_string()"hello" (literal), &s[0..4], &s

Khởi Tạo Và Cập Nhật Chuỗi#

[!NOTE] Toán tử + trong s1 + &s2 thực chất gọi method fn add(self, s: &str) -> String. Nó lấy quyền sở hữu của s1, nối bản sao của s2 và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}`
rust

Nguyê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ứ NN sẽ tốn thời gian O(N)O(N) để 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:

Lư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:

src/main.rs
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:

src/main.rs
// 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
}
rust

Tổng kết#

  • String là kiểu dữ liệu sở hữu, lưu trên heap và có thể co giãn kích thước.
  • &str là 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 &str là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.

Tài liệu tham khảo#