Lưu Trữ Văn Bản Mã Hóa UTF-8 với Chuỗi

Chúng ta đã nói về chuỗi trong Chương 4, nhưng bây giờ chúng ta sẽ xem xét chúng chi tiết hơn. Những người mới học Rust thường bị mắc kẹt với chuỗi vì sự kết hợp của ba lý do: khuynh hướng của Rust trong việc tiết lộ các lỗi có thể xảy ra, chuỗi là một cấu trúc dữ liệu phức tạp hơn nhiều so với những gì nhiều lập trình viên đánh giá, và UTF-8. Những yếu tố này kết hợp theo cách dường như khó khăn khi bạn đến từ các ngôn ngữ lập trình khác.

Chúng ta thảo luận về chuỗi trong bối cảnh của các bộ sưu tập vì chuỗi được triển khai như một bộ sưu tập các byte, cộng thêm một số phương thức để cung cấp chức năng hữu ích khi những byte này được diễn giải dưới dạng văn bản. Trong phần này, chúng ta sẽ nói về các hoạt động trên String mà mọi loại bộ sưu tập đều có, chẳng hạn như tạo, cập nhật và đọc. Chúng ta cũng sẽ thảo luận về những cách mà String khác với các bộ sưu tập khác, cụ thể là cách mà việc lập chỉ mục vào một String bị phức tạp hóa bởi sự khác biệt giữa cách con người và máy tính diễn giải dữ liệu String.

Chuỗi Là Gì?

Đầu tiên, chúng ta sẽ định nghĩa những gì chúng ta hiểu bằng thuật ngữ chuỗi. Rust chỉ có một kiểu chuỗi trong ngôn ngữ cốt lõi, đó là lát cắt chuỗi str thường được thấy dưới dạng mượn &str. Trong Chương 4, chúng ta đã nói về lát cắt chuỗi, là tham chiếu đến một số dữ liệu chuỗi mã hóa UTF-8 được lưu trữ ở nơi khác. Các chuỗi nghĩa đen, chẳng hạn, được lưu trữ trong tệp nhị phân của chương trình và do đó là lát cắt chuỗi.

Kiểu String, được cung cấp bởi thư viện chuẩn của Rust thay vì được mã hóa trong ngôn ngữ cốt lõi, là một kiểu chuỗi có thể phát triển, có thể thay đổi, được sở hữu, mã hóa UTF-8. Khi người dùng Rust nhắc đến "chuỗi" trong Rust, họ có thể nhắc đến kiểu String hoặc kiểu lát cắt chuỗi &str, không chỉ một trong những kiểu đó. Mặc dù phần này chủ yếu nói về String, cả hai kiểu đều được sử dụng nhiều trong thư viện chuẩn của Rust, và cả String và lát cắt chuỗi đều được mã hóa UTF-8.

Tạo Một Chuỗi Mới

Nhiều hoạt động tương tự có sẵn với Vec<T> cũng có sẵn với StringString thực sự được triển khai như một bao bọc xung quanh một vector byte với một số đảm bảo, hạn chế và khả năng bổ sung. Một ví dụ về một hàm hoạt động giống nhau với Vec<T>String là hàm new để tạo một thể hiện, như hiển thị trong Listing 8-11.

fn main() {
    let mut s = String::new();
}

Dòng này tạo ra một chuỗi mới, rỗng có tên là s, mà chúng ta sau đó có thể tải dữ liệu vào. Thường thì chúng ta sẽ có một số dữ liệu ban đầu mà chúng ta muốn bắt đầu chuỗi với. Để làm điều đó, chúng ta sử dụng phương thức to_string, có sẵn trên bất kỳ kiểu nào triển khai trait Display, như các chuỗi nghĩa đen có. Listing 8-12 hiển thị hai ví dụ.

fn main() {
    let data = "initial contents";

    let s = data.to_string();

    // The method also works on a literal directly:
    let s = "initial contents".to_string();
}

Mã này tạo ra một chuỗi chứa initial contents.

