Object-Oriented Programming (OOP) is a way of modeling programs. The concept of an object originated from the Simula programming language in the 1960s. These objects influenced Alan Kay’s programming architecture, where objects communicate by passing messages to each other. He coined the term “object-oriented programming” in 1967. There are many conflicting definitions of what OOP is; under some definitions, Rust is considered object-oriented; under others, it is not.
1. Rust and Object-Oriented Programming
The Gang of Four defines object-oriented programming as follows:
An object-oriented program consists of objects. An object contains data and processes that operate on that data. These processes are typically referred to as methods or operations.
Under this definition, Rust is object-oriented: structs and enums contain data, and the <span>impl</span> block provides methods on top of structs and enums. Although structs and enums with methods are not referred to as objects, they provide the same functionality as objects, referencing the definition of objects from The Gang of Four.
Generally, the three basic characteristics of object-oriented programming are: encapsulation, inheritance, and polymorphism.
1.1 Encapsulation
Encapsulation: The implementation details of an object are not accessible to the code that uses the object. Therefore, the only way to interact with an object is through its public API; the code using the object should not be able to directly access and modify the object’s internal data or behavior. This allows programmers to change and refactor an object’s internal implementation without changing the code that uses the object.
Although the Rust language itself does not support traditional classes and inheritance found in object-oriented programming, it provides features that allow developers to encapsulate and abstract in an object-oriented manner. Here is an example that demonstrates encapsulation in Rust:
// Define a struct `Rectangle`, equivalent to a class in object-oriented programming
struct Rectangle {
width: u32,
height: u32,
}
// Implement methods for the `Rectangle` struct, encapsulating operations related to `Rectangle`
impl Rectangle {
// Associated function to create a new `Rectangle`
fn new(width: u32, height: u32) -> Rectangle {
Rectangle { width, height }
}
// Instance method to calculate the area of the rectangle
fn area(&self) -> u32 {
self.width * self.height
}
// Instance method to calculate the perimeter of the rectangle
fn perimeter(&self) -> u32 {
(self.width + self.height) * 2
}
// Instance method to set the width of the rectangle
fn set_width(&mut self, width: u32) {
self.width = width;
}
// Instance method to set the height of the rectangle
fn set_height(&mut self, height: u32) {
self.height = height;
}
}
fn main() {
// Create an instance of a rectangle
let mut rect = Rectangle::new(10, 20);
// Call instance methods to get area and perimeter
println!("The area of the rectangle is {}", rect.area());
println!("The perimeter of the rectangle is {}", rect.perimeter());
// Modify the rectangle's width and height
rect.set_width(15);
rect.set_height(25);
// Call instance methods again to get updated area and perimeter
println!("The new area of the rectangle is {}", rect.area());
println!("The new perimeter of the rectangle is {}", rect.perimeter());
}
Output
The area of the rectangle is 200
The perimeter of the rectangle is 60
The new area of the rectangle is 375
The new perimeter of the rectangle is 80
-
Struct:
<span>Rectangle</span>struct is similar to a class in object-oriented programming, defining the data structure of a rectangle, including width and height. -
Method:
<span>impl</span>block implements methods for the<span>Rectangle</span>struct. These methods provide operations on the<span>Rectangle</span>instance, such as creating new instances, calculating area and perimeter, and setting width and height. -
Encapsulation: In Rust, encapsulation is achieved by combining data (struct fields) and functions (methods) that operate on that data. In this example, the fields
<span>width</span>and<span>height</span>of<span>Rectangle</span>are private and can only be accessed and modified through the methods provided by the struct, protecting the data from direct access by external code. -
Associated Function:
<span>new</span>is an associated function that does not operate on an instance of the struct but is used to create a new instance of the struct. -
Instance Method:
<span>area</span>,<span>perimeter</span>,<span>set_width</span>, and<span>set_height</span>are instance methods that operate on the<span>Rectangle</span>instance and can access and modify the instance’s state.
1.2 Inheritance
Inheritance is a mechanism where one object can inherit elements from another object’s definition, thus acquiring the data and behavior of the parent object without redefining them.
If a language must have inheritance to be considered an object-oriented language, then Rust is not one of them. Rust does not support defining a struct that inherits fields and methods from a parent struct unless using macros.
However, if you are accustomed to using inheritance in programming, Rust provides other solutions depending on the reasons for using inheritance.
There are two main reasons for choosing inheritance. One is code reuse: you can implement specific behavior for one type, and inheritance allows it to be reused across different types. In Rust code, limited code reuse can be achieved using default trait method implementations. The second reason relates to the type system: subtypes can be used in places where the parent type is used. This is also known as polymorphism: if multiple objects share certain characteristics, they can be substituted for each other at runtime.
In Rust, similar to inheritance features in object-oriented programming can be achieved through traits and default methods. Default methods allow traits to provide default implementations for certain methods, which can be overridden in the types implementing that trait. By using traits and default methods, Rust allows developers to implement code reuse and method overriding in a way similar to object-oriented programming, even though it does not have a traditional class inheritance mechanism. This approach allows Rust to maintain type safety while providing a flexible way to organize code.
Here is an example that uses traits and default methods to implement code reuse and demonstrate how to override default methods:
// Define a trait `Animal`
trait Animal {
fn make_sound(&self);
// Default method implementation that can be overridden
fn description(&self) {
println!("This animal makes a sound.");
}
}
// Define a struct `Dog` that implements the `Animal` trait
struct Dog;
impl Animal for Dog {
fn make_sound(&self) {
println!("Woof!");
}
// Override the default `description` method
fn description(&self) {
println!("A dog is a loyal companion that says Woof!");
}
}
// Define another struct `Cat` that also implements the `Animal` trait
struct Cat;
impl Animal for Cat {
fn make_sound(&self) {
println!("Meow!");
}
// Use the default `description` method without overriding
}
fn main() {
let dog = Dog;
let cat = Cat;
dog.make_sound();
dog.description();
cat.make_sound();
cat.description();
}
Output
Woof!
A dog is a loyal companion that says Woof!
Meow!
This animal makes a sound.
-
Trait:
<span>Animal</span>trait defines the methods that all animals should implement, including<span>make_sound</span>and a default method<span>description</span>. -
Default Methods:
<span>description</span>is a default method in the<span>Animal</span>trait that provides a generic description. This method can be overridden in the types implementing that trait to provide a more specific description. -
Implementation: Both
<span>Dog</span>and<span>Cat</span>structs implement the<span>Animal</span>trait. The<span>Dog</span>struct overrides the default<span>description</span>method to provide a more specific description, while the<span>Cat</span>struct uses the default<span>description</span>method implementation. -
Overriding Default Methods: In the implementation of
<span>Dog</span>, the<span>description</span>method is overridden to provide more specific information about the<span>Dog</span>. This is similar to inheritance and method overriding in object-oriented programming. -
Code Reuse: Through traits and default methods, Rust allows code reuse. In the implementation of
<span>Cat</span>, since the<span>description</span>method is not overridden, it directly uses the default implementation provided by the<span>Animal</span>trait. -
Polymorphism: Rust’s traits allow for polymorphism, meaning different types can implement the same trait and can override its default methods.
As a design solution, inheritance is gradually falling out of favor in many new programming languages because it often risks sharing too much code. Subclasses should not always share all characteristics of their parent class, but inheritance always does. It also introduces the possibility of calling methods on subclasses that may not make sense or may lead to errors because the methods do not apply to the subclass. Additionally, some languages only allow single inheritance (meaning subclasses can only inherit from one class), further limiting the flexibility of program design.
1.3 Polymorphism
For many people, polymorphism is synonymous with inheritance. However, it is actually a broader concept that refers to code that can handle multiple types of data. For inheritance, these types are usually subclasses. Rust uses generics to abstract over different possible types and constrains what these types must provide through trait bounds. This is sometimes referred to as bounded parametric polymorphism.
Here is an example that uses generics and trait bounds to implement polymorphism:
// Define a trait `Animal` that includes a method `make_sound`
trait Animal {
fn make_sound(&self);
}
// Define a struct `Dog` that implements the `Animal` trait
struct Dog;
impl Animal for Dog {
fn make_sound(&self) {
println!("Woof!");
}
}
// Define another struct `Cat` that also implements the `Animal` trait
struct Cat;
impl Animal for Cat {
fn make_sound(&self) {
println!("Meow!");
}
}
// Use generics and trait bounds to define a function that can accept any type implementing the `Animal` trait
fn animal_sound<t: animal="">(animal: &T) {
animal.make_sound();
}
fn main() {
let dog = Dog;
let cat = Cat;
// Call the `animal_sound` function, passing different types of references
animal_sound(&dog);
animal_sound(&cat);
}
</t:>
Output
Woof!
Meow!
-
Trait:
<span>Animal</span>trait defines a method<span>make_sound</span>that all types implementing this trait must provide a specific implementation for. -
Implementation: Both
<span>Dog</span>and<span>Cat</span>structs implement the<span>Animal</span>trait. This means they both provide specific implementations of the<span>make_sound</span>method, printing “Woof!” and “Meow!” respectively. -
Generics: The
<span>animal_sound</span>function uses generics<span>T</span>to indicate it can accept any type of parameter. -
Trait Bounds:
<span>T: Animal</span>is a trait bound that constrains the generic<span>T</span>to types that must implement the<span>Animal</span>trait. This means the<span>animal_sound</span>function can accept references to any type that implements the<span>Animal</span>trait. -
Polymorphism: Through generics and trait bounds, the
<span>animal_sound</span>function can handle multiple types that implement the<span>Animal</span>trait. At runtime, Rust will call the corresponding<span>make_sound</span>method based on the specific type passed in, demonstrating polymorphism. -
Type Safety: Rust’s type system ensures that only types implementing the
<span>Animal</span>trait can be passed as parameters to the<span>animal_sound</span>function, ensuring type safety.
2. Trait Objects
Trait objects point to an instance of a type that implements the specified trait, along with a table for looking up the trait methods of that type at runtime. We create trait objects by specifying some kind of pointer, such as a <span>&</span> reference or a <span>Box<T></span> smart pointer, along with the <span>dyn</span> keyword and the associated trait. We can use trait objects in place of generics or concrete types. In any context where trait objects are used, Rust’s type system ensures at compile time that any values used in that context will implement the trait of its trait object, so there is no need to know all possible types at compile time.
As mentioned earlier, Rust deliberately does not refer to structs and enums as “objects” to distinguish them from objects in other languages. In structs or enums, the data in the struct fields and the behavior in the <span>impl</span> blocks are separate, unlike the concept of an object in other languages that combines data and behavior into one. Trait objects combine both data and behavior, making them more similar to objects in other languages in that sense. However, trait objects differ from traditional objects because you cannot add data to a trait object. Trait objects are not as general as objects in other languages: their specific purpose is to allow abstraction over common behavior.
When using generics with trait bounds, the compiler performs monomorphization: the compiler generates non-generic implementations of functions and methods for each specific type parameter replaced by a concrete type. Monomorphization results in static dispatch. Static dispatch occurs when the compiler knows at compile time what method is being called. This contrasts with dynamic dispatch, where the compiler cannot know at compile time what method is being called. In dynamic dispatch scenarios, the compiler generates code responsible for determining at runtime which method to call.
When using trait objects, Rust must use dynamic dispatch. The compiler cannot know all possible types that could be used with the trait object code, so it also does not know which method implementation to call for which type. Therefore, Rust uses pointers in the trait object at runtime to determine which method needs to be called. Dynamic dispatch also prevents the compiler from selectively inlining method code, which disables some optimizations.
Here is an example using trait objects and dynamic dispatch:
// Define a trait `Animal`
trait Animal {
fn make_sound(&self);
}
// Implement `Animal` trait for `Dog`
struct Dog;
impl Animal for Dog {
fn make_sound(&self) {
println!("Woof!");
}
}
// Implement `Animal` trait for `Cat`
struct Cat;
impl Animal for Cat {
fn make_sound(&self) {
println!("Meow!");
}
}
// Define a function that uses trait objects, here using dynamic dispatch
fn make_animal_sound(animal: &dyn Animal) {
animal.make_sound();
}
fn main() {
let dog = Dog;
let cat = Cat;
// Convert different types of references to trait objects
let dog_trait: &dyn Animal = &dog;
let cat_trait: &dyn Animal = &cat;
// Call the function using trait objects
make_animal_sound(dog_trait);
make_animal_sound(cat_trait);
}
Output
Woof!
Meow!
In this example, the <span>make_animal_sound</span> function takes a parameter of type <span>&dyn Animal</span>, which is a reference to any type that implements the <span>Animal</span> trait.<span>dyn Animal</span> is a trait object that uses dynamic dispatch to call the <span>make_sound</span> method.
-
Trait Object:
<span>&dyn Animal</span>is a trait object that allows storing a reference to any type that implements the<span>Animal</span>trait. -
Dynamic Dispatch: When using
<span>&dyn Animal</span>to call the<span>make_sound</span>method, Rust needs to determine at runtime which specific implementation of<span>make_sound</span>should be called. This is achieved by converting the method call at compile time into a virtual function table (vtable) index. -
Polymorphism: Using trait objects allows handling different types at runtime, demonstrating polymorphism.
-
Memory Allocation: Because of using
<span>&dyn</span>, Rust needs to allocate memory on the heap to store the pointer to the virtual function table, which adds some runtime overhead compared to static dispatch. -
Flexibility: Trait objects provide great flexibility, allowing developers to write code that can handle multiple types without needing to care about the specific implementation details of those types.
Using trait objects and dynamic dispatch is one way to achieve polymorphism in Rust, but it is generally not recommended as the first choice because Rust’s generics and trait bounds usually provide sufficient flexibility while maintaining better performance and type safety. Trait objects are very useful when dynamic or runtime type information is needed.
3. Object-Oriented Design Patterns
Below are several examples of object-oriented design patterns.
3.1 Simple Factory Pattern
trait Animal {
fn make_sound(&self);
}
struct Dog;
struct Cat;
impl Animal for Dog {
fn make_sound(&self) {
println!("Woof!");
}
}
impl Animal for Cat {
fn make_sound(&self) {
println!("Meow!");
}
}
struct AnimalFactory;
impl AnimalFactory {
fn create_animal(kind: &str) -> Box<dyn Animal> {
match kind {
"dog" => Box::new(Dog),
"cat" => Box::new(Cat),
_ => panic!("Unknown animal"),
}
}
}
fn main() {
let animal = AnimalFactory::create_animal("dog");
animal.make_sound();
}
Output
Woof!
<span>Animal</span>trait defines a method<span>make_sound</span>that all animal types must implement.<span>Dog</span>and<span>Cat</span>structs implement the<span>Animal</span>trait.<span>AnimalFactory</span>struct provides a method<span>create_animal</span>that returns an object implementing the<span>Animal</span>trait based on the input string. Here, a<span>Box<dyn Animal></span>is used to create a trait object, allowing storage of any type that implements the<span>Animal</span>trait.<span>main</span>function creates a<span>Dog</span>object using the<span>AnimalFactory</span>and calls its<span>make_sound</span>method.
3.2 Singleton Pattern
use std::sync::Mutex;
use lazy_static::lazy_static;
struct Singleton {
value: i32,
}
impl Singleton {
fn new() -> Singleton {
Singleton { value: 0 }
}
fn value(&self) -> i32 {
self.value
}
fn set_value(&mut self, value: i32) {
self.value = value;
}
}
lazy_static! {
static ref INSTANCE: Mutex<Singleton> = Mutex::new(Singleton::new());
}
fn main() {
let mut singleton = INSTANCE.lock().unwrap();
println!("Singleton value: {}", singleton.value());
singleton.set_value(42);
println!("Singleton value after set: {}", singleton.value());
}
Output
Singleton value: 0
Singleton value after set: 42
- Using
<span>lazy_static</span>creates a thread-safe<span>Mutex<Singleton></span>wrapped singleton. <span>Singleton</span>struct has a<span>value</span>field, along with methods to get and set this value.<span>main</span>function locks the singleton to access it, printing and modifying its<span>value</span>.
3.3 Strategy Pattern
trait Strategy {
fn execute(&self, a: i32, b: i32) -> i32;
}
struct Add;
struct Subtract;
impl Strategy for Add {
fn execute(&self, a: i32, b: i32) -> i32 {
a + b
}
}
impl Strategy for Subtract {
fn execute(&self, a: i32, b: i32) -> i32 {
a - b
}
}
struct Context {
strategy: Box<dyn Strategy>,
}
impl Context {
fn new(strategy: Box<dyn Strategy>) -> Context {
Context { strategy }
}
fn execute_strategy(&self, a: i32, b: i32) -> i32 {
self.strategy.execute(a, b)
}
}
fn main() {
let add_strategy = Add;
let subtract_strategy = Subtract;
let context_add = Context::new(Box::new(add_strategy));
let context_subtract = Context::new(Box::new(subtract_strategy));
println!("Add: 10 + 5 = {}", context_add.execute_strategy(10, 5));
println!("Subtract: 10 - 5 = {}", context_subtract.execute_strategy(10, 5));
}
Output
Add: 10 + 5 = 15
Subtract: 10 - 5 = 5
<span>Strategy</span>trait defines a method<span>execute</span>that different strategies will provide different implementations for.<span>Add</span>and<span>Subtract</span>structs implement the<span>Strategy</span>trait, representing addition and subtraction operations respectively.<span>Context</span>struct holds a field of type<span>Box<dyn Strategy></span>, allowing the strategy to be changed at runtime.<span>main</span>function creates two<span>Context</span>objects, one using the addition strategy and the other using the subtraction strategy, and executes the respective operations.
3.4 Observer Pattern
// Define an observer trait, all observers must implement the update method.
trait Observer {
// This method is called when the observer needs to be updated.
fn update(&self, data: &str);
}
// Subject struct contains a list of observers and a state.
struct Subject<'a> {
observers: Vec<&'a dyn Observer>, // List of observers, using lifetime 'a to ensure observers live as long as the Subject.
state: String, // State of the Subject.
}
// Implementation of Subject.
impl<'a> Subject<'a> {
// Create a new Subject instance.
fn new(state: String) -> Self {
Self {
observers: Vec::new(),
state,
}
}
// Add an observer to the Subject's list of observers.
fn attach(&mut self, observer: &'a dyn Observer) {
self.observers.push(observer);
}
// Remove an observer from the Subject's list of observers.
// Use std::ptr::eq to compare the observer's pointer since observers are trait objects.
fn detach(&mut self, observer: &dyn Observer) {
self.observers.retain(|o| !std::ptr::eq(*o, observer));
}
// Notify all observers that the state has been updated.
fn notify(&self) {
for o in &self.observers {
o.update(&self.state);
}
}
// Set the Subject's state and notify all observers that the state has been updated.
fn set_state(&mut self, state: String) {
self.state = state;
self.notify();
}
}
// Concrete observer implementation.
struct ConcreteObserver {
name: String, // Name of the observer.
}
// Implement Observer trait for ConcreteObserver.
impl Observer for ConcreteObserver {
fn update(&self, data: &str) {
println!("{} received data: {}", self.name, data);
}
}
// main function, the entry point of the program.
fn main() {
let mut subject = Subject::new("initial data".to_string()); // Create a new Subject.
let observer1 = ConcreteObserver {
name: "Observer 1".to_string(),
}; // Create the first observer.
let observer2 = ConcreteObserver {
name: "Observer 2".to_string(),
}; // Create the second observer.
subject.attach(&observer1); // Add observer1 to the Subject's list of observers.
subject.attach(&observer2); // Add observer2 to the Subject's list of observers.
subject.set_state("updated_data".to_string()); // Update the Subject's state and notify all observers.
subject.detach(&observer2); // Remove observer2 from the Subject's list of observers.
subject.set_state("Again updated data".to_string()); // Update the Subject's state again and notify remaining observers.
}
Output
Observer 1 received data: updated_data
Observer 2 received data: updated_data
Observer 1 received data: Again updated data
-
Observer Trait: Defines an
<span>Observer</span>trait that requires implementing an<span>update</span>method that takes a string reference as a parameter. -
Subject Struct: Contains a list of observers and a state. The observer list uses
<span>Vec</span>to store references to<span>Observer</span>trait objects, and has a lifetime parameter<span>'a</span>to ensure observers live as long as the<span>Subject</span>. -
Subject Implementation: Includes methods for creating new
<span>Subject</span>instances, adding and removing observers, notifying all observers of state updates, and setting new states and notifying observers. -
ConcreteObserver Struct: Defines a concrete observer that contains a name field.
-
ConcreteObserver Implementation: Implements the
<span>Observer</span>trait for<span>ConcreteObserver</span>, providing the<span>update</span>method that prints the observer’s name and the received data when notified. -
Main Function: Creates a
<span>Subject</span>and two<span>ConcreteObserver</span>instances, adds them to the Subject’s observer list, updates the state, and observes the output. Finally, it removes observers from the list and updates the state again to demonstrate that the observers no longer receive notifications.
3.5 Builder Pattern
The Builder Pattern is a commonly used design pattern for creating instances of complex objects while ensuring that the object creation process is flexible and extensible. The Builder Pattern separates the construction process of an object from its representation, making the construction process clearer and easier to manage.
Here is a simple Rust example of the Builder Pattern for creating an instance of a <span>Person</span> struct:
// Define the Person struct
struct Person {
name: String,
age: u8,
job: Option<String>,
}
// Define the PersonBuilder struct for building Person instances
struct PersonBuilder {
name: String,
age: u8,
job: Option<String>,
}
impl PersonBuilder {
// Create a new PersonBuilder instance
fn new(name: String) -> Self {
PersonBuilder {
name,
age: 0,
job: None,
}
}
// Set the age of the Person
fn age(mut self, age: u8) -> Self {
self.age = age;
self
}
// Set the job of the Person
fn job(mut self, job: String) -> Self {
self.job = Some(job);
self
}
// Build and return the Person instance
fn build(self) -> Person {
Person {
name: self.name,
age: self.age,
job: self.job,
}
}
}
fn main() {
// Use PersonBuilder to create a Person instance
let person = PersonBuilder::new("Alice".to_string())
.age(30)
.job("Engineer".to_string())
.build();
println!("Name: {}, Age: {}, Job: {:?}", person.name, person.age, person.job);
}
Output
Name: Alice, Age: 30, Job: Some("Engineer")
-
Define Person Struct: This is a simple struct that contains a name, age, and job (optional).
-
Define PersonBuilder Struct: This is a helper struct used to build
<span>Person</span>instances. It contains the same fields as<span>Person</span>, but the job field is of type<span>Option<String></span>, indicating that the job can be optional. -
Implement PersonBuilder:
<span>new</span>method: Creates a new<span>PersonBuilder</span>instance, initializing the name field, with age and job defaulting to<span>0</span>and<span>None</span>.<span>age</span>method: Sets the age field of the<span>PersonBuilder</span>and returns itself for chaining.<span>job</span>method: Sets the job field of the<span>PersonBuilder</span>and returns itself for chaining.<span>build</span>method: Builds and returns a<span>Person</span>instance.
Using in Main Function: By chaining the <span>new</span>, <span>age</span>, and <span>job</span> methods, a <span>Person</span> instance is constructed and its properties are printed.
References
- Rust Official Website: https://www.rust-lang.org/en-US
- Rust Official Documentation: https://doc.rust-lang.org/
- Rust Play: https://play.rust-lang.org/
- “The Rust Programming Language”
Non-Typical Programmer–☆ Link Coder Community ☆–Welcome to followIf you find it useful, remember tolike and recommend, and share with more people