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ả String
và slices
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 String
và
str
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::from
và to_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_str
và push
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
có &
, 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ự +
và "
, 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 3
là 208
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