Chúng ta cũng có thể sử dụng hàm String::from để tạo một String từ một chuỗi nghĩa đen. Mã trong Listing 8-13 tương đương với mã trong Listing 8-12 sử dụng to_string.

fn main() {
    let s = String::from("initial contents");
}

Vì chuỗi được sử dụng cho rất nhiều thứ, chúng ta có thể sử dụng nhiều API chung khác nhau cho chuỗi, cung cấp cho chúng ta rất nhiều tùy chọn. Một số trong số chúng có vẻ thừa thãi, nhưng tất cả đều có vị trí riêng của mình! Trong trường hợp này, String::fromto_string làm cùng một việc, vì vậy việc bạn chọn cái nào là vấn đề của phong cách và khả năng đọc.

Hãy nhớ rằng chuỗi được mã hóa UTF-8, vì vậy chúng ta có thể bao gồm bất kỳ dữ liệu nào được mã hóa đúng cách trong chúng, như hiển thị trong Listing 8-14.

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

Tất cả những điều này đều là các giá trị String hợp lệ.

Cập Nhật một Chuỗi

Một String có thể tăng kích thước và nội dung của nó có thể thay đổi, giống như nội dung của một Vec<T>, nếu bạn đẩy thêm dữ liệu vào nó. Ngoài ra, bạn có thể thuận tiện sử dụng toán tử + hoặc macro format! để nối các giá trị String.

Thêm vào Chuỗi với push_strpush

Chúng ta có thể làm cho một String phát triển bằng cách sử dụng phương thức push_str để thêm một lát cắt chuỗi, như hiển thị trong Listing 8-15.

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}

Sau hai dòng này, s sẽ chứa foobar. Phương thức push_str lấy một lát cắt chuỗi vì chúng ta không nhất thiết muốn lấy quyền sở hữu của tham số. Ví dụ, trong mã trong Listing 8-16, chúng ta muốn có thể sử dụng s2 sau khi thêm nội dung của nó vào s1.

fn main() {
    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 is {s2}");
}

Nếu phương thức push_str lấy quyền sở hữu của s2, chúng ta sẽ không thể in giá trị của nó trên dòng cuối cùng. Tuy nhiên, mã này hoạt động như chúng ta mong đợi!

Phương thức push lấy một ký tự duy nhất làm tham số và thêm nó vào String. Listing 8-17 thêm chữ cái l vào một String bằng cách sử dụng phương thức push.

fn main() {
    let mut s = String::from("lo");
    s.push('l');
}

Kết quả là, s sẽ chứa lol.

Nối với Toán Tử + hoặc Macro format!

Thường thì bạn sẽ muốn kết hợp hai chuỗi hiện có. Một cách để làm điều đó là sử dụng toán tử +, như hiển thị trong Listing 8-18.

fn main() {
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2; // note s1 has been moved here and can no longer be used
}

Chuỗi s3 sẽ chứa Hello, world!. Lý do s1 không còn hợp lệ sau khi thêm, và lý do chúng ta sử dụng một tham chiếu đến s2, có liên quan đến chữ ký của phương thức được gọi khi chúng ta sử dụng toán tử +. Toán tử + sử dụng phương thức add, có chữ ký trông giống như thế này:

