Mượn và Tham Chiếu Trong Rust (Borrowing & References): Nghệ Thuật Chia Sẻ Dữ Liệu An Toàn

Tiếp nối bài viết trước về Ownership, chắc chắn bạn sẽ nhận ra một điểm vướng víu trong thực tế: Cứ mỗi lần truyền biến vào một hàm là biến gốc lại bị "Move" mất quyền sở hữu. Nếu muốn tiếp tục sử dụng biến đó ở bên ngoài hàm, bạn buộc phải dùng phương thức .clone() để sao chép dữ liệu lên Heap hoặc phải viết các tuple cồng kềnh để trả dữ liệu ngược lại. Cách làm này không chỉ tốn tài nguyên bộ nhớ mà còn làm chậm chương trình.
May mắn thay, ngôn ngữ Rust cung cấp một cơ chế cực kỳ thanh lịch để giải quyết triệt để vấn đề này: Borrowing (Mượn) thông qua các References (Tham chiếu). Cơ chế này cho phép bạn chia sẻ quyền truy cập dữ liệu một cách tạm thời mà không hề làm mất quyền sở hữu gốc. Cùng phân tích sâu cách hoạt động của nó ngay dưới đây.
🤝 1. Tham chiếu (&) là gì và cơ chế Borrowing hoạt động ra sao?
Trong Rust, Tham chiếu (Reference) được ký hiệu bằng dấu ampersand (&). Thay vì truyền trực tiếp toàn bộ dữ liệu (hoặc chuyển dịch quyền sở hữu), bạn chỉ truyền một con trỏ chứa địa chỉ trỏ tới vùng nhớ đang chứa dữ liệu đó. Hành động mượn địa chỉ này gọi là Borrowing.
Hãy xét một ví dụ thực tế khi chúng ta muốn tính độ dài của một chuỗi mà không muốn làm mất chuỗi gốc:
fn main() {
let s1 = String::from("hello");
// Truyền tham chiếu &s1 thay vì chuyển hẳn quyền sở hữu của s1 vào hàm
let len = calculate_length(&s1);
// ✅ HOÀN TOÀN HỢP LỆ! s1 vẫn sống nhăn và dùng bình thường ở đây.
println!("Độ dài của chuỗi '{s1}' là {len}.");
}
// Hàm nhận vào một tham chiếu kiểu &String thay vì sở hữu String
fn calculate_length(s: &String) -> usize {
s.len()
} // s ra khỏi scope ở đây, nhưng vì nó chỉ là "tham chiếu" (mượn địa chỉ) nên không có bộ nhớ Heap nào bị giải phóng cả!
Giải mã bản chất: Hãy tưởng tượng việc này giống như bạn cho bạn bè mượn quyển sách để đọc. Bạn vẫn là chủ sở hữu hợp pháp của quyển sách đó. Hàm calculate_length chỉ mượn địa chỉ để đọc nội dung chứ hoàn toàn không có quyền vứt bỏ hay tiêu hủy quyển sách. Khi hàm kết thúc, quyền đọc kết thúc, mọi thứ quay lại nguyên trạng.
🔒 2. Tính bất biến mặc định của Tham chiếu (Immutable References)
Cũng giống như các biến thông thường được khai báo bằng let, các tham chiếu trong Rust mặc định mang tính chất bất biến (immutable). Nghĩa là khi bạn mượn một dữ liệu thông qua kiểu &T, bạn chỉ có quyền đọc (read-only) chứ tuyệt đối không được phép thay đổi giá trị của nó.
fn main() {
let s = String::from("hello");
// change(&s); // ❌ LỖI BIÊN DỊCH! Không thể thay đổi dữ liệu được mượn bất biến.
}
fn change(some_string: &String) {
// some_string.push_str(", world"); // Lỗi biên dịch ngay lập tức!
}
Việc ép buộc tham chiếu phải bất biến mặc định giúp lập trình viên kiểm soát tuyệt đối luồng dữ liệu, ngăn chặn việc các hàm vô tình làm thay đổi trạng thái của biến gốc khi không được phép.
✍️ 3. Tham chiếu thay đổi được (Mutable References - &mut)
Nhưng nếu bài toán thực tế yêu cầu hàm phải mượn dữ liệu và có quyền chỉnh sửa trực tiếp nội dung của biến gốc thì sao? Lúc này, bạn phải sử dụng Mutable Reference được ký hiệu bằng &mut.
Tuy nhiên, việc sử dụng tham chiếu thay đổi được đi kèm với những điều kiện ràng buộc cực kỳ khắt khe từ trình biên dịch:
fn main() {
// 1. Biến gốc bắt buộc phải được khai báo là mut
let mut s = String::from("hello");
// 2. Truyền tham chiếu thay đổi được bằng ký hiệu &mut
append_world(&mut s);
// ✅ Thành công! Biến s đã bị thay đổi trực tiếp tại chỗ.
println!("{s}"); // In ra: hello, world
}
fn append_world(some_string: &mut String) {
// Dùng phương thức chỉnh sửa nội dung trên Heap
some_string.push_str(", world");
}
⚔️ 4. Quy tắc vàng của "Borrow Checker" và Tranh chấp dữ liệu
Đây chính là "linh hồn" và cũng là thử thách lớn nhất khi học Rust. Để ngăn chặn triệt để hiện tượng Data Races (tranh chấp dữ liệu xảy ra khi nhiều luồng cùng ghi đè lên một ô nhớ cùng lúc) và lỗi hỏng bộ nhớ, trình biên dịch (được quản lý bởi BorrowChecker) áp dụng hai quy tắc bất di bất dịch:
Quy tắc A: Bạn có thể có MỘT HOẶC NHIỀU tham chiếu bất biến (&T) tại cùng một thời điểm.
Trong cùng một khoảng thời gian, bạn được phép tạo ra bao nhiêu tham chiếu đọc (&) tùy thích, bởi vì không ai trong số các tham chiếu đó có quyền làm thay đổi dữ liệu gốc, nên việc đọc song song là hoàn toàn an toàn.
Quy tắc B: ...tuy nhiên, tại một thời điểm CHỈ ĐƯỢC PHÉP TỒN TẠI MỘT tham chiếu thay đổi được (&mut T).
Bạn không bao giờ được phép mượn một biến dưới dạng &mut nếu biến đó đang có bất kỳ tham chiếu nào khác (dù là tham chiếu đọc & hay tham chiếu ghi &mut) đang hoạt động cùng lúc.
fn main() {
let mut s = String::from("hello");
let r1 = &s; // 🟢 Mượn đọc lần 1
let r2 = &s; // 🟢 Mượn đọc lần 2 (Hoàn toàn hợp lệ, nhiều người cùng đọc!)
// let r3 = &mut s; // ❌ LỖI BIÊN DỊCH NGAY LẬP TỨC!
// Không thể mượn kiểu ghi (&mut) trong khi đang có người mượn đọc (&).
}
Tại sao Rust lại khắt khe đến vậy? Hãy tưởng tượng bạn đang cầm một cuốn sách để đọc (&r1), nhưng một người khác lại xé một trang sách đi để sửa nội dung (&mut r3) ngay trong khoảnh khắc bạn đang đọc. Sự hỗn loạn dữ liệu đó sẽ phá hỏng toàn bộ chương trình. Trình biên dịch Rust chủ động chặn đứng kịch bản thảm họa này ngay từ giai đoạn biên dịch thay vì đợi đến lúc chạy chương trình mới phát sinh lỗi.
👻 5. Triệt tiêu hoàn toàn lỗi tham chiếu lơ lửng (Dangling References)
Trong các ngôn ngữ lập trình hệ thống truyền thống như C hoặc C++, một trong những lỗi bảo mật nguy hiểm nhất là Dangling Pointer (con trỏ trỏ tới một vùng nhớ đã bị giải phóng hoặc không còn hợp lệ).
Trong Rust, Borrow Checker sẽ kiểm tra vòng đời (lifetime) của mọi tham chiếu để đảm bảo điều này không bao giờ xảy ra:
fn main() {
// let reference_to_nothing = dangle(); // ❌ Trình biên dịch chặn đứng!
}
// fn dangle() -> &String {
// let s = String::from("hello"); // s được tạo ra và nằm cục bộ bên trong hàm
// &s // Trả về tham chiếu trỏ tới s...
// }
// 💥 NGAY LẬP TỨC LỖI! s sẽ bị hủy khi ra khỏi hàm này. Trả về tham chiếu trỏ tới một vùng nhớ đã chết là hành vi bị cấm tuyệt đối.
Cách giải quyết đơn giản: Thay vì cố gắng trả về tham chiếu của một biến cục bộ sắp chết, hãy trả về trực tiếp giá trị String (chuyển luôn Ownership ra ngoài phạm vi hàm) là xong!
Thông qua hệ thống Borrowing & References, bạn đã hiểu cách Rust cho phép các phần trong chương trình chia sẻ dữ liệu một cách cực kỳ linh hoạt nhưng vẫn duy trì được sự kiểm soát an toàn tuyệt đối về mặt bộ nhớ.
Ở bài viết tiếp theo (Bài 6), chúng ta sẽ nâng tầm kỹ năng lập trình của mình lên một bậc với Tổ chức dữ liệu nâng cao qua Struct, Enum và sức mạnh của Pattern Matching!
Bình luận