Lưu trữ UTF-8 Encoded Text (Chữ định dạng UTF-8) với kiểu Strings

Chúng ta đã nói về string trong chương 4, nhưng chúng sẽ xem xét kỹ lưỡng chúng bây giờ. Một Rustaceans mới thường gặp rắc rối với string khi kết hợp 3 thứ sau: Biểu thị lỗi trong string, cấu trúc dữ liệu phức tạp của string và thứ 3: Mã hoá dạng UTF-8. Ba nhân tố này kết hợp thành thứ mà dường như khó khi bạn đến từ một ngôn ngữ khác.

Chúng ta thảo luận string trong ngữ cảnh collections bởi vì strings được coi như một tập hợp của các bytes, vài phương thức thêm vào cung cấp chức năng hữu dụng khi bytes của nó được coi như là text. Trong chương này, chúng ta sẽ nói về những điều khiển trong String (kiểu dữ liệu) mà mọi kiểu collection có, giống như điều khiển thông thường như tạo, cập nhật, và đọc. Chúng ta cũng sẽ bàn về việc String là đôi chút khác với các kiểu collection khác, và việc indexing thành một String là phức tạp bởi có sự khác nhau giữa cách người và máy tính giải thích kiểu String;

String là gì?

Đầu tiên, Chúng ta sẽ định nghĩa thuật ngữ string. Rust chỉ có một kiểu string trong ngôn ngữ, nơi nó là dạng slice str mà thường được coi biểu thị trong &str (mượn). Trong chương 4, chúng ta đã nói về string slices, mà là tham chiếu tới dữ liệu text định dạng UTF-8. String, bản chất ví dụ, là được lưu trữ trong tệp nhị phân và do đó là string slices

Kiểu String, nơi được cung cấp bởi thư viện chuẩn của Rust hơn là được lập trình thành ngôn ngữ core, là một kiểu chuỗi (string) có thể mở rộng, thay đổi, owned, định dạng UTF8. Khi lập trình viên Rust (Rustaceans) tham chiếu "string" trong Rust, họ có thể đang tham chiếu tới kiểu String hoặc kiểu &str, hoặc không phải một trong 2 kiểu trên. Mặc dù chương này mục đích chính là về String, cả 2 kiểu là được sử dụng nhiều trong thư viện chuẩn Rust, và cả Stringslices là dưới dạng UTF8-encoded

Thư viện chuẩn Rust cũng bao gồm một số kiểu string khác, giống như OsString, OsStr, CString, và CStr. Những thư viện crates có thể cung cấp cả nhiều lựa chọn cho việc lưu trữ dữ liệu dạng string. Nhìn cách kiểu kia đều kết thức với String hoặc Str?. Chúng tahm chiếu tới biến hoặc sở hữu hoặc mượn, cũng giống Stringstr bạn đã nhìn thầy trước đó. Các kiểu string này có thể lưu trữ text trong định dạng mã hoá khác nhau hoặc được thể hiện trong bộ nhớ theo một cách nào đó. Ví dụ, Chúng ta sẽ không thảo luận các kiểu dữ liệu này trong chương này; xem tài liệu của chúng nhiều hơn hoặc cách sử dụng để biết cách khi nào sử dụng chúng một cách thích hợp.

Tạo một String

Nhiều thao tác với Vec<T> cũng được sử dụng với String, ví dụ hàm new để tạo một string, xem Listing 8-11

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

Listing 8-11: Tạo mới một String rỗng

Dòng này tạo một mới một string rỗng và gán tới s. Thông thường, chúng ta sẽ khởi tạo với một giá trị mà chúng ta muốn Để làm điều này, chúng ta có thể sử dụng phương thức to_string, mà nó có trong tất cả các kiểu mà thực hiện trait Display, và string cũng thế. Listing 8-12 xem xét 2 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();
}

Listing 8-12: Using the to_string method to create a String from a string literal

This code creates a string containing initial contents.

We can also use the function String::from to create a String from a string literal. The code in Listing 8-13 is equivalent to the code from Listing 8-12 that uses to_string.

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