fn add(self, s: &str) -> String {

Trong thư viện chuẩn, bạn sẽ thấy add được định nghĩa bằng cách sử dụng generics và liên kết kiểu. Ở đây, chúng ta đã thay thế bằng các kiểu cụ thể, điều này xảy ra khi chúng ta gọi phương thức này với các giá trị String. Chúng ta sẽ thảo luận về generics trong Chương 10. Chữ ký này cung cấp cho chúng ta những manh mối chúng ta cần để hiểu các phần phức tạp của toán tử +.

Đầu tiên, s2 có một &, có nghĩa là chúng ta đang thêm một tham chiếu của chuỗi thứ hai vào chuỗi đầu tiên. Điều này là do tham số s trong hàm add: chúng ta chỉ có thể thêm một &str vào một String; chúng ta không thể thêm hai giá trị String với nhau. Nhưng khoan—kiểu của &s2&String, không phải &str, như đã được chỉ định trong tham số thứ hai cho add. Vậy tại sao Listing 8-18 biên dịch?

Lý do chúng ta có thể sử dụng &s2 trong lệnh gọi đến add là vì trình biên dịch có thể ép buộc đối số &String thành &str. Khi chúng ta gọi phương thức add, Rust sử dụng ép buộc deref, nơi đây biến &s2 thành &s2[..]. Chúng ta sẽ thảo luận về ép buộc deref chi tiết hơn trong Chương 15. Bởi vì add không lấy quyền sở hữu của tham số s, s2 vẫn sẽ là một String hợp lệ sau hoạt động này.

Thứ hai, chúng ta có thể thấy trong chữ ký rằng add lấy quyền sở hữu của selfself không&. Điều này có nghĩa s1 trong Listing 8-18 sẽ bị di chuyển vào lệnh gọi add và sẽ không còn hợp lệ sau đó. Vì vậy, mặc dù let s3 = s1 + &s2; trông như thể nó sẽ sao chép cả hai chuỗi và tạo một chuỗi mới, câu lệnh này thực sự lấy quyền sở hữu của s1, thêm một bản sao nội dung của s2, và sau đó trả lại quyền sở hữu của kết quả. Nói cách khác, nó trông như thể nó đang tạo ra rất nhiều bản sao, nhưng không phải vậy; việc triển khai hiệu quả hơn là sao chép.

Nếu chúng ta cần nối nhiều chuỗi, hành vi của toán tử + trở nên khó xử lý:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = s1 + "-" + &s2 + "-" + &s3;
}

Lúc này, s sẽ là tic-tac-toe. Với tất cả các ký tự +", khó có thể thấy điều gì đang xảy ra. Để kết hợp các chuỗi theo cách phức tạp hơn, chúng ta có thể sử dụng macro format!:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = format!("{s1}-{s2}-{s3}");
}

Mã này cũng đặt s thành tic-tac-toe. Macro format! hoạt động giống như println!, nhưng thay vì in đầu ra ra màn hình, nó trả về một String với nội dung. Phiên bản mã sử dụng format! dễ đọc hơn nhiều, và mã được tạo ra bởi macro format! sử dụng tham chiếu nên lời gọi này không lấy quyền sở hữu của bất kỳ tham số nào của nó.

Lập Chỉ Mục vào Chuỗi

Trong nhiều ngôn ngữ lập trình khác, việc truy cập các ký tự riêng lẻ trong một chuỗi bằng cách tham chiếu đến chúng bằng chỉ mục là một hoạt động hợp lệ và phổ biến. Tuy nhiên, nếu bạn cố gắng truy cập các phần của một String sử dụng cú pháp lập chỉ mục trong Rust, bạn sẽ gặp lỗi. Xem xét mã không hợp lệ trong Listing 8-19.

fn main() {
    let s1 = String::from("hi");
    let h = s1[0];
}

