Borrowing và Lifetime trong Rust qua ví dụ

1. Tổng quan

Borrowing và lifetime là hai khái niệm khiến người mới học Rust “vật” nhiều nhất với compiler. Bài này giải thích borrowing (&T, &mut T) và lifetime annotation ('a) qua các ví dụ domain quen thuộc như Order, Customer. Đọc xong, bạn sẽ đọc được thông báo lỗi của borrow checker, biết khi nào cần viết lifetime annotation và khi nào compiler tự suy ra được.

2. Ownership nhắc lại nhanh

Trước khi nói đến borrowing, cần nhớ: mỗi giá trị trong Rust chỉ có một owner. Khi bạn truyền giá trị theo value, ownership bị move đi.

struct Customer {
    name: String,
}

fn print_customer(customer: Customer) {
    println!("Khách hàng: {}", customer.name);
}

fn main() {
    let customer = Customer { name: String::from("Nguyễn Văn A") };
    print_customer(customer);
    // println!("{}", customer.name); // lỗi: customer đã bị move
}

print_customer nhận Customer theo value, nên customer bị move vào hàm. Dòng comment nếu bỏ comment ra sẽ báo lỗi:

error[E0382]: borrow of moved value: `customer`

Borrowing sinh ra để giải quyết đúng vấn đề này: cho hàm khác “mượn” dữ liệu mà không cần lấy ownership.

3. Borrowing bất biến: &T

Dùng &T khi chỉ cần đọc dữ liệu, không cần sửa.

struct Order {
    items: Vec<f64>,
}

fn calculate_total(order: &Order) -> f64 {
    order.items.iter().sum()
}

fn main() {
    let order = Order { items: vec![100.0, 250.0, 50.0] };
    let total = calculate_total(&order);

    println!("Tổng: {}", total);
    println!("Số món: {}", order.items.len()); // order vẫn dùng được
}
Tổng: 400
Số món: 3

calculate_total nhận &Order – một tham chiếu bất biến. order vẫn thuộc về main, hàm chỉ “mượn tạm” để đọc. Vì vậy sau khi gọi hàm, main vẫn dùng order bình thường. Bạn có thể tạo nhiều &Order cùng lúc, miễn không có &mut Order nào xen vào.

4. Borrowing khả biến: &mut T

Khi cần sửa dữ liệu qua tham chiếu, dùng &mut T.

struct Order {
    items: Vec<f64>,
    discount_percent: f64,
}

fn apply_discount(order: &mut Order, percent: f64) {
    order.discount_percent = percent;
}

fn main() {
    let mut order = Order { items: vec![100.0, 200.0], discount_percent: 0.0 };
    apply_discount(&mut order, 10.0);

    println!("Giảm giá: {}%", order.discount_percent);
}
Giảm giá: 10%

Chú ý hai điểm: biến order phải khai báo mut, và lời gọi hàm phải truyền &mut order. Khác với &T, tại một thời điểm chỉ được tồn tại một &mut T, và không được có &T nào khác sống cùng lúc.

5. Borrow checker: quy tắc “một chủ, nhiều người đọc hoặc một người viết”

Quy tắc cốt lõi: với một dữ liệu, tại một thời điểm bạn chỉ được có nhiều &T hoặc một &mut T, không được trộn lẫn.

fn main() {
    let mut order = Order { items: vec![100.0, 200.0], discount_percent: 0.0 };

    for item in &order.items {
        order.items.push(*item); // lỗi: order.items đang bị mượn bất biến để lặp
    }
}

for item in &order.items tạo một tham chiếu bất biến sống suốt vòng lặp. Trong vòng lặp, order.items.push(...) cần mượn khả biến cùng dữ liệu đó → xung đột. Compiler báo:

error[E0502]: cannot borrow `order.items` as mutable because it is
also borrowed as immutable

Cách sửa thường gặp: thu thập giá trị cần thêm vào một Vec tạm, rồi extend sau khi vòng lặp kết thúc – tức là tách hai borrow ra hai thời điểm khác nhau.

Lưu ý: borrow checker phân tích theo “scope sống thực tế” (non-lexical lifetimes), không phải theo block {}. Hai borrow không chồng lấp về thời gian sống thì vẫn hợp lệ, dù nằm trong cùng một hàm.

6. Lifetime là gì

Lifetime là cách Rust mô tả “tham chiếu này sống được bao lâu”, để compiler đảm bảo bạn không bao giờ dùng một tham chiếu sau khi dữ liệu gốc đã bị drop. Phần lớn thời gian bạn không phải viết lifetime – compiler tự suy ra. Nhưng khi một hàm nhận nhiều tham chiếu và trả về một tham chiếu, compiler cần bạn nói rõ tham chiếu trả về “mượn” từ tham số nào.

6.1. Lifetime annotation trên hàm

fn longest_name<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() >= b.len() { a } else { b }
}

fn main() {
    let customer1 = String::from("Nguyễn Văn A");
    let customer2 = String::from("Trần Thị B dài hơn");

    let longest = longest_name(&customer1, &customer2);
    println!("Tên dài nhất: {}", longest);
}
Tên dài nhất: Trần Thị B dài hơn

'a không tạo ra thời gian sống mới, nó chỉ là tên để ràng buộc: cả a, b và giá trị trả về đều phải sống ít nhất bằng 'a. Compiler không biết hàm trả về a hay b trong runtime, nên nó bắt buộc cả hai input và output dùng chung một lifetime để đảm bảo an toàn trong mọi trường hợp.

Nếu bạn viết sai – trả về tham chiếu không liên quan đến lifetime khai báo:

