Trait object trong Rust
Ở trang Trait, bạn đã viết những hàm nhận “bất kỳ kiểu nào cài đặt Speak”. Ở trang Generic, bạn đã thấy Rust tạo một bản riêng của hàm generic cho từng kiểu. Cả hai đều rất tốt khi bạn biết kiểu lúc biên dịch. Nhưng nếu bạn muốn một danh sách chứa nhiều kiểu khác nhau thì sao?
Đó là việc của trait object. Hãy nghĩ tới một thầy giáo dạy nhạc với cả phòng học trò. Người chơi piano, người chơi violin, người chơi trống. Thầy không cần biết từng học trò cầm nhạc cụ gì: thầy chỉ nói “chơi đi!” và mỗi người chơi theo cách của mình. Trait object cho code của bạn làm điều tương tự: “tôi không biết chính xác kiểu là gì, nhưng tôi biết nó làm được việc này”.
Vấn đề: một Vec, nhiều kiểu khác nhau
Phần tiêu đề “Vấn đề: một Vec, nhiều kiểu khác nhau”Giả sử bạn có hai hình, và muốn giữ chúng trong một Vec:
struct Circle { radius: f64 }struct Square { side: f64 }
fn main() { let shapes = vec![Circle { radius: 1.0 }, Square { side: 2.0 }];}Đoạn này không biên dịch được:
error[E0308]: mismatched types --> src/main.rs:5:47 |5 | let shapes = vec![Circle { radius: 1.0 }, Square { side: 2.0 }]; | ^^^^^^^^^^^^^^^^^^^^ expected `Circle`, found `Square`Rustc báo kiểu không khớp: nó mong một Circle nhưng lại gặp một Square. Một Vec chỉ chứa được một kiểu. Mọi phần tử phải có cùng kích thước trong bộ nhớ, để Rust xếp chúng nối tiếp nhau. Circle và Square là hai kiểu khác nhau, nên không thể chung một Vec thông thường.
Box<dyn Trait>: một danh sách nhiều loại hình
Phần tiêu đề “Box<dyn Trait>: một danh sách nhiều loại hình”Cách sửa gồm hai phần. Thứ nhất, cho cả hai hình một trait chung. Thứ hai, đặt mỗi hình vào một Box và khai báo kiểu là Box<dyn Shape>:
trait Shape { fn name(&self) -> String; fn area(&self) -> f64;}
struct Circle { radius: f64 }struct Square { side: f64 }
impl Shape for Circle { fn name(&self) -> String { String::from("hình tròn") } fn area(&self) -> f64 { 3.14159 * self.radius * self.radius }}
impl Shape for Square { fn name(&self) -> String { String::from("hình vuông") } fn area(&self) -> f64 { self.side * self.side }}
fn main() { let shapes: Vec<Box<dyn Shape>> = vec![ Box::new(Circle { radius: 2.0 }), Box::new(Square { side: 3.0 }), Box::new(Circle { radius: 1.0 }), ];
for shape in &shapes { println!("{} có diện tích {:.2}", shape.name(), shape.area()); }}Kết quả:
hình tròn có diện tích 12.57hình vuông có diện tích 9.00hình tròn có diện tích 3.14Hãy đọc Box<dyn Shape> từng phần:
dyn Shapenghĩa là “một kiểu nào đó cài đặtShape; ta sẽ biết là kiểu nào khi chương trình chạy”.dynlà viết tắt của dynamic (động).Boxđặt giá trị lên heap và giữ một con trỏ tới nó (bạn đã gặpBoxở trang Con trỏ thông minh). MọiBoxcó cùng kích thước, bất kể bên trong chứa gì. Đó là lý doVecgiờ đã vui vẻ.
Giá trị trong hộp (cộng với một con trỏ ẩn cho biết “cái này là Circle”) chính là thứ chúng ta gọi là trait object.
&dyn Trait: mượn thay vì đóng hộp
Phần tiêu đề “&dyn Trait: mượn thay vì đóng hộp”Không phải lúc nào bạn cũng cần Box. Nếu các giá trị đã nằm ở chỗ khác, một tham chiếu cũng dùng được. &dyn Shape nghĩa là “một tham chiếu mượn tới một kiểu nào đó cài đặt Shape”:
trait Shape { fn area(&self) -> f64;}
struct Circle { radius: f64 }struct Square { side: f64 }
impl Shape for Circle { fn area(&self) -> f64 { 3.14159 * self.radius * self.radius }}
impl Shape for Square { fn area(&self) -> f64 { self.side * self.side }}
fn print_area(shape: &dyn Shape) { println!("Diện tích: {:.1}", shape.area());}
fn main() { let circle = Circle { radius: 1.0 }; let square = Square { side: 4.0 };
print_area(&circle); print_area(&square);
let both: Vec<&dyn Shape> = vec![&circle, &square]; println!("Chúng ta có {} hình", both.len());}Kết quả:
Diện tích: 3.1Diện tích: 16.0Chúng ta có 2 hìnhQuy tắc đơn giản: dùng Box<dyn Trait> khi danh sách cần sở hữu các giá trị, và &dyn Trait khi bạn chỉ cần mượn chúng một lúc.
Dispatch tĩnh và dispatch động
Phần tiêu đề “Dispatch tĩnh và dispatch động”“Dispatch” chỉ có nghĩa là “quyết định gọi phương thức nào”. Rust làm việc này theo hai cách.
Dispatch tĩnh (static dispatch) xảy ra với generic và impl Trait. Trình biên dịch biết chính xác kiểu, nên nó tạo một bản riêng của hàm cho mỗi kiểu (như bạn đã thấy ở trang Generic). Lời gọi đi thẳng tới đúng phương thức. Rất nhanh.
Dispatch động (dynamic dispatch) xảy ra với dyn Trait. Trình biên dịch không biết chính xác kiểu. Vì vậy mỗi trait object mang theo một bảng ẩn nhỏ (gọi là vtable) ghi rằng “với giá trị này, area nằm ở đây”. Lúc chạy, Rust tra phương thức trong bảng đó rồi mới gọi. Việc tra cứu ấy tốn thêm một chút xíu chi phí.
// Dispatch tĩnh: Rust tạo một bản của hàm này cho mỗi kiểufn static_area(shape: &impl Shape) -> f64 { shape.area()}
// Dispatch động: chỉ một hàm, phương thức được tra cứu lúc chạyfn dynamic_area(shape: &dyn Shape) -> f64 { shape.area()}Cả hai hàm cho cùng kết quả với cùng một hình. Khác biệt duy nhất là cách Rust tìm đúng phương thức area. Vậy bạn nên chọn cái nào?
Dùng generic / impl Trait khi… |
Dùng dyn Trait khi… |
|---|---|
| Mỗi lời gọi làm việc với một kiểu đã biết | Bạn cần trộn nhiều kiểu trong một collection |
| Bạn muốn code nhanh nhất có thể | Bạn quyết định kiểu lúc chạy (ví dụ, từ dữ liệu người dùng nhập) |
| Bạn không ngại chương trình to hơn một chút | Bạn muốn code nhỏ hơn và biên dịch nhanh hơn |
Mặc định tốt cho người mới: bắt đầu với generic hoặc impl Trait. Chuyển sang dyn khi bạn gặp vấn đề “nhiều kiểu trong một danh sách”. Đừng lo quá về khác biệt tốc độ: trong hầu hết chương trình, bạn sẽ không bao giờ nhận ra nó.
Trả về Box<dyn Trait> từ một hàm
Phần tiêu đề “Trả về Box<dyn Trait> từ một hàm”Đôi khi một hàm phải quyết định lúc chạy sẽ trả về kiểu nào. Bạn có thể thử -> impl Shape, nhưng cách đó chỉ chạy khi hàm luôn trả về cùng một kiểu. Nếu các nhánh khác nhau trả về kiểu khác nhau, hãy trả về Box<dyn Shape>:
trait Shape { fn describe(&self) -> String;}
struct Circle { radius: f64 }struct Square { side: f64 }
impl Shape for Circle { fn describe(&self) -> String { format!("một hình tròn bán kính {}", self.radius) }}
impl Shape for Square { fn describe(&self) -> String { format!("một hình vuông cạnh {}", self.side) }}
fn make_shape(kind: &str, size: f64) -> Box<dyn Shape> { if kind == "circle" { Box::new(Circle { radius: size }) } else { Box::new(Square { side: size }) }}
fn main() { let a = make_shape("circle", 1.5); let b = make_shape("square", 2.0); println!("Tôi đã tạo {}", a.describe()); println!("Tôi đã tạo {}", b.describe());}Kết quả:
Tôi đã tạo một hình tròn bán kính 1.5Tôi đã tạo một hình vuông cạnh 2Đây là khuôn mẫu phổ biến cho các hàm “factory” (nhà máy), hệ thống plugin và code điều khiển bằng cấu hình: bất cứ chỗ nào mà kiểu chính xác phụ thuộc vào dữ liệu bạn chỉ thấy khi chương trình chạy.
Một ví dụ thực tế: gửi thông báo
Phần tiêu đề “Một ví dụ thực tế: gửi thông báo”Đây là thứ gần với một ứng dụng thật hơn. Chương trình của bạn cần thông báo cho người dùng, nhưng mỗi người đã chọn kênh khác nhau: email, SMS, hoặc cả hai. Mỗi kênh là một kiểu riêng, và chúng cùng chia sẻ trait Notifier:
trait Notifier { fn send(&self, message: &str) -> String;}
struct Email { address: String }struct Sms { phone: String }
impl Notifier for Email { fn send(&self, message: &str) -> String { format!("Email tới {}: {}", self.address, message) }}
impl Notifier for Sms { fn send(&self, message: &str) -> String { format!("SMS tới {}: {}", self.phone, message) }}
fn notify_all(channels: &[Box<dyn Notifier>], message: &str) { for channel in channels { println!("{}", channel.send(message)); }}
fn main() { let channels: Vec<Box<dyn Notifier>> = vec![ Box::new(Sms { phone: String::from("+1-555-0100") }), ];
notify_all(&channels, "Đơn hàng của bạn đã được gửi đi!");}Kết quả:
Email tới [email protected]: Đơn hàng của bạn đã được gửi đi!SMS tới +1-555-0100: Đơn hàng của bạn đã được gửi đi!Điểm hay là: nếu sau này bạn thêm một kênh thông báo Push, bạn chỉ viết một struct mới và một impl Notifier for Push. Hàm notify_all không phải đổi gì cả.
Giới hạn: dyn compatibility
Phần tiêu đề “Giới hạn: dyn compatibility”Không phải trait nào cũng trở thành trait object được. Một trait phải dyn compatible (tương thích với dyn; sách và blog cũ gọi là object safety). Quy tắc đầy đủ khá dài, nhưng hai quy tắc sau bao quát hầu hết trường hợp thực tế:
- Phương thức không được trả về
Self. (Đằng sau mộtdyn, Rust không biếtSelflà gì, nên không biết giá trị trả về to cỡ nào.) - Phương thức không được có tham số kiểu generic của riêng nó, như
fn process<T>(&self, item: T).
Đây là chuyện xảy ra nếu bạn vi phạm quy tắc thứ nhất:
trait Shape { fn area(&self) -> f64; fn duplicate(&self) -> Self;}// ... sau đó:let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 1.0 })];error[E0038]: the trait `Shape` is not dyn compatible |14 | let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 1.0 })]; | ^^^^^ `Shape` is not dyn compatible | 1 | trait Shape { | ----- this trait is not dyn compatible... 2 | fn area(&self) -> f64; 3 | fn duplicate(&self) -> Self; | ^^^^ ...because method `duplicate` references the `Self` type in its return type = help: consider moving `duplicate` to another traitThông báo lỗi rất thẳng thắn và hữu ích: nó chỉ đúng phương thức gây vấn đề (duplicate dùng kiểu Self trong kiểu trả về) và gợi ý chuyển phương thức đó sang trait khác. Lựa chọn của bạn là chuyển phương thức đó sang một trait khác, hoặc thêm where Self: Sized vào phương thức. Điều đó nói với Rust rằng “phương thức này chỉ tồn tại trên các kiểu thật, đã biết, không có trên trait object”:
trait Shape { fn area(&self) -> f64; fn duplicate(&self) -> Self where Self: Sized;}Đây cũng là lý do một số trait chuẩn, như Clone, không dùng được dưới dạng dyn Clone: clone() trả về Self.
Lỗi thường gặp
Phần tiêu đề “Lỗi thường gặp”Quên Box. Rất dễ bị cám dỗ viết Vec<dyn Shape>:
let shapes: Vec<dyn Shape> = Vec::new();error[E0277]: the size for values of type `dyn Shape` cannot be known at compilation time --> src/main.rs:3:17 |3 | let shapes: Vec<dyn Shape> = Vec::new(); | ^^^^^^^^^^^^^^ doesn't have a size known at compile-time | = help: the trait `Sized` is not implemented for `dyn Shape`Rustc báo không thể biết kích thước của dyn Shape lúc biên dịch. Một dyn Shape có thể có bất kỳ kích thước nào: một Square nhỏ hay một struct lớn với nhiều trường. Rust cần kích thước cố định, nên trait object luôn phải nằm sau một con trỏ: Box<dyn Shape>, &dyn Shape hoặc Rc<dyn Shape>.
Dùng dyn ở khắp nơi. Nếu một hàm mỗi lần chỉ nhận một kiểu, impl Trait hoặc generic sẽ đơn giản và nhanh hơn (trang Trait nâng cao nói thêm về impl Trait). Hãy để dành dyn cho khi bạn thật sự cần trộn nhiều kiểu.
Luyện tập
Phần tiêu đề “Luyện tập”1. Cả sở thú trong một Vec
Phần tiêu đề “1. Cả sở thú trong một Vec”Hãy định nghĩa trait Animal với fn name(&self) -> String và fn sound(&self) -> String. Cài đặt nó cho Dog, Cow và Duck, đặt mỗi con một bản vào Vec<Box<dyn Animal>>, rồi duyệt qua để in tiếng kêu của từng con. Gợi ý: các struct có thể rỗng (struct Dog;), và mỗi con được thêm vào bằng Box::new(...).
Xem lời giải
trait Animal { fn name(&self) -> String; fn sound(&self) -> String;}
struct Dog;struct Cow;struct Duck;
impl Animal for Dog { fn name(&self) -> String { String::from("Chó") } fn sound(&self) -> String { String::from("gâu gâu") }}
impl Animal for Cow { fn name(&self) -> String { String::from("Bò") } fn sound(&self) -> String { String::from("ụm bò") }}
impl Animal for Duck { fn name(&self) -> String { String::from("Vịt") } fn sound(&self) -> String { String::from("cạp cạp") }}
fn main() { let zoo: Vec<Box<dyn Animal>> = vec![Box::new(Dog), Box::new(Cow), Box::new(Duck)];
for animal in &zoo { println!("{} kêu {}", animal.name(), animal.sound()); }}Kết quả:
Chó kêu gâu gâuBò kêu ụm bòVịt kêu cạp cạp2. Chọn người chào lúc chạy
Phần tiêu đề “2. Chọn người chào lúc chạy”Hãy định nghĩa trait Greeter với fn hello(&self, name: &str) -> String, và hai kiểu: English và Spanish. Viết fn make_greeter(lang: &str) -> Box<dyn Greeter> trả về Spanish cho "es" và English cho mọi mã khác. Chào "Mia" với từng mã trong ["en", "es", "fr"]. Gợi ý: cả hai nhánh của if phải trả về cùng một kiểu, nên hãy bọc mỗi nhánh trong Box::new(...).
Xem lời giải
trait Greeter { fn hello(&self, name: &str) -> String;}
struct English;struct Spanish;
impl Greeter for English { fn hello(&self, name: &str) -> String { format!("Hello, {name}!") }}
impl Greeter for Spanish { fn hello(&self, name: &str) -> String { format!("¡Hola, {name}!") }}
fn make_greeter(lang: &str) -> Box<dyn Greeter> { if lang == "es" { Box::new(Spanish) } else { Box::new(English) }}
fn main() { for lang in ["en", "es", "fr"] { let greeter = make_greeter(lang); println!("{lang}: {}", greeter.hello("Mia")); }}Kết quả:
en: Hello, Mia!es: ¡Hola, Mia!fr: Hello, Mia!Lời chào được giữ nguyên tiếng Anh và tiếng Tây Ban Nha, vì chính chúng là nội dung của bài tập.
Tóm tắt
Phần tiêu đề “Tóm tắt”- Một
Vecchỉ chứa được một kiểu. Để trộn nhiều kiểu, hãy dùng trait object nhưBox<dyn Trait>. Box<dyn Trait>sở hữu giá trị;&dyn Traitmượn nó. Trait object luôn nằm sau một con trỏ.- Generic và
impl Traitdùng dispatch tĩnh (nhanh, quyết định lúc biên dịch).dyn Traitdùng dispatch động (tra vtable lúc chạy). - Trả về
Box<dyn Trait>khi một hàm chọn giữa các kiểu khác nhau lúc chạy. - Chỉ những trait dyn compatible mới làm trait object được: không có phương thức trả về
Selfvà không có phương thức generic (trừ khi chúng cówhere Self: Sized).
Kiểm tra nhanh
Phần tiêu đề “Kiểm tra nhanh”Kiểm tra nhanh
1. Vì sao không thể đặt Circle và Square vào cùng một Vec thông thường?
2. dyn trong Box<dyn Shape> nói với Rust điều gì?
3. Cái nào sau đây dùng dispatch tĩnh?
4. Vì sao Vec<dyn Shape> (không có Box) là lỗi?
5. Phương thức nào khiến một trait không còn dyn compatible?
Điểm: 0 / 5
Đọc thêm
Phần tiêu đề “Đọc thêm”- Using Trait Objects to Abstract over Shared Behavior — The Rust Book (tiếng Anh): chương chính thức, dựng một danh sách thành phần kiểu GUI với
Box<dyn Draw>. - Returning Traits with dyn — Rust by Example (tiếng Anh): một ví dụ ngắn, chạy được, về hàm trả về
Box<dyn Trait>. - Trait objects — The Rust Reference (tiếng Anh): các quy tắc chính xác cho kiểu
dyn Trait, kể cả lifetime và các marker trait bổ sung nhưSend. - Dyn compatibility — The Rust Reference (tiếng Anh): danh sách đầy đủ các quy tắc mà trait phải tuân theo để dùng được với
dyn. - The
dynkeyword — tài liệu std (tiếng Anh): giải thích nhanh vềdyn, vtable, và những đánh đổi so với generic.