Kịch bản lỗi
Nếu bạn đã có kinh nghiệm với JavaScript, Python hoặc Java, việc nối hai chuỗi bằng toán tử + dường như là một phản xạ tự nhiên. Tuy nhiên, Rust quản lý bộ nhớ chặt chẽ hơn nhiều. Bạn có thể sẽ gặp khó khăn ngay lần đầu tiên cố gắng nối hai string literal (&str) bằng cú pháp quen thuộc đó.
Hãy xem xét đoạn mã phổ biến sau đây khiến trình biên dịch báo lỗi:
fn main() {
let hello = "Hello, ";
let world = "world!";
// Đoạn mã này sẽ không thể biên dịch
let greeting = hello + world;
println!("{}", greeting);
}
Chạy lệnh cargo build sẽ dẫn đến thông báo lỗi cụ thể sau:
error[E0369]: binary operation `+` cannot be applied to type `&str`
--> src/main.rs:6:26
|
6 | let greeting = hello + world;
| ----- ^ ----- &str
| |
| &str
Tại sao Rust từ chối điều này
Để hiểu cách khắc phục, bạn cần hiểu &str thực sự là gì. Trong Rust, &str là một string slice. Về cơ bản, nó là một cấu trúc 16-byte trên hệ thống 64-bit, bao gồm một pointer trỏ đến các byte UTF-8 và một length. Vì nó chỉ là một view vào bộ nhớ — thường là bộ nhớ read-only — nên nó không thể mở rộng.
Toán tử + trong Rust được hỗ trợ bởi trait Add. Thư viện chuẩn chỉ triển khai trait này cho String + &str, chứ không phải &str + &str. Dưới đây là cái nhìn đơn giản về cách triển khai đó hoạt động:
impl Add<&str> for String {
type Output = String;
fn add(mut self, other: &str) -> String {
self.push_str(other);
self
}
}
Điều này tiết lộ một chi tiết quan trọng. Phía bên trái phải là một String có quyền sở hữu (owned). Thao tác này thực sự tiêu thụ (consume) String đó, thêm văn bản mới vào buffer của nó và trả lại kết quả. Vì một slice (&str) không sở hữu buffer của nó, nên nó không có nơi nào để lưu trữ các ký tự bổ sung.
Các giải pháp thực tế
1. Chuyển đổi chuỗi đầu tiên thành một String có quyền sở hữu
Cách khắc phục nhanh nhất là chuyển slice đầu tiên thành một String được cấp phát trên heap. Bạn có thể thực hiện việc này bằng cách sử dụng .to_string() hoặc String::from(). Thao tác này tạo ra một buffer cho phép mở rộng.
fn main() {
let hello = "Hello, ";
let world = "world!";
// Chuyển đổi phần đầu tiên thành String; phần thứ hai có thể giữ nguyên là một slice
let greeting = hello.to_string() + world;
println!("{}", greeting);
}
Lưu ý rằng hello ở đây là một literal. Nếu hello vốn đã là một biến String có quyền sở hữu, việc sử dụng + sẽ di chuyển (move) nó, nghĩa là bạn không thể sử dụng biến ban đầu đó ở phần sau trong mã của mình.
2. Sử dụng macro format! (Cách tiếp cận gọn gàng nhất)
Khi bạn cần kết hợp nhiều biến hoặc trộn văn bản với số, format! là người bạn tốt nhất của bạn. Nó dễ đọc hơn nhiều so với việc nối các toán tử + và tự xử lý các chuyển đổi cho bạn.
fn main() {
let user = "Alice";
let id = 42;
// format! xử lý &str và số nguyên một cách dễ dàng
let message = format!("User: {}, ID: {}", user, id);
println!("{}", message);
}
Mặc dù format! chậm hơn một chút so với việc nối chuỗi thủ công do phải phân tích cú pháp template lúc runtime, nhưng sự khác biệt là không đáng kể đối với hầu hết các ứng dụng.
3. Sử dụng push_str để có hiệu suất tốt hơn
Nếu bạn đã có một mutable String, đừng tạo những chuỗi mới bằng +. Hãy sử dụng push_str. Phương thức này sửa đổi trực tiếp buffer hiện có, giúp hiệu quả hơn đáng kể trong các vòng lặp.
fn main() {
let mut buffer = String::with_capacity(50);
buffer.push_str("First ");
buffer.push_str("Second");
println!("{}", buffer);
}
4. Nối một tập hợp các chuỗi
Nếu bạn đang làm việc với một danh sách hoặc một vector các chuỗi, phương thức join là lựa chọn đúng chuẩn (idiomatic). Nó tự động xử lý các ký tự phân cách giữa các phần tử.
fn main() {
let words = vec!["Rust", "is", "fast"];
let sentence = words.join(" ");
assert_eq!(sentence, "Rust is fast");
}
Cách xác minh giải pháp
Sau khi áp dụng một trong các phương pháp này, hãy kiểm tra lại bằng lệnh cargo check. Nó nhanh hơn nhiều so với việc biên dịch đầy đủ. Nếu trình biên dịch không báo lỗi, các kiểu dữ liệu của bạn đã được căn chỉnh chính xác. Nếu bạn sử dụng Phương pháp 1, hãy kiểm tra kỹ để đảm bảo bạn không cố gắng sử dụng lại biến đầu tiên nếu nó là một String có quyền sở hữu, vì toán tử + sẽ lấy quyền sở hữu của nó.
Cân nhắc về hiệu suất
- **Cấp phát trước (Pre-allocation):** Nếu bạn biết mình đang xây dựng một chuỗi 1MB, hãy sử dụng `String::with_capacity(1_000_000)`. Điều này giúp CPU không phải sao chép lại dữ liệu mỗi khi buffer bị đầy.
- **Memory Slices:** Bất cứ khi nào có thể, hãy giữ dữ liệu dưới dạng `&str`. Chỉ chuyển đổi sang `String` khi bạn thực sự cần sửa đổi văn bản hoặc lưu trữ nó trong một struct yêu cầu quyền sở hữu (ownership).
- **Tránh vòng lặp:** Đừng bao giờ sử dụng `s = s + "more"` bên trong một vòng lặp lớn. Điều này tạo ra một phân bổ bộ nhớ mới trong mỗi lần lặp, dẫn đến độ phức tạp O(n²).

