Danh sách bài viết

Bài 176: Lifetime Là Gì — Reference Không Được Dangling

Bài 176 của series Rust Cơ Bản — bài đầu Nhóm 23 Lifetimes. Bản chất lifetime đơn giản hơn vẻ ngoài: đó là scope mà một reference còn valid, được compiler track tại compile time để đảm bảo không có dangling reference (reference trỏ vào memory đã free). Lifetime là khái niệm compile-time hoàn toàn: binary cuối cùng không có byte nào liên quan đến lifetime, hiệu năng giống hệt C. Mỗi reference &T đều ngầm mang một lifetime; compiler infer trong đa số trường hợp nhờ lifetime elision rule, chỉ khi quan hệ phức tạp bạn mới phải annotate bằng 'a (lowercase + tick). Bài dựng nền vững: khái niệm lifetime, lý do tồn tại, mỗi reference đều có lifetime, borrow checker chính là lifetime analyzer (NLL từ Rust 2018), syntax 'a, lỗi E0106 missing lifetime specifier, phân biệt lifetime vs scope, và quan hệ với ownership — Vec<T> không có lifetime nhưng &Vec<T> thì có.

09/06/2026
12 phút đọc
2 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Định nghĩa được lifetime trong Rust: scope mà một reference vẫn còn valid; compiler dùng thông tin lifetime để đảm bảo reference không bao giờ trỏ vào memory đã free.
  • Hiểu lifetime là khái niệm compile time thuần tuý — không có runtime overhead, không vtable, không thẻ tag chạy theo từng reference.
  • Mô tả được dangling reference và lý do Rust cần lifetime để chặn từ compile time (ôn lại Bài 71 - Dangling Reference).
  • Nhận ra mọi &T trong code đều ngầm mang lifetime; compiler infer trong đa số trường hợp, chỉ đôi khi cần annotate explicit.
  • Hình dung được borrow checker chính là lifetime analyzer: từ Rust 2018 dùng Non-Lexical Lifetime (NLL) — phân tích last-use thay vì cuối block.
  • Đọc và viết được annotation 'a: lowercase + tick, khai báo trong <'a>, gắn vào reference type thành &'a T.
  • Biết lỗi E0106 missing lifetime specifier nghĩa là gì và khi nào xuất hiện.
  • Phân biệt lifetime với scope: scope theo block { }, lifetime theo last-use NLL hoặc annotation cụ thể.
  • Hiểu quan hệ giữa lifetime và ownership: lifetime chỉ apply cho reference; Vec<T> không có lifetime, &Vec<T> có.

Đây là bài mở Nhóm 23 — không có code function annotate phức tạp; mọi cú pháp cụ thể (function signature, struct với lifetime, multiple lifetime) sẽ trình bày từ Bài 177 trở đi. Nhiệm vụ Bài 176 là dựng đúng mental model.

2

Lifetime Là Gì

Lifetime là scope (vùng code) mà một reference còn valid. Reference r có lifetime L nghĩa là: trong toàn bộ vùng L đó, value mà r trỏ tới vẫn còn tồn tại trong bộ nhớ. Khi r được dùng tại bất kỳ điểm nào trong L, dereference luôn ra value hợp lệ.

Compiler track lifetime cho mọi reference trong chương trình. Quy tắc cốt lõi: reference không được outlive value mà nó trỏ tới. Nói cách khác, lifetime của reference phải nằm bên trong lifetime của value gốc. Nếu compiler phát hiện một đường code nào đó có thể dùng reference sau khi value đã bị drop, code bị reject ngay tại compile time — chương trình không build ra được.

Quan trọng: lifetime là khái niệm compile time hoàn toàn. Sau khi compile xong, binary không còn dấu vết của lifetime: không có metadata gắn theo từng pointer, không có check runtime, không có vtable lifetime. Reference Rust ở runtime chỉ là một pointer thuần — y hệt C. Mọi chứng minh "không dangling" đã làm xong tại compile time, runtime chỉ chạy.