Listing 8-13: Sử dụng hàm String::from để tạo String từ một chuỗi Bởi vì chuỗi được sử dụng cho nhiều thứ, chúng ta có thể sử dụng generic khác nhau cho chuỗi, cho phép chúng ta nhiều lựa chọn. Một trong số đó dường như hơi thừa thãi, nhưng không sao cả. Trong trường hợp này, String::fromto_string cùng hành động giống nhau, do đó bạn có thể chọn phương thức nào cũng được phụ thuộc vào bạn. Nhớ rằng strings là có định dạng mã hoá UTF-8, do dó chúng có bao gồm bất kỳ dữ liệu mã hoá trong đó, Listing 8-14 là ví dụ

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");
}

Listing 8-14: Lưu trữ câu lời chào trong các ngôn ngữ khác nhau

Tất cả chúng là giá trị String hợp lệ

Cập nhật một String

Nếu bạn đẩy nhiều dữ liệu, một String có thể tự tăng kích thước, và nội dụng sẽ thay đổi, giống nội dụng của một Vec<T>. Thêm vào đó, bạn có thể sử dụng toán tử + hoặc macro format! để hợp nhất 2 giá trị String

Thêm dữ liệu tới một String sử dụng push_strpush

Chúng ta thêm dữ liệu vào String bởi sử dụng phương thức push_str và kích thước String sẽ tăng lên. Xem Listing 5-15

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

Listing 8-15: Thêm một string slice vào một String sử dụng phương thức push_str

Sau 2 dòng trên, s sẽ là foobar. Phương thức push_str nhận tham số là string sclie bởi vì chúng ta không cần thiết truyền vào tham số với một ownership. Ví dụ, trong code Listing 8-16, chúng ta muốn sử dụng s2 sau khi thêm nội dung nó tới s1.(Nếu truyền ownership vào hàm push_str rồi thì s2 không dùng được nữa - đó là điều chúng ta không muốn)

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

Listing 8-16: Sử dụng string slice sau khi thêm nội dung của nó tới một String

Nếu push_str lấy quyền sở hữu của s2, chúng sẽ không thể in được giá trị của nó ở dòng cuối, Tuy nhiên, dòng code này cũng sẽ chạy được như kỳ vọng

Phương thức push nhân một ký tự đơn như một tham số và thêm nó tới String. Listing 8-17 thêm ký tự "l" tới một String sử dụng phương thức push

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

Listing 8-17: Thêm một ký tự tới String sử dụng `push

Kết quả là s sẽ bao gồm chuỗi lol As a result, s will contain lol.

Gộp 2 chuỗi với toán tử + hoặc sử dụng macro format!

Thông thương, bạn sẽ muốn kết hợp 2 chuỗi với nhau. Một cách để làm điều này là sử dụng toán tử +,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
}

Listing 8-18: Sử dụng toán tử + để kết hợp 2 String thành một String mới

Chuỗi s3 sẽ bao gồm Hello, world!. Lý do s1 là không còn tồn tại (valid) sau thêm vào và lý do chúng ta đã sử dụng một tham chiếu đến s2, là bởi vì Rust đối cử toán tử + như sử dụng phương thức add mà trông giống như sau:

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

Trong thư viện chuẩn, bạn sẽ nhìn thấy định nghĩa add sử dụng generic. Ở đây, chúng ta đã thay thế kiểu cụ thể cho generic nơi mà chúng ta gọi hàm này với String. Chúng ta sẽ thảo luận về generic trong chương 10. Hàm chữ ký cho toán tử + sẽ giúp chúng ta có ý niệm để hiểu một chút cách + hoạt động

Đầu tiên, s2&, nghĩa là chúng ta thêm một tham chiếu (reference) của chuỗi thứ 2 tới chuỗi thứ nhất. Điều này bởi vì tham số s trong hàm add: chúng ta có thể chi thêm &str tới một String; Chúng ta không có thể cộng 2 String cùng nhau. Nhưng khoan, kiểu của &s2 là '&String, không phải là &str` mà. Nhưng tại sao Listing 8-18 lại vẫn có thể biên dịch?

Lý do là chúng ta có thể sử dụng &s2 trong lời gọi hàm tới add là rằng, trình biên dịch có thể ép buộc (coere) &String thành một &str. Khi chúng ta gọi phương thức add, Rust sử dụng một sự ép buộc deref (deref coercion) nơi mà nó biến &s2 thành &s2[..]. Chúng ta sẽ nói về ép buộc deref sâu hơn trong chương 15 Bởi vì hàm add không nhận quyền sở hữu của tham số s, s2 sẽ có thể được dùng tiêp sau điều khiển này.

