Trang chủ

Lifetime trong struct: khi nào mượn và khi nào sở hữu

Lần đầu tôi viết một chú thích lifetime lên một struct là vì trình biên dịch đòi và tôi làm theo lời nó. Việc đó tạo ra một kiểu dữ liệu khổ sở khi dùng, và cách chữa là thôi mượn đi.

Cái chú thích ấy nghĩa là gì

struct Parser<'a> {
    input: &'a str,
    position: usize,
}

'a không phải một thuộc tính của struct. Nó là một ràng buộc về nơi struct được phép tồn tại: cái Parser này không thể sống lâu hơn chuỗi mà nó trỏ vào. Mọi hàm nhận một cái, mọi struct chứa một cái, và mọi kiểu trả về nhắc đến nó đều thừa hưởng ràng buộc ấy.

Đó là cái giá, và nó lây lan. Chỉ một trường được mượn trong một kiểu nằm gần đáy chương trình sẽ truyền các chú thích lifetime lan lên qua mọi thứ chạm vào nó.

Khi nào mượn là đúng

Hai tình huống, cả hai đều hẹp:

Một khung nhìn ngắn hạn lên dữ liệu do người khác sở hữu. Một bộ phân tích cú pháp hay một iterator tồn tại bên trong một lời gọi hàm và không bao giờ thoát ra:

fn count_words(text: &str) -> usize {
    let parser = Parser { input: text, position: 0 };   // không rời khỏi phạm vi này
    parser.count()
}

Lifetime ở đây vô hình vì nó được suy ra và không bao giờ vượt qua một ranh giới nào.

Một đường chạy nóng nơi việc sao chép thật sự tốn kém. Nếu bạn đang dựng hàng triệu cái này trong một vòng lặp và mỗi cái sẽ clone một chuỗi lớn thì việc mượn xứng đáng với cái chú thích. Hãy đo trước — trường hợp này hiếm hơn nhiều so với những gì bản năng cộng đồng Rust gợi ra.

Khi nào nên sở hữu

Mọi chỗ khác, và đặc biệt là ba trường hợp sau.

Bất cứ thứ gì lưu trong một struct sống lâu hơn một lời gọi hàm. Một cấu hình, một giá trị đã cache, một mẩu trạng thái ứng dụng:

// đau đớn: mọi kẻ giữ Config giờ đều cần một lifetime
struct Config<'a> {
    name: &'a str,
    url: &'a str,
}

// ổn
struct Config {
    name: String,
    url: String,
}

Bất cứ thứ gì vượt qua ranh giới luồng hay ranh giới async. Một trường được mượn nghĩa là struct đó không phải 'static, mà tokio::spawn thì đòi 'static. Bạn sẽ chiến đấu với điều này và sẽ thua.

Bất cứ thứ gì trong một API công khai. Một lifetime trong chữ ký của bạn là một ràng buộc bạn áp lên mọi bên gọi, mãi mãi. Sở hữu dữ liệu cho phép họ làm bất cứ điều gì họ muốn.

Mẹo

Quy tắc bỏ túi: nếu struct là một danh từ trong lĩnh vực của chương trình — Config, User, Request — hãy sở hữu dữ liệu. Nếu nó là một động từ tạm thời — Parser, Iterator, Visitor — thì mượn là hợp lý. Các kiểu thuộc lĩnh vực sống lâu hơn những thứ chúng được dựng lên từ đó; bộ máy tạm thời thì không.

Việc sở hữu thật sự tốn gì

Đây là phần khiến tôi thôi suy nghĩ quá nhiều về nó.

Một trường String thay vì &str tốn một lần cấp phát heap và một lần memcpy các byte, tại thời điểm dựng đối tượng. Với một cấu hình nạp một lần lúc khởi động, đó là những nano giây bạn sẽ không bao giờ đo được. Với một struct dựng cho mỗi request HTTP, đó là sai số làm tròn bên cạnh phần I/O mạng mà request ấy vốn đã làm.

Những trường hợp nó thật sự quan trọng là các vòng lặp chặt chạy hàng triệu lượt. Chúng có tồn tại, và đó đúng là những chỗ mà một trình đo hiệu năng chỉ ra ngay lập tức. Viết code đầy chú thích lifetime ở khắp nơi để né một lần cấp phát mà chẳng ai đo là tối ưu sớm kèm thêm cú pháp và trải nghiệm dùng tệ hơn.

Cow cho lúc bạn thật sự không quyết được

Cow<'a, str> — clone khi ghi — giữ hoặc một tham chiếu mượn hoặc một giá trị sở hữu, và chỉ cấp phát nếu có thứ gì cần thay đổi nó:

use std::borrow::Cow;

fn normalise(input: &str) -> Cow<'_, str> {
    if input.contains('\t') {
        Cow::Owned(input.replace('\t', "    "))    // đã cấp phát, vì ta đã đổi nó
    } else {
        Cow::Borrowed(input)                        // miễn phí, chẳng có gì phải đổi
    }
}

Cái này thật sự hữu ích cho một hàm thường trả về đầu vào của nó không đổi. Nó cũng thường bị với tới quá sớm — nó vẫn mang theo một lifetime, nên không giải quyết được vấn đề lây lan, và nó thêm một nhánh rẽ ở mọi chỗ dùng.

Quy tắc tôi dùng bây giờ

Hãy bắt đầu bằng cách sở hữu tất cả. String, Vec<T>, PathBuf. Viết xong chương trình đã.

Rồi, nếu một trình đo hiệu năng chỉ ra một đường chạy nóng bị việc clone chiếm lĩnh, hãy đưa một phép mượn vào đúng chỗ đó, trong phạm vi hẹp nhất chữa được nó. Thường thì nghĩa là một hàm nhận &str thay vì String — thứ chẳng cần lifetime nào trên struct cả, vì lifetime của một tham số hàm được suy ra.

Điểm cuối đó đáng nói cho rõ, vì đó là nơi sinh ra sự nhầm lẫn: nhận &str làm tham số thì miễn phí và đúng thành ngữ. Lưu &str trong một struct là một quyết định thiết kế có hệ quả thật. Hai thứ trông giống nhau và chúng không phải một, và việc gộp chúng lại chính là thứ khiến tôi viết <'a> lên những kiểu lẽ ra không bao giờ nên có nó.