Đây cũng là điểm khác biệt then chốt với garbage collector. GC track tuổi thọ object tại runtime (mark-and-sweep, reference counting...), tốn CPU và memory. Rust track tuổi thọ tại compile time — runtime zero cost, đổi lại lập trình viên phải viết code mà compiler chứng minh được. Đây là lý do Rust được mô tả là "memory safe without garbage collector".

3

Tại Sao Cần Lifetime — Dangling Reference

Bài 71 đã giới thiệu dangling reference: reference trỏ vào memory đã được free (deallocated). Đọc vào memory đó cho ra giá trị rác, ghi vào có thể corrupt heap hoặc bị OS kill bằng segfault. Trong C/C++, dangling pointer là một trong những nguyên nhân hàng đầu gây bug bảo mật — use-after-free CVE xuất hiện liên tục trong browser, kernel, OS image suốt nhiều thập kỷ.

Rust quyết định chặn dangling từ compile time. Để chặn được, compiler cần biết: với mỗi reference, value gốc còn sống đến khi nào? Đó chính là lifetime. Code dưới đây minh hoạ một dangling reference điển hình:

fn main() {
    let r;                  // declare reference (chưa init).
    {
        let x = 5;          // x: i32 trên stack, scope trong {}.
        r = &x;             // r mượn x.
    }                       // x ra khỏi scope, bị drop.
    println!("{}", r);      // dùng r — ref tới memory đã free!
}

Lifetime của x chỉ kéo dài đến cuối block { ... } bên trong. Lifetime của r kéo dài đến cuối main. Khi println! dùng r, x đã bị drop từ lâu — đây là dangling. Compiler Rust reject:

error[E0597]: `x` does not live long enough
 --> src/main.rs:5:13
  |
5 |         r = &x;
  |             ^^ borrowed value does not live long enough
6 |     }
  |     - `x` dropped here while still borrowed
7 |     println!("{}", r);
  |                    - borrow later used here

Trong C tương đương, code sẽ compile và chạy — đôi khi in ra 5 (vì memory chưa kịp ghi đè), đôi khi in rác, đôi khi crash. Rust không cho phép sự bất định đó: hoặc code đúng tuyệt đối, hoặc không build.

4

Mỗi Reference Có Lifetime

Một sự thật ít người mới Rust để ý: mọi reference trong chương trình đều có lifetime. &i32 không phải "reference không lifetime" — nó là &'_ i32, với '_ là lifetime compiler tự suy. Cú pháp ngắn gọn vì 99% trường hợp infer được, không cần gõ ra.

Compiler dùng lifetime elision rule (sẽ chi tiết ở Bài 180) để điền lifetime ngầm. Ba quy tắc đơn giản giúp nhiều function viết được mà không cần annotate:

  • Mỗi tham số reference của function nhận một lifetime riêng.
  • Nếu có đúng một tham số reference input, lifetime đó cũng là lifetime của return value reference.
  • Nếu có &self hoặc &mut self, lifetime của self là lifetime mặc định cho output reference.

Khi ba quy tắc trên đủ phủ, bạn viết code thoải mái không thấy 'a. Khi compiler vẫn còn ambiguity sau khi áp đủ rule, nó dừng lại và yêu cầu lập trình viên annotate manually — đó là lúc bạn gặp E0106 (sẽ nói ở Bước 7). Quan trọng cần nhớ: annotate hay không annotate, lifetime vẫn luôn tồn tại — chỉ là ai phải gõ.

5

Borrow Checker Là Lifetime Analyzer (NLL)

Component compiler kiểm tra lifetime gọi là borrow checker. Trước Rust 2018, borrow checker dùng lexical lifetime: lifetime của reference kéo dài đến cuối block { } chứa nó. Cách này đơn giản nhưng cứng — đoạn code rõ ràng đúng vẫn bị reject vì block bao quanh quá lớn.

Từ Rust 2018 (edition 2018), borrow checker chuyển sang Non-Lexical Lifetime (NLL): lifetime chỉ kéo dài đến last use của reference, không phụ thuộc dấu }. Đây là một trong những cải tiến UX lớn nhất của Rust. Ví dụ:

