Common Rust std Traits
Prerequisites
- All of the previous chapter
- File operations
- writeln!() and write!() macro
- Strings
- Splitting strings
- Reading from Standard input
Following from our more concrete look at Rust's pointers in the previous chapter, it is time to do the same for the essential traits in the standard library. As stated previously on a couple occasions, traits are the cornerstone of Rust.
In fact, the mere existence of certain traits is a hard requirement by the compiler.
In extremely limited hardware circumstances, it is possible to not program without
the standard library (crate-level attribute #![no_std]), but also without the
completely platform- and allocator independent core library (crate-level attribute #![no_core]),
this however requires you to provide implementations of certain traits such as Sized and
mark them for the compiler.
For this chapter, we felt that we can build off already existing free resources available on the internet. Please start by reading through the following two links:
All in all, the list of essential traits is pretty much the following:
- From/Into
- Debug/Display
- Fn traits: Fn, FnMut, FnOnce
- Iterator traits: mostly Iterator, IntoIterator, FromIterator, ExactSizeIterator
- Borrowing/Ownership traits - Deref, AsMut, AsRef, Borrow, BorrowMut, ToOwned
- Drop
- Any
- Clone, Copy
- (Partial)Eq, (Partial)Ord
- Arithmetic/other operator traits
- Error
- IO traits: Read, Write
- Hash
- Default
Some of these traits are mentioned in part 2 of the Strangely Linked List, make sure to check it out. You also don't need to know all about all of these, the most important ones are mentioned in the articles, but it's nice to have an overview.
Notes
The articles listed at the top of the page are a bit outdated. While due to Rust's backwards- and forwards-compatibility guarantees, everything in them is absolutely correct, there was a couple development in recent years which are useful to know to be able to produce top-notch 2022 Rust ;-)
try!()syntax is now obsolete and its function is completely covered by the?operator, it does the same thing&Traitsyntax for trait objects has been deprecated in favor of&dyn Trait, that makes it clear that we are dealing with a trait object and not a reference to a concrete type- Many traits now have a fallible counterpart, such as
TryFrom, if the operation can fail, prefer using these and properly propagating the error in favor of panicking. That makes your code much more friendly for library users, and makes it also easier for you to build more robust applications - Function-like format macros such as
(e)println!()now take their arguments by reference by default, soprintln!("{}", &thing)is an anti-pattern. Keep in mind that this is not the case fordbg!(), which is a pass-through macro and returns its parameter(s) - More of these basic traits are now included in the standard library
preludemodule and are imported automatically. Consultclippyafter running your code to ensure no line of code is wasted
ToString trait
This trait is mentioned in the article by Steve Donovan, it is used to turn things into String explicitly
via the .to_string() method. However, you usually don't want to implement this trait by hand.
There exists a blanket implementation of:
#![allow(unused)] fn main() { impl<T> ToString for T where T: Display + ?Sized }
This means that ToString is automatically implemented for every type that has a Display implementation.
Therefore, writing format!("{}", variable) is an anti-pattern. It is the same as variable.to_string(), however, it comes at a cost
to performance, as each format is pretty slow and you want to keep nested formats to a minimum (and remember that since it's derived
from Display, the .to_string() blanket implementation already has a format usage insider it)
new(), from_* and Default
Rust does not have constructor methods you might be used to from languages like C++, C# or Java.
You may have seen Type::new() a lot in Rust code, however, it is just a plain method that we use by convention.
The entire convention is as such:
fn new() -> Selfshould create a new instance of the type. It should not take any parameters, unless it is impossible to create the type without any other inputfn from_something(param1: ..., param2: ...)to create types from input, when you can also do it with default values vianew(). If you can, you should prefer implementingFrom. This approach should only be preferred when it's from several parameters, or if you for some reason don't want to implementFrom
But you may also remember that you can construct new instances of types via the Default trait, which serves to provide
a default value.
Default can be derived:
#![allow(unused)] fn main() { #[derive(Default)] struct Point { x: i32, y: i32, } }
(You can only derive Default if all member types also implement Default)
For types which can derive Default, you should just call it from the new function:
#![allow(unused)] fn main() { #[derive(Default)] struct Point { x: i32, y: i32, } impl Point { fn new() -> Self { Default::default() } } }
clippy actually has this convention as a lint and will complain if it's not like this and can be.
From and Into
From is the reciprocal trait of Into.
There are two generic implementations:
Into<U> for T where From<T> for UFrom<T> for <T>andInto<T> for T
As such, it is considered an anti-pattern to implement Into when From can be implemented.
The only reason where you might have to implement Into is if orphaning rules don't allow otherwise,
see the relevant section of Advanced traits
Closures and Fn traits
Fn, FnMut, FnOnce
When it boils down to implementation details, closures are actually structures containing values or references to values of the environment they close over and a single method that actually manipulates these. And because The environment and the action generally differ between each closure, each closure is its own type and you can't make conversions. In fact, the type itself is actually anonymous.
To the rescue come the aforementioned traits. These allow us to elegantly handle different closures that have the same signature, and it even comes with a bit of syntax sugar to make writing trait bounds easier:
#![allow(unused)] fn main() { fn execute_closure<F>(closure: F, string: String) -> (i32, i32) where F: Fn(String) -> (i32, i32) { closure(string) } }
Consider the traits to be restraints:
- a
Fnclosure only accesses its environment by reference, and thus is valid in any context - a
FnMutclosure accesses its environment mutably by reference, and so it is only valid inFnMutandFnOncepositions - a
FnOnceclosure moves the values into itself, and so you can only use it in aFnOncecontext
This is important to know when choosing what trait bounds you need in your code.
The Task: The most generic file-backed Shopping list
In this project, you will be developing a shopping list as a library and as a cli tool.
Every Rust crate can have several targets, a single library, and many binaries. Each of these have different entry-points (that is, root modules), and to use items from the library part of your project, you need to import it just like any other library.
- For the main executable target, the root Rust file is
main.rs - For the library target, the root is
lib.rs - For the sake of completeness, other executable targets are modules located under
src/bin
You can see an overview of how Cargo targets are specified here
Our shopping list will be backed by a file and thus, we will be able load and save it to disk.
While the on-disk format is completely up to you, you may want to do something like this:
item name:amount
item name:amount
item name:amount
...
Whatever you will consider easy to parse is fine, we will assume the input will never contain any
whitespace other than a space and that it will not contain the delimiter.
1. Basic API
Create a structure called ShoppingList,
and create implementations of the following methods on it (consider this pseudo-code, it is up to you to
decide self parameters and concrete types):
fn new(path: String) -> Self- create a new shopping list bound to the path specified by string. If the path exists, open, load and parse the filefn insert(item, amount)- Add a new item of a specified amount to the shopping list. It doesn't matter if the item is already in the list, just add a new entry even if it's duplicatefn update(item, amount)- Modify first occurrence of item to have new amountfn remove(item) -> (item, amount)- Remove first occurrence of item, returning it and its amountfn get(item) -> amount- Find the first occurrence of item, returning its amountfn save()- Save list to its internally stored pathfn save_to(path: String)- Save list to path specified by parameter
TIP: Using Option or Result is generally preferred to panicking, which should only be reserved
for fatal irrecoverable errors
Derive traits that you consider will be useful to you for development.
Try that the list works as expected. As stated previously, duplicates are fine.
2. Generics
It is generally bad optics to use just Strings for paths. In Rust, the pattern is to use
every type that can be referenced as Path. Adjust the relevant functions to that instead.
If you do it correctly, all of these should be valid:
#![allow(unused)] fn main() { ShoppingList::new("myfile") ShoppingList::new(String::from("myfile")) ShoppingList::new(Path::new("myfile")) }
Conversely, it might also be useful if we could look at the shopping list as a Vec of pairs of item and amount,
Implement the same trait for ShoppingList, so do that next.
Remember that Path is not an owned type, and you might have to store its owned equivalent, or be prepared to deal
with lifetimes.
If you have time on your hands, do the same for the item: String params. In some places, we don't need to pass
an owned String, it is wasteful for memory, an so getting something that can be referenced as str would
be optimal. In places where an owned string better, let's make it generic taking something convertible to string.
3. Conversions and Equality
What if we already have a File lined up and ready? Furthermore, what if we don't know the path to said file? Well, in that case, for maximum flexibility, we might want to implement a conversion from a file.
Choose the most appropriate trait and implement it for ShoppingList. Keep in mind that parsing or even reading
the File given might fail.
Also, now you are in a situation where you might not have any path available, so .save() should fail until
a path is set with a new method called .set_path(new_path).
Finally, implement the Eq traits such that:
- Lists with the same path are considered the same
- Lists with a different path are considered the same only if their contents are the same
4. Sorting and de-duplication
Without using loops and doing this manually, add methods .sort() and .dedup(), where
.sort()will sort the elements alphabetically by item name (ignoring the amount).dedup()will remove same consecutive elements
For the de-duplication operation, make sure that the total amount of items is preserved.
What might come in handy is that .dedup_by() on Vec hands mutable references to elements.
5. Memory
Add a function called .save_on_drop(), which determines whether the ShoppingList should try
saving itself before going out of scope.
Look into the Drop trait
6. CLI
Write a simple cli that allows:
- reading and printing a list nicely (implement the correct trait for printing to
stdout), - inserting and removing elements
- sorting and de-duplication
- saving or discarding the modified list
Whether you do this via cli parameters or interactively is up to you. You may also use a third party
library. For Braiins, the most useful cli argument parsing libraries are clap and structopt / argh.
7. End product
In the end you should be left with a well prepared project, that has the following:
- documented code explaining your reasoning where it isn't self-evident
- tests where you see fit
- clean git history that does not contain fix-ups, merge commits or malformed/misformatted commits
Your Rust code should be formatted by rustfmt / cargo fmt and should produce no
warnings when built.