Traits và generics để trừu tượng hóa
Vì sao trait là xương sống của service ngân hàng
Bài trước bạn đã gắn lifetime vào domain model tiền tệ để Transfer<'a> mượn Account mà không phải clone. Nhưng khi nghiệp vụ lớn dần (nhiều loại phí, nhiều loại giao dịch, nhiều kênh nhập liệu), câu hỏi thật sự là: làm sao thêm loại mới mà không sửa hàng loạt match và không phá invariant tiền tệ? Trait và generics là câu trả lời, và bạn sẽ dùng lại chúng xuyên suốt capstone.
Sau bài này bạn cần:
- Viết được trait
ValidatablevàPricingcho domain tiền tệ. - Dùng generics với trait bounds thay cho tư duy kế thừa kiểu Java/C#.
- Phân biệt static dispatch (
impl Trait, generic) và dynamic dispatch (dyn Trait), biết chọn cái nào. - Nhận diện 4 lỗi thiết kế thường gặp khi nghiệp vụ mở rộng.
Trait: hợp đồng hành vi, không phải cây kế thừa
Trong Rust không có extends. Một trait mô tả một khả năng: "kiểu này validate được", "kiểu này tính được giá". Type nào muốn tham gia thì impl tường minh. Điều này quan trọng với fintech vì bạn kiểm soát chính xác type nào được đi vào luồng tiền.
Validation: mỗi khả năng một trait nhỏ
pub trait Validatable {
type Error;
fn validate(&self) -> Result<(), Self::Error>;
}
impl Validatable for Transfer {
type Error = ValidationError;
fn validate(&self) -> Result<(), Self::Error> {
if self.amount.amount_minor <= 0 {
return Err(ValidationError::NonPositiveAmount);
}
if self.to_account.is_empty() {
return Err(ValidationError::MissingBeneficiary);
}
Ok(())
}
}
type Error là associated type: mỗi impl chọn đúng một kiểu lỗi, và compiler ép bạn nhất quán. Đây là chỗ dễ sai nhất của người mới: đừng gộp validation, pricing, persistence vào một trait AccountService. Trait càng to thì impl càng dễ phải viết unimplemented!(), tức là panic trong production.
Bài tập 1 (đưa vào capstone): viết
impl Validatable for Withdrawalvới ba luật: amount dương, amount không vượt balance, currency khớp.
Xem gợi ý
impl Validatable for Withdrawal {
type Error = ValidationError;
fn validate(&self) -> Result<(), Self::Error> {
if self.amount.amount_minor <= 0 {
return Err(ValidationError::NonPositiveAmount);
}
if self.amount.amount_minor > self.account.balance.amount_minor {
return Err(ValidationError::InsufficientFunds);
}
if self.amount.currency != self.account.currency {
return Err(ValidationError::CurrencyMismatch);
}
Ok(())
}
}
Trả Result, tuyệt đối không assert! hay panic!: một request sai không được làm chết cả service.
Pricing: default method giữ invariant ở một chỗ
pub trait Pricing {
fn unit_price(&self) -> Money;
fn total(&self, qty: u32) -> Result<Money, PricingError> {
self.unit_price()
.checked_mul(qty)
.ok_or(PricingError::Overflow)
}
}
total là default method: mọi impl chỉ cần khai báo đơn giá, còn phép nhân có kiểm tra overflow nằm duy nhất một nơi. Nếu mỗi loại phí tự viết amount * qty, sớm muộn sẽ có một impl quên checked_mul.
Generics với trait bounds thay cho kế thừa
pub fn quote_line<T>(item: &T, qty: u32) -> Result<Money, QuoteError>
where
T: Validatable<Error = ValidationError> + Pricing,
{
item.validate().map_err(QuoteError::Validation)?;
item.total(qty).map_err(QuoteError::Pricing)
}
Đọc theo cách này: "tôi không cần biết T là gì, miễn nó validate được và tính được giá". Đó chính là composition thay cho kế thừa. Rust biên dịch generic bằng monomorphization, sinh code riêng cho từng kiểu cụ thể, nên không có chi phí gọi hàm ảo.
dyn Trait khi cần danh sách chiến lược
pub trait FeeRule {
fn apply(&self, amount: Money) -> Money;
fn name(&self) -> &'static str;
}
pub struct FeeSchedule {
rules: Vec<Box<dyn FeeRule>>,
}
Khi tập luật phí được cấu hình từ database, bạn không biết trước kiểu nào, nên dyn Trait là lựa chọn đúng. Cái giá: dynamic dispatch qua vtable, không inline được.
Bài tập 2: vì sao
fn run(rules: &[impl FeeRule])không nhận đượcVecchứa nhiều loại phí khác nhau?
Xem gợi ý
impl Trait ở vị trí tham số là generic, mỗi lần gọi chỉ ứng với một kiểu cụ thể. Slice nhiều kiểu bắt buộc phải là &[Box<dyn FeeRule>]. Đổi lại, generic cho phép inline và kiểm tra kiểu tại compile time, nên dùng generic cho hàm nội bộ, dyn cho nơi cần linh hoạt.
Bốn lỗi thiết kế khi nghiệp vụ mở rộng
- God trait: một trait ôm validation, pricing, persistence. Impl nào cũng phải viết method rỗng hoặc
unimplemented!(). - Method generic trong trait cần
dyn: trait mất object safety, không tạo đượcBox<dyn ...>. - Impl trait ngoại cho type ngoại: Rust chặn bằng orphan rule. Giải pháp là newtype:
pub struct Vnd(Money);. - Trừu tượng quá sớm: mới có một impl đã dựng trait, rồi phải sửa trait ba lần khi thêm nghiệp vụ.
Cạm bẫy khi trừu tượng hóa tiền tệ
- Rule of three: đợi đến impl thứ hai hoặc thứ ba, hoặc đến khi cần mock trong test, rồi hãy tạo trait.
- Đặt bound ở biên, không rải khắp nơi. Chỉ hàm thật sự cần
T: Pricingmới khai báo bound; đừng thêm bound vào struct. - Đừng nhét
Clonevào bound để né borrow checker. Trong luồng tiền, clone im lặng thường là dấu hiệu bạn đang che một lỗi ownership. - Đặt tên trait theo khả năng, không theo thực thể:
Pricing,Validatable,FeeRule, chứ không phảiAccountTrait. - Sealed trait (
pub trait Pricing: private::Sealed) khi invariant tài chính không được cho crate ngoài impl. Nếu không, ai đó có thể tạo phép tính phí sai và bạn không kiểm soát được.
Chốt lại và bước tiếp theo
Ba điều cần mang theo: trait mô tả một khả năng, generics + bounds thay cho kế thừa, và dyn Trait chỉ dùng khi thật sự cần một collection nhiều kiểu. Kiểm tra nhanh: generic được biên dịch theo hướng _____, còn dyn Trait dùng _____ qua vtable. Muốn chặn crate ngoài impl trait của mình, dùng pattern _____. Không thể impl trait ngoại cho type ngoại vì _____.
Bài tiếp theo là checkpoint Module 1: bạn sẽ ghép ownership, borrowing, lifetimes, trait và generics vào một bài tính toán tiền hoàn chỉnh, không clone thừa và không panic.
Thẻ ôn tập nhanh
Front: Trait trong Rust tương ứng khái niệm nào ở Java/C#? Back: Interface, nhưng là hợp đồng hành vi tách rời, không có kế thừa implementation theo cây lớp.
Front: impl Trait ở vị trí tham số tương đương với gì?
Back: Một generic parameter có trait bound, tức static dispatch.
Front: Khi nào nên dùng Box<dyn Trait>?
Back: Khi cần field hoặc collection chứa nhiều kiểu khác nhau cùng implement một trait.
Front: Chi phí của dyn Trait là gì?
Back: Dynamic dispatch qua vtable: gọi hàm gián tiếp, không inline được.
Front: Default method trong trait dùng để làm gì?
Back: Chia sẻ implementation chung, ví dụ total() gọi unit_price() để invariant nằm một chỗ.
Front: Vì sao trait có method generic không dùng được làm trait object?
Back: Nó mất object safety, nên không tạo được Box<dyn Trait>.
Front: Associated type khác generic parameter ở điểm nào? Back: Associated type: mỗi impl chọn đúng một kiểu; generic: một impl phục vụ nhiều kiểu.
Front: Orphan rule phát biểu thế nào? Back: Chỉ được impl trait local cho type ngoại, hoặc trait ngoại cho type local.
Front: Newtype wrapper giải quyết vấn đề gì?
Back: Cho phép impl trait ngoại trên type ngoại và tạo ranh giới domain rõ ràng, ví dụ struct Vnd(Money).
Front: Sealed trait là gì? Back: Trait có supertrait private trong module riêng, chặn crate ngoài impl.
Front: Rule of three khi trừu tượng hóa là gì? Back: Đợi có 2-3 impl, hoặc cần mock trong test, rồi mới tạo trait.
Front: God trait gây hại gì trong codebase ngân hàng?
Back: Buộc impl phải viết unimplemented!() (panic) và mọi thay đổi lan ra toàn bộ hệ thống.
Front: Tại sao không nên thêm Clone vào trait bound cho tiện?
Back: Nó che lỗi ownership và tạo bản sao dữ liệu tiền không cần thiết.