Mã này sẽ dẫn đến lỗi sau:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`
 --> src/main.rs:3:16
  |
3 |     let h = s1[0];
  |                ^ string indices are ranges of `usize`
  |
  = note: you can use `.chars().nth()` or `.bytes().nth()`
          for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
  = help: the trait `SliceIndex<str>` is not implemented for `{integer}`
          but trait `SliceIndex<[_]>` is implemented for `usize`
  = help: for that trait implementation, expected `[_]`, found `str`
  = note: required for `String` to implement `Index<{integer}>`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 previous error

Lỗi và ghi chú kể câu chuyện: chuỗi Rust không hỗ trợ lập chỉ mục. Nhưng tại sao không? Để trả lời câu hỏi đó, chúng ta cần thảo luận về cách Rust lưu trữ chuỗi trong bộ nhớ.

Biểu Diễn Nội Bộ

Một String là một bao bọc trên một Vec<u8>. Hãy xem một số ví dụ chuỗi được mã hóa UTF-8 đúng của chúng ta từ Listing 8-14. Đầu tiên là ví dụ này:

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

Trong trường hợp này, len sẽ là 4, có nghĩa là vector lưu trữ chuỗi "Hola" dài 4 byte. Mỗi chữ cái này chiếm một byte khi được mã hóa trong UTF-8. Tuy nhiên, dòng sau đây có thể làm bạn ngạc nhiên (lưu ý rằng chuỗi này bắt đầu bằng chữ cái Cyrillic viết hoa Ze, không phải số 3):

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

Nếu bạn được hỏi chuỗi dài bao nhiêu, bạn có thể nói 12. Thực tế, câu trả lời của Rust là 24: đó là số byte cần thiết để mã hóa "Здравствуйте" trong UTF-8, vì mỗi giá trị vô hướng Unicode trong chuỗi đó chiếm 2 byte lưu trữ. Do đó, một chỉ mục vào byte của chuỗi sẽ không phải lúc nào cũng tương quan với một giá trị vô hướng Unicode hợp lệ. Để minh họa, hãy xem xét mã Rust không hợp lệ này:

let hello = "Здравствуйте";
let answer = &hello[0];

Bạn đã biết rằng answer sẽ không phải là З, chữ cái đầu tiên. Khi được mã hóa trong UTF-8, byte đầu tiên của З208 và byte thứ hai là 151, vì vậy nó sẽ có vẻ như answer thực sự nên là 208, nhưng 208 không phải là một ký tự hợp lệ tự nó. Trả về 208 có thể không phải là điều mà người dùng muốn nếu họ hỏi về chữ cái đầu tiên của chuỗi này; tuy nhiên, đó là dữ liệu duy nhất mà Rust có tại chỉ mục byte 0. Người dùng thường không muốn giá trị byte được trả về, ngay cả khi chuỗi chỉ chứa các chữ cái Latin: nếu &"hi"[0] là mã hợp lệ trả về giá trị byte, nó sẽ trả về 104, không phải h.

Câu trả lời, vì vậy, là để tránh trả về một giá trị không mong đợi và gây ra lỗi có thể không được phát hiện ngay lập tức, Rust không biên dịch mã này và ngăn chặn hiểu lầm sớm trong quá trình phát triển.

Byte, Giá Trị Vô Hướng và Cụm Tự Hình! Ôi Trời!

Một điểm khác về UTF-8 là thực sự có ba cách liên quan để nhìn chuỗi từ góc độ của Rust: dưới dạng byte, giá trị vô hướng, và cụm tự hình (điều gần nhất với những gì chúng ta gọi là chữ cái).

Nếu chúng ta nhìn vào từ tiếng Hindi "नमस्ते" được viết bằng chữ viết Devanagari, nó được lưu trữ dưới dạng một vector các giá trị u8 trông như thế này:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]

Đó là 18 byte và đây là cách máy tính cuối cùng lưu trữ dữ liệu này. Nếu chúng ta nhìn vào chúng như các giá trị vô hướng Unicode, đó là kiểu char của Rust, những byte đó trông như thế này:

['न', 'म', 'स', '्', 'त', 'े']

Có sáu giá trị char ở đây, nhưng giá trị thứ tư và thứ sáu không phải là chữ cái: đó là các dấu phụ không có ý nghĩa khi đứng một mình. Cuối cùng, nếu chúng ta nhìn vào chúng như cụm tự hình, chúng ta sẽ có được những gì mà một người sẽ gọi là bốn chữ cái tạo nên từ tiếng Hindi:

["न", "म", "स्", "ते"]

Rust cung cấp các cách khác nhau để diễn giải dữ liệu chuỗi thô mà máy tính lưu trữ để mỗi chương trình có thể chọn cách diễn giải mà nó cần, bất kể ngôn ngữ con người nào mà dữ liệu đó thuộc về.

Một lý do cuối cùng mà Rust không cho phép chúng ta lập chỉ mục vào một String để lấy một ký tự là vì các hoạt động lập chỉ mục được mong đợi luôn mất thời gian không đổi (O(1)). Nhưng không thể đảm bảo hiệu suất đó với một String, vì Rust sẽ phải đi qua nội dung từ đầu đến chỉ mục để xác định có bao nhiêu ký tự hợp lệ.

Cắt Chuỗi

Lập chỉ mục vào một chuỗi thường là một ý tưởng tồi vì không rõ kiểu trả về của hoạt động lập chỉ mục chuỗi nên là gì: một giá trị byte, một ký tự, một cụm tự hình, hay một lát cắt chuỗi. Do đó, nếu bạn thực sự cần sử dụng chỉ mục để tạo lát cắt chuỗi, Rust yêu cầu bạn cần cụ thể hơn.

Thay vì lập chỉ mục bằng [] với một số duy nhất, bạn có thể sử dụng [] với một phạm vi để tạo một lát cắt chuỗi chứa các byte cụ thể:

#![allow(unused)]
fn main() {
let hello = "Здравствуйте";

let s = &hello[0..4];
}

Ở đây, s sẽ là một &str chứa bốn byte đầu tiên của chuỗi. Trước đó, chúng ta đã đề cập rằng mỗi ký tự này chiếm hai byte, điều đó có nghĩa s sẽ là Зд.

Nếu chúng ta cố gắng cắt chỉ một phần byte của một ký tự với một cái gì đó như &hello[0..1], Rust sẽ hoảng loạn tại thời điểm chạy theo cách tương tự như khi một chỉ mục không hợp lệ được truy cập trong một vector:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/collections`

