Defining behavior with Traits
There is no genuine concept of object-oriented-style inheritance in Rust. Traits introduce the notion of defining shared behavior for data structures. A trait defines a set of shared functions, expectations, and behavior that can be used for an indefinite number of types.
Creating a Trait
Defining a trait utilizes the trait
keyword, followed by the name of the trait. In this example,
notice the use of the pub
(public) keyword. This exposes the trait to any external files within a
Rust crate and project to utilize:
pub trait Transferrable {
fn transfer(&mut self, who: &mut Self, amount: i32) -> i32;
}
This function defines a trait Transferrable
, which also defines a method signature, called
transfer
. This signature is a blueprint for what we expect from any type that implements
Transferrable
as a trait. As implied by the name, this trait would allow a type to send currency
from the caller to who
. The use of this trait would allow for a particular type to now possess
these methods in order to achieve this functionality.
In order these methods to be used, however, they must be implemented on that specific type.
Take note of the difference of usage between self
and Self
. Remember, self
refers to an
already instantiated instance of that type, whereas Self
refers to simply that type. The use of
Self
here allows for this trait to look for the specific type being implemented when it comes to
who
should receive currency.
Common Traits in Rust
As you may have already seen, there are quite a few traits that are used in Rust. A few common traits are:
- Debug - formats the output in a debugging context.
- PartialEq - A trait for equality comparisons.
- Clone - Describes how a new value can be created, or "Cloned".
There are many more, but these, when used in conjunction with the derive
macro with structs, can
be very useful in making structs more pleasant to handle.
Using the derive macro
A line you may have encountered above a struct
declaration is #[derive()]
. This is what is
called a procedural macros, which create a sort of auto-implementation for a set of compatible
traits. Between the parentheses, any traits compatible with this macro, along with the struct's
fields within can be implemented:
#[derive(PartialEq)]
struct Stormtrooper {
name: String
}
Because String
also implements PartialEq
, this is perfectly acceptable and easier than fully
writing out the implementation for this particular trait. It's a shorthand way of implementing
common traits within the standard library. Without PartialEq
in this context, we wouldn't be able
to utilize the equality operator (==
).
let stormtrooper_one = StormTrooper { "Stormtrooper 1".to_string() };
let stormtrooper_two = StormTrooper { "Stormtrooper 2".to_string() };
// Possible due to PartialEq! No need for a full trait implementation.
println!("Is Stormtrooper One equal to Stormtrooper Two: {}", stormtrooper_one == stormtrooper_two);
Implementing a Trait on an "Account" struct
To implement (impl
) this trait, we will create a struct called Account
, which will also be
marked as pub
:
Here, the concept of an Account
is more in the context of a blockchain, where an account has some
identification (id
, in this case, although this could also be an address) and a monetary balance.
This theme will become more present throughout the course, as these terms will become more prevalent
when learning more about developing with Substrate.
pub struct Account {
pub id: i32,
pub balance: i32,
pub is_legit: bool,
}
impl Transferrable for Account {}
If the above code was run, we'd be presented with this error:
error[E0046]: not all trait items implemented, missing: `transfer`
--> src/main.rs:11:1
|
8 | fn transfer(&mut self, who: &mut Self, amount: i32) -> i32;
| ------------------------------------------- `transfer` from trait
...
11 | impl Transferrable for Account {}
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `transfer` in implementation
For more information about this error, try `rustc --explain E0046`.
As the compiler clearly says - we must implement all method signatures for this implementation!
Now, we may implement our Transferrable
trait to properly reflect the method signatures:
impl Transferrable for Account {
fn transfer(&mut self, who: &mut Self, amount: i32) -> i32 {
// Take from our (self) balance,
self.balance = self.balance - amount;
// Add that amount to someone else's balance,
who.balance = who.balance + amount;
// return our remaining balance.
self.balance
}
}
Because we implemented Transferrable
with this type, now any instance of the account struct can
utilize the transfer()
method and ensure shared behavior:
let mut alice = Account {
id: 1,
balance: 100,
is_legit: true,
};
let mut bob = Account {
id: 2,
balance: 200,
is_legit: true,
};
// In theory, account two could also easily call this!
alice.transfer(&mut bob, 50);
println!(
"Account One Balance: {}, Account Two Balance: {}",
alice.balance, bob.balance
);
In fact, any type that impl
s Transferrable
can now transfer funds, so long as it's implemented
correctly as per the method and trait signatures.
To illustrate this let us add a method signature to Transferrable
to drain all funds from an
Account
:
pub trait Transferrable {
fn transfer(&mut self, who: &mut Self, amount: i32) -> i32;
/// New method signature! Now we *have* to implement it.
fn drain_funds(&mut self) -> bool;
}
impl Transferrable for Account {
fn transfer(&mut self, who: &mut Self, amount: i32) -> i32 {
// Take from our (self) balance,
self.balance = self.balance - amount;
// Add that amount to someone else's balance,
who.balance = who.balance + amount;
// return our remaining balance.
self.balance
}
fn drain_funds(&mut self) -> bool {
// Drain it all!
self.balance = 0;
// Really, we should have a Result that ensures that the account was successfully drained.
true
}
}
While drain_funds
returns a bool
for simplicity, a Result<T, E>
would be more appropriate
here, along with more robust checking on if the user is allowed to drain their funds or not.
And as before, it's as simple as calling the drain_funds
on any type, in this case, Account
,
that implements the Transferrable
trait:
// Account two's balance is now 0
bob.drain_funds();
Feel free to define more types and implement, or even expand Transferrable
with more methods as
you see fit.
Trait Parameters & Bounds
A common use of traits is their use as bounds in functions, which can then be used to define parameters that abide by these bounds. With generics, this becomes possible and produces very reusable code:
For this scenario, we added an additional method to the Transferrable
trait - is_legit()
, which
verifies the legitimacy of a particular entity. This entity could be an Account
, or even something
like a SmartContract
type.
fn verify_entity(entity: &impl Transferrable) {
if entity.is_legit() {
println!("Entity is legit!");
} else {
println!("Entity is NOT legit!");
}
}
The function's parameter, entity
, is saying something very particular here: any type which
implements Transferrable
as a trait is a valid parameter. If we were to define another type, say
SmartContract
, that implemented Transferrable
, then that would also be completely valid.
It can also be written using trait bound syntax, which allows for a better view of generics at work:
fn verify_entity<T: Transferrable>(entity: &T) {
if entity.is_legit() {
println!("Entity is legit!");
} else {
println!("Entity is NOT legit!");
}
}
To call a function like this would be quite interesting, as we would need to specify which type, so
long as it abides by the trait bound Transferrable
:
let mut account = Account {
id: 1,
balance: 100,
};
// Account goes in the angled brackets, as it's a type 'T' that implements 'Transferrable':
verify_entity::<Account>(&account);
Try it yourself!
What's happening here?
In this example, we define a common trait Transferrable
, which allows a struct to define and
access three methods: transfer
, is_legit
, and drain_funds
. When a struct implements this
trait, it must specify the functionality for each method. A function, verify_entity
, also
introduces a trait bound T: Transferrable
. This bound ensures that only types that have
correctly implemented Transferrable
are to be valid arguments.