Skip to main content

Command Palette

Search for a command to run...

Object-Oriented Thinking in Rust

Updated
16 min read
Object-Oriented Thinking in Rust
S

I am Quantitative Developer, Rust enthusiast and passionate about Algo Trading.

Object-oriented programming, also called OOP, is a programming style that is dependent on the concept of objects. It is very popular and established. It is like designing and organizing your code by thinking of parts of your program as real-world objects. These objects bring together data (attributes) and functions (behaviors or methods) into one neat package that can communicate with each other. Every object has its own unique set of properties.

The building blocks of OOPs are attributes, methods, classes and object. The principles of OOPs are encapsulation, abstraction, inheritance and polymorphism. We will discuss these blocks and principles.

Rust isn't "object-oriented" in the usual inheritance-based way, but it offers tools—structs, impl blocks, and traits—that let you use many of the same design patterns: encapsulation, abstraction, and polymorphism.

In fact, Rust's approach is very close to object oriented way of thinking, can help you create more maintainable and strong systems.

Encapsulation

Encapsulation is a core principle of object-oriented programming (OOP). It refers to bundling data (fields/attributes) and behavior (methods) together. In Rust, this is done by defining structs (similar to classes in other programming languages). Beyond just packaging data, encapsulation also involves hiding an object's internal state and implementation details, while providing a clear and accessible way to interact with it. In many traditional OOP languages like Java, C#, and C++, encapsulation is achieved using private fields (attributes) and public methods (getters, setters, business logic) to control access to these fields.

Structs

In classical OO languages:

  • A class is a blueprint that defines the data (fields or attributes) and behaviors (methods).

  • An object is an instance of a class.

In Rust, structs can be used to group related data (similar to fields in a class). Along with impl blocks, which define associated functions and methods, struct + impl can serve a similar role to classes.

Attributes are variables that represent the state of the object. In the below example future contract attributes represent its state. In other words attributes (or fields) refer to the data contained within an object.

Example: A Basic Future Contract Struct

A futures contract has attributes such as:

  • The underlying asset (e.g., a commodity, stock index)

  • The quantity or number of units

  • The maturity date

  • The entry price or the price at which the contract was agreed

Here’s an example of how we can represent these attributes in Rust:

#[derive(Debug)]
struct FutureContract {
    underlying: String,
    quantity: u64,
    maturity_date: String,
    entry_price: f64,
}

This FutureContract struct holds all the relevant data for our futures contract. It’s analogous to defining a class with four attributes in an OO language.

We can create instances of a struct (akin to objects) by calling the struct’s name with the field values.

fn main() {
    let future = FutureContract {
        underlying: String::from("Crude Oil"),
        quantity: 100,
        maturity_date: String::from("2025-12-31"),
        entry_price: 60.50,
    };

    println!("Created a future: {:?}", future);
}

Created a future: FutureContract { underlying: "Crude Oil", quantity: 100, maturity_date: "2025-12-31", entry_price: 60.5 }

Here, future is an instance of FutureContract, just as an object would be an instance of a class in a classical OO paradigm.

Rust ensures that objects (instances of structs) are in a valid state after they are created. In an object-oriented way of thinking, an object must have a state; it cannot exist without a valid state. For example, if you create a point with x-axis and y-axis for a 2D coordinate system, a point object must have valid coordinates. You cannot create a point where you know the x-coordinate but the y-coordinate is unknown. Rust provides this by default. In C# you can achieve this using the required modifier.

#[derive(Debug)]
struct point{
    x:i64 ,
    y: i64
}
fn main() {
    let a = point{x:1};
    println!("Created a point: {:?}", a);
}
error[E0063]: missing field `y` in initializer of `point`
 --> src/main.rs:8:13
  |
8 |     let a = point{x:1};
  |             ^^^^^ missing `y`

If we try to create a point without y coordinate we get compile time error.

Methods and Associated Functions

In many OO languages, methods are defined within the class. Rust does not store methods within structs but uses impl blocks (implementation blocks) to define methods associated with a given struct.

Let’s add a constructor-like function (commonly named new in Rust). Rust does not have constructors in the classical sense. Instead, Rust uses associated functions (often called “factory” or “constructor-like” functions, typically named new) within an impl block to initialize structs.

Another method to calculate the contract’s notional value (quantity times entry price) and a method to settle the contract.

