Borrow checker là một người đọc, không phải một người gác cổng
Trong khoảng một tháng, Rust cho tôi cảm giác như đang cãi nhau với một trình biên dịch đã kết luận
chương trình của tôi sai trước cả khi đọc nó. Mỗi cái &mut là một cuộc thương lượng. Tôi rắc
.clone() khắp nơi cho tới khi build được, cách đó chạy, và chẳng dạy tôi điều gì.
Thứ chữa được chuyện này là một thay đổi trong việc tôi nghĩ borrow checker sinh ra để làm gì.
Nó không kiểm tra an toàn bộ nhớ
Đó là thứ nó đạt được, chứ không phải thứ nó đang làm. Thứ nó thật sự thi hành là một quy tắc về bí danh và sự thay đổi:
Tại bất kỳ thời điểm nào, bạn có thể có một tham chiếu khả biến tới một giá trị, hoặc bao nhiêu tham chiếu bất biến cũng được. Không bao giờ có cả hai.
Quy tắc đó tồn tại vì trạng thái khả biến có nhiều bí danh chính là thứ khiến chương trình không thể lập luận nổi — chứ không chỉ là không an toàn. Data race là một hệ quả. Iterator bị vô hiệu là một hệ quả khác. Cả cái lớp lỗi mà một hàm bạn gọi đã sửa thứ bạn đang cầm cũng vậy.
Một khi tôi đọc quy tắc đó như chuyện về khả năng lập luận chứ không phải chuyện bộ nhớ, các lỗi bắt đầu có nghĩa, vì trình biên dịch đang từ chối những chương trình mà chính tôi cũng đã không lập luận nổi.
Swift có đúng vấn đề đó và giấu nó đi
Đây là mối liên hệ khiến mọi thứ vỡ ra với tôi, khi đến từ Swift.
var items = [1, 2, 3]
for item in items {
items.append(item) // vừa đọc vừa sửa cùng một mảng
}
Swift xử lý chuyện đó bằng cách cho vòng for duyệt trên một bản sao — ngữ nghĩa giá trị,
copy-on-write, và một lần cấp phát heap mà bạn không hề yêu cầu. Nó an toàn, và cái giá thì bị giấu
đi.
let mut items = vec![1, 2, 3];
for item in &items {
items.push(*item); // lỗi: không thể mượn `items` dạng khả biến
} // vì nó cũng đang được mượn dạng bất biến
Rust thì từ chối. Cùng một mối nguy, hai câu trả lời khác nhau: Swift lặng lẽ sao chép, Rust bắt bạn quyết định. Không bên nào sai, và biết rằng Swift đã luôn giải đúng vấn đề đó khiến phiên bản của Rust bớt vẻ tùy tiện đi nhiều.
Ba quy tắc dẹp được phần lớn lỗi của tôi
Một lần mượn kết thúc ở lần dùng cuối, không phải ở cuối phạm vi. Đây là non-lexical lifetime, và tôi đã mất một tuần vì không biết điều này:
let mut data = vec![1, 2, 3];
let first = &data[0];
println!("{first}"); // lần dùng cuối của `first` — lần mượn kết thúc ở đây
data.push(4); // ổn
Một nửa số lỗi tôi vật lộn được chữa bằng cách dời một dòng, chứ không phải bằng cách tái cấu trúc gì cả.
Mượn tách rời hoạt động trên từng trường. Bộ kiểm tra theo dõi từng trường riêng lẻ, không chỉ theo dõi cả struct:
struct Editor {
buffer: String,
cursor: usize,
}
fn edit(editor: &mut Editor) {
let text = &editor.buffer; // mượn một trường
let position = &mut editor.cursor; // mượn trường khác — ổn
}
Nó không làm được điều này qua một lời gọi phương thức, vì một phương thức nhận &mut self và
như thế là mượn tất cả. Cách chữa là một hàm tự do nhận vào các trường, hoặc các hàm trợ giúp kiểu
split_at_mut.
Trả về một tham chiếu nghĩa là hứa rằng nó sống lâu hơn cái hàm. Gần như mọi chú thích lifetime mà tôi viết không nổi đều là một chữ ký hứa một điều mà phần thân không thực hiện được, và cách chữa thành thật là trả về một giá trị sở hữu.
Clone không phải là thua cuộc
Ở đây tôi muốn phản biện một phần văn hóa Rust, vì nó đã lấy đi thời gian của tôi.
.clone() là một thứ bình thường để viết. Clone một String trên một đường code chạy một lần mỗi
request là một lần cấp phát vài chục byte, trong một chương trình sắp sửa làm I/O với file hay mạng.
Nó miễn phí theo mọi nghĩa đáng kể.
Phiên bản quy tắc tôi dùng bây giờ: cứ clone thoải mái lúc đầu, rồi mới đo. Những chỗ mà clone thật sự quan trọng là các vòng lặp chạy hàng triệu lần, và đó đúng là những chỗ mà một trình đo hiệu năng tìm ra ngay lập tức. Viết code chú thích lifetime vòng vèo để 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.
Chỗ mô hình này vẫn đổ vỡ
Hai trường hợp vẫn đòi hỏi suy nghĩ chứ không dựa vào trực giác được, và điều đó đáng nói ra.
Đồ thị và tham chiếu ngược. Một cái cây mà con trỏ về cha thì không diễn đạt được bằng tham chiếu
thuần — quyền sở hữu ở đó thật sự có chu trình. Rc<RefCell<T>> cùng Weak cho các cạnh ngược là
câu trả lời tiêu chuẩn, và nó chuyển phép kiểm tra từ lúc biên dịch sang lúc chạy. RefCell panic
khi bị vi phạm chứ không từ chối biên dịch.
Struct tự tham chiếu. Một struct giữ cả một vùng đệm lẫn một lát cắt vào chính vùng đệm ấy thì
không di chuyển an toàn được, và đó là lý do Pin tồn tại. Nó thật sự khó, và nếu bạn đụng phải nó
trong code ứng dụng thì câu trả lời gần như luôn là tái cấu trúc — hãy lưu một chỉ số thay vì một
tham chiếu.
Không cái nào trong hai thứ đó xuất hiện trong công việc thường ngày. Thứ xuất hiện là một trăm lỗi nhỏ, và mô hình ở trên hóa giải phần lớn chúng.
Điều tôi sẽ nói với chính mình ngày trước
Hãy đọc thông báo lỗi từ đầu đến cuối, kể cả phần ghi chú ở dưới cùng — trình biên dịch Rust giải thích về chính nó tốt hơn mọi trình biên dịch tôi từng dùng, và cả tháng đầu tôi đã không đọc quá dòng đầu tiên.
Và hãy thôi coi một lỗi mượn là chướng ngại giữa bạn và một chương trình chạy được. Đã hai lần nó từ chối một thứ hóa ra là lỗi thật trong cách tôi tổ chức dữ liệu, và ở Swift thì tôi đã không tìm ra cái nào trong hai lỗi ấy cho tới rất lâu sau.