Rust Intermediate

Intermediate Rust

Rust Intermediate

Traits and Generics in Rust

Traits and Generics in Rust

In our earlier exploration of the Result and Option enums, you encountered using <T, E> and <T> within angle brackets. These constructs are known as generics. Generics are a powerful feature in programming that allows you to write functions, types, and structures that can work with different data types while maintaining type safety and code reusability.

Generics in Rust

Generics in Rust enable you to write code that can work with multiple data types without duplicating logic. This enhances code flexibility and maintainability by avoiding the need to write similar functions or structures for different types. The T and E placeholders you saw in Result<T, E> and <T> within Option<T> are examples of generic type parameters.

Using Generics in Functions

Let's start by looking at how generics are used in functions:

fn print_twice<T>(value: T) {
    println!("{:?}", value);
    println!("{:?}", value);
}

fn main() {
    print_twice(42);
    print_twice("Hello, Generics!");
}

In this example, print_twice takes a single argument value of type T, representing any data type. This function can work with both integers and strings without needing separate implementations. It can also work in any data type fed into it. Try this out.

Defining a Generic Struct

You can also define structs with generic fields:

// generic-typed struct with multiple types.
pub struct ExchangeTx<T, U> {
    pub amount: T,
    pub  exchange_rate: U,
}

pub fn use_exchange_tx() {
    let dollar_to_transfer = ExchangeTx {
        amount: 500, exchange_rate: 940.80
    };

    println!("Your dollar value is {}, and the current exchange rate is {}", dollar_to_transfer.amount, dollar_to_transfer.exchange_rate);
}

Here, ExchangeTx is a generic struct that can hold two values of potentially different types.

Implementing Traits with Generics

Traits are features that specify the functionalities available to a particular generic type. In programming languages like Go, traits are very similar to interfaces. A trait can be bound to a generic type to make it behave in a certain way.

Traits in Rust can also make use of generics. For example, the Display trait, which allows types to be formatted as strings, often uses generics:

use std::fmt::Display;

// Display Trait with Generics.
impl<T: Display, U:Display> ExchangeTx<T, U> {
    pub fn display_exchange_tx_info(&self) {
        let dollar_to_convert = ExchangeTx {
            amount: 500 as f64, exchange_rate: 940.85
        };
    
        println!("Your dollar value is {}, and the current exchange rate is {}", self.amount, self.exchange_rate);
    }
}

fn main() {
    // Create an instance of ExchangeTx.
    let exchange_tx = ExchangeTx {
        amount: 500 as f64,
        exchange_rate: 940.85,
    };

    // Call the method to display exchange transaction info.
    exchange_tx.display_exchange_tx_info();
}

In this case, the display_exchange_tx_info function takes any type that implements the Display trait as its argument. The Clone trait is another popularly used trait.

Constraints with Trait Bounds

You can place constraints on generic types using trait bounds to specify which traits the type must implement. This ensures that the generic code only works with types that meet those constraints:

fn show_display<T: Display>(item: T) {
    println!("{}", item);
}

fn main() {
    let string = "Hello, Generics!";
    show_display(string);

    let number = 42;
    // show_display(number); // Uncommenting this line will result in an error
}

We have been using the provided traits on our generic types. Let’s create our custom trait and then see how to implement methods for generic types bounded by that trait.

For this example, let's consider a scenario where we're dealing with different types of databases in a backend system.

We'll create a trait called Database that defines methods for connecting to the database, querying, and saving data. Then, we'll implement this trait for two types of databases: SqlDatabase and NoSqlDatabase.

// Define a custom trait for different types of databases.
pub trait Database {
    fn connect(&self) -> bool;
    fn query(&self, query: &str) -> Vec<String>;
    fn save(&self, data: &str) -> bool;
}

// Implement the Database trait for an SQL database.
pub struct SqlDatabase {
    pub connection_string: String,
}

// Implement the generic trait's methods for the SqlDatabase type.
impl Database for SqlDatabase {
    fn connect(&self) -> bool {
        println!("Connected to SQL database: {}", self.connection_string);
        true
    }

    fn query(&self, query: &str) -> Vec<String> {
        println!("Executing SQL query: {}", query);
        vec!["Result 1".to_string(), "Result 2".to_string()]
    }

    fn save(&self, data: &str) -> bool {
        println!("Saving data to SQL database: {}", data);
        true
    }
}

// Implement the Database trait for a NoSQL database.
pub struct NoSqlDatabase {
    pub endpoint: String,
}

impl Database for NoSqlDatabase {
    fn connect(&self) -> bool {
        println!("Connected to NoSQL database: {}", self.endpoint);
        true
    }

    fn query(&self, query: &str) -> Vec<String> {
        println!("Executing NoSQL query: {}", query);
        vec!["Document 1".to_string(), "Document 2".to_string()]
    }

    fn save(&self, data: &str) -> bool {
        println!("Saving data to NoSQL database: {}", data);
        true
    }
}

fn main() {
    let sql_db = SqlDatabase {
        connection_string: "mysql://localhost:3306".to_string(),
    };

    sql_db.connect();
    sql_db.query("SELECT * FROM users");
    sql_db.save("New user data");

    let nosql_db = NoSqlDatabase {
        endpoint: "mongodb://localhost:27017".to_string(),
    };

    nosql_db.connect();
    nosql_db.query("db.collection.find()");
    nosql_db.save("New document");
}

In this example, we define a custom trait called Database with three methods: connect, query, and save. We then implement this trait for two different types of databases: SqlDatabase and NoSqlDatabase. Each implementation provides its logic for connecting, querying, and saving data. The main function demonstrates how you can use these implementations to interact with different databases.

Custom traits like Database allow you to define a common interface for different types, enabling polymorphism and code reuse in your backend system.

Note: If you put a pub keyword before a trait definition, you do not need to put it before the methods or functions that implement the trait. If you are writing the program inside a single main.rs file, you do not need pub anywhere.

Generics are a fundamental concept in Rust that enables you to write versatile, reusable, and type-safe code. They are particularly useful when creating functions, structs, or traits that can work with various data types while maintaining code efficiency and readability. Generics are a key tool for building flexible and maintainable code in Rust.

Whenever you're ready

There are 4 ways we can help you become a great backend engineer:

The MB Platform

Join 1000+ backend engineers learning backend engineering. Build real-world backend projects, learn from expert-vetted courses and roadmaps, track your learnings and set schedules, and solve backend engineering tasks, exercises, and challenges.

The MB Academy

The “MB Academy” is a 6-month intensive Advanced Backend Engineering BootCamp to produce great backend engineers.

Join Backend Weekly

If you like post like this, you will absolutely enjoy our exclusive weekly newsletter, Sharing exclusive backend engineering resources to help you become a great Backend Engineer.

Get Backend Jobs

Find over 2,000+ Tailored International Remote Backend Jobs or Reach 50,000+ backend engineers on the #1 Backend Engineering Job Board