Trait trong Rust: định nghĩa hành vi dùng chung
1. Tổng quan
Trong Rust, trait là cách mô tả một hành vi mà nhiều kiểu dữ liệu cùng có. Nó gần giống interface trong Java, nhưng có thêm một số khả năng: default implementation, trait bound cho generics, và lựa chọn giữa static dispatch hay dynamic dispatch.
Đọc xong bài này, bạn sẽ biết cách định nghĩa trait, implement nó cho struct, dùng trait làm tham số và giá trị trả về, tạo danh sách đối tượng khác kiểu bằng dyn Trait. Bạn cũng sẽ biết vì sao trình biên dịch đôi khi từ chối code của bạn.
Toàn bộ ví dụ chạy được với Rust stable (edition 2021 trở lên) và không cần thư viện ngoài.
2. Định nghĩa và implement một trait
Giả sử hệ thống bán hàng có hai kiểu Order và Customer. Ta muốn cả hai đều “tóm tắt” được chính mình thành một chuỗi ngắn.
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct Order {
pub id: u32,
pub total: u64, // đơn vị: đồng
}
pub struct Customer {
pub name: String,
pub email: String,
}
impl Summary for Order {
fn summarize(&self) -> String {
format!("Đơn hàng #{} - {} đồng", self.id, self.total)
}
}
impl Summary for Customer {
fn summarize(&self) -> String {
format!("Khách hàng {} <{}>", self.name, self.email)
}
}
fn main() {
let order = Order { id: 42, total: 350_000 };
let customer = Customer {
name: "An".to_string(),
email: "an@example.com".to_string(),
};
println!("{}", order.summarize());
println!("{}", customer.summarize());
}
Giải thích:
pub trait Summarykhai báo trait với một methodsummarize. Ở đây chỉ có chữ ký, không có thân hàm.impl Summary for Ordercung cấp phần cài đặt cụ thể choOrder. Mỗi kiểu phải implement tất cả method chưa có thân trong trait.&selfnghĩa là method mượn đối tượng, không lấy quyền sở hữu.
Kết quả:
Đơn hàng #42 - 350000 đồng
Khách hàng An <an@example.com>
Lưu ý: Muốn gọi
order.summarize()từ module khác, traitSummaryphải nằm trong scope (use crate::Summary;). Đây là lỗi rất hay gặp. Trình biên dịch báo “no method namedsummarizefound” dù bạn đã implement đúng.
3. Default implementation
Trait có thể cung cấp sẵn thân hàm. Kiểu nào implement trait sẽ được dùng bản mặc định, hoặc ghi đè nếu cần.
pub trait Summary {
fn title(&self) -> String;
fn summarize(&self) -> String {
format!("[Tóm tắt] {}", self.title())
}
}
impl Summary for Order {
fn title(&self) -> String {
format!("Đơn hàng #{}", self.id)
}
// Ghi đè bản mặc định
fn summarize(&self) -> String {
format!("{} - {} đồng", self.title(), self.total)
}
}
impl Summary for Customer {
fn title(&self) -> String {
format!("Khách hàng {}", self.name)
}
// Không ghi đè summarize -> dùng bản mặc định
}
Khi chạy lại main ở mục 2, kết quả là:
Đơn hàng #42 - 350000 đồng
[Tóm tắt] Khách hàng An
Điểm đáng chú ý là default method summarize gọi được title(), dù title() chưa có thân trong trait. Đây là pattern hữu ích. Bạn chỉ bắt kiểu cụ thể cung cấp phần tối thiểu (title), còn trait xây phần còn lại trên đó.
Từ đây, bài dùng phiên bản trait này cho các ví dụ tiếp theo.
4. Trait làm tham số
4.1. Cú pháp impl Trait
Cách đơn giản nhất để viết hàm nhận “bất kỳ thứ gì có Summary”:
fn print_summary(item: &impl Summary) {
println!("Thông báo: {}", item.summarize());
}
fn main() {
let order = Order { id: 7, total: 120_000 };
print_summary(&order);
}
Thông báo: Đơn hàng #7 - 120000 đồng
4.2. Trait bound
&impl Summary thực chất là cú pháp rút gọn của generic có trait bound:
fn print_summary<T: Summary>(item: &T) {
println!("Thông báo: {}", item.summarize());
}
Hai cách viết khác nhau khi có nhiều tham số:
// a và b có thể là hai kiểu khác nhau
fn compare_loose(a: &impl Summary, b: &impl Summary) { /* ... */ }
// a và b bắt buộc cùng một kiểu T
fn compare_strict<T: Summary>(a: &T, b: &T) { /* ... */ }
Gọi compare_strict(&order, &customer) sẽ lỗi biên dịch vì Order và Customer không cùng kiểu.
4.3. Nhiều bound và mệnh đề where
Khi cần nhiều trait cùng lúc, nối bằng +. Khi danh sách dài, chuyển sang where cho dễ đọc:
use std::fmt::{Debug, Display};
fn log_item<T>(item: &T)
where
T: Summary + Display + Debug,
{
println!("{item} | {item:?} | {}", item.summarize());
}
Hàm này chỉ nhận kiểu implement đủ cả ba trait. Thiếu một trait là trình biên dịch báo lỗi ngay tại chỗ gọi hàm, không đợi tới runtime.
Generics trong Rust dùng monomorphization. Trình biên dịch sinh một bản log_item riêng cho mỗi kiểu cụ thể. Nhờ vậy lời gọi không tốn chi phí runtime (static dispatch). Đổi lại, binary có thể to hơn.
5. Trả về impl Trait
Bạn cũng có thể trả về “một kiểu nào đó implement Summary” mà không nêu tên kiểu:
fn latest_order() -> impl Summary {
Order { id: 100, total: 999_000 }
}
fn main() {
println!("{}", latest_order().summarize());
}
Đơn hàng #100 - 999000 đồng
Cách này đặc biệt hữu ích với closure và iterator. Kiểu thật của chúng thường rất dài hoặc không thể viết ra.
Lưu ý:
impl Traitở vị trí trả về vẫn là một kiểu cụ thể duy nhất. Đoạn code sau không biên dịch được:fn pick(vip: bool) -> impl Summary { if vip { Customer { name: "An".into(), email: "an@example.com".into() } } else { Order { id: 1, total: 0 } // lỗi: `if` and `else` have incompatible types } }Muốn trả về nhiều kiểu khác nhau, bạn cần
Box<dyn Summary>(mục 6).
6. Trait object với dyn Trait
Generics yêu cầu kiểu được xác định lúc biên dịch. Đôi khi bạn cần một danh sách chứa cả Order lẫn Customer. Lúc đó hãy dùng trait object:
fn main() {
let items: Vec<Box<dyn Summary>> = vec![
Box::new(Order { id: 1, total: 50_000 }),
Box::new(Customer {
name: "Bình".to_string(),
email: "binh@example.com".to_string(),
}),
];
for item in &items {
println!("{}", item.summarize());
}
}
Đơn hàng #1 - 50000 đồng
[Tóm tắt] Khách hàng Bình
Giải thích:
dyn Summarylà kiểu không biết kích thước lúc biên dịch. Vì vậy nó phải nằm sau một con trỏ nhưBox,&hoặcRc.- Mỗi trait object gồm hai phần: con trỏ tới dữ liệu và con trỏ tới vtable. Lời gọi
summarize()được tra trong vtable lúc runtime (dynamic dispatch). - Hàm
pickở mục 5 sửa được bằng cách đổi kiểu trả về thànhBox<dyn Summary>và bọc mỗi nhánh trongBox::new(...).
So sánh nhanh:
| Tiêu chí | Generics / impl Trait |
dyn Trait |
|---|---|---|
| Dispatch | Static, có thể inline | Dynamic qua vtable |
| Danh sách nhiều kiểu | Không | Có |
| Kích thước binary | Lớn hơn | Nhỏ hơn |
Lưu ý: Không phải trait nào cũng dùng được với
dyn. Trait phải dyn-compatible (trước đây gọi là object safe). Một trait không dyn-compatible nếu nó có method generic, ví dụfn send<T>(&self, t: T). Trait cũng mất tính chất này nếu có method trả vềSelfmà không kèmwhere Self: Sized. Lý do là vtable không thể chứa vô số phiên bản của method generic, và trình biên dịch không biếtSelfthật là gì.
7. Derive và các trait chuẩn
Thư viện chuẩn có nhiều trait quen thuộc. Một số trait sinh tự động được bằng #[derive]:
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub struct Order {
pub id: u32,
pub total: u64,
}
impl fmt::Display for Order {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Order#{}", self.id)
}
}
fn main() {
let a = Order { id: 5, total: 10_000 };
let b = a.clone();
println!("{a}"); // Display
println!("{a:?}"); // Debug
println!("{}", a == b); // PartialEq
println!("{}", a.to_string().len());
}
Order#5
Order { id: 5, total: 10000 }
true
7
Ta không hề implement ToString mà vẫn gọi được a.to_string(). Đó là nhờ blanket implementation trong thư viện chuẩn:
impl<T: Display + ?Sized> ToString for T { /* ... */ }
Nghĩa là mọi kiểu có Display đều tự động có ToString. Bạn cũng có thể viết blanket impl cho trait của mình, ví dụ impl<T: Display> Summary for T. Tuy nhiên hãy cẩn thận, vì nó sẽ xung đột với mọi impl riêng lẻ khác.
8. Orphan rule và newtype pattern
Rust có quy tắc orphan rule. Bạn chỉ được viết impl Trait for Type nếu trait hoặc kiểu được định nghĩa trong crate của bạn. Quy tắc này ngăn hai crate cùng implement một trait cho một kiểu theo hai cách khác nhau.
Ví dụ sau bị từ chối vì cả Display lẫn Vec đều thuộc thư viện chuẩn:
// lỗi E0117: only traits defined in the current crate can be implemented for types defined outside of the crate
impl std::fmt::Display for Vec<Order> { /* ... */ }
Cách giải quyết là newtype pattern. Bạn bọc kiểu ngoài trong một struct của mình:
pub struct Cart(pub Vec<Order>);
impl fmt::Display for Cart {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let total: u64 = self.0.iter().map(|o| o.total).sum();
write!(f, "Giỏ hàng: {} đơn, tổng {} đồng", self.0.len(), total)
}
}
fn main() {
let cart = Cart(vec![
Order { id: 1, total: 100_000 },
Order { id: 2, total: 250_000 },
]);
println!("{cart}");
}
Giỏ hàng: 2 đơn, tổng 350000 đồng
Cart là kiểu cục bộ nên được phép implement Display. Newtype không tốn chi phí runtime: Cart có cùng layout với Vec<Order>.
9. Viết test cho trait
Test trong Rust nằm ngay cùng file, trong module có #[cfg(test)]. Ta kiểm tra cả bản ghi đè lẫn bản mặc định, và cả trường hợp gọi qua trait object:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn order_overrides_default_summary() {
let order = Order { id: 42, total: 350_000 };
assert_eq!(order.summarize(), "Đơn hàng #42 - 350000 đồng");
}
#[test]
fn customer_uses_default_summary() {
let customer = Customer {
name: "An".to_string(),
email: "an@example.com".to_string(),
};
assert_eq!(customer.summarize(), "[Tóm tắt] Khách hàng An");
}
#[test]
fn trait_objects_dispatch_to_correct_impl() {
let items: Vec<Box<dyn Summary>> = vec![
Box::new(Order { id: 1, total: 0 }),
Box::new(Customer { name: "Bình".into(), email: "b@x.vn".into() }),
];
let result: Vec<String> = items.iter().map(|i| i.summarize()).collect();
assert_eq!(result, vec!["Đơn hàng #1 - 0 đồng", "[Tóm tắt] Khách hàng Bình"]);
}
}
Chạy test:
cargo test
running 3 tests
test tests::customer_uses_default_summary ... ok
test tests::order_overrides_default_summary ... ok
test tests::trait_objects_dispatch_to_correct_impl ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Test thứ ba quan trọng. Nó xác nhận rằng khi đi qua vtable, mỗi phần tử vẫn gọi đúng cài đặt của kiểu thật.
10. Kết luận
Trait là nền tảng của việc chia sẻ hành vi trong Rust. Các điểm chính:
- Dùng
traitđể khai báo hành vi vàimpl Trait for Typeđể cài đặt. Default method giúp giảm code lặp. - Dùng
impl Traithoặc trait bound (T: Trait,where) khi kiểu đã biết lúc biên dịch. Cách này cho static dispatch, không tốn chi phí runtime. - Dùng
Box<dyn Trait>khi cần chứa hoặc trả về nhiều kiểu khác nhau. Hãy nhớ yêu cầu dyn-compatible. - Nhớ đưa trait vào scope bằng
usetrước khi gọi method của nó. - Gặp orphan rule thì dùng newtype pattern.
Khi nào không nên tạo trait? Nếu hành vi chỉ có một cài đặt duy nhất và không cần thay thế khi test, một method thường trên struct là đủ. Trait chỉ đáng thêm vào khi thật sự có nhiều kiểu cùng chia sẻ một hành vi, hoặc khi bạn cần trừu tượng hoá để tách các phần của hệ thống.