Thứ hai, chúng ta có thể nhìn thấy trong chữ của hàm add nhận quyền sở hữu của self, bởi vì self không có &. Điều này nghĩa là s1 trong Listing 8-18 sẽ được move vào lời gọi hàm add và sẽ không còn valid nữa. Do đó, mặc dù let s3 = s1 + &s2; trông như nó sẽ copy cả 2 strings và tạo một string nmoiws, câu lệnh này thật sự lấy ownership của s1 thêm một bản sao nội dung s2 và trở về một sỡ hữu của kết quả. Nói một cách khác, nó làm nhiều việc và hiểu quả hơn là chỉ copy. Nếu chúng ta cần gộp nhiều chuỗi, hành vi của toán tử + sẽ hơi rườm rà và không hiệu quả(unwieldy)

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

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

Tại điểm này, s sẽ có giá trị là tic-tac-toe. Với tất cả ký tự +", nó sẽ khó để nhìn thấy thế nào. Cho việc kết hợp nhiều chuỗi phức tạp, chúng ta sử dụng macro format! thay vì.

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

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

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

Chỉ số trong String (Indexing into String)

Trong nhiều ngôn ngữ lập trình khác, truy cập ký tự riêng rec trong một chuỗi bởi tham chiếu đến chúng thông của chỉ số (index). Tuy nhiên nếu bạn cố gắng truy xuất phần của String sử dụng cú pháp chỉ số trong Rust, bạn sẽ nhận về một lỗi. Xem xét đoạn code không đúng sau trong Listing 8-19

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

Listing 8-19: Cố gắng để sử dụng chỉ số để truy cập trong String

Đoạn code này sẽ nhận về một lỗi sau:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `String` cannot be indexed by `{integer}`
 --> src/main.rs:3:13
  |
3 |     let h = s1[0];
  |             ^^^^^ `String` cannot be indexed by `{integer}`
  |
  = help: the trait `Index<{integer}>` is not implemented for `String`

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

Lỗi này nói với chúng ta một thứ: Chuỗi trong Rust không hỗ trợ indexing (chỉ số). Nhưng tại sao không? Để trả lời được câu hỏi này, chúng ta cần hiểu cách Rust lưu trữ strings trong bộ nhớ.

Biểu thị bộ nhớ bên trong của String

Một String là một đối tượng chứa (wrapper) một kiểu Vec<u8>. Cùng xem chuỗi ví dụ mã hoá UTF-8 từ Listing 8-14. Đầu tiền, điều 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, hàm len là 4, nghĩa vector đang giữ chuỗi "Hola" (độ dài 4 bytes). Mỗi một ký tự chúng chiếm 1 byte khi được mã hoá trong UTF-8. Theo đoạn code trên, có thể làm cho bạn bất ngờ. (Chú ý rằng chuỗi bắt đầu với chũ cái Cyrillic Ze, không phải số 3 Ả rập)

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");
}

Câu hỏi là chuỗi kia chiếm mấy bytes, bạn có thể nói là 12. Sự thật, Câu trả lời là 24: Số bytes nó chiếm để mã hoá chuỗi “Здравствуйте” trong UTF-8, bởi mỗi giá trị scalar Unicode trong chuỗi chiếm 2 bytes lưu trữ. Do đó, một chỉ số trong string sẽ không tương quan tới giá trị scalar Unicode. Để minh hoạ, xem đoạn code không đúng sau:

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

Bạn cũng đã biết rằng câu trả lời sẽ không phải là 3, ký tự đầu tiên. Với mã hoá UTF-8, byte đầu tiên của 3208 và byte thứ 2 là 151, nhưng 208 không phải là ký tự hợp lệ. Trở về 208 là không giống những gì một người dùng muốn nếu họ hỏi về ký tự đầu tiên của chuỗi; tuy nhiên, điều này dữ liệu mà Rust có tại chỉ số index 0. Người dùng một cách tổng quát không muốn sử dụng giá trị byte trở về, dù nếu chuỗi bao gồm chỉ ký tự Latin : Nếu &"hello"[0] là code hợp lệ mà trở về giá trị byte, nó sẽ trở về 104 chứ không phải h

Câu trả lời là để tránh trở về một giá trị không kỳ vọng và gây lỗi, Rust không biên dịch đoạn code này và ngăn chặn hiểu nhầm một cách sớm nhất trong quá trình viết code.

