Pluggable behavior and API Design with Rust traits
Discover how Rust enables extremely flexible API design via Traits, aka Interfaces done right.
Lets start with a simple problem: Imagine that you are creating a library that helps a user print colored text to the terminal. Narrowing the problem a bit, imagine that you only need to help the user print red colored text.
We can go about this by creating a function that receives a normal string then returns it with the red ANSI escape code concatenated at the start. Then, all the user needs to do is print it.
Here’s an example in Java:
public String toRed(String source) {
return "\033[0;31m" + source;
}
public static void main(String[] args) {
System.out.println(toRed("Hello in red!"));
}
Also one in Golang:
func Red(source string) string {
return "\033[0;31m" + source
}
func main() {
fmt.Println(Red("Hello in red!"))
}
It would also be nice to append at the end of the string the ANSI escape code for clearing all colors, but i will keep it like that for the example.
Now, imagine that you want to also be able to print bold text to the terminal. This can also be done with an ANSI escape code, and they can be concatenated together so the text is both red and bold.
That is easy, we just need to create another function that concatenates the bold ANSI code to the start!
public String toBold(String source) {
return "\033[0;1m" + source;
}
public static void main(String[] args) {
System.out.println(
toBold(toRed("I am red and bold!"))
);
}
func Bold(source string) string {
return "\033[0;1m" + source;
}
func main() {
fmt.Println(
Bold(Red("I am red and bold!"))
)
}
If i were to say that there is now another two styles that i want to apply, lets say for example, a foreground color in white and underline, you could see the creation of more functions that just concatenate ansi code strings and blahblahblah… At the end the code would look like this:
public static void main(String[] args) {
System.out.println(
toWhiteFg(
withUnderline(
toRed(
toBold(
"I am imersed in lots of parentheses"
)
)
)
)
);
}
func main() {
fmt.Println(
FgWhite(Underline(Red(Bold(
"How do i even format this"
))))
)
}
I think you can see where this is going. This infinity of nested function calls is ugly, hard to read and cumbersome to edit (You need to keep track of how many parentheses are in each side, everyone has already been there haha). In this specific situation we’re lucky that the order of the operations does not matter, since we concatenate all the codes at the start anyway, but it could be even worse. Happily, we already have more elegant ways of doing this: We can use methods instead, and conveniently chain our function calls with dots!
public static void main(String[] args) {
System.out.println(
"Hey!"
.toRed()
.toBold()
.withUnderline()
.toWhiteFg()
);
}
func main() {
fmt.Println(
"This looks way easier to reason about. :)"
.Red()
.Bold()
.Underline()
.FgWhite()
)
}
Unfortunately, this convenient syntax is not achievable on both languages.
In Java, we cannot simply add these methods to the String
class, the proper way to do it
would be to create a wrapper class (for example ColoredString
) that inherits the default
String
class, and then create our methods in the ColoredString
one.
In Go, the language does not allow method creation directly in primitive types (such as the string
type).
The best we can do is assign a type to another type.
type ColoredString string
Now we can create methods for the ColoredString type:
func (s ColoredString) Red() ColoredString {
return ColoredString("\033[0;31m") + s
}
func main() {
value := ColoredString("Hello in red!")
fmt.Println(value.Red())
}
There is a catch here, though. Our type ColoredString
is a different type from the default string
,
so it cannot be used like a normal string. Trying to concatenate it to a normal string gives a compiler error.
The problem with this now is that for everything we were doing before with normal strings now needs to use our ColoredString
type. Depending in how your codebase is structured, think changing the parameter & return types of lots of functions, or
even structs, and then having to cover cases where you used functions that receive strings, now you need to introduce typecasts
everywhere. We can call those tipical wrapper problems.
It is important to note another thing as well: Rust, Go and Java are all statically typed languages.
What about dynamic ones?
Adding a method to any class like Integer or String would be trivial in a dynamic and extensible language like Ruby, using what we call as ”monkey patching”. This technique does have its tradeoffs though, as it can easily lead to buggy and confuse behavior in your code. It is a kind of polemic topic even, and there is plenty of places that will try to teach you how to use such a powerful feature in a “responsible” way.
Dynamic languages are more powerful in this aspect, but it comes with a capacity of introducing obscure behavior, especially when you are tinkering with such features for the first time. It is common for beginners using dynamic languages to spend hours trying to debug some strange thing going on in their code, and even experient folks sometimes have to take a long time to get what is going on in a messy codebase.
Back to our static world - Go’s solution is relatively nice compared to most languages and i kind of like it, but it does introduce all of the tipical wrapper problems, that are all also valid for the Java one.
Finally, enter Traits!
Traits are a important construct of the Rust language and are similar to what interfaces are in Java. As the official Rust book states, Traits allow for the definition of shared behavior. Lets take a look at their example to understand it better.
pub trait Summary {
fn summarize(&self) -> String;
}
It simply defines a set of functions that other types can now implement. Take a look at this other piece of code:
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
Now, thanks to the impl
block, the NewsArticle struct have access to the method summarize
.
You can implement as many traits for your types as you want, and you can use them in function signatures as well:
pub fn print_summary(something: &impl Summary) {
println!("{}", something.summarize());
}
This function accepts any type that implements the trait Summary
as its parameter, and thus can make use of its methods,
without knowing the type of the parameter beforehand. Designing APIs that receive types implementing traits instead of concrete
types as parameters makes it so that any consumer can adapt their types to fit your API, just by implementing a trait.
The Rust standard library and the most used crates make use of this heavily.
One example that i like is the method parse
of the string slice type (&str
). Here is its signature:
pub fn parse<F>(&self) -> Result<F, <F as FromStr>::Err>
where
F: FromStr
What is most important here is the where
clause. The parse function can parse strings to basically anything, as long as it
implements the FromStr
trait. There is no parseInt
or parseFloat
functions, this method already can handle all cases.
Of course, the language already implements the FromStr
trait for the most common types, such as i32, so you dont need to
write those.
fn main() {
// There are two ways of doing it:
// In the first one parse can infer the generic type to be i32 since our variable already is an i32.
let number: i32 = "4".parse().unwrap();
// In the second one we specify the generic using the "turbofish" syntax (::<>), so the number variable infers the parse type here.
let number = "4".parse::<i32>().unwrap();
assert_eq!(4, number); // Assert equals
}
Here comes the neat part: Suppose i have a struct called Person, that looks like this:
struct Person {
name: String,
age: i32
}
Now, suppose that i also have a spreadsheet with thousands of people in the following format: “Name, age”. An example
would be: “Viktor, 19”.
And i want to be able to parse strings into my struct, using the same parse function present in the string slice type.
We can do this by implementing the FromStr trait for our Person
struct!
struct Person {
name: String,
age: i32
}
impl FromStr for Person {
}
When we write the following code, the Rust compiler will display this helpful message:
error[E0046]: not all trait items implemented, missing: `Err`, `from_str`
--> example/src/lib.rs:8:1
|
8 | impl FromStr for Person {
| ^^^^^^^^^^^^^^^^^^^^^^^ missing `Err`, `from_str` in implementation
|
= help: implement the missing item: `type Err = /* Type */;`
= help: implement the missing item: `fn from_str(_: &str) -> Result<Self, <Self as FromStr>::Err> { todo!() }`
For more information about this error, try `rustc --explain E0046`.
error: could not compile `example` (lib) due to previous error
With this, we know that we need to add the from_str
function to our impl
block, but what about this Err
type?
Let’s look at the FromStr
trait definition.
pub trait FromStr: Sized {
type Err;
// Required method
fn from_str(s: &str) -> Result<Self, Self::Err>;
}
If you look at the top, you can see how FromStr
specifies that for implementing this trait, you also need to implement
the trait Sized
. You can just keep it like that for now, we won’t talk about it in this article.
In trait definitions, you can also include the need for a type definition, in this case he calls this one Err
.
That is just the type name, do not confuse it with an specific Err
type. Later he specifies that in the from_str function,
that does the parsing, you need to return a Result that contains either your type as a success value (which he refers to as Self
)
or your custom Err
, that he declared up there, and is refering to as Self::Err
. And that is why we need to define a type for
our error as well, it is the type of the error we will return when anything goes wrong in our parsing, like for example, if the
string does not comply with our needed format.
Now that you hopefully got a clearer picture, lets implement this trait for our struct!
#[derive(Debug)]
struct PersonParsingError;
impl FromStr for Person {
type Err = PersonParsingError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let split: Vec<&str> = s.split(", ").collect();
let name = split
.get(0)
.ok_or_else(|| PersonParsingError)?
.to_string();
let age: i32 = split
.get(1)
.ok_or_else(|| PersonParsingError)?
.parse()
.map_err(|_| PersonParsingError)?;
Ok(Person { name, age })
}
}
Lets take a moment to understand what is happening here, line by line.
First, i define a struct called PersonParsingError
that contains no fields, and it will just
serve as a type for our error. On the top of the struct (#[derive(Debug)]
), you can see that i’m using what is called a
derive macro. We will get to it later, for now you can just know that this gives PersonParsingError
the ability to be easily printed/inspected for debugging purposes.
Now to our function that transforms a string slice into our Person struct:
We first use the split
method on our slice specifying ”, ” as the separator. The split method returns a type called Split
,
which we can easily collect
into a vector of string slices Vec<&str>
.
Right now our split
variable looks something like this:
[example/src/lib.rs:17] &split = [
"Viktor",
"19",
]
Next thing we do is get the name. If you are used to languages that utilize an exception system for handling errors,
this part may be a little confusing, but bear with me a bit. At first we use the get
method of the vector to try
to get the value at index 0. The try is important. In a language with a exception system, methods like that would either:
- return a string if there is something at the position 0, and if there is nothing return null;
- return a string if there is something at the position 0, and if there is nothing throw an error.
Rust recognizes that errors are a normal part of the programs that we write, and error handling ergonomics should be a priority.
So we treat errors as values. The get
method returns an instance of the Option
enum, which can be either a Some()
variant
containing our string slice inside or a None
variant containing nothing.
In this case the index 0 of the vector contains a value, so the returned result is Some("Viktor")
. What we are doing in the next
line is using a method of the Option
enum, called ok_or_else
for handling the case of a None value. We need to do this,
because there is no way of using the value inside the Option without before consuming it by handling the error. And by doing that,
Rust doesn’t let the developer forget about checking for missing values (null or undefined).
You dont need to be worried about handling every possible error at all times though, this could be cumbersome while prototyping, but Rust has you covered with the
unwrap
method (There are more, but im keeping them out for now). Theunwrap
method can be used in aOption
to kind of reproduce the behavior of languages with an exception layer. When using unwrap the program panics onNone
variants. This is only for prototyping obviously, but the fact that you skipping error handling for now leaves explicit code behind makes it easier to refactor it later now handling all the possible cases.
Look at our function signature. We return a Result
, an enum really similar to the Option
one. This one is used for representing
results of operations that can error, instead of possible missing values.
Rust haves syntatic sugar for early returning an Error from any function that returns a Result
. We can use a ?
in any Result value,
and then if it is an error it will early return from the function with the error value itself.
Now for the ok_or_else
method, we pass a closure (or callback function) as an argument that gets executed in case our Option
is None
.
After executing our Option
turn into a Result
, and if the value were None
, it will now be the error returned by our closure. If there
is indeed a value Some
inside our option, the function will be ignored and the Option
will be consumed.
So now a quick recap:
- We try to get the value at index 0, and it returns a
Option<&str>
- We then apply the
ok_or_else
method to theOption
, passing a closure that returns our custom error if the value isNone
. - Now the value got converted to a
Result<&str, PersonParsingError>
. Note how we made a operation with the internal value without having to “unwrap” it. - We then use the
?
operator to consume our result, getting the string slice from inside, or returning early from the function in the case of aPersonParsingError
- Now we have a
&str
, this is a read-only reference to a string slice, a stack allocated string. We then convert it to theString
type using theto_string
method, a heap allocated string that we are using for ourPerson
struct.
Whew, that was a lot. I hope you could grasp some of the error handling flow of Rust. With the age field, we do the same, and then use the
string slice parse
method to convert it to a 32-bit integer - the i32
. This parse function returns a Result that can contain the success
value of a i32 or a integer parsing error. This is not our error, so we use the map_err function to map this error to the PersonParsingError
-
only in the case it is indeed an error. Then we use the ? operator to propagate the possible error again.
After that, we safely have our String
name and our i32
age. Now the job is easy: we return an instance of our Person
struct
wrapped in an Ok
variant, representing the success of our operation, and wrapping our Result
for returning.
Finally, now that we know what is happening, lets test our API!
At the bottom of the same file, i wrote a test module:
mod tests {
use crate::Person;
#[test]
fn parse_valid_strings() {
let result: Person = "Viktor, 19".parse().unwrap();
assert_eq!(result.name, String::from("Viktor"));
assert_eq!(result.age, 19);
}
#[test]
fn errors_on_invalid_strings() {
let result = "Viktor, blabla".parse::<Person>();
assert!(result.is_err());
}
}
Thanks to Rust’s built-in testing support, that is all we need to do (no third-party packages needed). Running
cargo test
now should run all our tests and output the following:
running 2 tests
test tests::errors_on_invalid_strings ... ok
test tests::parse_valid_strings ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
After this adventure implementing the FromStr
trait, there is a library that i would like to show you.
Look at those snippets from the crate colored
:
use colored::Colorize;
"this is blue".blue();
"this is red".red();
"this is red on blue".red().on_blue();
"this is also red on blue".on_blue().red();
"you can use truecolor values too!".truecolor(0, 255, 136);
"background truecolor also works :)".on_truecolor(135, 28, 167);
"bright colors are welcome as well".on_bright_blue().bright_red();
"you can also make bold comments".bold();
"Hello in bold red!".red().bold();
Looks great, doesn’t it? It is what i would call a next level API design. I bet i don’t need to explain this one.
All we need to do is bring the Colorize
trait into scope, and as the author of the crate already
have implemented this trait for string slices, we can freely use its methods to style our strings!
It is almost like we could simply plug this functionality in a primitive type of the language with a single line of code.
This represents an extremely satisfying characteristic, or should i say, trait: easy and pluggable behavior.
If you’ve made it this far, i would like to thank you a lot. That’s it!