Base concepts
Rust is not exactly an object-oriented programming language (OOP), even though it might seem like one. Object-oriented programming relies on three main principles, and Rust doesn’t strictly follow them:
- encapsulation – “organizing the separation of concerns“,
- inheritance – “organizing the specialization” and
- polymorphism – “allowing generalization“.
Encapsulation
Encapsulation involves the concept of using classes to structure the layout. Objects are instances with assigned values. Classes define “fields” and “methods” with varying levels of visibility. The basic form, not specific to object orientation, involves organizing code into procedures that the program flow can jump to.
However, in more complex programs, additional encapsulation mechanisms are necessary. In Rust, for example, modules (using the “mod” keyword) play this role. Modules typically contain a set of procedures, some accessible externally, and some not.
This leads to a division of classes into two parts:
- Field interfaces become structs.
- Method interfaces become traits.
Inheritance
Inheritance broadens encapsulation by assigning one “name” to serve multiple purposes. From an object-oriented programming (OOP) standpoint, Rust simply imitates inheritance.
Trait Polymorpishm
The most basic (1 level) form of inheritance is referred to as “Trait Polymorphism.” In the given example, functions can accept “Square” and “Rectangle” objects using the common type “Area.”

pub trait Area {
fn area(&self) -> f64;
}
pub struct Square {
pub SideLength: f64,
}
pub struct Rectangle {
pub Width: f64,
pub Height: f64
}
impl Area for Square {
fn area(&self) -> f64 {
return self.SideLength * self.SideLength;
}
}
impl Area for Rectangle {
fn area(&self) -> f64 {
return self.Width * self.Height;
}
}
Polymorphism
Rust lacks a super class similar to Smalltalk’s “meta class” or Java’s “Object” class. Consequently, it offers multiple approaches to achieve polymorphism-like behavior:
- Static Polymorphism: This occurs at compile time through templates, “Trait Polymorphism” using the “impl” keyword (as mentioned earlier), or “Enum Polymorphism” also utilizing the “impl” keyword. The latter can be managed on both the stack and the heap simultaneously.
- Dynamic Polymorphism: This happens at runtime through mechanisms like smart pointers, boxing, or traits with the “dyn” keyword. In this scenario, every object must be stored on the heap to accommodate varying memory sizes.
Enum Polymorphism
Enum Polymorphism, also known as “sum type” or “tagged union” in other programming languages, enables the implementation of multi-level inheritance.

enum Shape {
Square { SideLength: i32 },
Rectangle { Width: i32, Height: i32 },
Circle { Radius: i32 },
}
impl Shape {
fn area(&self) -> Option<i32> {
match self {
Square { SideLength } => Some(SideLength * SideLength),
Rectangle { Width, Height } => Some(Width * Height),
Circle { Radius } => None,
}
}
}
Dynamic Polymorphism
Dynamic polymorphism is a challenging aspect in Rust, closely tied to the memory management mechanism known as “ownership.”
Now, let’s discuss an anti-pattern called “Deref Polymorphism” (also referred to as “deref trait” or “smart pointer”). This anti-pattern deals with the functional aspects and typing for data structures such as lists, hash maps, etc. In technical terms, it specifies where the data and code in main memory can be located.
use std::ops::Deref;
pub trait Color {
fn printColor(&self);
}
impf Color {
...
}
impl Square {
}
impl Deref for Square {
type Target = Color;
fn deref(&self) -> &Color {
&self.f
}
}
Composites
First and foremost, Rust doesn’t handle “garbage collection” through reference counting. Instead, it relies on “ownership” and “scopes” to ensure memory safety. When data is no longer owned, Rust automatically deallocates it. Consequently, programmers need to furnish sufficient information to enable the compiler to accurately determine data lifetimes. This is crucial for ensuring that the code doesn’t result in pointers accessing values that have been moved or destroyed.
In Rust, each object can have only one owner. The following code snippet would result in a compile error:
let myshape = Square::new(3.,4.);
do_something1(myshape);
// Cause an error, because ownership is moved to the function
do_something2(myshape);
Let us have a look on what “moving ownership” means.

