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.