fn longest_name_broken<'a>(a: &'a str, _b: &str) -> &'a str {
    let local = String::from("tạm");
    &local // lỗi: local bị drop cuối hàm
}
error[E0515]: cannot return value referencing local variable `local`

6.2. Lifetime trong struct

Struct chứa tham chiếu cũng cần lifetime annotation, vì Rust phải đảm bảo struct không “sống lâu hơn” dữ liệu nó mượn.

struct Customer {
    name: String,
}

struct OrderSummary<'a> {
    customer: &'a Customer,
    total: f64,
}

fn build_summary<'a>(customer: &'a Customer, total: f64) -> OrderSummary<'a> {
    OrderSummary { customer, total }
}

fn main() {
    let customer = Customer { name: String::from("Lê Văn C") };
    let summary = build_summary(&customer, 500.0);

    println!("{} - tổng: {}", summary.customer.name, summary.total);
}

OrderSummary<'a> không sở hữu Customer, nó chỉ giữ một tham chiếu. Lifetime 'a buộc summary không thể sống lâu hơn customer. Nếu bạn để customer bị drop trước khi dùng summary, compiler chặn ngay tại compile-time – không có dangling pointer nào lọt được xuống runtime.

6.3. Khi nào không cần viết lifetime (elision)

Rust có 3 quy tắc suy luận lifetime tự động (lifetime elision), áp dụng cho phần lớn hàm:

  1. Mỗi tham số là tham chiếu được gán một lifetime riêng.
  2. Nếu hàm chỉ có đúng một input lifetime, nó được gán cho output.
  3. Nếu có &self hoặc &mut self, lifetime của self được gán cho output.
fn first_word(text: &str) -> &str {
    // quy tắc 2: chỉ 1 input lifetime -> tự gán cho output
    match text.find(' ') {
        Some(index) => &text[..index],
        None => text,
    }
}

Hàm này không cần viết 'a vì chỉ có một tham số tham chiếu – compiler tự suy ra output mượn từ text. Bạn chỉ cần viết lifetime tay khi có nhiều hơn một tham chiếu đầu vào và output không rơi vào trường hợp 2, 3.

7. Ví dụ thực tế: parser trả về slice

fn first_word(text: &str) -> &str {
    match text.find(' ') {
        Some(index) => &text[..index],
        None => text,
    }
}

fn main() {
    let order_note = String::from("giao hàng nhanh trong ngày");
    let word = first_word(&order_note);

    println!("Từ đầu: {}", word);
}
Từ đầu: giao

first_word không tạo String mới, nó chỉ trả về một slice (&str) nằm trong vùng nhớ của order_note. Đây là pattern rất phổ biến trong parser Rust: tránh allocate thêm, chỉ “trỏ” vào dữ liệu gốc. Lifetime đảm bảo word không thể sống lâu hơn order_note.

Thử viết sai để thấy compiler bảo vệ bạn:

fn first_word_broken() -> &str {
    let text = String::from("giao hàng nhanh");
    first_word(&text) // text bị drop khi hàm kết thúc
}
error[E0106]: missing lifetime specifier

Compiler từ chối biên dịch ngay vì không có cách nào gán lifetime hợp lệ cho output – text chắc chắn chết trước khi giá trị trả về được dùng ở nơi gọi.

Test cho first_word

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn tra_ve_dung_tu_dau_tien() {
        let note = "giao hang nhanh trong ngay";
        assert_eq!(first_word(note), "giao");
    }

    #[test]
    fn tra_ve_ca_cau_khi_khong_co_khoang_trang() {
        let note = "ship";
        assert_eq!(first_word(note), "ship");
    }
}

Chạy cargo test:

running 2 tests
test tests::tra_ve_ca_cau_khi_khong_co_khoang_trang ... ok
test tests::tra_ve_dung_tu_dau_tien ... ok

8. Lỗi thường gặp

  • Trả về tham chiếu tới biến local: biến bị drop cuối hàm, nhưng tham chiếu vẫn “sống” ở nơi gọi → compiler chặn bằng lỗi lifetime, không bao giờ cho qua runtime.
  • Giữ &T quá lâu rồi mới mượn &mut T: thường gặp khi lưu kết quả .iter() vào biến rồi mutate struct gốc trong cùng scope. Cách sửa: thu hẹp scope của borrow bất biến bằng block {} hoặc tách logic đọc/viết thành hai bước.
  • Lạm dụng clone() để né borrow checker: clone() luôn compile được nhưng tốn chi phí copy dữ liệu. Chỉ dùng khi bạn thực sự cần một bản độc lập, không phải để “chữa cháy” lỗi borrow.
  • Viết lifetime annotation thừa: nếu hàm chỉ có một tham chiếu đầu vào, đừng viết 'a tay – elision rule đã xử lý, viết thêm chỉ làm code rối.

Lưu ý: nếu bạn thấy mình phải thêm 'static để code compile được, dừng lại và xem xét lại thiết kế. 'static ép dữ liệu sống suốt chương trình, thường là dấu hiệu bạn đang né vấn đề ownership thật thay vì giải quyết nó.

9. Kết luận

Borrowing cho bạn mượn dữ liệu mà không cần lấy ownership, với quy tắc “nhiều người đọc hoặc một người viết” được borrow checker kiểm tra tại compile-time. Lifetime là ngôn ngữ để compiler và bạn cùng thống nhất một tham chiếu sống được bao lâu, đặc biệt quan trọng khi hàm trả về tham chiếu hoặc struct giữ tham chiếu bên trong. Phần lớn trường hợp compiler tự suy ra lifetime qua elision rule; bạn chỉ cần viết tay khi có nhiều hơn một tham chiếu đầu vào mơ hồ về nguồn gốc của output.

rustborrowinglifetimeownershipmemory-safety