Igor Yusupov
Posted on July 26, 2024
Introduction
First, let's recall the main principles of OOP:
- Inheritance
- Encapsulation
- Polymorphism
While there are no issues with encapsulation in Rust, developers who have worked with OOP languages like Python might face difficulties implementing the remaining principles. This is especially relevant for those who decided to rewrite a project from a language like Python/Kotlin to Rust.
Inheritance
What is inheritance? Inheritance is a mechanism that allows extending and overriding the functionality of a type. Let's look at each idea separately.
Extending Functionality
Let's consider an example in Python.
class Employee:
def __init__(self, name: str) -> None:
self.name = name
def say_name(self) -> None:
print(self.name)
class Programmer(Employee):
pass
if __name__ == "__main__":
programmer = Programmer("Jack")
programmer.say_name()
Extension means that the child class will have all the functionality of the parent. However, this is easily addressed with composition. How would it look in Rust:
struct Employee {
name: String,
}
impl Employee {
fn say_name(&self) {
println!("{}", self.name);
}
}
struct Programmer {
employee: Employee,
}
impl Programmer {
fn say_name(&self) {
self.employee.say_name();
}
}
fn main() {
let programmer = Programmer {
employee: Employee {
name: "Jack".to_string(),
},
};
programmer.say_name();
}
It may seem that there is some code duplication here. However, the key advantage is that the child object doesn't need to inherit all the functionality from the parent, thus avoiding the creation of superclasses. Moreover, Rust has crates that minimize code duplication.
Overriding Functionality
Let's look at another example in Python
from abc import ABC, abstractmethod
class Employee(ABC):
@abstractmethod
def work(self) -> None:
pass
class Programmer(Employee):
def work(self) -> None:
print("*coding...*")
class ProductManager(Employee):
def work(self) -> None:
print("*doing nothing*")
if __name__ == "__main__":
programmer = Programmer()
product_manager = ProductManager()
programmer.work()
product_manager.work()
I intentionally made the Employee class abstract. This is one of the interesting features in Python, allowing you to create something akin to interfaces for child classes.
In Rust, this is solved more elegantly with traits. Let's see how it would look in Rust:
struct Programmer {}
struct ProductManager {}
trait Employee {
fn work(&self);
}
impl Employee for Programmer {
fn work(&self) {
println!("*coding...*");
}
}
impl Employee for ProductManager {
fn work(&self) {
println!("*doing nothing*");
}
}
fn main() {
let programmer = Programmer {};
let product_manager = ProductManager {};
programmer.work();
product_manager.work();
}
Additionally, traits can have default implementations for functions to avoid code duplication if the same behavior is needed for multiple structures.
Defining functionality through interfaces is also more elegant because there's no need to inherit all the functionality again. You can assign functionality "pointwise" to different structures, and a single structure can implement multiple different traits.
Polymorphism
Let's first define what polymorphism is. Polymorphism is a mechanism where a function or structure can work with different data types that implement a common interface.
In Python, this is done quite easily, just specify the base class as the type of the object (although given that you can pass anything as an argument, you might not even need to do this 😅):
from abc import ABC, abstractmethod
class Employee(ABC):
@abstractmethod
def work(self) -> None:
pass
class Programmer(Employee):
def work(self) -> None:
print("*coding...*")
class ProductManager(Employee):
def work(self) -> None:
print("*doing nothing*")
def make_work(worker: Employee):
worker.work()
if __name__ == "__main__":
programmer = Programmer()
product_manager = ProductManager()
make_work(programmer)
make_work(product_manager)
In Rust, there are several ways to achieve this. I will talk about the two most commonly used methods.
For virtual calls in Rust, there is a special mechanism using the dyn
keyword. How would this code look in Rust:
struct Programmer {}
struct ProductManager {}
trait Employee {
fn work(&self);
}
impl Employee for Programmer {
fn work(&self) {
println!("*coding...*");
}
}
impl Employee for ProductManager {
fn work(&self) {
println!("*doing nothing*");
}
}
fn make_work(worker: Box<dyn Employee>) {
worker.work();
}
fn main() {
let programmer = Programmer {};
let product_manager = ProductManager {};
make_work(Box::new(programmer));
make_work(Box::new(product_manager));
}
But this is not the only way. By avoiding pointer indirection, we can achieve better performance. To avoid this, we can use enum
.
Performance is higher in this case as it uses simple pattern matching instead of pointer indirection. Let's look at an example:
enum Employee {
Programmer,
ProductManager,
}
impl Employee {
fn work(&self) {
match *self {
Employee::Programmer => println!("*coding...*"),
Employee::ProductManager => println!("*doing nothing*"),
}
}
}
fn make_work(worker: Employee) {
worker.work();
}
fn main() {
let programmer = Employee::Programmer;
let product_manager = Employee::ProductManager;
make_work(programmer);
make_work(product_manager);
}
Conclusion
Rust has all the necessary tools to rewrite an old project that was written using OOP principles. Moreover, if you use traits correctly, the result will be much more elegant. Additionally, inheritance in OOP can be a bad pattern as child classes often inherit attributes and methods that they don't use.
Posted on July 26, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.