Hướng dẫn Rust: Tham chiếu và Mượn (References and Borrowing)
Bài viết này đi sâu vào cơ chế tham chiếu (references) và mượn (borrowing) trong ngôn ngữ lập trình Rust. Chúng ta sẽ tìm hiểu cách sử dụng giá trị mà không cần chuyển quyền sở hữu, sự khác biệt giữa tham chiếu bất biến và biến đổi, các quy tắc hạn chế để tránh lỗi data race, và cách trình biên dịch Rust ngăn chặn các con trỏ lơ lửng (dangling pointers).

Phần này thực tế có nhiều điểm tương đồng với cách ngữ nghĩa di chuyển (move semantics) của các con trỏ thông minh trong C++ bị giới hạn ở cấp độ trình biên dịch. Cách viết tham chiếu trong Rust, thông qua các hạn chế của trình biên dịch, trở thành cách viết con trỏ lý tưởng và chuẩn hóa nhất trong C++. Vì vậy, bất kỳ ai đã từng học C++ đều sẽ thấy chương này rất quen thuộc.
Tham chiếu (References)
Tham chiếu cho phép một hàm sử dụng một giá trị mà không cần nhận quyền sở hữu (ownership) đối với giá trị đó. Khi khai báo, hãy thêm dấu & trước kiểu dữ liệu để biểu thị một tham chiếu. Ví dụ, tham chiếu đến String là &String. Nếu bạn đã học C++, toán tử giải tham chiếu trong C++ là *, và trong Rust cũng hoàn toàn tương tự.
Sau khi hiểu về tham chiếu, chúng ta có thể rút gọn ví dụ ở cuối bài viết trước.
Dưới đây là đoạn code trước đây:
fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_length(s1);
println!("Độ dài của '{}' là {}", s2, len);
}
fn calculate_length(s: String) -> (String, usize) {
let length = s.len();
(s, length)
}
Và đây là đoạn code đã được sửa đổi:
fn main() {
let s1 = String::from("hello");
let length = calculate_length(&s1);
println!("Độ dài của '{}' là {}", s1, length);
}
fn calculate_length(s: &String) -> usize {
s.len()
}
So sánh hai phiên bản, ở phiên bản sau, một con trỏ trỏ đến dữ liệu được truyền vào hàm calculate_length để hàm này thao tác, trong khi quyền sở hữu dữ liệu vẫn thuộc về biến s1. Chúng ta không cần trả về một tuple, cũng không cần khai báo thêm biến s2, giúp code trở nên ngắn gọn hơn nhiều.
Tham số s của hàm calculate_length thực chất là một con trỏ trỏ đến vị trí bộ nhớ stack nơi s nằm (nó không trỏ trực tiếp đến dữ liệu trên heap). Khi con trỏ này ra khỏi phạm vi (scope), Rust sẽ không hủy dữ liệu mà nó trỏ tới, bởi vì s không sở hữu dữ liệu đó. Rust chỉ loại bỏ thông tin con trỏ được lưu trên stack, nghĩa là nó giải phóng bộ nhớ bị chiếm dụng bởi phần bên trái trong hình dưới đây.
Sơ đồ minh họa tham chiếu trong Rust
Việc sử dụng tham chiếu làm tham số hàm được gọi là mượn (borrowing).
Đặc tính của Mượn (Borrowing)
Nội dung được mượn không thể được sửa đổi trừ khi đó là tham chiếu biến đổi (mutable reference).
Hãy lấy ví dụ về một ngôi nhà: nếu bạn cho thuê một ngôi nhà mà bạn sở hữu, đó chính là mượn. Người thuê có thể sống trong đó nhưng không thể tự ý sửa chữa; đây là đặc tính mà nội dung mượn không thể thay đổi. Nếu bạn cho phép người thuê sửa chữa, thì đó là tham chiếu biến đổi.
Xét đoạn code sau:
fn main() {
let s1 = String::from("hello");
let length = calculate_length(&s1);
println!("Độ dài của '{}' là {}", s1, length);
}
fn calculate_length(s: &String) -> usize {
s.push_str(", world");
s.len()
}
Đoạn code này sẽ gây ra lỗi biên dịch:
error[E0596]: cannot borrow `*s` as mutable, as it is behind a `&` reference
Nguyên nhân gây lỗi là dòng s.push_str(", world");: tham chiếu mặc định là bất biến (immutable), nhưng dòng này lại cố gắng sửa đổi dữ liệu.
Giống như khai báo biến thông thường, tham chiếu mặc định là bất biến, nhưng sẽ trở nên biến đổi khi thêm từ khóa mut:
fn main() {
let mut s1 = String::from("hello");
let length = calculate_length(&mut s1);
println!("Độ dài của '{}' là {}", s1, length);
}
fn calculate_length(s: &mut String) -> usize {
s.push_str(", world");
s.len()
}
Viết như thế này sẽ không gây lỗi, nhưng hãy nhớ khai báo s1 là một biến biến đổi khi bạn khởi tạo nó.
Loại tham chiếu có thể sửa đổi dữ liệu này được gọi là tham chiếu biến đổi (mutable reference).
Các hạn chế của Tham chiếu Biến đổi
Tham chiếu biến đổi có hai hạn chế rất quan trọng. Thứ nhất là: trong một phạm vi cụ thể, đối với một dữ liệu cụ thể, chỉ được phép có một tham chiếu biến đổi duy nhất.
Xét đoạn code sau:
fn main() {
let mut s = String::from("hello");
let s1 = &mut s;
let s2 = &mut s;
}
Vì cả s1 và s2 đều là tham chiếu biến đổi trỏ đến s, và chúng nằm trong cùng một phạm vi, trình biên dịch sẽ báo lỗi:
error[E0499]: cannot borrow `s` as mutable more than once at a time
Mục đích của quy tắc này là để ngăn chặn data race (cuộc đua dữ liệu). Data race xảy ra khi cả ba điều kiện sau được thỏa mãn cùng lúc:
- Hai hoặc nhiều con trỏ truy cập cùng một dữ liệu cùng một lúc.
- Ít nhất một con trỏ được dùng để ghi dữ liệu.
- Không có cơ chế nào được dùng để đồng bộ hóa việc truy cập dữ liệu.
Thông báo lỗi nhắc đến "at a time", nghĩa là cùng một lúc, hay nói cách khác là trong cùng một phạm vi. Vì vậy miễn là không cùng một lúc, tức là hai tham chiếu biến đổi trỏ đến cùng một dữ liệu ở các phạm vi khác nhau là được phép. Đoạn code sau minh họa điều này:
fn main() {
let mut s = String::from("hello");
{
let s1 = &mut s;
} // s1 ra khỏi phạm vi ở đây, nên chúng ta có thể tạo tham chiếu biến đổi mới
let s2 = &mut s;
}
s1 và s2 không có cùng phạm vi, nên việc trỏ đến cùng một dữ liệu là được phép.
Hạn chế quan trọng thứ hai của tham chiếu biến đổi là: bạn không thể có một tham chiếu biến đổi và một tham chiếu bất biến cùng một lúc. Mục đích của tham chiếu biến đổi là để sửa đổi dữ liệu, trong khi mục đích của tham chiếu bất biến là giữ dữ liệu không thay đổi. Nếu cả hai cùng tồn tại, một khi tham chiếu biến đổi thay đổi giá trị, tham chiếu bất biến không còn phục vụ mục đích của nó nữa.
fn main() {
let mut s = String::from("hello");
let s1 = &mut s;
let s2 = &s;
}
Vì s1 là tham chiếu biến đổi và s2 là tham chiếu bất biến, và cả hai cùng xuất hiện trong một phạm vi trỏ đến cùng một dữ liệu, trình biên dịch sẽ báo lỗi:
error[E0502]: cannot borrow `s` as mutable because it also borrowed as immutable
Tất nhiên, nhiều tham chiếu bất biến có thể tồn tại cùng một lúc.
Tóm lại: nhiều người đọc (tham chiếu bất biến) có thể tồn tại đồng thời, nhiều người viết (tham chiếu biến đổi) có thể tồn tại nhưng không được đồng thời, và nhiều người viết kết hợp với việc đọc/ghi đồng thời là không được phép.
Tham chiếu lơ lửng (Dangling References)
Khi sử dụng con trỏ, rất dễ gây ra một lỗi gọi là con trỏ lơ lửng (dangling pointer). Nó được định nghĩa là: một con trỏ trỏ đến một địa chỉ bộ nhớ nào đó, nhưng bộ nhớ đó có thể đã được giải phóng và cấp phát lại cho người khác sử dụng.
Nếu bạn tham chiếu đến một dữ liệu nào đó, trình biên dịch của Rust đảm bảo rằng dữ liệu đó sẽ không ra khỏi phạm vi trước khi tham chiếu đó ra khỏi phạm vi. Đây là cách Rust đảm bảo rằng tham chiếu lơ lửng không bao giờ xảy ra.
Xét đoạn code sau:
fn main() {
let r = dangle();
}
fn dangle() -> &String {
let s = String::from("hello");
&s
}
- Một biến cục bộ
sđược tạo ra: Biếnslà mộtString. Nó được cấp phát trên stack, nhưng dữ liệu nền của nó được lưu trên heap. - Một tham chiếu đến
sđược trả về: Hàm trả về tham chiếu đếnsthông qua&sở cuối. sra khỏi phạm vi: Sau khi hàmdangletrả về, biếnsrời khỏi phạm vi. Theo quy tắc sở hữu của Rust, bộ nhớ chosđược tự động giải phóng. Dữ liệu bộ nhớ mà&strỏ tới không còn lưu dữ liệu củas, nên tham chiếu trả về trỏ đến một địa chỉ bộ nhớ đã được giải phóng và trở thành tham chiếu lơ lửng.
Trình biên dịch của Rust sẽ phát hiện điều này và báo lỗi tại thời điểm biên dịch.
Quy tắc Tham chiếu
- Tại bất kỳ thời điểm nào, bạn chỉ có thể thỏa mãn một trong các điều kiện sau:
- Một tham chiếu biến đổi.
- Bất kỳ số lượng tham chiếu bất biến nào.
- Tham chiếu phải luôn hợp lệ.



