Bài 2 đã hé: một tham chiếu không được sống lâu hơn dữ liệu nó trỏ tới, và trả & tới biến cục bộ bị chặn. Lifetime là cơ chế đằng sau lời hứa đó — hệ thống Rust dùng để theo dõi mỗi tham chiếu được phép sống trong bao lâu, và đảm bảo không tham chiếu nào trỏ vào dữ liệu đã chết. Điều khiến lifetime vừa mạnh vừa khó chịu là: phần lớn thời gian Rust tự suy ra chúng, nhưng có những lúc nó không đủ thông tin và bắt bạn viết ra tường minh. Bài này đo cả hai mặt — các lỗi khi thiếu, và một sự thật an ủi: lifetime không tốn một byte nào lúc chạy. Tất cả trong container rust:1 (rustc 1.99.1).
Khi nào Rust cần bạn nói rõ: E0106
Hàm này trông hoàn toàn hợp lý — trả về chuỗi dài hơn trong hai chuỗi:
fn dai_hon(a: &str, b: &str) -> &str {
if a.len() >= b.len() { a } else { b }
}
Nhưng nó không biên dịch:
error[E0106]: missing lifetime specifier
= help: this function's return type contains a borrowed value, but the
signature does not say whether it is borrowed from `a` or `b`
help: consider introducing a named lifetime parameter
| fn dai_hon<'a>(a: &'a str, b: &'a str) -> &'a str {
Vấn đề không nằm ở thân hàm mà ở chữ ký. Người gọi dai_hon chỉ nhìn chữ ký, và chữ ký nói "tôi trả về một &str mượn" — nhưng mượn từ đâu? Từ a, hay từ b? Câu trả lời quyết định kết quả được phép sống bao lâu: nếu mượn từ b thì nó chết khi b chết. Trình biên dịch không đoán; nó bắt bạn khai một tham số lifetime 'a và gắn vào cả hai đầu vào lẫn đầu ra: <'a>(a: &'a str, b: &'a str) -> &'a str. Đọc là: "kết quả sống đúng bằng phần chung của tuổi thọ a và b" — tức bằng cái nào chết trước. Giờ chữ ký tự nó đủ nói lên ràng buộc, không cần nhìn vào thân hàm.
Chú thích đó có hiệu lực thật: E0597
Lifetime không phải thủ tục hình thức để làm vừa lòng trình biên dịch — nó thay đổi mã nào được chấp nhận. Với dai_hon<'a> đã chú thích, thử dùng kết quả lâu hơn một trong hai nguồn:
let a = String::from("cà phê dài");
let ket_qua;
{
let b = String::from("ngắn");
ket_qua = dai_hon(&a, &b); // kết quả mượn từ a HOẶC b
} // b bị drop ở đây
println!("{}", ket_qua); // nhưng ket_qua có thể trỏ b!
error[E0597]: `b` does not live long enough
8 | let b = String::from("ngắn");
| - binding `b` declared here
9 | ket_qua = dai_hon(&a, &b);
| ^^ borrowed value does not live long enough
11 | } - `b` dropped here while still borrowed
12 | println!("{}", ket_qua); ------- borrow later used here
Trình biên dịch lý luận đúng như chú thích 'a dạy nó: ket_qua có thể mượn từ b (nó không biết nhánh if nào chạy — mà cũng không cần biết), nên ket_qua không được sống lâu hơn b. Khi b bị drop lúc đóng khối mà ket_qua còn dùng ở ngoài, đó là một dangling pointer sắp hình thành — và E0597 chặn lại. Để ý: nếu bỏ dòng cuối đi, hoặc chuyển b ra ngoài khối, mã biên dịch ngay. Lifetime không cấm đoán vô cớ; nó chỉ cấm đúng những cách dùng tạo ra tham chiếu treo.

