Rust Traits, First Crack
Back in October, as some of you might be aware, I moved out of daily ops and CTI analysis. Since then, I’ve been working on two projects that are being developed in Rust and Go. Both are great, but as historically a systems-level programmer specializing in C and C++, prior to entering cybersecurity, the announcement of Rust 1.0, and what it promised to deliver, has intrigued me since I started reading about it. A couple years ago, when we were all picking up new hobbies to maintain sanity, I took some time of my own to finally sit down and learn Rust. In addition to The Rust Programming Language (TRPL), I also found the following two resources extremely helpful:
- Rustlings - A series of hands-on learning challenges built into a validation framework
- A Gentle Intro Into Rust - A great companion to TRPL, by Steve Donovan
- Rust by Example - Official companion documentation to TRPL
For anyone wanting to get started with Rust, these were fairly solid introductions for me. I won’t spend any more
time dwelling on these, as I started this entry to discuss a specific feature of the language: Generics using the
Rust trait
feature.
Example of an Existing Trait
The most common vector through which a novice Rust programmer might first encounter traits, is through the implementation
of the fmt::Display
trait. Here’s the definition of this trait
from the Rust std
library:
pub trait Display {
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>;
}
Traits are used to describe language-level APIs that provide uniform access patterns and/or operations across multiple data types.
Traits, Interfaces, Protocols!
In this role, they offer functionality commonly seen in C++’s abstract classes,
or Java’s interface
feature. Used as above, they
will functionally fill a role much more similar to the latter, as the trait
feature is limited to defining a specification
without an implementation, which is similar to the interface
keyword’s role in Java. The interface
keyword in Java is commonly
used to achieve the outcome for which multiple inheritance is often relied upon for, in C++, as Java maintains a stricter set of
inheritance rules that don’t allow for multiple parent classes.
Similar language features can be found
in ObjectiveC/C++’s @protocol
.
Curiously enough, ObjectiveC/C++ use the keyword @interface
to define the type-specific definition, and rely upon @protocol
to
define more generic API definitions that can be implemented by multiple data types.
(Click here for another example of ObjectiveC
@protocol
and @interface
). As it owes its lineage to the Smalltalk-inspired ObjectiveC, Swift similarly
adopts this lexicon, too.
Definition vs. Implementation: The Division of Responsibilities
Among the key language-level constraints encountered with Rust is that type definitions (encapsulated data, such as member variables) and
implementation are written as separate organizational blocks. For example, the below is a struct
that contains two member attributes, _a
and _b
, that are private-access:
struct TwoNums {
_a: u32,
_b: u32,
}
And, the following is the implementation of this data types methods, implementing a basic getter and setter for each, as well as a straightforward constructor:
impl TwoNums {
fn new() -> Self {
Self {
_a: 0,
_b: 0,
}
}
fn set_a(&mut self, new_a: u32) {
self._a = new_a;
}
fn set_b(&mut self, new_b: u32) {
self._b = new_b;
}
fn get_a(&self) -> u32 {
self._a
}
fn get_b(&self) -> u32 {
self._b
}
}
Then, in our main
function, we might try to write the following code:
fn main() {
let mut o: TwoNums = TwoNums::new(); // Instantiate a new instance of TwoNums
o.set_a(1);
o.set_b(2);
println!("{}", o);
}
Trying cargo run
on this will result in an error similar to the following:
error[E0277]: `TwoNums` doesn't implement `std::fmt::Display`
--> src/main.rs:33:20
|
33 | println!("{}", o);
| ^ `TwoNums` cannot be formatted with the default formatter
|
= help: the trait `std::fmt::Display` is not implemented for `TwoNums`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
The compiler here is pointing out that our TwoNums
type doesn’t implement the Display
trait. In Rust, the
println!()
macro expects the parameters corresponding to the positional arguments of the provided format str
to
implement this trait. Again, this approach is very similar to how you might integrate a custom-defined type up to
the library-standard APIs in Java. In effect, to implement a trait
in Rust is to declare an
“is-A” relationship in Rust. Unlike a lot of other languages,
however, the structure is more rigid and you always declare in terms of “<TYPE> is a <TRAIT>”. In this case,
we need to make TwoNums
a type that is Display-able.
Trait Implementation
Looking at the trait
definition from above again, the definition defines a single member function (or, method)
that must be implemented:
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>;
In this definition, Rust is declaring the following needs from your code:
- The method must be named
fmt
- The method must return a
Result
type with()
on success, and an error on failure - The method will receive
&self
(it must be called on an instance of the type) - The method will receive a mutable reference to a
Formatter
instance, in varf
- this is where we write our data
Generally, you’ll learn the interface API from the code, but will have to refer to the Rust Docs to
understand better what Rust expects you to do with these paramters. For the Display
trait. For our example:
Reading the documentation, it explains that you are expected to call the write!()
macro on simple data types (or other
complex data types that already implement Display
) in order to display your data into the buffer (which is provided by f
). In
the case of our example, lets say that we want the data to be displayed as a string similar to the following:
[a=1, b=2]
The implementation of the Display
trait for our custom type TwoNums
would look like below, and would be implemented
as a third independent block of code:
impl fmt::Display for TwoNums {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[a={}, b={}]", self._a, self._b);
Ok(())
}
}
At the top of the source file, you’ll also want to make sure to import the std::fmt
library, with the following declaration:
use std::fmt;
Conclusion
Once complete, the compiler error that was caused earlier should no longer occur, and the application should build & run, displaying the following output:
[a=1, b=2]
Having written in a number of different languages over my career, I felt it would be helpful to try
relating the Rust trait
feature to some other common mechanisms used in other langauges used to
achieve similar modularity and code reuse capabilities.
Full Source of src/main.rs
The full source code of the final working program, built using the fragments above, is provided below:
use std::fmt;
struct TwoNums {
_a: u32,
_b: u32,
}
impl TwoNums {
fn new() -> Self {
Self {
_a: 0,
_b: 0,
}
}
fn set_a(&mut self, new_a: u32) {
self._a = new_a;
}
fn set_b(&mut self, new_b: u32) {
self._b = new_b;
}
fn get_a(&self) -> u32 {
self._a
}
fn get_b(&self) -> u32 {
self._b
}
}
impl fmt::Display for TwoNums {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[a={}, b={}]", self._a, self._b);
Ok(())
}
}
fn main() {
let mut o: TwoNums = TwoNums::new();
o.set_a(1);
o.set_b(2);
println!("{}", o);
}