Trang chủ

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 self theo giá trị (trừ khi nằm sau Box)
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 enum thay 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.