Phần lớn những gì làm người quen C++ hay Java ngạc nhiên về Rust bắt nguồn từ đúng một ý tưởng: mỗi giá trị có đúng một chủ, và khi chủ hết phạm vi thì giá trị bị dọn ngay tại đó. Không có bộ gom rác chạy nền, cũng không có lời gọi free thủ công dễ quên. Trình biên dịch theo dõi ai đang sở hữu cái gì, và từ chối biên dịch nếu bạn dùng một giá trị đã trao đi. Sê-ri này mổ xẻ Rust bằng cách đo thật từng cơ chế trong container rust:1 (rustc 1.99.1) — và điều đặc biệt ở Rust là nhiều phép đo quan trọng nhất không phải con số thời gian mà là lỗi biên dịch: bằng chứng cứng rằng một lớp bug không thể lọt qua. Bài mở đầu đặt nền: ownership, move, và drop.

Ownership: mỗi giá trị có đúng một chủ

Quy tắc gốc chỉ có ba dòng. Mỗi giá trị trong Rust có một biến là chủ của nó. Tại mỗi thời điểm chỉ có đúng một chủ. Khi chủ ra khỏi phạm vi, giá trị bị drop — bộ nhớ và mọi tài nguyên nó giữ (heap, file, socket) được trả lại ngay lập tức. Nghe đơn giản, nhưng chính ba dòng này thay thế cho cả một bộ gom rác: thời điểm giải phóng không cần suy đoán lúc chạy, nó được chốt ngay khi biên dịch từ cấu trúc phạm vi của mã.

Điểm mấu chốt là chuyện gì xảy ra khi bạn gán một giá trị cho biến khác, hoặc truyền nó vào hàm. Với kiểu nằm trên heap như String, Rust không sao chép dữ liệu heap — làm vậy thì mỗi phép gán có thể là một cú cấp phát đắt đỏ ngầm. Thay vào đó nó chuyển quyền sở hữu: biến cũ không còn là chủ nữa, và trình biên dịch cấm bạn dùng lại nó.

Move: dùng lại giá trị đã trao đi là lỗi biên dịch

Đây là đoạn mã tưởng như vô hại trong hầu hết ngôn ngữ khác:

fn main() {
    let s = String::from("cà phê");
    let t = s;            // move: quyền sở hữu chuyển sang t
    println!("{}", s);    // dùng lại s sau khi đã move
}

Trong Java hay Go, s và t chỉ là hai tham chiếu cùng trỏ một đối tượng, cả hai dùng tiếp đều được. Trong Rust, let t = s move quyền sở hữu chuỗi từ s sang t; s không còn là chủ hợp lệ. Thử biên dịch:

error[E0382]: borrow of moved value: `s`
 --> move.rs:4:20
  |
2 |     let s = String::from("cà phê");
  |         - move occurs because `s` has type `String`, which does not implement the `Copy` trait
3 |     let t = s;
  |             - value moved here
4 |     println!("{}", s);
  |                    ^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let t = s.clone();
  |              ++++++++

Chương trình không chạy được — nó không tồn tại dưới dạng nhị phân để mà chạy sai. Đây chính là phép "đo" đặc trưng của Rust: thay vì một lỗi lúc chạy (use-after-free, double-free) nổ ra ở production lúc 2 giờ sáng, ta có một dòng error[E0382] ngay trên máy lập trình viên. Và để ý lời khuyên cuối: trình biên dịch nói thẳng nếu bạn thật sự cần hai bản thì gọi .clone(), và nhắc rằng nó có chi phí — Rust bắt cái giá của việc nhân bản heap phải hiện ra tường minh trong mã, không bao giờ ngầm.

Move rẻ: chỉ 3 word trên stack, heap đứng yên

Nếu move không sao chép heap thì nó sao chép cái gì? Một String trên stack chỉ là ba word: con trỏ tới vùng heap, độ dài, và sức chứa. Move sao chép đúng ba word đó rồi đánh dấu biến cũ là "đã chuyển". Dữ liệu ký tự trên heap không hề bị đụng tới — ta chứng minh bằng cách in địa chỉ con trỏ heap trước và sau move:

let s = String::from("một chuỗi khá dài để chắc chắn nằm trên heap");
println!("trước move: {:p}", s.as_ptr());
let t = s;                       // move
println!("sau  move: {:p}", t.as_ptr());
trước move: con trỏ heap = 0xaaaaef997d60
sau  move: con trỏ heap = 0xaaaaef997d60

Cùng một địa chỉ. Move là một thao tác O(1) bất kể chuỗi dài bao nhiêu — nó chỉ chuyển ba word mô tả, còn khối byte trên heap vẫn nằm nguyên chỗ cũ với một chủ mới. Đây là lý do move vừa an toàn vừa rẻ: không có bản sao heap ngầm, và vì biến cũ bị cấm dùng lại nên không bao giờ có hai chủ cùng tưởng mình được giải phóng cùng một vùng.

Kiểu Copy: số nguyên thì khác

Không phải mọi phép gán đều là move. Những kiểu nhỏ, nằm trọn trên stack và không giữ tài nguyên nào — i32, bool, char, f64 — cài đặc tính Copy: gán chúng là sao chép bit, và bản gốc vẫn sống:

let x = 42i32;
let y = x;                 // i32 là Copy: sao chép bit
println!("x={} y={}", x, y);   // cả hai còn dùng được
x=42 y=42 (cả hai còn sống)
size_of String = 24 byte (ptr+len+cap = 3 word)
size_of &str   = 16 byte (ptr+len = 2 word)
size_of i32    = 4 byte

