Phần trước đóng lại với một câu hỏi để ngỏ: nếu mỗi giá trị có đúng một chủ và gán là move, thì làm sao viết một hàm chỉ muốn đọc một String mà không nuốt luôn quyền sở hữu của nó? Câu trả lời là mượn (borrow): tạo một tham chiếu tới giá trị thay vì chuyển quyền sở hữu. Nhưng mượn mở ra đúng cái cửa mà ownership vừa đóng — khả năng có nhiều đường cùng chạm tới một dữ liệu — nên Rust gác cửa đó bằng borrow checker, và nó áp đúng một luật. Bài này đo luật ấy bằng chính những lỗi biên dịch nó sinh ra, trong container rust:1 (rustc 1.99.1).
Một luật: chia sẻ thì bất biến, thay đổi thì độc quyền
Toàn bộ borrow checker gói trong một câu, thường gọi là aliasing XOR mutability: tại mỗi thời điểm, với một giá trị, bạn được chọn một trong hai — hoặc nhiều tham chiếu chia sẻ (&T, chỉ đọc), hoặc đúng một tham chiếu độc quyền (&mut T, đọc-ghi) — không bao giờ cả hai cùng lúc. Lý do rất vật lý: nếu một đường đang ghi vào dữ liệu trong khi đường khác đang đọc hoặc cũng ghi, bạn có đua tranh (data race) trong đa luồng, và iterator invalidation hay use-after-free trong đơn luồng. Cấm aliasing đi đôi với mutability là cắt tận gốc cả một họ bug.
Nhiều tham chiếu chia sẻ cùng lúc thì hoàn toàn hợp lệ, vì không ai ghi:
let mut v = vec![1, 2, 3];
let r1 = &v; // mượn chia sẻ 1
let r2 = &v; // mượn chia sẻ 2 — OK
println!("{:?} {:?}", r1, r2);
v.push(4); // &mut sau khi r1, r2 đã hết dùng — cũng OK
[1, 2, 3] [1, 2, 3]
[1, 2, 3, 4]
Để ý v.push(4) ở cuối vẫn biên dịch được, dù phía trên có hai mượn chia sẻ. Đó là nhờ non-lexical lifetime (NLL): borrow checker tính tuổi thọ của một mượn tới lần dùng cuối cùng, không phải tới hết khối {}. r1 và r2 dùng xong ở dòng println!, nên tới push chúng đã "chết", và &mut được cấp. Luật không hề máy móc theo dấu ngoặc — nó theo dòng chảy thật của mã.
&mut đụng một & đang sống: E0502
Đổi một chi tiết: giữ cho mượn chia sẻ còn sống qua điểm cần &mut. Giờ hai thứ chồng lên nhau trong thời gian, và luật bị vi phạm:
let mut v = vec![1, 2, 3];
let first = &v[0]; // mượn chia sẻ, CÒN dùng ở dòng cuối
v.push(4); // cần &mut v — xung đột
println!("phần tử đầu = {}", first);
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
3 | let first = &v[0];
| - immutable borrow occurs here
4 | v.push(4);
| ^^^^^^^^^ mutable borrow occurs here
5 | println!("phần tử đầu = {}", first);
| ----- immutable borrow later used here
Đây không phải sự khó tính vô cớ. v.push(4) có thể làm Vec cấp phát lại vùng nhớ (khi vượt sức chứa — xem phần nói về Vec), và nếu thế thì first, vốn trỏ vào vùng cũ, sẽ thành con trỏ treo. Trong C++ đây đúng là use-after-free lúc chạy, âm thầm đọc bộ nhớ đã giải phóng. Rust biến nó thành ba dòng error[E0502] trên máy lập trình viên: nó thấy first còn được dùng ở dòng 5, nên từ chối cấp &mut ở dòng 4.
Hai &mut cùng lúc: E0499
Luật cũng cấm hai tham chiếu độc quyền chồng nhau — vì "độc quyền" mà có hai thì đâu còn độc quyền:
let a = &mut v;
let b = &mut v; // &mut thứ hai khi &mut thứ nhất còn sống
a.push(10);
error[E0499]: cannot borrow `v` as mutable more than once at a time
3 | let a = &mut v;
| ------ first mutable borrow occurs here
4 | let b = &mut v;
| ^^^^^^ second mutable borrow occurs here
5 | a.push(10);
| - first borrow later used here
Chính vì &mut được bảo đảm độc quyền, trình biên dịch (và CPU) có thể suy luận mạnh hơn về mã: không đường nào khác đổi dữ liệu sau lưng, nên nhiều tối ưu trở nên an toàn. Độc quyền không chỉ để an toàn, nó còn mở đường cho hiệu năng.

