Common Rust std Traits

Prerequisites

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:

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
  • &Trait syntax 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, so println!("{}", &thing) is an anti-pattern. Keep in mind that this is not the case for dbg!(), which is a pass-through macro and returns its parameter(s)
  • More of these basic traits are now included in the standard library prelude module and are imported automatically. Consult clippy after 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() -> Self should create a new instance of the type. It should not take any parameters, unless it is impossible to create the type without any other input
  • fn from_something(param1: ..., param2: ...) to create types from input, when you can also do it with default values via new(). If you can, you should prefer implementing From. This approach should only be preferred when it's from several parameters, or if you for some reason don't want to implement From

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 U
  • From<T> for <T> and Into<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 Fn closure only accesses its environment by reference, and thus is valid in any context
  • a FnMut closure accesses its environment mutably by reference, and so it is only valid in FnMut and FnOnce positions
  • a FnOnce closure moves the values into itself, and so you can only use it in a FnOnce context

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 file
  • fn 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 duplicate
  • fn update(item, amount) - Modify first occurrence of item to have new amount
  • fn remove(item) -> (item, amount) - Remove first occurrence of item, returning it and its amount
  • fn get(item) -> amount - Find the first occurrence of item, returning its amount
  • fn save() - Save list to its internally stored path
  • fn 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.