Trait objects and polymorphism in Rust
Trait objects – it is a way to implement abstract data types. So we can group different types, united by common properties or functionality. Let’s say we have different structures, each of which represents some kind of geometric figure. They are different, but they all have a common method for calculating area. We define Trait Shape
with method area
, and then implement this Trait for each of the structures. Trait objects in Rust allow you to use polymorphism right at runtime!
Encapsulation using Trait objects allows you to hide implementation details. Returning to our geometric shapes example, when we use Trait objects, the user of our code only sees the method area
, but not the details of how each shape calculates its area. And polymorphism here acts as a way to use the same interface (in our case, the method area
) for various data types.
Interchangeability of Trait objects – this is when the same code can work with different data types that implement a certain Trait. We can easily extend our code by adding new types that implement the desired Trait, without having to change existing code.
For example, if we wanted to add another shape, say a trapezoid, to our program, we would simply need to implement Trait Shape
for this new structure. And all the functions that worked with Shape
will automatically be able to work with trapezoids.
Static and dynamic dispatching
Static dispatch means that the decision about which function implementation to call is made at compile time rather than at program runtime.
Static dispatch via parametric polymorphism
In Rust, parametric polymorphism is achieved through the use of generics. This way you can write functions or data structures that can operate on any type of data. Eg fn generic_function<T>(arg: T) { ... }
. T
can be any type, and the function will operate on that type without knowing in advance what it will be.
Monomorphization is the process that Rust uses to implement parametric polymorphism. This means creating specific copies of functions or structures for each unique data type used.
If there is a function generic_function<T>(arg: T)
and it is called with i32
And f64
the Rust compiler will create two versions of this function – one for i32
and one for f64
.
IN In the static approach, Rust generates unique functions for each data type that is used with the function. This improves performance because the decision about which function to call is made at compile time. But, yeahIf the same generic function is used with many different types, this can result in a larger compiled binary file because each variant of the function will be represented separately.
Example implementation:
trait Walkable {
fn walk(&self);
}
struct Cat;
struct Dog;
impl Walkable for Cat {
fn walk(&self) {
println!("Cat is walking");
}
}
impl Walkable for Dog {
fn walk(&self) {
println!("Dog is walking");
}
}
fn generic_walk<T: Walkable>(t: T) {
t.walk();
}
generic_walk
is a generic function that takes an argument T
implementing Trait Walkable
. At compile time, Rust will create versions of this function for each type Cat
And Dog
which is used to call generic_walk
.
Dynamic dispatch
Dynamic dispatch refers to a mechanism in which method calls are resolved during program execution rather than at compile time. This contrasts with static dispatch, where method calls are resolved at compile time
Trait objects allow the use of pointers to data that implement a specific Trait, without specifying a specific data type. This is a must-have when you need to work with a collection of objects of different types, but which implement a common Trait.
Worddyn
is an explicit way to indicate that a variable or parameter should be processed using dynamic dispatch.
Example implementation:
trait Walkable {
fn walk(&self);
}
struct Cat;
struct Dog;
impl Walkable for Cat {
fn walk(&self) {
println!("Cat is walking");
}
}
impl Walkable for Dog {
fn walk(&self) {
println!("Dog is walking");
}
}
fn dynamic_dispatch(w: &dyn Walkable) {
w.walk();
}
fn main() {
let cat = Cat{};
let dog = Dog{};
dynamic_dispatch(&cat);
dynamic_dispatch(&dog);
}
dynamic_dispatch
accepts a reference to any type that implements Walkable
. Unlike static dispatch, a concrete implementation walk
is determined at runtime, not at compile time.
IN In the dynamic approach, the specific function to call is determined during program execution, which is usually slower than in the static approach. The main reason is the need to look up the corresponding function in the virtual function table (vtable) at runtime. ABOUTHowever, the dynamic approach uses memory more efficiently because it does not require creating multiple copies of the function for different types.
Trait Bounds
Trait Bounds, or restrictions on traitsallow you to set restrictions on the data types used in generics, ensuring that they implement certain traits
A trait constraint is used to specify that a type must implement a specific set of behaviors. For example, if there is a function that works with types that implement the trait Display
you can specify this constraint using the syntax <T: Display>
. This way the types passed to the function can be mapped.
Trait Bounds can be applied not only to functions, but also to structures and enumerations. An example would be the structure Printer<T: Display>
Where T
limited by trait Display
. Printer
can only be created with types that implement Display
.
Word where
provides an alternative syntax for specifying trait constraints. For example, in a function that compares two values, fn compare<T, U>(a: T, b: U) where T: PartialOrd + Display, U: PartialOrd + Display
, where
allows you to clearly separate trait constraints from function parameters.
Polymorphism using Enums
Enums allow us to determine a type that can take one of several different forms. Each form can include data and even have different data types.
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
Message
is an Enum that represents four different message types. Each option can store different data.
fn process_message(msg: Message) {
match msg {
Message::Quit => println!("Quit"),
Message::Move { x, y } => println!("Move to x: {}, y: {}", x, y),
Message::Write(text) => println!("Text message: {}", text),
Message::ChangeColor(r, g, b) => println!("Change color to RGB: {}, {}, {}", r, g, b),
}
}
process_message(Message::Write(String::from("Hello, Rust!")));
process_message
uses pattern matching to handle different Enum variants Message
. Each option is handled differently.
Enums can be combined with trait objects to create more complex polymorphic structures:
trait Draw {
fn draw(&self);
}
struct Circle {
radius: f64,
}
impl Draw for Circle {
fn draw(&self) {
// рисование круга
}
}
struct Square {
side: f64,
}
impl Draw for Square {
fn draw(&self) {
// рисование квадрата
}
}
enum Shape {
Circle(Circle),
Square(Square),
}
impl Draw for Shape {
fn draw(&self) {
match self {
Shape::Circle(c) => c.draw(),
Shape::Square(s) => s.draw(),
}
}
}
Shape
is an Enum which can be either Circle
or Square
. Each of these types implements the trait Draw
. Let’s implement Draw
for the most Shape
which allows you to call draw
on copies Shape
without knowing what specific type it contains.
Enums and Traits are used to implement polymorphism, but each is suitable for different scenarios. Enums, which contain different variants of data, are great for situations where all possible forms of data are known in advance and efficient use of memory is required.
On the other hand, Traits offer very good flexibility, allowing you to create data structures that can contain any type that implements a particular Trait.
Overall, the choice between Enums and Traits depends on the flexibility and memory requirements of your application.
My friends from OTUS talk more about Rust and other programming languages in practical online courses. For a complete list of courses you can check out the catalog.