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
.
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
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.
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.