Hình 1: E0106 khi chữ ký không nói kết quả mượn từ đâu; E0597 khi dùng kết quả lâu hơn nguồn; và lifetime bị xoá sau biên dịch nên không tốn byte lúc chạy.
Struct giữ tham chiếu cũng cần lifetime
Quy tắc trải sang struct: nếu một struct giữ một tham chiếu, nó phải khai lifetime để nói "tôi không được sống lâu hơn thứ tôi mượn":
struct TrichDan<'a> { nguon: &'a str }
Thiếu <'a> thì cũng E0106. Lý do giống hệt: một TrichDan ôm một &str, nếu nó sống lâu hơn chuỗi gốc thì nguon thành con trỏ treo. Tham số <'a> buộc mọi TrichDan phải nằm gọn trong tuổi thọ của dữ liệu nó trỏ tới — trình biên dịch kiểm tra điều đó tại mọi nơi bạn tạo một TrichDan.
Phần an ủi: lifetime tốn 0 byte lúc chạy
Sau bao lỗi biên dịch, đây là tin tốt và cũng là điều cốt lõi nhất của bài: lifetime hoàn toàn biến mất sau khi biên dịch. Nó là một công cụ kiểm tra tĩnh, không phải dữ liệu lúc chạy. Đo kích thước:
size_of &str = 16 byte (ptr + len, KHÔNG có cờ lifetime)
size_of TrichDan<'a> = 16 byte (đúng bằng &str — lifetime bị xoá)
=> lifetime: 0 byte, 0 lệnh, 0 chi phí lúc chạy
TrichDan<'a> ôm một &str và chiếm đúng 16 byte — bằng &str trần. Không có một bit nào, một "thẻ thế hệ" nào, một bộ đếm nào để biểu diễn 'a trong bộ nhớ lúc chạy. Khác hẳn bộ gom rác (phải theo dõi đối tượng lúc chạy) hay con trỏ thông minh Rc ở bài 5 (có bộ đếm thật tốn byte và thời gian), lifetime là thuần tuý lúc biên dịch: trình biên dịch dùng nó để chứng minh mã an toàn, rồi quăng đi. Đây chính là tinh thần xuyên suốt sê-ri — Rust trả cái giá an toàn ở thời điểm biên dịch, để lúc chạy không còn gì phải trả.

Hình 2: Số đo thật — E0106 thiếu chú thích, E0597 nguồn không sống đủ lâu, và chứng cứ zero-cost: TrichDan<''a> đúng 16 byte bằng &str trần.
Khi nào bạn không phải viết lifetime
Nếu phải chú thích mọi tham chiếu thì Rust đã không dùng được. Thực tế, phần lớn thời gian trình biên dịch tự suy qua ba quy tắc lược bỏ lifetime (elision): mỗi tham chiếu đầu vào được gán một lifetime riêng; nếu chỉ có một đầu vào tham chiếu, lifetime của nó gán luôn cho đầu ra; và nếu có &self, lifetime của self gán cho đầu ra. Nhờ đó một hàm như fn dau(s: &str) -> &str không cần chú thích gì — chỉ một nguồn, không có mơ hồ. dai_hon cần chú thích đúng vì nó có hai nguồn tham chiếu, phá vỡ quy tắc thứ hai. Nói cách khác: bạn chỉ phải viết lifetime ở đúng những chỗ trình biên dịch thật sự không thể đoán — và lời khuyên của nó (help:) thường chính là lời giải.
Gói lại
Lifetime là phần của Rust khiến người mới nản nhất, nhưng cơ chế của nó rõ ràng và đo được: E0106 xuất hiện khi chữ ký không đủ nói kết quả mượn từ đâu, và trình biên dịch chỉ bạn cách thêm 'a; E0597 xuất hiện khi một cách dùng cụ thể tạo ra tham chiếu treo, đúng ca mà lifetime sinh ra để chặn. Phần thưởng cho mọi rắc rối đó: lifetime không tốn một byte hay một lệnh nào lúc chạy — TrichDan<'a> đúng 16 byte, bằng tham chiếu trần. An toàn tham chiếu, với chi phí lúc chạy bằng không. Bài sau sang một chủ đề mà chi phí không bằng không và ta đo được từng nano giây: xử lý lỗi — Result so với panic, và cái giá của việc tháo cuộn ngăn xếp.