This is a premium alert message you can set from Layout! Get Now!

How to build polymorphic components in Rust

0

If you are familiar with object-oriented programming (OOP), you may have heard of Polymorphism. Polymorphism is a useful feature of OOP that describes the ability of a variable or interface to exhibit behaviors of different objects within its hierarchical tree.

Polymorphism allows you to build flexible applications that easily adjust to runtime requirements without breaking the system. In addition to reducing boilerplate codebase, polymorphism also ensures that your application’s components are not tightly coupled to one another.

In this article, we’ll explore the concepts of polymorphism in the Rust programming language. We will also demonstrate different ways to implement polymorphism in Rust.

Jump ahead:

Understanding polymorphism in Rust

The underlying theory of polymorphism in Rust is similar to that of any other OOP language.

Consider a program that makes different creatures walk. You could design a walk function to accept a concrete type of a specific creature (e.g., a Cat) as a parameter.

Now, suppose you want to walk a Dog; you’d have to create another function that accepts Dog as its parameter.

Following this concept, it’s implied that each creature would need its respective function to perform the corresponding walk as shown in the below snippet:

struct Cat;

impl Cat {
    fn walk(&self) -> (){
       println!("Cat is walking")
    }
}

struct Dog;

impl Dog {
    fn walk(&self) -> (){
       println!("Dog is walking")
    }
}

fn main() {
   let cat = Cat{};
   let dog = Dog {};
   cat.walk();
   dog.walk();
}

This is a flawed implementation; it introduces duplicate code all over your application and also makes it difficult to maintain the growing number of functions for each creature. As you create more and more individual creatures in your application, the number of functions will grow exponentially.

With polymorphism, you can create a single function that accepts any object as long as it performs a specific operation. In the context of the creatures example, this function will accept any object that implements a walk() method.

Each creature will have its custom implementation of the walk() method that runs whenever it is invoked on the respective creature object.

Implementing polymorphism in Rust

Rust provides different options for implementing polymorphism in your application through traits. Each option has its own peculiarities and advantages. There are several tradeoffs to consider when deciding which implementation to adopt. Let’s take a look.

Static dispatch

The static dispatch concept decides which implementation of a trait to invoke at compile time, based on the type value it receives as an argument. By default, this is how a Rust program performs its operations.

It uses generics to reduce duplicate codes by allowing you to write functions or types that are acceptable with other types, without having to specify the concrete types in advance. Instead, you provide variables for these types that the program will invoke at runtime.

Using our creatures example, here’s how you could use generics to implement polymorphism:

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();
}

fn main() {
    let cat = Cat{};
    let dog = Dog{};

    generic_walk(cat); // Cat is walking
    generic_walk(dog); // Dog is walking
}

The above code declares a Walkable trait that contains a method, walk, with no return type. Any struct or type that implements this trait will also have to provide the concrete implementation for the walk method. Hence, the declared structs, Cat and Dog, which both implement the trait, also each provide their respective concrete implementation of the walk method.

The generic_walk() function is a generic function that accepts any type as long as it implements the Walkable trait. This function invokes the walk() method of the object passed as an argument and runs the operation specific to the type.

The main() method demonstrates how the generic_walk() function accepts the Cat and Dog objects each time it is invoked, thereby, implicitly calling the walk() method for each type.

Choosing the static dispatch approach

The static dispatch trait uses monomorphization to compile and create multiple copies of the generic_walk() function for each type passed as the argument, hence, it is aware of the execution process of each type. This is quite efficient in terms of performance because the program decides the implementation to use at compile time rather than at runtime, but this efficiency comes at a cost.

Since the program creates a copy of the generic_walk() function for each type passed, it’s implied that it allocates memory space for each copy. If you are interested in improved performance and do not have a concern with increasing binary size in the application scheme, then you should consider this approach.

Dynamic dispatch

In the dynamic dispatch approach, the program decides the implementation of the trait to execute at runtime depending on the concrete type provided. The dynamic dispatch concept differs from the static dispatch in that it does not have to create multiple copies of the generic function it executes. Instead, it refers to a single copy of the function and determines the concrete implementation to execute at runtime.

Using our creatures example, here’s how you could use the dynamic dispatch approach to implement polymorphism:

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); // Cat is walking
    dynamic_dispatch(dog); // Dog is walking
}

Just like the previous static dispatch example, the above code snippet contains the implementations of the Walkable trait by the Cat and Dog structs. The significant difference here is that the dynamic_dispatch() function accepts a trait object as an argument.

The dyn keyword in the function parentheses is used to indicate that the function performs dynamic dispatch polymorphism through the concrete implementations of the trait provided.

As shown in the main method, the dynamic_dispatch() function accepts both Cat and Dog objects and implicitly invokes their respective walk() methods.

Choosing the dynamic dispatch approach

Dynamic dispatch is less efficient than static dispatch in terms of performance, particularly because it has to determine the implementation to run along with its execution dependencies during runtime. However, dynamic dispatch makes up for this deficiency through minimal memory usage. This is because it does not need to create multiple copies of the polymorphic function like the static dispatch approach.

The dynamic dispatch approach can be very useful in situations where you need to prioritize memory usage over performance, such as when building embedded systems or any other program that depends on memory utilization.

Enums

Enums are data structures that allow you to present data in different variants. They are also very useful when implementing polymorphism.

With enums, you can execute specific operations for each variant using pattern matching as shown below:

    Cat{},
    Dog{},
}

impl Creature {

    pub fn walk(&self) {
        match self {
            Creature::Cat{} => println!("Cat is walking"),
            Creature::Dog{} => println!("Dog is walking")
        }
    }
}

fn enum_walk(c: Creature) {
    c.walk();
}

fn main() {
    let cat = Creature::Cat{};
    let dog = Creature::Dog{};

    enum_walk(cat); // Cat is walking
    enum_walk(dog); // Dog is walking
}

In the code above, the Cat and Dog structs are declared as variants in the Creature enum. Also, using pattern matching, custom implementations are created for each variant when invoking the walk() method on the enum.

The enum_walk() function accepts a Creature enum as an argument (i.e., any variant or derivative of the enum is a valid argument for the function). Then it invokes the walk() method on the variant passed at runtime as demonstrated in the main() method.

Choosing the enums approach

Building a polymorphic program through enums is generally considered less complex than the static and dynamic dispatch approaches discussed in previous sections. However, it can introduce tight coupling.

All variants of the enum must be declared within the enum scope and it is not extensible by types declared in other libraries. This could be a limitation in cases where you want to leverage the polymorphic benefits of the enum in other areas of your application. If you’re interested in learning more about enums, here is a guide to get you started.

Conclusion

In this article, we discussed the concept of polymorphism in OOP and its peculiarities in the Rust programming language. We also demonstrated how to implement polymorphism in Rust using static dispatch, dynamic dispatch, and enums, and we discussed the advantages and tradeoffs of each approach.

The code snippets provided in this tutorial are available on GitHub.

The post How to build polymorphic components in Rust appeared first on LogRocket Blog.



from LogRocket Blog https://ift.tt/rR9CiQ1
Gain $200 in a week
via Read more

Post a Comment

0 Comments
* Please Don't Spam Here. All the Comments are Reviewed by Admin.
Post a Comment

Search This Blog

To Top