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.

Ảnh chụp sơ đồ nền tối luật aliasing XOR mutability của borrow checker Rust, bên trái cột HỢP LỆ nhiều tham chiếu chia sẻ ký hiệu và ampersand T cùng trỏ vào một giá trị tất cả chỉ đọc không ai ghi nên an toàn ví dụ r1 bằng và v r2 bằng và v cùng lúc được, ở giữa luật trung tâm tại mỗi thời điểm với một giá trị chọn MỘT trong hai hoặc nhiều và T chia sẻ bất biến hoặc đúng một và mut T độc quyền đọc ghi không bao giờ cả hai cùng lúc, bên phải cột VI PHẠM một và mut T độc quyền đang ghi mà vẫn còn một và T khác trỏ vào thì sinh đua tranh dữ liệu hoặc con trỏ treo nên bị chặn, phía dưới ba lỗi biên dịch cụ thể E0502 và mut đụng một và đang sống, E0499 hai và mut cùng lúc, E0515 trả tham chiếu tới biến cục bộ, và ghi chú non-lexical lifetime tuổi thọ mượn tính tới lần dùng cuối cùng chứ không tới hết khối nên push sau khi tham chiếu hết dùng vẫn biên dịch được

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.

Ảnh chụp sơ đồ nền tối tổng hợp output thật các lỗi biên dịch borrow checker trong rust 1.99.1, khối E0502 và mut đụng và đang sống dòng 3 let first bằng và v 0 mượn bất biến dòng 4 v push 4 mượn khả biến dòng 5 println dùng lại first nên mượn bất biến còn sống, khối E0499 hai và mut cùng lúc dòng 3 let a bằng và mut v mượn khả biến thứ nhất dòng 4 let b bằng và mut v mượn khả biến thứ hai, khối E0502 sửa vec khi đang duyệt iterator invalidation dòng 3 for x in và v dòng 5 v push 99 kèm so sánh C plus plus hỏng lúc chạy Java ConcurrentModificationException Rust chặn lúc biên dịch, khối E0515 trả tham chiếu tới biến cục bộ dangling returns a reference to data owned by the current function, và vế tích cực fn do_dai nhận và String chỉ mượn in ra len bằng 17 17 s vẫn dùng được cà phê và code len 17 vì len đếm byte UTF-8

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.

Phần sau