I'm surprised I haven't seen this suggested so far, but I encourage you to try modeling this with enum
s. It's likely to be less code and easier to write. I use this pattern whenever I run into something that feels like inheritance, and it's worked great for me.
(I am making some assumptions here, which I will discuss at the end.)
Group the common fields into a struct, and include the variable part as a field. Here I'm calling it TransactionKind
because it encodes the kind of transaction.
struct Transaction {
id: Id,
date: Date,
location: Location,
employee: EmployeeId,
detail_lines: Vec<DetailLine>,
kind: TransactionKind, // <--- here
}
Now, implement TransactionKind
to carry the variant data:
enum TransactionKind {
Sales {
customer: CustomerId,
billing_address: Address,
// and so on
kind: SaleKind,
},
Purchase {
// fields go here
},
}
enum SaleKind {
Invoice {
due_date: Date,
// and so on
},
Receipt { ... },
// ...
}
Advantages of this approach
The entire hierarchy of options is presented right there, together. (With OO inheritance, you can often inherit from a class anywhere in the code, so you may need an IDE to find all the subclasses.)
It's also a lot less code than writing out specialized traits and whatnot.
You can use pattern matching to distinguish special cases. You may or may not like this, but it helps with a particular pattern in OO code where you either wind up using instanceof
/dynamic_cast
to check for a particular subclass, or implement a method that's empty except for one subclass...with an enum you can just check for the type you want:
if let Transaction::Sales { customer, .. } = transaction {
println!("customer: {}", customer);
}
Disadvantages / limiting assumptions
All the "subclasses" (variants) have to be listed within the enum. Third parties can't add new ones without changing the code (as opposed to "open subclassing"). I argue that this is usually what you want -- arbitrary new subclasses can break your assumptions and your code -- but maybe you need open subclassing in your application.
You have to use pattern-matching to distinguish variants. Operations on enums wind up looking like a match
or switch
statement, and generating corresponding code. Operations on class hierarchies in OO languages tend to use "virtual dispatch," which generates different code (an indirect function call). Sometimes indirect function calls are faster, but I would strongly encourage you to profile your code before assuming this -- I've found the enum approach to generate faster code in my systems.
The way I wrote the enum above, every Transaction
will be the size of the largest variant. That is, if PurchaseTransaction is relatively small and SalesTransaction is big, you're going to be allocating one SalesTransaction-worth of space for every Transaction. (That's how Rust enums work.) You can limit this by introducing indirection. For example, in SalesTransaction
, you can box the contents to make the whole SalesTransaction
the size of a pointer:
// inside Transaction
Sales(Box<SalesTransaction>),
...
// Fields have been separated into a struct so they can be boxed.
struct SalesTransaction {
customer: CustomerId,
billing_address: Address,
// and so on
kind: SaleKind,
}
(If you've done C++, this is basically equivalent to the pImpl
pattern from Effective C++, but we use it for different reasons -- in C++ it's used to hide implementation details, which Rust does with modules.)