Typically, in UML aggregations and compositions, there’s a transfer of ownership involved. The collecting type assumes ownership.
Moving object ownership
impl Square {
...
}
impl Figure {
...
}
fn main() {
let mut myfigure = Figure::new();
let myshape = Square::new(3.,4.);
// Here the move happens
myfigure.add_shape(myshape);
// here my figure and mishap will be destroyed
}
Make a object copy
If you like to move an object, just make a copy (aka clone).
fn main() {
...
// Here the copy happens
myfigure.add_shape(myshape.clone());
}
Allocate heap objects (aka dynamic Boxing)
Normally, objects are initially allocated on the stack. To extend their lifetime throughout the entire program, the Box trait is used, placing the value on the heap.
let myboxedshape = Box::new(Square::new(3.,4.));
In a flexible class tree, the memory size is not predetermined at compile time. For instance, a Rectangle requires two variables, while a Square needs only one. This variability could lead to runtime issues for data structures such as vectors. By employing the “dyn” keyword, the compiler acknowledges and addresses this variability.
let mut mylist: Vec<Box<dyn Shape>> = Vec::new();
The “dyn” keyword is appropriately used in scenarios related to “inversion of control“, also known as dependency injection. In such cases, a framework library assumes control, and the client using it specifies how to handle the concrete actions. In essence, it enables writing code that interacts with dependencies as black boxes, without needing intricate knowledge of the internal details.
pub trait Operations<T> {
fn fill(t: T);
fn erease() -> T;
...
}
fn do_stuff(op: Operations<Shape>) {
let something: Shape = op.erease();
op.fill(something);
}
OOP convenience
Some mechanism has been commonly agreed, such as
- Constructor
- Default constructor
- Default parameter and function overloading
- Destructor
- Return types
- Multi ownership
Constructors
Rust does not have constructors. Per convention a method “new” is used.
pub struct Square {
SideLength: f64
}
impl Square {
// Constructor
pub fn new(SideLength: f64) -> Self {
Self { SideLength }
}
}
Default constructor
The “Default” trait allow to create objects without any parameter.
impl Default for Square {
fn default() -> Self {
Self { SideLength: 0 }
}
}
...
let s = Square::default();
Alternative to the “Default” trait a respective template is available.
#[derive(Default)]
pub struct Square {
SideLength: f64
}
You could use the “Default ” trait also for partial initialization (aka “Struct Update Syntax”).
pub struct Rectangle {
pub Width: f64,
pub Height: f64
}
impl Default for Rectangle {
fn default() -> Self {
Self { SideLength: 0 }
}
}
...
let my_instance = Rectangle {
Width: 456,
..Default::default()
};
Default parameter and function overloading
Rust lacks support for default parameters in function signatures and doesn’t allow function overloading. However, alternative mechanisms are available to simulate these behaviors:
- “default args” macro and
- “Some”-and “into”-traits.
Let’s start with the “default args” macro.
use default_args::default_args;
default_args! {
fn combine(important_arg: Shape, optional: Shape = Square::default() ) -> Shape {
...
}
}
...
combine!(Square::default(), Square::default());
combine!(Square::new(3.,4.));
Next logic step is to switch to the “Option” type.
fn combine(maybe_payload: Option<Vec<u8>>) -> Result<Figure, io::Error> {
...
}
combine(Some( vec![Square::new(3.,4.),Circle::default()] ))?;
combine(None)?;
combine()?;
Next step for newbies in Rust would be the “Generics” approach. Key traits are the following two:
fn combine<Shape>(thing: impl Into<Option<Shape>>) -> Figure where Figure: Default {
...
}
All the mentioned approaches are valid to some extent, but each introduces runtime overhead. The “Into-Option” treatment with a “Vec” exhibits the best performance.
Note: Using Some
and None
is the conventional “safe” way to navigate the limitation in Rust, where the language doesn’t support the “safe” use of NULL
pointers.
Destructors
In object-oriented programming (OOP), the term is used somewhat differently than what is described in the Rust documentation here. In this context, it signifies something more akin to “finally” in other programming languages – a segment of code that will be executed regardless of how a function is exited. This becomes crucial for functions with multiple exits that require independent closing actions.
fn any_function() -> f32{
struct ExitHandler;
...
// Implement a destructor for Foo.
impl Drop for ExitHandler {
fn drop(&mut self) {
println!("exit");
}
}
...
let _exit = ExitHandler;
...
12.
}
Return types and exception handling
In general most Rust programs return one of the following types
Result is employed for error handling, similar to the concept of “Exception handling” in other programming languages. On the other hand, Option is used to indicate the presence or absence of a result.
Return handling becomes more critical in relation to inheritance. Similar to function arguments, the compiler needs to know the memory size of the return type. By utilizing a heap-allocated return type (using the Box trait), the result can be flexibly adjusted.
fn find_largest_area(...) -> Box<dyn Shape> {
if ... {
Box::new(Circle...)
} else {
Box::new(Square...)
}
}
Reference counting
Ownership stands out as a fundamental management feature in Rust. In the context of fluent object-oriented programming (OOP), it can either result in unreadable code or well-structured code. Personally, I find Java’s approach with reference counting to be appealing. While it introduces additional computational overhead and a potential for “memory leaks” through dangling reference counts, the code remains clean.
Despite differing opinions, Rust also supports reference counting, enabling multi-ownership. Similar to the “Box” trait, which allocates memory on the heap, the “Rc” trait and its thread-safe counterpart, “Arc” (thread safe “Rc”) allow for multi-ownership of values.