Rust: Traits

Home · Blog

21 February 2023

A trait is an interface that types can implement. It consists of a set of associated items, which can be types, constants, functions, and methods. As an example, let’s take a look at a trait used for logging messages with a log level:

trait Logger {
    // associated type
    type Level;

    // associated constant
    const ERROR_LEVEL: Self::Level;

    // associated function
    fn new() -> Self;

    // method
    fn log(&self, message: &str, level: Self::Level);

    // method with default implementation
    fn log_error(&self, message: &str) {
        self.log(message, Self::ERROR_LEVEL);
    }

    // mutating method
    fn set_minimum_level(&mut self, level: Self::Level);
}

A constant in a trait can be either a declaration (a constant without a value, like Level in the example) or a definition (a constant that is assigned a value). A definition provides a default value that an implementation of the trait may or may not override. In the same way, a function or method can be a declaration (with a semicolon where the function body would usually be, like log in the example) or a definition (like log_error in the example). The types are always declarations, though.

In a trait definition, Self stands for the implementing type. The example makes use of Self in several places: new has Self as its return type, the declaration of ERROR_LEVEL uses the Self::Level associated type, and the default implementation of log_error then refers to Self::ERROR_LEVEL.

Trait implementations

Let’s implement the trait. We’ll need two types here, one that we’ll assign to Level and then one that will actually implement Logger:

#[derive(Debug, PartialEq, PartialOrd)]
enum SimpleLevel {
    Info,
    Warning,
    Error,
}

struct SimpleLogger {
    minimum_level: SimpleLevel,
}

To implement the trait we need a trait implementation that includes a definition for each item in the trait (except, perhaps, the ones with a default implementation):

impl Logger for SimpleLogger {
    type Level = SimpleLevel;

    const ERROR_LEVEL: SimpleLevel = SimpleLevel::Error;

    fn new() -> SimpleLogger {
        Self {
            minimum_level: SimpleLevel::Info,
        }
    }

    fn log(&self, message: &str, level: SimpleLevel) {
        if level < self.minimum_level {
            return;
        }
        println!("[{:?}] {}", level, message);
    }

    fn set_minimum_level(&mut self, level: SimpleLevel) {
        self.minimum_level = level;
    }
}

This looks pretty similar to the inherent implementations in the previous article, Rust: Methods, except we specify the trait name at the top.

Here’s some code using the various items defined in trait:

let mut logger = SimpleLogger::new();
logger.log("Hello!", SimpleLevel::Info);   // prints: [Info] Hello!
logger.set_minimum_level(SimpleLevel::Warning);
logger.log("Hello!", SimpleLevel::Info);   // does not print message

Trait objects

In the last example we defined a variable of type SimpleLogger and used it to call the methods defined in the Logger trait. What if we just wanted a variable of type Logger?

We’ll try this out using a really simple trait first. Let’s say we have an application where we need to check a user’s password when they log in. We’ll define a trait to do that:

trait Auth {
    fn check_password(&self, username: &str, password: &str) -> bool;
}

In the real application we store passwords in a database (hashed and salted, like you’re supposed to), but we have another implementation of Auth that’s used in tests and just has one fixed username and password:

struct TestAuth {
    username: String,
    password: String,
}

impl Auth for TestAuth {
    fn check_password(&self, username: &str, password: &str) -> bool {
        username == self.username && password == self.password
    }
}

You might think we could just define an Auth variable like this:

let auth: Auth = TestAuth {
    // ...
};

but that does’t quite work. In Rust, the size of a variable (the amount of memory it’ll use at runtime) must be known at compile time, but with a trait you don’t know how large an instance of the trait will be. The solution is to use a trait object. This must be some kind of pointer, and you need to use the dyn keyword to indicate that the object it points to is dynamically sized. One option is to use a reference:

let test_auth = TestAuth {
    username: String::from("jane"),
    password: String::from("123"),
};
let ref_to_auth: &dyn Auth = &test_auth;
println!("{}", ref_to_auth.check_password("jane", "123"));   // true

We can also use the Box type (see Rust: The Stack and the Heap) to allocate the object on the heap:

let boxed_auth: Box<dyn Auth> = Box::new(TestAuth {
    username: String::from("joe"),
    password: String::from("456"),
});
println!("{}", boxed_auth.check_password("joe", "456"));   // true

At runtime a trait object consists of two pointers: one that points to the object and one that points to a table with pointers to the method implementations, called a vtable. When a method is called, the program looks up the method in the vtable and calls it with the pointer to the object as the &self argument.

So, going back to the original example: how about we define a variable of type Box<dyn Logger>? It turns out we can’t do that: the Logger trait has an associated constant, which means it’s not object-safe. Only object-safe traits can be used for trait objects. Associated functions (that aren’t methods) also disqualify a trait. For the full definition, see the Rust Reference: Object Safety.

Looking ahead

We can’t use &dyn Logger, but we could use &impl Logger like in this function:

fn log_error_code(logger: &impl Logger, code: i32) {
    let message = format!("error code: {}", code);
    logger.log_error(&message);
}

That means something quite different, though. It’s basically a shorthand syntax for generics, or parametric polymorphism, which I want to talk about in the next article. So that one will have to wait a bit.