impl FutureContract {
    /// Associated function (like a constructor)
    fn new(underlying: &str, quantity: u64, maturity_date: &str, entry_price: f64) -> Self {
        FutureContract {
            underlying: String::from(underlying),
            quantity,
            maturity_date: String::from(maturity_date),
            entry_price,
        }
    } 
    /// Calculate the notional value of the future
    fn calculate_notional(&self) -> f64 {
        self.quantity as f64 * self.entry_price
    }
    /// Some method representing contract settlement
    fn settle(&self, settlement_price: f64) -> f64 {
        // Gain or loss = (Settlement price - Entry price) * Quantity
        let pnl = (settlement_price - self.entry_price) * self.quantity as f64;
        pnl
    }
}

To define the function within the context of FutureContract, we start an impl (implementation) block for FutureContract. Everything within this impl block will be associated with the FutureContract type.

All functions defined within an impl block are called associated functions because they’re associated with the type named after the impl. The associated functions that take self as parameters are called methods because they need instance of the struct. Associated functions that aren’t methods (don’t need self in parameters) are often used for constructors that will return a new instance of the struct. These are often called new, but new isn’t a special name and isn’t built into the language. The new function in our struct doesn’t take self and act as a constructor.

The Self keywords in the return type and in the body of the function are aliases for the type that appears after the impl keyword, which in this case is FutureContract. To call this associated function, we use the :: syntax with the struct name.

fn main() {
    let oil_future = FutureContract::new("Crude Oil", 100, "2025-12-31", 60.50);
    let notional = oil_future.calculate_notional();
    println!("Notional value: ${:.2}", notional);
    let pnl = oil_future.settle(65.00);
    if pnl > 0.0 {
        println!("Profit: ${:.2}", pnl);
    } else {
        println!("Loss: ${:.2}", pnl.abs());
    }
}

Self, self and &self in Methods

Self (capital “S”) refers to the type within an impl block. For instance, in our impl FutureContract { ... } block, Self is an alias for FutureContract. Typically used in associated functions (which do not take a self parameter) to construct or return an instance of the same type, for example, the new function in impl FutureContract { ... }. Here, Self is simply FutureContract. If you rename the struct, you won’t need to update the type references in the methods—Self will remain valid as it automatically refers to the type in the current impl.

Additionally, you can also see Self used as a return type in methods that consume self and return another struct of the same type, or partial transformations:

impl FutureContract {
    fn into_option(self) -> Option<Self> {
        // Example of returning an Option of Self.
        Some(self)
    }
}

Here, Option<Self> could also be written as Option<FutureContract>.

Rust requires us to explicitly declare how we want to receive the instance: (self), by immutable reference (&self), or by mutable reference (&mut self).

&self — Immutable Reference

When a method signature takes &self, it borrows the struct immutably. This means the method can read the fields of the struct but cannot modify them. calculate_notional borrows self immutably, we can call it multiple times on the same instance, even simultaneously, as long as we don’t try to have mutable reference.

self — Ownership Transfer

A method that takes self literally consumes the struct instance. After calling this method, the caller loses ownership unless something is returned.

&mut self — Mutable Reference

A method with &mut self borrows the instance mutably, allowing the method to modify the struct’s fields.

impl FutureContract {
    // A method that mutates the struct
    fn update_price(&mut self, new_price: f64) {
        self.entry_price = new_price;
    }
}

Only one mutable reference to a given object can exist at a time, enforcing Rust’s borrow-checker rules.

The self is similar to self in Python, in both Python and Rust, “self” is the way you reference the current instance of a type. Both are explicit in the sense that they appear in the method signature. Rust requires you to be explicit about the ownership and borrowing (self, &self, &mut self). In Python, you can read and write to attributes through self without any explicit ownership or mutability rules. Rust enforces these constraints via the type system.

Associated Functions vs. Static Methods in Other Languages