fn main() {
    let mut v = vec![1, 2, 3];

    let first = &v[0];       // immutable borrow.
    println!("{}", first);   // last use của first ở đây.

    v.push(4);               // mutable borrow của v — OK với NLL!
}                            // Trước NLL: bị reject vì first còn "lifetime" tới đây.

Với lexical lifetime, lifetime của first kéo đến hết block main — trùng với mutable borrow v.push(4) → reject. Với NLL, lifetime của first chỉ kéo đến dòng println!; sau đó v tự do, push hợp lệ. Idiomatic Rust phụ thuộc nặng vào NLL — đa số code bạn đọc sẽ không build nổi nếu compiler vẫn dùng lexical lifetime cũ.

Hiểu borrow checker = lifetime analyzer giúp giải mã error message: mọi thông báo cannot borrow, does not live long enough, borrow may still be in use... đều là compiler chỉ ra rằng phân tích lifetime tìm thấy mâu thuẫn — đâu đó có reference outlive value, hoặc hai borrow đụng nhau trên cùng một lifetime overlap.

6

Annotation 'a — Cú Pháp Cơ Bản

Khi cần annotate manually, bạn dùng cú pháp lifetime parameter. Tên lifetime bắt đầu bằng dấu tick (apostrophe) rồi đến chữ thường, ngắn nhất là một ký tự — quy ước phổ biến: 'a, 'b, 'c... Lifetime parameter khai báo trong cặp <> sau tên function hoặc struct, rồi tham chiếu trong type:

// Function nhận hai reference cùng lifetime 'a, trả reference cùng 'a.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

// Struct giữ reference: lifetime parameter bắt buộc.
struct Excerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first = novel.split('.').next().expect("no period");
    let e = Excerpt { part: first };
    println!("{}", e.part);
}

Ba điểm cú pháp cần nhớ:

  • Tick + lowercase: 'a đúng, 'A hay 'AaA được nhưng lệch convention.
  • Khai báo trong <>: fn foo<'a> hay struct S<'a> — giống cách khai báo generic type T.
  • Gắn vào reference type: viết giữa dấu & và type: &'a T, &'a mut T, &'a [u8].

Lifetime annotation không tạo ra lifetime mới — chỉ mô tả quan hệ giữa các lifetime đã tồn tại để compiler kiểm tra. Đây là điểm rất nhiều người mới hiểu sai và sẽ làm rõ ở Bài 177.

7

Compile Error E0106 — Missing Lifetime Specifier

Khi lifetime elision rule không đủ để suy lifetime của output reference, compiler dừng và yêu cầu lập trình viên annotate explicit. Lỗi này là error[E0106]: missing lifetime specifier. Function có hai reference input không có &self là một trigger điển hình:

// Không elide được: hai input ref, không biết output 'a là của x hay y.
fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}
error[E0106]: missing lifetime specifier
 --> src/main.rs:1:33
  |
1 | fn longest(x: &str, y: &str) -> &str {
  |               ----     ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the
          signature does not say whether it is borrowed from `x` or `y`

Compiler nói rõ vấn đề: signature không khai báo output reference mượn từ x hay từ y. Hai input có thể có lifetime khác nhau ở caller site; output là một trong hai, compiler không tự chọn được. Fix bằng cách thêm 'a manually:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

Annotation đọc là: "cho mọi lifetime 'a, function này nhận hai reference đều có lifetime ít nhất là 'a, và return reference cũng valid trong 'a". Caller truyền hai reference vào — compiler tìm 'a là phần giao của lifetime hai input, đảm bảo output cũng nằm trong giao đó. Chi tiết function signature sẽ làm kỹ ở Bài 178.

8

Lifetime Vs Scope

Hai khái niệm dễ lẫn: scopelifetime. Cả hai đều mô tả "vùng code", nhưng đo bằng cây thước khác nhau.

  • Scope: được định nghĩa bởi cú pháp — bắt đầu khi declare biến, kết thúc tại dấu } của block chứa nó. Scope của owned value trùng với scope biến; value bị drop khi ra khỏi scope. Đây là khái niệm cú pháp (lexical).
  • Lifetime của reference: từ Rust 2018 (NLL) được tính từ điểm tạo ref đến last use, không bắt buộc kéo tới cuối block. Đây là khái niệm luồng dữ liệu, mịn hơn scope.