Bytes và Scalar Values và Grapheme Clusters! Oh My!

Một điểm khác về UTF-8 là rằng có 3 cách thực sự liên quan để quan niệm chuỗi trong Rust: Như là mảng bytes, các giá trị scalar và biểu đồ cụm (grapheme clusters) (thứ tưởng tượng gần như là các ký tự (letters))

Nếu bạn nhìn vào chữ Hindi “नमस्ते” được viết trong Devanagari script, nó sẽ được lưu trữ trong một vector của u8 trông giống như sau:

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

Đó là 18 bytes cách mà máy tính lưu trữ dữ liệu của chúng ta. Nếu chúng ta nhìn chúng như giá trị Unicode scalar, Giả trị char trong Rust sẽ là

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

Có 6 giá trị char ở đay, nhưng kí tự thứ 4' (्') và thứ 6('े') không phải là từ, chúng là những dấu mà không có ý nghĩa gì cả. Cuối cùng, nếu chúng ta nhìn chúng như biểu đồ cụm, chúng ta get 4 ký tự trong chữ Hindi

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

Rust cung cấp những cách khác nhau để thông dịch những dữ liệu chuỗi thô mà máy tính lưu trữ mà mỗi chương trình có thể lựa chọn thông dịch nó cần, bất kể dữ liệu được lưu trữ trong ngôn ngữ nào.

Một lý do cuối cùng, Rust không cho phép chúng ta sử dụng index trong một String để lấy ký tự là rằng điều khiển indexing là được kỳ vọng để lấy với thời gian độ phức tạp O(1). Nhưng điều này không có thể khả năng để đảm bảo hiệu năng với String, bởi vì Rust phải duyệt qua nội dung từ đầu tới chỉ số để xem xét có bao nhiêu ký tự hợp lệ

Slicing Strings (Phần của String/ Dạng lát cắt String)

Chỉ số trong string thường ý tưởng tồi bởi nếu không biết chính xác kiểu trả về: một giá trị byte, một ký tự, một cụm hoặc một string sclie. Nếu bạn thật sự cần để sử chỉ số để tạo ra phần của string, do đo, Rust sẽ hỏi bạn chi tiết hơn.

Thay vì sử dụng chỉ số với [] với một số, bạn có thể sử dụng []` với một dãy để tạo một string slice bao gồm các bytes cụ thể

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

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

Ở đây, s sẽ là một &str mà bao gồm 4 bytes của 1 string. Trước đây, chúng ta đã đề cập mỗi một ký tự là 2 bytes, nghĩa là s sẽ là Зд

Nếu như chúng ta cố gắng tạo một phần của slice như sau &hello[0..1], Rust sẽ panic tại lục run time bởi vì sử dụng chỉ số không xác định để truy xuất trong 1 vector

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
    Finished dev [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/collections`
thread 'main' panicked at 'byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`', src/main.rs:4:14
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Bạn nên sử dụng dãy để tạo một string sclice với sự cẩn trọng, bởi vì có thể làm crash chương trình của bạn

Các phương thức để duyệt qua một String

Cách tốt nhất để điều khiên một phần của strings là biết được bạn muốn ký tự hoặc bytes. Cho giá trị Unicode scalar, sử dụng phương thức chars. Gọi hàm chars trong “नमस्ते” chia tách và trở về 6 giá trị của char, và bạn có thể duyệt qua kết quả để truy xuất tới mỗi thành phần:

#![allow(unused)]
fn main() {
for c in "नमस्ते".chars() {
    println!("{}", c);
}
}

Đoạn code sẽ in ra như sau

न
म
स
्
त
े

Một cách thay thế, phương thức bytes trở về mỗi byte raw, mà có thể chính xác ý muốn chúng ta hơn:

#![allow(unused)]
fn main() {
for b in "नमस्ते".bytes() {
    println!("{}", b);
}
}

Đoạn code này in 18 bytes tạo nên String này:

224
164
// --snip--
165
135

Nhưng đảm bảo luôn nhớ rằng, giá trị Unicode scalar hợp lệ có thể được tạo thành nhiều hơn 1 byte

Grapheme Clusters của strings là phức tạp, do đó chức năng này không được cung cấp bởi thư viện chuẩn. Tìm kiếm trong crates.io nếu bạn cần chức năng này

String là không đơn giản

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

Cùng chuyển tới một thứ phức tạp ít một chút là: Hash Maps