In many object-oriented languages (e.g., Java, C#), you can mark a method as static, meaning it does not need an instance of the class to be called. This approach goes against the principles of OOP and can lead to less maintainable code. This might be why there is no specific static keyword for methods in Rust.

As we discussed earlier, associated functions that do not have a self parameter are called directly from the type, like this: TypeName::function_name(), instead of from an instance of the type. These associated functions are the closest equivalent to a "static method."

In JAVA/C#, you write public static void foo() { ... }, and call MyClass.foo(). In C++, You might have static void foo(); inside a class, and call MyClass ::foo();. Python uses @staticmethod decorator. In Rust, again, an associated function with no self is your “static method.”

You write:

impl MyStruct {
    fn foo() {
        // ...
    }
}

and call MyStruct::foo().

No static field inside a struct

Using the static keyword on a class field means that all instances share that field, which isn't really in line with OOP principles and can lead to issues. Many object-oriented languages have moved away from OOP principles to include this feature. In Java/C#, the static keyword is used on a class field and is usually accessed with ClassName.staticField. C++ follows the same idea. Python doesn't have a static keyword but uses class attributes that all instances share. Luckily, Rust doesn't directly support a "static field inside a struct." It avoids this behavior and sticks more closely to OOP principles in this respect.

pub keywords

Encapsulation is also a practice of restricting direct access to some of the object's components. In Rust, this is done using pub (public) and private visibility modifiers.

Overloading

When you have two or more methods with the same name but different parameters, that's called overloading. Rust does not support function or method overloading in the same way that languages like C++, Java, or C# do. In those languages, you can define multiple functions with the same name but different parameter lists or types (e.g., foo(int x) vs. foo(double x) vs. foo(int x, int y)).

Rust, however, disallows having two or more functions in the same scope.

#[derive(Debug)]
struct point{
    x:i64 ,
    y:i64
}
impl point{
    fn my_point(){
        println!("my_point function with no parameter");
    }
    fn my_point(_x:i64){
        println!("my_point function with int parameter");
    }
}
fn main() {
    let a = point{x:1,y:2};
    println!("Created a point: {:?}", a);
    point::my_point();
}

Rust does not allow these two my_point methods to coexist, even though they have different signatures, because it doesn’t support method overloading by parameter signatures.

error[E0592]: duplicate definitions with name `my_point`
  --> src/main.rs:10:5
   |
7  |     fn my_point(){
   |     ------------- other definition for `my_point`
...
10 |     fn my_point(_x:i64){
   |     ^^^^^^^^^^^^^^^^^^^ duplicate definitions for `my_point`

Some would argue that overloadimg makes code base more readable and manageable, but Rust don’t believe in this. Overloading can obscure which function is actually getting called, especially if conversions are implicit. Rust reduce confusion and make sure you have cleaner code. You can use some pattern to achieve this.

Polymorphism

Polymorphism is a big idea in programming languages. It basically means "many forms." Depending on the situation, your functions can take on different forms and behave differently. For example, function overloading is one kind of polymorphism. Generics are another type, where a function or struct is written in a way that it can work with different types while still keeping everything type-safe, like this: fn do_something<T: SomeTrait>(x: T) { ... }.

In the context of object-oriented concepts, we're particularly interested in subtype polymorphism (often called inheritance-based polymorphism or inclusion polymorphism), where an object (or reference) of a subtype can be used anywhere its supertype is expected.

In classical OOP languages (e.g., Java, C#), subtype polymorphism typically relies on inheritance: a derived class “is a kind of” the base class and can be used in any context expecting the base. Rust does not support inheritance in that traditional sense, but it provides a powerful mechanism—trait objects—which enable runtime polymorphism (often called “dynamic dispatch”).

Rust uses traits to define shared behaviors or capabilities. In Rust, when we talk about subtype polymorphism, it means that if different concrete types implement the same trait, you can keep them in the same data structure or pass them to the same function by just referring to that trait.

Traits

Traits are fundamental feature in Rust that provide a way for shared behavior, abstraction and polymorphism. 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.

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 believe the impl keyword is inspired by the 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're familiar with C++, it's like class EquityOption: public Contract. Now, let's create another struct called InterestRateSwap that also 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.

    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 trait object. This means we can pass a vector of any type that implements the Contract trait. This is an example of dynamic dispatch. We'll talk more about this when we dive into dyn in detail. Now, let's take a look at another implementation of the same function using static dispatch. Traits can also be used to set constraints on generic types, known as trait bounds. Here's 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.

  • Dynamic 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 closely tied to its ownership and borrowing model, and it clearly distinguishes between static and dynamic dispatch.

Inheritance

Actually no inheritance, no super or “abstract class” or no deep class hierarchies. You can’t call a parent class method from a child; Rust’s approach is about interfaces and implementations. Inheritance in rust is only 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 of Traits

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.

Abstraction

Abstraction is a key idea in Object-Oriented Programming (OOP) that lets you hide complex implementation details and show only the essential features or functions to the user. In Rust, you achieve abstraction using traits, modules, and encapsulation.

Traits define a set of methods that a type must implement, allowing you to abstract over behavior without specifying the implementation.

Modules (Organizing and Abstracting Code)

Rust’s module system allows you to organize code into logical units and control visibility. By default, items in a module are private, and you can expose only what’s necessary using pub.