dyn Trait hay generic: quyết định nằm ở cái tập hợp
Rust cho bạn hai cách nhận “một thứ gì đó cài đặt trait này”, và chúng biên dịch ra hai thứ hoàn toàn khác nhau:
fn render(shape: &impl Shape) { … } // generic — chuyên biệt hóa
fn render(shape: &dyn Shape) { … } // trait object — vtable
Đến từ Swift, đây vẫn là ngã rẽ some Protocol so với any Protocol, và cách lập luận chuyển sang
gần như y nguyên.
Mỗi cái biên dịch ra thứ gì
impl Trait ở vị trí tham số là cách viết ngọt của một generic. Trình biên dịch sinh ra một bản sao
riêng của hàm cho mỗi kiểu cụ thể mà nó được gọi cùng — monomorphisation. Mỗi bản đều được chuyên
biệt hóa, nội tuyến được, và biết chính xác shape.area() nghĩa là gì.
render(&circle); // biên dịch ra một render::<Circle>
render(&square); // biên dịch ra một render::<Square>
dyn Trait là một con trỏ béo: một con trỏ tới dữ liệu, một con trỏ tới bảng vtable các con trỏ hàm.
Một bản sao duy nhất của hàm, và mỗi lời gọi phương thức là một cú nhảy gián tiếp qua cái bảng.
Câu hỏi quyết định
Không phải “cái nào nhanh hơn” — câu trả lời cho câu đó gần như luôn là “chẳng ăn thua gì”. Câu hỏi là:
Tôi có cần một tập hợp gồm nhiều kiểu cụ thể khác nhau không?
Nếu có, bạn cần dyn. Không có lựa chọn nào khác:
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle::new(3.0)),
Box::new(Square::new(4.0)),
];
Vec<impl Shape> không có nghĩa là “một vector gồm nhiều hình khác nhau”. Nó nghĩa là “một vector
của đúng một kiểu cụ thể mà tôi không gọi tên”, và mọi phần tử phải cùng kiểu. Đó là một thứ hoàn
toàn khác, và trình biên dịch sẽ nói cho bạn biết như vậy.
Nếu không — một hàm nhận một giá trị, một struct giữ một thứ — hãy dùng generic. Nó nhanh hơn, nó cho phép nội tuyến, và nó chẳng bắt bên gọi trả gì.
Ba lý do còn lại để với tới dyn
Kích thước binary. Monomorphisation nhân bản code. Một hàm generic được gọi với hai mươi kiểu sẽ biên dịch hai mươi lần, và trong một codebase lớn thì con số ấy cộng dồn lại.
Thời gian biên dịch. Cùng lý do. Code nhiều generic thì biên dịch chậm hơn, và điều này thấy được trước cả khi kích thước binary kịp thành vấn đề.
An toàn đối tượng như một ranh giới thiết kế. Một hệ thống plugin, hay bất cứ thứ gì mà tập các
bên cài đặt là mở và chưa biết lúc biên dịch, thì tự nhiên là dyn.
Mẹo
Khác biệt hiệu năng nhỏ hơn người ta tưởng. Một lời gọi qua vtable là thêm một lần giải tham chiếu con trỏ — vài nano giây. Nó quan trọng trong một vòng lặp chạy hàng triệu lần, mà chủ yếu là vì nó chặn việc nội tuyến chứ không phải vì bản thân cú nhảy. Với bất cứ thứ gì làm I/O, nó không đo được.
An toàn đối tượng, và những lỗi nó sinh ra
Không phải trait nào cũng làm trait object được. Một trait chỉ an toàn đối tượng nếu không phương thức nào của nó:
- trả về
Self - có tham số kiểu generic
- nhận
selftheo giá trị (trừ khi nằm sauBox)
trait Shape {
fn area(&self) -> f64; // ổn
fn scaled(&self, by: f64) -> Self; // KHÔNG an toàn đối tượng
}
Thông báo lỗi nói “the trait Shape cannot be made into an object”, câu đó chính xác và không cho
bạn biết phương thức nào chịu trách nhiệm. Gần như luôn là một chỗ trả về -> Self.
Cách chữa thường là chẻ trait ra:
trait Shape {
fn area(&self) -> f64; // an toàn đối tượng — đây là phần dyn
}
trait ScalableShape: Shape {
fn scaled(&self, by: f64) -> Self where Self: Sized;
}
Ràng buộc where Self: Sized là cửa thoát hiểm còn lại — nó loại riêng phương thức đó khỏi yêu cầu
an toàn đối tượng, nên trait vẫn dùng được như dyn còn phương thức thì chỉ gọi được trên các kiểu
cụ thể.
Trait bất đồng bộ
Đây là chỗ hiện tại cắn đau nhất. async fn trong một trait giờ chạy được, nhưng trait kết quả không
an toàn đối tượng — kiểu trả về là một impl Future vô danh, tức là một kiểu trả về generic.
trait Fetcher {
async fn fetch(&self, url: &str) -> Result<Vec<u8>>; // không dyn được
}
Nếu bạn cần Box<dyn Fetcher> — thường là để thay thế phần cài đặt trong test — câu trả lời hiện
tại là async_trait, thứ đóng hộp cái future:
#[async_trait]
trait Fetcher: Send + Sync {
async fn fetch(&self, url: &str) -> Result<Vec<u8>>;
}
Cách đó cấp phát cho mỗi lời gọi, và đó là cái giá của giải pháp thay thế. Với một trait được gọi một lần mỗi request thì chẳng ăn thua; trong một vòng lặp nóng thì có.
Tôi làm gì
Mặc định là generic, dùng dyn khi cái tập hợp hoặc cái ranh giới đòi hỏi. Cụ thể:
- Tham số hàm →
impl Trait. Chuyên biệt hóa miễn phí, không có lý do gì để không dùng. - Trường struct giữ một phần cài đặt được chọn lúc khởi động → tham số generic nếu kiểu đã biết
tĩnh,
Box<dyn>nếu nó được chọn lúc chạy. - Tập hợp gồm nhiều kiểu trộn lẫn →
Vec<Box<dyn Trait>>, không có lựa chọn khác. - Một trait với hai hoặc ba bên cài đặt đã biết → hãy cân nhắc một
enumthay thế. Nó nhanh hơn cả hai, giữ được phép kiểm tra vét cạn, và không cần đóng hộp. Đây là lựa chọn mà người ta quên mất là nó tồn tại, và nó thường là lựa chọn tốt nhất.