blog.dopana

Back

Quản lý bộ nhớ an toàn mà không cần đến Garbage Collector (Bộ dọn rác) là điểm bán hàng lớn nhất của Rust. Để làm được điều này, trình biên dịch sử dụng một công cụ gọi là Borrow Checker (Bộ kiểm tra mượn) để đảm bảo rằng mọi tham chiếu (reference) luôn trỏ tới vùng nhớ hợp lệ.

Cơ chế chính mà borrow checker sử dụng để ngăn ngừa lỗi dangling references (tham chiếu lơ lửng - tham chiếu trỏ vào vùng nhớ đã bị giải phóng) chính là Lifetimes (Thời gian sống).

Trong bài viết này, chúng ta sẽ cùng mổ xẻ khái niệm lifetimes trong Rust, hiểu tại sao trình biên dịch lại cần chúng, cách chú thích (annotate) lifetime và các mô hình tư duy trực quan nhất.

Vấn Đề: Dangling References (Tham Chiếu Lơ Lửng)#

Lỗi dangling reference xảy ra khi chương trình truy cập vào một địa chỉ bộ nhớ đã bị thu hồi. Dưới đây là một ví dụ đơn giản bị Rust từ chối biên dịch:

fn main() {
    let r;

    {
        let x = 5;
        r = &x; // ❌ x bị giải phóng (drop) ở cuối khối lệnh này
    }

    println!("r: {r}"); // ❌ r trỏ vào vùng nhớ đã bị giải phóng!
}
rust

Nếu cố gắng biên dịch đoạn mã này, Rust sẽ báo lỗi: x does not live long enough (x sống không đủ lâu). Compiler so sánh phạm vi hoạt động (scope) của các biến để đảm bảo không tham chiếu nào sống lâu hơn chủ sở hữu của nó.

gantt
    title Phạm vi sống của x và r
    dateFormat  X
    axisFormat %s
    section Biến x
    Phạm vi sống x : active, 0, 2
    section Tham chiếu r
    Phạm vi sống r : active, 0, 4

Ở đây, r sống từ 0 đến 4, nhưng x chỉ sống từ 0 đến 2. Vì r sống lâu hơn x, tham chiếu trỏ tới x trở nên vô hiệu.

Lifetime Là Gì?#

Lifetime là phạm vi hoạt động mà một tham chiếu có hiệu lực. Hầu hết mọi lúc, lifetimes được trình biên dịch tự động suy luận một cách ngầm định. Tuy nhiên, khi mối quan hệ giữa các tham chiếu trong các hàm hoặc struct trở nên mơ hồ, chúng ta bắt buộc phải chú thích chúng một cách thủ công.

[!IMPORTANT] Chú thích lifetime không làm thay đổi thời gian sống thực tế của bất kỳ giá trị nào. Thay vào đó, chúng mô tả mối quan hệ giữa các thời gian sống của nhiều tham chiếu để chứng minh cho compiler thấy rằng không có hành vi truy cập bộ nhớ lỗi nào xảy ra.

Chú Thích Lifetime Generic Trong Hàm#

Hãy cùng xem xét một hàm trả về chuỗi có độ dài lớn hơn trong hai chuỗi slice:

// ❌ Đoạn mã này sẽ KHÔNG biên dịch được
fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
rust

Trình biên dịch từ chối vì nó không biết tham chiếu trả về trỏ đến x hay y. Nếu xy có thời gian sống khác nhau, tham chiếu trả về có thể sống lâu hơn một trong hai tham chiếu đầu vào, dẫn đến lỗi dangling pointer.

Để khắc phục, chúng ta khai báo một tham số lifetime generic:

// ✅ Biên dịch thành công!
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
rust
  • <'a> khai báo một tham số lifetime generic tên là 'a.
  • x: &'a stry: &'a str chỉ định rằng cả hai tham chiếu đầu vào phải sống ít nhất bằng thời gian sống 'a.
  • -> &'a str cam kết rằng tham chiếu trả về cũng sẽ sống ít nhất bằng thời gian sống 'a.

Trong thực tế, thời gian sống 'a của giá trị trả về sẽ bằng thời gian sống ngắn hơn giữa hai đầu vào xy.

Ví Dụ Trực Quan (ELI5): Thuê Phòng Trọ#

Hãy nghĩ về lifetime như một hợp đồng thuê nhà:

  • Chủ Nhà (Owner): Biến sở hữu dữ liệu thực tế.
  • Người Thuê (Reference): Bên mượn sử dụng dữ liệu.
  • Hợp Đồng Thuê Lại (Lifetime): Hợp đồng mượn dữ liệu.