Ranh giới rất rõ: giá trị có "đuôi" trên heap (như String) thì gán là move; giá trị gọn trên stack không giữ tài nguyên gì thì là Copy. Con số size_of xác nhận một String đúng 24 byte trên stack (ba word 8 byte) — và đó chính xác là thứ được chuyển khi move, không hơn.

Ảnh chụp sơ đồ nền tối cơ chế ownership và move trong Rust, bên trái một biến s là chủ của một String phần trên stack gồm ba word con trỏ pointer độ dài len sức chứa cap mũi tên trỏ xuống vùng heap chứa các byte cà phê, ở giữa phép let t bằng s là MOVE ba word trên stack được sao chép sang t còn biến s bị gạch chéo đánh dấu đã chuyển không còn là chủ hợp lệ vùng heap KHÔNG bị sao chép con trỏ vẫn cùng một địa chỉ 0xaaaa7d60 thao tác O của 1 bất kể chuỗi dài bao nhiêu, bên phải tương phản i32 là kiểu Copy let y bằng x sao chép bit cả x và y cùng sống vì không có đuôi trên heap, phía dưới ba hệ quả dùng lại s sau move cho lỗi biên dịch E0382 borrow of moved value chương trình không chạy nổi, muốn hai bản thật phải gọi clone chi phí hiện tường minh, size_of String bằng 24 byte đúng ba word được chuyển khi move

Hình 1: Move chuyển ba word mô tả trên stack sang chủ mới và vô hiệu hóa chủ cũ; heap không bị sao chép. Kiểu Copy như i32 thì nhân bản bit và giữ cả hai bản.

Drop: dọn dẹp xác định, ngược thứ tự khai báo

Vế còn lại của ownership là chuyện gì xảy ra khi chủ hết phạm vi. Rust chèn lời gọi drop tự động tại đúng điểm đóng phạm vi, và thứ tự là ngược với thứ tự khai báo — biến khai sau bị dọn trước, như tháo một chồng đĩa. Ta cài đặc tính Drop để in ra mỗi lần dọn:

struct Resource(&'static str);
impl Drop for Resource {
    fn drop(&mut self) { println!("drop {}", self.0); }
}
fn main() {
    let _a = Resource("a (khai trước)");
    let _b = Resource("b (khai sau)");
    {
        let _c = Resource("c (trong khối con)");
        println!("-- cuối khối con --");
    }
    println!("-- cuối main --");
}
-- cuối khối con --
drop c (trong khối con)
-- cuối main --
drop b (khai sau)
drop a (khai trước)

Đọc kỹ thứ tự này là thấy cả mô hình bộ nhớ của Rust. _c bị drop ngay khi đóng khối con, trước dòng "cuối main" — không phải chờ hết hàm, không phải chờ một chu kỳ gom rác nào đó trong tương lai. Rồi tại cuối main, _b (khai sau) drop trước _a (khai trước). Mọi thời điểm giải phóng đều xác định và nhìn thấy được ngay từ mã nguồn: bạn chỉ cần nhìn cấu trúc phạm vi là biết chính xác tài nguyên được trả lại lúc nào.

Đây là điều thay thế cho bộ gom rác. Với String, hàm drop do thư viện chuẩn cài sẽ gọi bộ cấp phát trả vùng heap về — đúng một lần, tại đúng một điểm, do đúng một chủ. Không có GC quét nền tốn CPU và gây khựng (pause), cũng không có free thủ công mà quên gọi thành rò rỉ hay gọi hai lần thành hỏng heap. Cùng một cơ chế này áp cho file (đóng khi drop), khóa mutex (nhả khi drop), kết nối (ngắt khi drop) — mẫu hình RAII, nhưng được trình biên dịch ép buộc chứ không trông vào kỷ luật của lập trình viên.

Ảnh chụp sơ đồ nền tối tổng hợp output thật đo trong container rust 1.99.1, khối một lỗi biên dịch dùng lại sau move error E0382 borrow of moved value s giá trị bị move tại dòng let t bằng s rồi borrow lại ở println chương trình không tạo được nhị phân, khối hai move rẻ con trỏ heap trước move 0xaaaaef997d60 sau move 0xaaaaef997d60 cùng một địa chỉ chỉ ba word trên stack được chuyển thao tác O của 1, khối ba kiểu Copy x bằng 42 y bằng 42 cả hai còn sống size_of String 24 byte size_of tham chiếu str 16 byte size_of i32 4 byte, khối bốn thứ tự drop in ra cuối khối con rồi drop c trong khối con rồi cuối main rồi drop b khai sau rồi drop a khai trước chứng tỏ dọn dẹp xác định ngược thứ tự khai báo ngay tại cuối phạm vi không chờ bộ gom rác

Hình 2: Bốn phép đo thật trong container rust 1.99.1 — lỗi biên dịch E0382, con trỏ heap bất biến qua move, kích thước kiểu và tính Copy, và thứ tự drop xác định.

Vì sao điều này quan trọng

Gộp lại, ba cơ chế — ownership, move, drop — cho Rust một thứ hiếm: an toàn bộ nhớ mà không cần bộ gom rác. Không có runtime theo dõi đối tượng, không có pause lúc chạy, không có chi phí ẩn; thời điểm cấp phát và giải phóng được chốt tại lúc biên dịch từ cấu trúc sở hữu. Đổi lại, ta phải nói cho trình biên dịch biết rõ ai sở hữu cái gì — và đôi khi nó từ chối mã trông có vẻ đúng. Bài sau sẽ gỡ đúng tình huống đó: khi bạn muốn mượn một giá trị mà không lấy quyền sở hữu, và borrow checker áp luật "chia sẻ thì bất biến, thay đổi thì độc quyền" — lại một lần nữa, đo bằng chính những lỗi biên dịch mà nó sinh ra.

Phần sau