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 Summary khai báo trait với một method summarize. Ở đây chỉ có chữ ký, không có thân hàm.
  • impl Summary for Order cung cấp phần cài đặt cụ thể cho Order. Mỗi kiểu phải implement tất cả method chưa có thân trong trait.
  • &self nghĩ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, trait Summary phả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 named summarize found” 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 Summary là 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ặc Rc.
  • 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ành Box<dyn Summary> và bọc mỗi nhánh trong Box::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ề Self mà không kèm where 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ết Self thậ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 Trait hoặ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 use trướ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.

rusttraitgenericspolymorphism