Hợp đồng gốc của chủ nhà:  ==================== (Dữ liệu tồn tại)
Hợp đồng thuê lại (Mượn):      ==========      (Hợp lệ) ✅
Hợp đồng thuê lại (Mượn):      ====================== (Vô hiệu!) ❌
text

Hợp đồng thuê lại (tham chiếu) không thể kéo dài hơn hợp đồng gốc của chủ nhà (phạm vi của owner). Nếu bạn tiếp tục ở lại căn hộ sau khi hợp đồng thuê nhà của chủ nhà hết hạn, bạn đang xâm phạm vùng nhớ bất hợp pháp!

Struct Chứa Tham Chiếu#

Nếu bạn định nghĩa một struct chứa tham chiếu thay vì một kiểu dữ liệu có quyền sở hữu (owned type), bạn bắt buộc phải khai báo chú thích lifetime trên struct đó. Điều này đảm bảo thực thể của struct không thể sống lâu hơn tham chiếu mà nó nắm giữ:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().expect("Không tìm thấy dấu '.'");
    
    // Thực thể của struct được liên kết với lifetime của first_sentence
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}
rust

Ở đây, một thực thể của ImportantExcerpt không bao giờ được phép sống lâu hơn tham chiếu được lưu trữ trong thuộc tính part.

Quy Tắc Ẩn Lược Lifetime (Lifetime Elision)#

Bạn có thể nhận ra rằng trước đây chúng ta từng viết nhiều hàm trả về tham chiếu mà không cần bất kỳ chú thích nào, ví dụ như:

fn first_word(s: &str) -> &str { ... }
rust

Tại sao đoạn mã này vẫn biên dịch được mà không cần 'a?

Đội ngũ phát triển Rust nhận thấy các lập trình viên thường viết đi viết lại cùng một kiểu mẫu lifetime giống nhau. Để tăng tính tiện dụng khi lập trình, họ đã lập trình sẵn ba quy tắc xác định vào compiler (gọi là Quy tắc ẩn lược lifetime):

  1. Mỗi tham số đầu vào là một tham chiếu sẽ nhận một lifetime riêng biệt (ví dụ: fn foo<'a, 'b>(x: &'a i32, y: &'b i32)).
  2. Nếu chỉ có duy nhất một tham chiếu đầu vào, lifetime của nó sẽ được gán cho toàn bộ tham chiếu đầu ra (ví dụ: fn foo<'a>(x: &'a i32) -> &'a i32).
  3. Nếu có nhiều tham chiếu đầu vào nhưng một trong số đó là &self hoặc &mut self (phương thức của struct), lifetime của self sẽ được gán cho tất cả tham chiếu đầu ra.

Nếu trình biên dịch áp dụng ba quy tắc này mà vẫn không thể xác định được thời gian sống của giá trị trả về, nó mới báo lỗi và yêu cầu bạn chú thích thủ công.

Static Lifetime#

Rust có một lifetime đặc biệt là 'static. Nó chỉ ra rằng tham chiếu có thể sống trong toàn bộ thời gian chạy của chương trình.

Tất cả các chuỗi literal đều có lifetime là 'static vì dữ liệu của chúng được nhúng trực tiếp vào file nhị phân của chương trình:

let s: &'static str = "Tôi có thời gian sống static.";
rust

[!WARNING] Mặc dù 'static rất hữu dụng, hãy tránh sử dụng nó như một giải pháp lười biếng để dập tắt các lỗi của borrow checker. Ép một tham chiếu thành 'static khi đáng lẽ nó chỉ mang tính tạm thời sẽ dẫn đến các lỗi biên dịch phức tạp về sau.

Tổng Kết#

  • Lifetimes là các phạm vi đặt tên giúp compiler biết các tham chiếu hợp lệ trong bao lâu.
  • *Chúng ngăn chặn lỗi dangling references bằng cách đảm bảo dữ liệu mượn không sống lâu hơn chủ sở hữu.
  • Quy tắc ẩn lược lifetime giúp bạn lược bỏ việc chú thích trong phần lớn các trường hợp thông thường.
  • Struct chứa tham chiếu bắt buộc phải khai báo ràng buộc lifetime để đảm bảo an toàn bộ nhớ.

Trong bài viết tiếp theo, chúng ta sẽ dựa trên kiến thức này để khám phá Smart Pointers (Con trỏ thông minh), công cụ giúp chúng ta tùy biến quản lý tham chiếu trên heap.

Tài liệu tham khảo#