thread 'main' panicked at src/main.rs:4:19:
byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Bạn nên thận trọng khi tạo lát cắt chuỗi với phạm vi, vì làm như vậy có thể làm sập chương trình của bạn.

Phương Thức để Lặp Qua Chuỗi

Cách tốt nhất để hoạt động trên các phần của chuỗi là rõ ràng về việc bạn muốn ký tự hay byte. Đối với các giá trị vô hướng Unicode riêng lẻ, hãy sử dụng phương thức chars. Gọi chars trên "Зд" tách ra và trả về hai giá trị kiểu char, và bạn có thể lặp qua kết quả để truy cập từng phần tử:

#![allow(unused)]
fn main() {
for c in "Зд".chars() {
    println!("{c}");
}
}

Mã này sẽ in ra như sau:

З
д

Ngoài ra, phương thức bytes trả về từng byte thô, điều này có thể phù hợp cho miền của bạn:

#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
    println!("{b}");
}
}

Mã này sẽ in ra bốn byte tạo nên chuỗi này:

208
151
208
180

Nhưng hãy nhớ rằng các giá trị vô hướng Unicode hợp lệ có thể bao gồm nhiều hơn một byte.

Lấy cụm tự hình từ chuỗi, như với chữ viết Devanagari, là phức tạp, vì vậy chức năng này không được cung cấp bởi thư viện chuẩn. Các crate có sẵn trên crates.io nếu đây là chức năng bạn cần.

Chuỗi Không Đơn Giản

Tóm lại, chuỗi là phức tạp. Các ngôn ngữ lập trình khác nhau đưa ra những lựa chọn khác nhau về cách trình bày sự phức tạp này cho lập trình viên. Rust đã chọn làm cho việc xử lý dữ liệu String đúng cách trở thành hành vi mặc định cho tất cả các chương trình Rust, điều này có nghĩa là lập trình viên phải suy nghĩ nhiều hơn về việc xử lý dữ liệu UTF-8 ngay từ đầu. Sự đánh đổi này tiết lộ nhiều sự phức tạp của chuỗi hơn là rõ ràng trong các ngôn ngữ lập trình khác, nhưng nó ngăn bạn phải xử lý lỗi liên quan đến các ký tự không phải ASCII sau này trong vòng đời phát triển của bạn.

Tin tốt là thư viện chuẩn cung cấp rất nhiều chức năng được xây dựng dựa trên các kiểu String&str để giúp xử lý các tình huống phức tạp này một cách chính xác. Hãy chắc chắn kiểm tra tài liệu để biết các phương thức hữu ích như contains để tìm kiếm trong một chuỗi và replace để thay thế các phần của một chuỗi bằng một chuỗi khác.

Hãy chuyển sang một thứ ít phức tạp hơn một chút: bảng băm!