Khi nào unsafe là chính đáng, và bạn nợ gì khi dùng nó
Cách dùng sai unsafe phổ biến nhất trong Rust là coi nó như cửa thoát hiểm khỏi borrow checker.
Không phải vậy. Nó không tắt việc kiểm tra mượn, không tắt hệ thống kiểu, và không khiến một thiết kế
đang đánh nhau với trình biên dịch bỗng dưng trở nên đúng.
Thứ nó làm là mở khóa năm thao tác, và lấy trách nhiệm về các điều kiện tiên quyết của chúng khỏi trình biên dịch rồi trao cho bạn.
Năm thứ nó cho phép
- Giải tham chiếu một con trỏ thô
- Gọi một hàm
unsafe, bao gồm mọi FFI - Truy cập hoặc sửa một
statickhả biến - Cài đặt một trait
unsafe(Send,Sync) - Truy cập một trường của một
union
Đó là toàn bộ danh sách. Mọi thứ khác trong ngôn ngữ hoạt động y hệt bên trong một khối unsafe —
các phép mượn vẫn được kiểm tra, các kiểu vẫn được thi hành, các lifetime vẫn áp dụng.
Nếu lỗi của bạn là “cannot borrow as mutable more than once” thì unsafe không giúp được gì. Trình
biên dịch đang mô tả một vấn đề bí danh có thật, và bọc nó lại chẳng thay đổi điều gì.
Ba lý do chính đáng
Giao diện hàm ngoại lai. Gọi C, hoặc bị C gọi. Trình biên dịch không kiểm chứng được một hợp đồng sống ở một ngôn ngữ khác, nên phải có ai đó làm việc ấy, và người đó là bạn.
extern "C" {
fn compute_checksum(data: *const u8, length: usize) -> u32;
}
pub fn checksum(data: &[u8]) -> u32 {
// SAFETY: `data` là một lát cắt hợp lệ, nên con trỏ khác null, đúng căn chỉnh,
// và hợp lệ cho `len` byte. `compute_checksum` không giữ lại con trỏ.
unsafe { compute_checksum(data.as_ptr(), data.len()) }
}
Dựng một lớp trừu tượng mà borrow checker không diễn đạt được. Vec, RefCell, Rc và Mutex
đều được cài đặt bằng unsafe bên trong. Chúng tồn tại để cung cấp một API an toàn trên một thao
tác mà bộ kiểm tra không chứng minh được là đúng.
pub fn split_at_mut(&mut self, index: usize) -> (&mut [T], &mut [T]) {
// hai tham chiếu khả biến vào cùng một lát cắt — bộ kiểm tra không thấy được chúng rời nhau
}
Hiệu năng đã được đo và thật sự cần. Bỏ một phép kiểm tra biên trong một vòng lặp mà trình đo hiệu năng đã chỉ ra là nóng. Đây là lý do chính đáng hiếm gặp nhất và là lý do được viện dẫn nhiều nhất.
Bạn nợ gì: dòng chú thích về tính an toàn
Mọi khối unsafe đều nên mang theo một dòng chú thích nói rõ vì sao nó đúng đắn — những bất biến
nào đang giữ, và ai bảo đảm chúng.
// SAFETY: `index` đã được kiểm tra với `self.len` ở trên, nên độ dời nằm trong
// vùng cấp phát. `self.pointer` khác null và đúng căn chỉnh trong suốt vòng đời của
// `self` vì nó đến từ một lần `alloc` thành công trong `with_capacity`.
unsafe { *self.pointer.add(index) }
Đây không phải phép lịch sự về tài liệu. Trình biên dịch đã thôi kiểm tra; dòng chú thích là bản ghi duy nhất còn lại về thứ khiến nó đúng. Không có nó, người tiếp theo — kể cả chính bạn sau sáu tháng — không kiểm chứng hay sửa đổi an toàn được đoạn code, vì lập luận khiến nó đúng đắn đã biến mất.
Cảnh báo
Hãy thi hành điều đó. #![deny(clippy::undocumented_unsafe_blocks)] biến một dòng chú thích an
toàn bị thiếu thành một lỗi build. Nó là một dòng và là cái lint có giá trị cao nhất trong cả ngôn
ngữ.
Giữ nó nhỏ và bọc nó lại
Hai quy tắc giới hạn thiệt hại.
Khối bao lấy thao tác, không bao cả hàm. Một unsafe fn dài một trăm dòng là một trăm dòng bạn
phải rà soát. Một khối ba dòng bên trong một hàm an toàn thì là ba dòng.
Một API an toàn đặt lên trên. Mục đích của unsafe là được giam lại. Nếu bên gọi phải duy trì
một bất biến nào đó thì mới dùng hàm của bạn an toàn được, thì hàm đó phải là unsafe fn và phải nói
ra điều ấy — đẩy gánh nặng ra ngoài mà không đánh dấu chính là cách một lỗi về tính đúng đắn lan ra
khắp một codebase.
// Sai: chữ ký an toàn, yêu cầu thì không an toàn
pub fn get_unchecked(&self, index: usize) -> &T { … }
// Đúng: chữ ký nói rằng bên gọi có một nghĩa vụ
pub unsafe fn get_unchecked(&self, index: usize) -> &T { … }
Các công cụ kiểm tra thứ trình biên dịch không kiểm tra được
Miri thông dịch code của bạn và phát hiện hành vi không xác định — truy cập ngoài biên, dùng sau khi giải phóng, căn chỉnh sai, đua dữ liệu:
cargo +nightly miri test
Nó chậm và nó tìm ra những con bug thật mà mọi bài test của bạn đi lọt qua. Mọi crate có unsafe đều
nên chạy nó trong CI.
AddressSanitizer cho FFI, nơi vấn đề thường nằm ở phía C:
RUSTFLAGS="-Z sanitizer=address" cargo +nightly test
Câu hỏi cần hỏi trước tiên
Trước khi viết unsafe, hãy hỏi: đã có ai bọc sẵn thứ này chưa?
Câu trả lời thường là rồi. bytemuck cho các phép chuyển kiểu an toàn, zerocopy cho chuyển đổi ở
mức byte, crossbeam cho các cấu trúc không khóa, parking_lot cho các cái khóa, slotmap cho các
khuôn mẫu vùng nhớ mà nếu không thì cần đến con trỏ thô.
Dùng một crate được kiểm thử kỹ có chứa unsafe hoàn toàn khác với việc tự viết của mình. Của họ đã
được review, fuzz và chạy dưới Miri bởi những người chuyên đúng việc này. Của tôi thì được đọc bởi
tôi, đúng một lần, vào cái ngày tôi viết nó.
Sự bất đối xứng đó mới là lập luận thật. Những trường hợp unsafe trong code ứng dụng là chính đáng
thì hiếm, và những trường hợp nó cần thiết — vì chưa ai dựng cái lớp trừu tượng ấy — còn hiếm hơn
nữa.