fn main() {
    let s = String::from("hello"); // scope của s: đến cuối main.
    {
        let r = &s;                // tạo r.
        println!("{}", r);         // last use của r → lifetime của r kết thúc ở đây.
    }                              // scope của r kết thúc ở dấu } này (cú pháp).
    // Nếu cần, có thể mượn lại s tại đây mà không xung đột với r.
    let r2 = &s;
    println!("{}", r2);
}                                  // scope của s kết thúc → s bị drop.

Phân biệt này cũng giải thích vì sao annotation 'a trong function signature không nói về block — nó nói về "vùng valid" của reference được caller truyền vào, tính theo dataflow ở caller site. Hai caller khác nhau dùng cùng function có thể truyền reference với lifetime hoàn toàn khác — 'a mỗi lần gọi mỗi khác, miễn rằng thoả ràng buộc trong signature.

9

Liên Quan Với Ownership

Một nguyên tắc nền tảng: lifetime chỉ áp dụng cho reference, không áp dụng cho owned value. Vec<T>, String, Box<T>, i32 — đều là owned value, không có lifetime parameter. Type &Vec<T>, &String, &str — đều là reference, đều có lifetime (ngầm hoặc explicit).

let v: Vec<i32> = vec![1, 2, 3];   // Vec<i32> — KHÔNG có lifetime.
let r: &Vec<i32> = &v;            // &Vec<i32> — CÓ lifetime (compiler infer).
let s: String = String::from("x"); // String — KHÔNG có lifetime.
let t: &str = &s;                 // &str — CÓ lifetime.

Hệ quả thực tiễn: nếu struct chỉ chứa owned field, struct không cần lifetime parameter — viết struct User { name: String } là đủ. Nếu struct chứa reference field, struct bắt buộc phải có lifetime parameter — viết struct Parser<'a> { source: &'a str }. Compiler không cho phép giữ reference trong struct mà không chỉ ra lifetime, vì làm vậy thì không cách nào kiểm tra struct không outlive source.

Quan hệ với ownership: ownership trả lời "ai sở hữu memory và sẽ free khi nào"; lifetime trả lời "reference được phép sống trong vùng nào trước khi memory đó free". Cả hai cùng tạo nên hệ thống memory safety của Rust — bỏ một trong hai, chương trình không an toàn nữa. Khi viết code, bạn tự hỏi nếu owner drop, reference này có chết theo không? — nếu có, compiler sẽ kiểm chứng giúp; nếu cố lừa, lifetime checker bắt được.

10

Tổng Kết

  • Lifetime = scope mà reference còn valid; compiler đảm bảo reference không outlive value mà nó trỏ tới.
  • Khái niệm compile time hoàn toàn — không có runtime overhead, không có vtable lifetime.
  • Sinh ra để chặn dangling reference (use-after-free) ngay từ build, không đợi runtime crash.
  • Mọi &T đều ngầm mang lifetime; compiler infer bằng elision rule, đôi khi cần annotate manually.
  • Borrow checker chính là lifetime analyzer; Rust 2018 dùng Non-Lexical Lifetime (NLL): phân tích last-use thay vì cuối block.
  • Annotation cú pháp 'a: lowercase + tick, khai báo trong <>, gắn vào reference type thành &'a T.
  • error[E0106] missing lifetime specifier = compiler không elide được, yêu cầu annotate explicit.
  • Scope theo block { } (cú pháp); lifetime theo last-use NLL (luồng dữ liệu) — hai khái niệm khác nhau dù gần nhau.
  • Lifetime chỉ áp dụng cho reference; owned value (Vec<T>, String) không có lifetime, nhưng reference của chúng (&Vec<T>, &String) có.
