Traits are the most important topic to understand the design patterns in Rust. Traits are fundamental feature in Rust that provide a way for shared behavior, abstraction and polymorphish. You can think of traits as interfaces in other programming languages. This is how The Rust Programming Language (“the Rust book”) defines traits:
A trait tells the Rust compiler about functionality a particular type has and can share with other types. We can use traits to define shared behavior in an abstract way. We can use trait bounds to specify that a generic can be any type that has certain behavior.
This is specially important as Rust is not an object-oriented language, in a traditional sense. Struct doesn't support inheritance. You can not define an abstract base struct and inherit to make a derived struct. To achieve the polymorphism in Rust, you need a pointer (reference) that can hold references of any type that have shared behavior. This is where traits come in.
Define a Trait
Let's say we have a variety of financial instruments from different asset classes like equity, equity options, and interest rate derivatives. We need a few generic methods on all the contracts so that we can analyze the portfolio of trades. We define a trait named Contract with one method named npv. The may look like a virtual method in C++.
pub trait Contract {
fn npv(&self) -> f64;
}
In this example, Contract is a trait that requires an implementing type to define the npv method. The npv method takes a reference to self and returns a f64 value.
Implement a Trait
Any type that implements the Contract trait must provide an implementation of the npv method. Here is an example of a struct named EquityOption that implements the Contract trait. impl keyword is used to implement traits for a struct. I think the impl keyword comes from implements keyword in Java.
pub struct EquityOption {
pub strike: f64,
pub expiry: f64,
pub is_call: bool,
}
impl Contract for EquityOption {
fn npv(&self) -> f64 {
println!("Calculating npv for EquityOption");
0.0
}
}
In this example, EquityOption is a struct that implements the Contract trait. If you are familiar with C++ then it is similar to
class EquityOption: public Contract
.
Let's create one more struct named InterestRateSwap that implements the Contract trait.
pub struct InterestRateSwap {
pub notional: f64,
pub fixed_rate: f64,
pub floating_rate: f64,
}
impl Contract for InterestRateSwap {
fn npv(&self) -> f64 {
println!("Calculating npv for InterestRateSwap");
0.0
}
}
We have totally different asset classes, EquityOption and InterestRateSwap, but they both implement the Contract trait. We can create a portfolio of equity options and interest rate swaps and calculate the net present value of the portfolio.
Polymorphism with Traits
fn calculate_portfolio_npv(contracts: Vec<&dyn Contract>) -> f64 {
let mut npv = 0.0;
for contract in contracts {
npv += contract.npv();
}
npv
}
Here, dyn Contract is a trade object. We can pass a vector of any type that implements the Contract trait. This is example of dynamic dispatch. We will discuss more shortly when discuss dyn in details. Let's understand see the another implementation of the same function with static dispatch. Traits can also be used to impose constraints on generic types, known as trait bounds. Here is an example of a generic function that calculates the net present value of a portfolio of contracts.
fn calculate_portfolio_npv<T: Contract>(contracts: Vec<T>) -> f64 {
let mut npv = 0.0;
for contract in contracts {
npv += contract.npv();
}
npv
}
In this example, T: Contract is a trait bound that specifies that T must implement the Contract trait. This is called static dispatch. The compiler generates a separate version of calculate_portfolio_npv for each type T used.
Static vs Dynamic Dispatch
Rust support both static and dynamic dispatch.
- Static dispatch is resolved at compile time. The compiler knows the exact type at compile time and method calls are resolved during compilation.
- Dynamci dispatch is resolved at runtime. The compiler doesn't know the exact type at compile time and method calls are resolved via virtual table (vtable), similar to virtual function in C++.
Rust's traits are closed tied to its ownsership and borrowing model, and it distinguishes between static and dynamic dispatch explicitly.
Inheritance in Rust
Inheritance in rust is onlt for traits. You can define a trait that inherits from another trait. Here is an example of a trait named Option that inherits from the Contract trait.
pub trait Option: Contract {
fn delta(&self) -> f64;
}
In this example, Option is a trait that inherits from the Contract trait and requires an implementing type to define the delta method. If we have a struct named EquityOption that implements the Option trait, it must provide implementations of both the npv and delta methods by implementing respective traits.
pub struct EquityOption {
pub strike: f64,
pub expiry: f64,
pub is_call: bool,
}
impl Contract for EquityOption {
fn npv(&self) -> f64 {
println!("Calculating npv for EquityOption");
0.0
}
}
impl Option for EquityOption {
fn delta(&self) -> f64 {
println!("Calculating delta for EquityOption");
0.0
}
}
If you implement the Option traits then you must implement the Contract trait as well, otherwise you have error like this:
error[E0277]: the trait bound `EquityOption: Contract` is not unfulfilled
This is another example of trait bounds in Rust. It forces the implementing type to implement all the required traits.
Default Implementations
You can provide default implementations for trait methods.
pub trait Contract {
fn npv(&self) -> f64 {
println!("Calculating npv for Contract");
0.0
}
}
In this example, Contract is a trait that provides a default implementation for the npv method. In this case, you don't have to provide the implementation of the npv method in the implementing type.
impl Contract for EquityOption {}
Multiple Trait Implementations
You can have only one implementation for each type.
pub trait Contract {
fn npv(&self) -> f64;
fn expiry(&self) -> f64;
}
impl Contract for EquityOption {
fn npv(&self) -> f64 {
println!("Calculating npv for EquityOption");
0.0
}
}
impl Contract for EquityOption {
fn expiry(&self) -> f64 {
println!("Calculating expiry for EquityOption in days");
0.0
}
}
This will give you an error like this:
error[E0119]: conflicting implementations of trait `Contract` for type `EquityOption`
This prevents the ambiguity in the code. You can have multiple traits but only one implementation for each type.
Using dyn with Traits
The dyn keyword is used to indicate a trait object, making it explict when dynamic dispatch is used. A trait object is a pointer to some type that implements a trait, but exact type is not known at compile time. Here is an example of a function that takes a trait object as an argument.
fn calculate_npv(contract: &dyn Contract) -> f64 {
contract.npv()
}
fn main() {
let equity_option = EquityOption {
strike: 100.0,
expiry: 1.0,
is_call: true,
};
let interest_rate_swap = InterestRateSwap {
notional: 1000000.0,
fixed_rate: 0.05,
floating_rate: 0.03,
};
println!("Equity Option NPV: {}", calculate_npv(&equity_option));
println!("Interest Rate Swap NPV: {}", calculate_npv(&interest_rate_swap));
}
In this example, calculate_npv takes a reference to a trait object that implements the Contract trait.
Using dyn with Box
You can also use the Box type to create a trait object.
fn main() {
let equity_option = EquityOption {
strike: 100.0,
expiry: 1.0,
is_call: true,
};
let interest_rate_swap = InterestRateSwap {
notional: 1000000.0,
fixed_rate: 0.05,
floating_rate: 0.03,
};
let contract: Box<dyn Contract> = Box::new(equity_option);
println!("Equity Option NPV: {}", contract.npv());
let contract = Box::new(interest_rate_swap) as Box<dyn Contract>;
println!("Interest Rate Swap NPV: {}", contract.npv());
}
By boxing and casting it to Box, you create a trait object on the heap.
Traits with Generics
Blanket Implementations
Rust allows you to provide a blanket implementation for all types that satisfy a certain trait bound. Here is an example of a blanket implementation. We define two more trait DiscountFactorProvider and CashFlow.
pub trait DiscountFactorProvider {
fn discount_factor(&self, date: NaiveDate) -> f64;
}
pub trait CashFlow {
fn amount(&self) -> f64;
fn date(&self) -> NaiveDate;
}
We can implement Contract with CashFlow trait
impl<T> Contract for T
where
T: CashFlow + DiscountFactorProvider,
{
fn npv(&self) -> f64 {
let df = self.discount_factor(self.date());
let npv = self.amount() * df;
println!("Calculating NPV using CashFlow and DiscountFactorProvider traits.");
npv
}
}
Any type implementing both CashFlow and DiscountFactorProvider will have npv implementation. Let's consider another example: Suppose you work on equity asset class and other team worked on fixed income asset class. Now your teams wants to do the cross asset combine Bonds with Equity. Bond doesn't have the npv method instead it has price method and different traits.
pub trait Pricable {
fn price(&self) -> f64;
}
use chrono::NaiveDate;
pub struct Bond {
pub face_value: f64,
pub coupon_rate: f64,
pub maturity: NaiveDate,
}
impl Pricable for Bond {
fn price(&self) -> f64 {
// Simplified pricing logic
self.face_value * (1.0 + self.coupon_rate)
}
}
Since Bond implements Pricable, we can implement Contract for Bond by using the blanket implementation.
impl<T> Contract for T
where
T: Pricable,
{
fn npv(&self) -> f64 {
println!("Calculating NPV using Pricable trait.");
self.price()
}
}
The npv method uses the price method from Pricable.
Using Traits with Generics Types
You can use traits with generic types to provide more flexibility.
pub trait EventHandler<E> {
fn handle_event(&mut self, event: E);
}
EventHandler handles events of different types, useful in event-driven trading systems. E is a generic type that represents the event type. Here is an example of a struct named EventProcessor that implements the EventHandler trait.
pub struct EventProcessor {
pub name: String,
}
impl<E> EventHandler<E> for EventProcessor {
fn handle_event(&mut self, event: E) {
println!("{} handling event", self.name);
}
}
Summary
In summary, traits enables ad-hoc polymorphism in Rust, allowing functions to operate on any type, as long as it satisfies certain constraints. Understanding how to define and use traits with generics is essential for leveraging Rust's strengths in abstraction and performance. Generics allow your traits to work with a variety of data types and structures, enabling you to model complex behaviors in a flexible and efficient way.