Daniel Brai

Building Composable Behaviour using Traits in Rust

By Daniel Brai

~
6 min read
Building Composable Behaviour using Traits in Rust image

Table of contents

Open Table of contents

Rust & Traits

Rust is a modern programming language that offers features such as memory safety, low-level control, and high performance. One of its key features is Traits, which allows the creation of reusable code that can be composed in different ways. In this article, we will explore how to use Traits to build composable behavior in Rust, using examples such as a database connection.

Defining Shared Behaviour with Traits

A trait defines functionality a particular type has and can share with other types. We can use traits to define shared behavior in an abstract way. We can use trait bounds to specify that a generic type can be any type that has certain behavior. Traits are similar to interfaces in other programming languages, but they offer a more flexible and powerful mechanism for defining behavior. A Trait defines a set of methods that can be implemented by any type, and it can be used as a type itself. This allows for code reuse and abstraction, and it is a key feature of Rust’s design. To define a trait, we use the trait keyword, the trait’s name and a list of method signatures..

First, we create a new library crate with the command cargo new --lib greeting. Then, let’s declare with a public trait with the name Greeting with the method signatures that describe the behaviors of the types that implement this trait, which in this case is fn introduce(&self) -> String in src/lib.rs file. Also, we’ve also declared the trait as pub so that crates depending on this crate can make use of this trait too.


  pub trait Greeting {
      fn introduce(&self) -> String;
  }

Implementing a trait on a Type

Now in the src/lib.rs file, we define the desired signatures of the Greeting trait’s methods that we want to implement on the types such as our Person struct. Below we have an implementation of the Greeting trait on the Person struct that uses the name, age, and occupation to create the return value of introduce.


  pub struct Person {
      pub name: String,
      pub age: u32,
      pub occupation: String,
  }

  impl Greeting for Person {
    fn introduce(&self) -> String {
        println!("Hi, my name is {}. I am {} years old and a {}.", self.name, self.age, self.occupation)
    }
  }

The above implementation defines a method called “introduce” for the Person struct that prints an introduction with the person’s name, age and occupation. Now that the library has implemented the Greeting trait on Person, users of the crate can call the trait methods on instances of Person in the same way we call regular methods. The only difference is that the user must bring the trait into scope as well as the types. Here’s an example of how a binary crate could use our greeting library crate:


  use greeting::{Greeting, Person};

  fn main() {
      let p = Person {
          username: String::from("Daniel Brai"),
          age: 21,
          occupation: String::from("Software Engineer"),
      };

      println!("Introduction: {}", p.introduce());
  }

This code prints Introduction: Hi, my name is Daniel Brai. I am 21 years old and a Software Engineer.

More examples

Now let’s consider a more practical example of using Traits for building composable behavior. Suppose we want to write a program that interacts with a database. We could define a Trait called DatabaseConnection that defines a set of methods for connecting to and querying a database:


  trait DatabaseConnection {
      fn connect(&self) -> Result<Connection, String>;
      fn execute(&self, query: &str) -> Result<Vec<Row>, String>;
  }

Note: In this example, we implement struct called Connection and Row which are used in the return type of our method signatures. Without getting into too much detail. Here, we can think of the Connection struct as having an associated function that opens a TCP connection to the database when a url path to the database is passed to it. Also, the Row struct represent a row of the database.

The DatabaseConnection trait defines two methods: “connect” and “execute”. The “connect” method returns a Result that contains a Connection object if the connection was successful, and an error message if it failed. The “execute” method takes a SQL query as a string and returns a Result that contains a vector of Row objects if the query was successful, and an error message if it failed. To use this Trait, we can implement it for different types of databases, such as MySQL or SQLite. Here is an example implementation for SQLite:


  struct SqliteConnection {
      path: String,
  }

  impl DatabaseConnection for SqliteConnection {
      fn connect(&self) -> Result<Connection, String> {
          Connection::open(&self.path).map_err(|err| format!("Error connecting to SQLite database: {}", err))
      }

      fn execute(&self, query: &str) -> Result<Vec<Row>, String> {
          let conn = self.connect()?;
          let mut stmt = conn.prepare(query).map_err(|err| format!("Error preparing SQL statement: {}", err))?;
          let rows = stmt.query_map(NO_PARAMS, |row| row).map_err(|err| format!("Error executing SQL statement: {}", err))?;
          let result: Result<Vec<Row>, _> = rows.collect();
          result.map_err(|err| format!("Error collecting SQL statement rows: {}", err))
      }
  }

This implementation defines a Struct called “SqliteConnection” that contains the path to the SQLite database file. It then implements the DatabaseConnection Trait for this Struct by defining the “connect” and “execute” methods. The “connect” method opens a connection to the SQLite database using the “rusqlite” crate and returns a Connection object if successful. The “execute” method prepares and executes a SQL.

The Overall Gist of Traits

In conclusion, Traits are a powerful feature of Rust that enable developers to define composable behaviors that can be shared across different types. By defining a set of methods that can be implemented by any type, Traits allow developers to create reusable code that can be composed in different ways. This not only improves code organization but also promotes code reuse, leading to more maintainable and flexible codebases.

With the help of Traits, Rust developers can build complex systems that are flexible, efficient, and maintainable. Whether you’re building a database connection, a web service, or a game engine, Traits provide a powerful tool for creating composable behavior that can be reused and adapted to meet your specific needs.

Comments