11

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. Giải thích bằng lời tại sao lifetime là khái niệm compile time, không runtime. Nếu Rust track lifetime tại runtime thay thế, performance sẽ thay đổi thế nào và lý do tại sao đội Rust chọn compile time?
  2. Viết một đoạn code Rust 5–7 dòng cố ý tạo dangling reference. Chạy cargo build và copy error message. Trên cùng đoạn code, nếu viết trong C, output runtime là gì (rác? segfault? đúng?)
  3. Cho function fn first_word(s: &str) -> &str trả phần đến space đầu. Function này build pass mà không cần annotate 'a — giải thích bằng elision rule nào. Sau đó viết fn longer(a: &str, b: &str) -> &str: tại sao function này gặp E0106?
  4. Cho ví dụ NLL ở Bước 5 (immutable borrow first rồi v.push(4)). Sửa lại để cố ý vi phạm NLL: di chuyển println!("{}", first) xuống sau v.push(4). Compiler báo lỗi nào? Vì sao?
  5. Phân biệt: với let s = String::from("hi"); let r = &s; — scope của r theo cú pháp dài đến đâu? Lifetime của r theo NLL dài đến đâu? Hai vùng này khác nhau ở chỗ nào?
  6. Cho struct struct User { name: String }struct View<'a> { name: &'a str }. Vì sao User không cần lifetime parameter còn View bắt buộc phải có?
Đáp án
  1. Lifetime là quan hệ tĩnh giữa region trong code; compiler phân tích bằng cách đi dataflow trên AST/MIR, không cần thông tin runtime. Nếu track runtime, mỗi reference cần metadata (tag, refcount, hoặc đi qua GC) → tốn memory + CPU mỗi lần borrow → performance giảm nhiều lần. Rust chọn compile time để giữ "C-level performance + memory safety" — đánh đổi là người viết code phải thoả mãn được proof của compiler.
  2. Đoạn code: let r; { let x = 5; r = &x; } println!("{}", r); — Rust báo E0597: x does not live long enough. Cùng đoạn trong C compile pass; runtime hành vi undefined: có thể in 5 nếu stack chưa kịp ghi đè, có thể in rác, có thể crash. UB là vấn đề chính C/C++ trying để fix bao thập kỷ.
  3. first_word elide được nhờ rule 2: đúng một input reference → output ref nhận cùng lifetime. longer có hai input reference, không có &self → rule 3 không áp dụng → compiler không biết output gắn với a hay bE0106 missing lifetime specifier. Fix: fn longer<'a>(a: &'a str, b: &'a str) -> &'a str.
  4. Compiler báo error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable. Lý do: NLL kéo dài lifetime của first đến điểm dùng cuối cùng (println!), khoảng đó overlap với mutable borrow của v.push(4) — violation borrow rule. Sửa: dùng first trước, push sau, hoặc clone giá trị.
  5. Scope cú pháp của r: từ let r = &s; đến dấu } của block chứa nó (có thể đến cuối function). Lifetime NLL: từ let r = ... đến last-use của r — nếu chỉ dùng một lần ngay sau đó, lifetime kết thúc ở dòng đó. Phần còn lại của block tuy thuộc scope nhưng không còn trong lifetime của r — borrow checker không tính khoảng đó vào "đang borrow", cho phép mượn mới.
  6. User chỉ chứa owned field (String) — User tự sở hữu memory, drop là đủ → không cần lifetime parameter. View chứa reference field (&str) — phải cam kết View không outlive source string. Lifetime parameter 'a giữ chỗ cho cam kết đó: View<'a> chỉ valid trong lifetime 'a của source. Không có 'a, compiler không biết kiểm tra "View không sống lâu hơn source" thế nào → reject.
12

Bài Tiếp Theo

Bài 177: Lifetime Annotation Syntax: 'a, 'b — đi sâu vào cú pháp lifetime annotation: &'a str, &'a mut T, ý nghĩa "reference này valid ít nhất trong scope 'a", quy ước đặt tên ('a, 'b, 'static), và làm rõ một hiểu lầm phổ biến: annotation không tạo lifetime mới, chỉ mô tả quan hệ giữa các lifetime sẵn có để compiler kiểm tra ràng buộc.