Hình 1: Luật aliasing XOR mutability — nhiều & chia sẻ thì được, một &mut thì phải độc quyền — và ba lỗi biên dịch khi vi phạm.
Sửa vec giữa vòng lặp: iterator invalidation bị chặn
Một hệ quả nổi tiếng của luật trên: bạn không thể sửa một tập hợp trong khi đang duyệt nó. Vòng for x in &v mượn chia sẻ v suốt thân vòng, nên mọi &mut bên trong đều xung đột:
let mut v = vec![1, 2, 3];
for x in &v { // vòng lặp mượn chia sẻ &v suốt thân vòng
if *x == 2 {
v.push(99); // sửa v giữa lúc đang duyệt
}
}
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
3 | for x in &v {
| -- immutable borrow occurs here / later used here
5 | v.push(99);
| ^^^^^^^^^^ mutable borrow occurs here
Trong C++ hay Java, sửa một container đang được duyệt là lỗi kinh điển: std::vector có thể cấp phát lại và làm hỏng iterator, còn Java ném ConcurrentModificationException lúc chạy. Rust phát hiện ngay lúc biên dịch, không cần chạy tới dòng đó. Muốn sửa khi duyệt thì phải viết tường minh một cách an toàn (thu thập chỉ số rồi sửa sau, hoặc dùng retain, drain) — trình biên dịch buộc bạn chọn một con đường không gãy.
Trả tham chiếu tới biến cục bộ: E0515
Luật cuối gắn mượn với ownership của bài trước: một tham chiếu không được sống lâu hơn giá trị nó trỏ tới. Thử trả về một tham chiếu tới biến cục bộ — biến sẽ bị drop khi hàm kết thúc:
fn treo() -> &'static str {
let s = String::from("cục bộ");
&s // s bị drop khi hàm kết thúc
}
error[E0515]: cannot return reference to local variable `s`
3 | &s
| ^^ returns a reference to data owned by the current function
Đây là dangling pointer — nguồn gốc của vô số lỗ hổng bảo mật trong C — bị chặn thẳng. s thuộc về treo; khi hàm trả về, s bị drop và vùng heap của nó được giải phóng, nên một tham chiếu tới nó sẽ trỏ vào bộ nhớ đã chết. Borrow checker biết &s không thể sống tới sau khi hàm kết thúc, nên từ chối. Chuyện "tham chiếu sống được bao lâu" chính là chủ đề lifetime mà một bài sau sẽ đo kỹ.
Mượn để làm việc, không cần sở hữu
Đừng quên vế tích cực: toàn bộ cơ chế này tồn tại để bạn dùng giá trị mà không nuốt nó. Một hàm nhận &String chỉ mượn để đọc, gọi bao nhiêu lần cũng được, và giá trị vẫn thuộc về nơi gọi:
fn do_dai(s: &String) -> usize { s.len() } // chỉ mượn
let s = String::from("cà phê và code");
let n1 = do_dai(&s); // mượn lần 1
let n2 = do_dai(&s); // mượn lần 2 — s vẫn thuộc main
println!("len={} {} — s vẫn dùng được: {}", n1, n2, s);
len=17 17 — s vẫn dùng được: cà phê và code
Không có .clone(), không có move — chỉ truyền một con trỏ, và s còn nguyên sau cả hai lời gọi. (Con số len=17 chứ không phải 14 vì .len() đếm byte UTF-8: ba ký tự có dấu à, ê, à mỗi cái chiếm 2 byte.) Đây là cách mã Rust đời thực chuyển dữ liệu quanh các hàm: mượn là mặc định, move chỉ khi thật sự muốn trao quyền sở hữu, và clone chỉ khi thật sự cần một bản thứ hai.

Hình 2: Bốn lỗi biên dịch đo thật trong rust 1.99.1 — E0502, E0499, E0502 (iterator invalidation), E0515 — và vế tích cực: mượn để đọc, giá trị vẫn thuộc nơi gọi.
Gói lại
Borrow checker không phải một danh sách điều cấm rời rạc; nó là một luật — aliasing XOR mutability — soi chiếu qua nhiều tình huống. E0502, E0499, E0515 chỉ là cùng nguyên tắc đó bắt gặp ở các hình dạng khác nhau: đừng để một đường ghi trong khi đường khác còn chạm, và đừng để tham chiếu sống lâu hơn dữ liệu. Nhờ non-lexical lifetime, luật ấy bám theo dòng chảy thật của mã chứ không máy móc theo dấu ngoặc, nên nó chặt mà vẫn dùng được. Cái giá: đôi khi bạn phải tổ chức lại mã cho rõ ai đọc, ai ghi, lúc nào. Cái được, như bài mở đầu đã nói, là cả một họ bug bộ nhớ biến mất khỏi lúc chạy — thành vài dòng lỗi lúc biên dịch.