Peter Varo - Senior Backend Engineer
Tom Steavenson - Engineering Manager
At hx, our Engineering team has a Rust lecture series for engineers, encouraging our teams to learn and get familiar with the programming language. We ran 30 hour-long lectures, following the structure of The Rust Book, and included homework exercises. New joiners to hx have enjoyed going through the recordings and submitting their solutions to the exercises for review by their peers.
Exercises are often the thing that's missing from programming language learning resources, so we thought to contribute these to the world! In this blog, we'll take you through 8 exercises to test your Rust knowledge - let's get into it!
Warm-up Exercises
These are some small warm-up exercises that you can write as a single function. The Rust Playground is a good place to write these up in quickly.
- Write a function to return the nth Fibonacci number
- Write a function to convert between Celsius and Fahrenheit
- Write a function to generate the lyrics to “The Twelve Days Of Christmas”
For each exercise, have cargo installed, and create a new project. Each exercise will provide you with a main.rs
to get you started.
Note: Some of the early exercises are designed around having only covered the book up to the accompanying chapter.If you are more experienced in Rust, there may be easier ways to solve the problem with more advanced language knowledge.
We’ll denote which chapters of the book the exercises correspond to, and you can decide whether to try and solve the problems with only the parts of Rust that have been covered up to that point or bring your full Rust knowledge to bare.
Exercise #1 - Rectangular
This exercise is based on Chapter 5 of the Rust Book (Structures).
In this exercise, you will build a small utility that calculates the area and perimeter of a rectangle from the width and height provided by the user.
Your program will draw a rectangle with the correct ratio on the screen (where the minimum screen width is 80
and the minimum height is 40
), place the width
and height
properties on the rectangle, and the calculated values inside it.
Note: You can get creative when it comes to drawing the rectangle but one good way of doing it is with the box-drawing characters: https://en.wikipedia.org/wiki/Box-drawing_character#Box_Drawing
To get started, replace the main.rs
of your new project with the code below. You can copy this code from the Rust Playground using this link.
And add clap to your dependencies in cargo.toml
:
clap = { version = "3.1", features = ["derive"] }
Example output
$ cargo run -- --width 22 --height 6
width: 22
┌────────────────────┐
│ │
│ AREA: 132 │ height: 6
│ PERIMETER: 56 │
│ │
└────────────────────┘
Draw wide or tall rectangles
You could use other characters to represent edge-cases, e.g. "half" lines and dashed lines to indicate visual trimming:
┌──────╴╌╶──────┐
│ │
╵ ╵
╎ ╎
╷ ╷
│ │
└──────╴╌╶──────┘
Bonus
If you like a bit of an extra challenge and you wish to practice responsibility separation more, you could add any of the following extra lines to the definition of the Arguments
struct. You can copy the code from the Rust Playground using this link.
#[derive(Parser)]
#[clap(author, version, about)]
struct Arguments {
// ...
#[clap(short, long, help = "Use ASCII only characters to draw the rectangle")]
ascii_only: bool,
#[clap(short, long, help = "Use thicker lines to draw the rectangle")]
bold_lines: bool,
}
And make your program draw the rectangles with bold lines and/or ASCII only character via the --bold-lines
and --ascii-only
command line parameters.
Exercise #2 - Gringotts
This exercise is based on Chapter 6 of the Rust Book (Enums and Pattern Matching). For more on pattern matching, also read Chapter 18.
A small utility for Muggles to convert between the different coins of the wizarding currency of Great Britain.
To get started, replace the main.rs
of your new project with the code below. You can copy the code from the Rust Playground using this link.
use clap::Parser;
#[derive(Parser)]
#[clap(author, version, about)]
struct Arguments {
#[clap(help = "The amount of money given in <FROM> coin")]
amount: f64,
#[clap(short, long, help = "\
Coin the <AMOUNT> is defined in \
(Possible values: knut, sickle, and galleon)\
")]
from: String,
#[clap(short, long, help = "\
Coin the <AMOUNT> should be converted to \
(Possible values: knut, sickle, and galleon)\
")]
to: String,
}
fn main() {
let arguments = Arguments::parse();
dbg!(arguments.amount, arguments.from, arguments.to);
}
And add clap to your dependencies in cargo.toml
:
clap = { version = "3.1", features = ["derive"] }
Example Output
$ cargo run -- 12 --from sickle --to knut
12 Sickles → 348 Knuts
Exchange Rate
Bonus
If you like a bit of an extra challenge you could add any of the following extra lines to the definition of the Arguments
struct
:
#[derive(Parser)]
#[clap(author, version, about)]
struct Arguments {
// ...
#[clap(short, long, help = "\
Coin the <AMOUNT> is defined in \
(Possible values: knut, sickle, galleon, pound, and ...)\
")]
from: String,
#[clap(short, long, help = "\
Coin the <AMOUNT> should be converted to \
(Possible values: knut, sickle, galleon, pound, and ...)\
")]
to: String,
#[clap(short, long, help = "Print out the result without fancy formatting")]
simple_output: bool,
}
For Muggles it might make more sense to convert from their currency to the wizard one, after all, that's what they are using on a daily basis. The addition to from
and to
is only in the help text: both should accept more currencies now, it's up to you how many currencies you wish to support. Have a look at the approximate exchange rate and cherry pick your favourites.
You can also use this gringotts
utility in conjunction with other programs. For instance, you might want to pipe the output of this program to another one and in that scenario having the fancy, formatted output is just making things much more complicated for the subsequent user of the conversion result. That's where the --simple-output
flag comes in e.g.
$ cargo run -- 12 -f sickle -t knut --simple-output
348
$ cargo run -- 12 -f sickle -t knut --simple-output | xargs printf "My sickles in knuts: %.2f\n"
My sickles in knuts: 348.00
Exercise #3 - Bye Bob
This exercise is based on Chapter 8 of the Rust Book (Common Collections).
A text interface that allows you to track and query employees within a company and its departments.
To get started, replace the main.rs
of your new project with the code below. You can copy the code from the Rust Playground using this link.
use std::io::{self, Write};
fn main() {
while true {
let input = prompt_user();
// Just echo back the input
println!("{input}");
}
}
/// Requests input from the user.
fn prompt_user() -> String {
print!("> ");
io::stdout().flush().expect("Error: Failed to flush stdout");
let mut line = String::new();
io::stdin()
.read_line(&mut line)
.expect("Error: Could not read from stdin");
return line.trim().to_string();
}
Example Output
$ cargo run
Welcome to Bye Bob, the next generation HiBob CLI! Please enter your commands below:
====================================================================================
> Add Alice to Engineering
Added Alice to Engineering
> Add Amir to Engineering
Added Amir to Engineering
> Add Bob to Sales
Added Bob to Sales
> List
Engineering
------------
Alice
Amir
Sales
-----------
Bob
> Get Engineering
Alice
Amir
> Remove Amir from Engineering
Removed Amir from Engineering
> List Engineering
Alice
Commands
Your text interface should support 4 commands:
The exact form of these commands is up to you, the example output above is just a suggestion.
Bonus
Add a 5th command, Transfer
This command should transfer an employee from one department to another.
Add additional sorting options
You could add additional sorting options when executing List
or Get
commands.
- Reverse alphabetically.
- Seniority, based on insertion order.
Exercise #4 - Can I Cook It?
This exercise is based on Chapter 9 of the Rust Book (Error Handling).
You have a very small kitchen, with very limited storage space, so you can only store one (or small portion) of each thing at any given time. But you also want to figure out what you can cook from them. This is where the can-i-cook-it
utility comes in: it helps you find out if you have the necessary ingredients for the meal(s) you're planning to prepare.
Things You Can Store
- Cod (fish)
- Haddock (fish)
- Peas
- Potatoes
- Eggs
- Shiitake (mushroom)
- Maitake (mushroom)
- Garlic
- Spinach
- Cheddar (cheese)
- Parmesan (cheese)
- Macaroni (pasta)
- Spaghetti (pasta)
- Onion
- Tomato
- Pepper
Recipes You Know
Mushy Peas
Ingredients:
- Peas
Fish & Chips
Ingredients:
- Fish
- Potato
Mushroom Spinach Omelette
Ingredients:
- Egg
- Mushroom
- Spinach
- Onion
- Cheese
Mac & Cheese
Ingredients:
- Cheese
- Pasta
To get started, replace the main.rs
of your new project with the code below. You can copy the code from the Rust Playground using this link.
use clap::Parser;
#[derive(Parser, Debug)]
#[clap(author, version, about)]
struct Arguments {
/// Ingredients to store
#[clap(short, long)]
stash: Vec<String>,
/// Meals to cook
#[clap(short, long)]
cook: Vec<String>,
}
fn main() {
let arguments = Arguments::parse();
dbg!(arguments);
}
And add clap to your dependencies in cargo.toml
:
clap = { version = "3.1", features = ["derive"] }
Example Usage
You look at your supplies in the kitchen and you see that you have a cod in the fridge and frozen peas in the freezer and you cannot think of anything else than a tasty battered fish and some nice crispy chips:
$ cargo run -- --stash cod --stash peas --cook fish-and-chips
You're not going to be able to cook a fish and chips, the ingredients you don't have are:
* Potatoes
Fortunately, it pops into your mind that you went to shopping this morning and you still have a bag of potatoes in your backpack:
$ cargo run -- --stash cod --stash potatoes --stash peas --cook fish-and-chips
You can cook the fish and chips, the ingredients you'll need are:
* Cod
* Potatoes
But then your friend calls, saying they are in town for a few hours. You don't mind the company so you invite them over, however you realise you really don't want them to starve and watch you eating alone. But you don't have time to pop into the nearby store, so you ask your friendly, next-door neighbour to borrow you whatever they have. They can spare you some eggs, a few shiitake mushrooms, garlic, and a bag of spinach:
$ cargo run -- \
--stash cod \
--stash potatoes \
--stash peas \
--stash cheddar \
--stash garlic \
--stash eggs \
--stash shiitake \
--cook fish-and-chips \
--cook mushroom-spinach-omelette
You can cook the FishAndChips, the ingredients you'll need are:
* Cod
* Potatoes
You're not going to be able to cook MushroomSpinachOmelette, the ingredients you don't have are:
* Onion
* Spinach
Bonus
The glue-code provided can be overridden if you wish to push the responsibility of validation to clap
(because you figured out a way to represent the input in a better way than just a Vec
of String
s), you could do that with its derive API as well.
Exercise #5 - Custom Collects
This exercise is based on Chapter 10 of the Rust Book (Generic Types, Traits, and Lifetimes).
Write a library to combine a Vec<Result<T, E>>
into a single Result<Vec<T>, F>
where the returned error type communicates the details of all the errors in the Vec
.
The returned type F
must implement std::error::Error
but you may need to place further requirements on F
.
You must decide what constraints to place on E
in order for all the error information to be collected into F
. These can be traits you define.
src/lib.rs
defines this challenge in code. Complete the collect_errors
function filling in the TODO
s.
To get started, replace the lib.rs
of your new project with the code below. You can copy the code from the Rust Playground using this link.
use std::error::Error;
/// TODO: Document this function with information on the purpose and use case.
/// See the Rust docs book for gudance: https://doc.rust-lang.org/rustdoc/
///
/// TODO: Provide worked examples that you can test compile with `cargo test`
/// https://doc.rust-lang.org/rustdoc/documentation-tests.html
pub fn collect_errors<T, E, F>(results: Vec<Result<T, E>>) -> Result<Vec<T>, F>
where
E: /* Your requirements */,
F: Error /* + Any further requriments */,
{
todo!("Implement this");
}
#[cfg(test)]
mod tests {
// TODO: How would you test `collect_errors`? Add some tests!
}
Tip: Minimise constraints (make it as generic as possible) without sacrificing functionality.
Exercise #6 - Iterators
This exercise is based on Chapter 13.2 of the Rust Book (Iterators)
Choose a mathematical series, e.g: The Fibonacci Sequence, The Colatz Sequence, etc..., and write an Iterator to deliver it.
Are there limits to how far the sequence can go?
Exercise #7 - Collecting All The Errors
This exercise is based on Chapter 13.2 of the Rust Book (Iterators)
The Problem
Say we've got an application that takes data from the user via a series of YAML files. When there's a mistake in the file, either an invalid YAML or an incorrect data structure, we present a validation error to our users.
Our users have explicitly asked us to provide more than one error at a time, when they've made multiple errors. Ideally, they'd like to know all the places they've made an error. They don't want to have to run our application multiple times to fix each error. Our users are techies and are used to language compilers that provide them with multiple errors and warnings at once.
In our application, we find ourselves iterating across the files and through the YAML data (if it parses as YAML ok), to validate the data. From our learnings, we know if you have an iterator of Result<T, E>
, you can use Iterator::collect
to see if any of them failed:
let results = [Ok(1), Ok(3)];
let result: Result<Vec<_>, &str> = results.iter().cloned().collect();
// gives us the list of answers
assert_eq!(Ok(vec![1, 3]), result);
let results = [Ok(1), Err("nope"), Ok(3), Err("bad")];
let result: Result<Vec<_>, &str> = results.iter().cloned().collect();
// gives us the first error
assert_eq!(Err("nope"), result);
But this is no good to our users. They want to see all their errors!
Editing the above to:
let results = [Ok(1), Err("nope"), Ok(3), Err("bad")];
let result: Result<Vec<_>, Vec<&str>> = results.iter().cloned().collect();
Gives us this compile error:
error[E0277]: a value of type `Result<Vec<_>, Vec<&str>>` cannot be built from an iterator over elements of type `Result<{integer}, &str>`
--> src/main.rs:4:65
|
3 | let result: Result<Vec<_>, Vec<&str>> = results.iter().cloned().collect();
| ^^^^^^^ value of type `Result<Vec<_>, Vec<&str>>` cannot be built from `std::iter::Iterator<Item=Result<{integer}, &str>>`
|
= help: the trait `FromIterator<Result<{integer}, &str>>` is not implemented for `Result<Vec<_>, Vec<&str>>`
note: required by a bound in `collect`
And looking at how std::iter::FromIterator
is implemented for std::result::Result
we can see the issue:
impl<A, E, V> FromIterator<Result<A, E>> for Result<V, E>
where
V: FromIterator<A>,
{
pub fn from_iter<I>(iter: I) -> Result<V, E> {...}
}
The error type E
that's in the Err
variant of each Result
inside the iterator is the same type as the Err
variant of the returned Result
.
If instead, it was something like:
impl<A, E, F, V> FromIterator<Result<A, E>> for Result<V, F>
where
V: FromIterator<A>,
F: FromIterator<E>,
{
pub fn from_iter<I>(iter: I) -> Result<V, F> {...}
}
We might get our "collecting all the errors". Unfortunately "the orphan rule" means we can't just implement this in our own crate.
Challenge
Come up with a way that we can "collect all the errors" from an iterator of Results.
“Ideas for a Solution”, below, contains some thoughts that might help get you started. For maximum challenge, don't read it.
There's not a right or wrong answer on how to do this, or what restrictions to place on the solution. The real question is "does this produce something that's usable and ergonomic to callers?". They could always not use Iterator::collect
and manually implement this conversion every time they need to do it, but we're looking for something that elides that. Create a library/crate that provides this abstraction.
…
…
…
…
…
Ideas for a solution
Don’t read this if you don’t want any hints for solving this problem
We can't implement a new std::iter::FromIterator
implementation for std::result::Result
, because of "orphan rule".
We could create our own type, implement the FromIterator
that we need, then provide a conversion from our type into std::result::Result
.
We'd have to document for calling code to call an extra .into()
after collection, but that could be still quite ergonomic:
let result: Result<_, F> = my_iterator.collect::<MyResult<_, _>>().into();
If we go down this road, there's a couple of things to consider:
- How and when is the "collected errors" type
F
specified by the user? - It's mighty annoying when the inner type of
Result::Err
doesn't implementstd::error::Error
, or have a premade conversion into a type that implementsstd::error::Error
(makes?
not very useful). How could/should we ensure this?
Our own collector trait
Instead of trying to build something that will allow us to get what we need from Iterator::collect
, we could create our own "collector trait", and implement it for a generic case of an iterator of results. You can copy the code from the Rust Playground using this link.
pub trait MyCollector<T, E> {
fn my_collect<I, F>(self) -> Result<I, F>
where
I: std::iter::FromIterator<T>;
F: std::iter::FromIterator<E>;
}
impl<T, U, E> MyCollector<T, E> for U
where
U: Iterator<Item = Result<T, E>>,
{
fn my_collect<I, F>(self) -> Result<I, F>
where
I: std::iter::FromIterator<T>;
F: std::iter::FromIterator<E> + std::error::Error;
{
todo!();
}
}
(I can't promise all those generics and trait bounds are right - they're only indicative, and you might choose different constraints to solve the problem within.)
This solution might build on top of what you learned in the previous error collection exercise. There will be many of the same considerations:
- What extra restrictions on the types (trait bounds) will be necessary?
- Do you want/need the
F: std::error::Error
constraint? Is this what's right for the users of this trait? - Do you want
F
to be generic and user defined? Or would it be better to provide our own type with some easy conversions into other errors?
…
…
…
…
…
Exercise #8 - Maybe Owned String
This exercise is based on Chapter 15 of the Rust Book (Smart Pointers).
You are writing a naive JSON parsing library. Your use case only wants to receive its data in strings, so you're offering an API like the following:
enum JsonValue {
Object(HashMap<String, JsonValue>),
Array(Vec<JsonValue>),
Value(String),
};
pub fn parse_json(contents: &str) -> JsonValue {
todo!()
}
So for example, the following JSON:
{
"key": "value",
"num": 5,
"inner": {
"value": "hmm"
},
"list": [
"foo",
"bar"
]
}
Would be parsed into:
JsonValue::Object {
"key" -> JsonValue::Value("value"),
"num" -> JsonValue::Value("5"),
"inner" -> JsonValue::Object {
"value" -> JsonValue::Value("hmm")
},
"list" -> JsonValue::Array[
JsonValue::Value("foo"),
JsonValue::Value("bar")
]
}
However, when testing and profiling your library, you notice that you have some performance problems. In particular, you seem to be allocating a lot of String
structs. You realise that most of the time when reading a key or value in JSON, the bytes in the source string are exactly the same as what will be present in your data structure, but you are copying the data over anyway. Only when you are serialising data types (number to string) or unescaping the contents of the string do you need to actually allocate a String
!
You want to write a structure that represents either an owned string (String
) or a borrowed one (&str
) to eliminate your allocation problems. This structure should have the following characteristics:
- Implement
Deref<Target = str>
,Hash
,PartialEq
,Debug
- Offer the following public methods:
fn into_owned(self) -> String
fn to_mut(&mut self) -> &mut String
fn is_owned(&self) -> bool
fn is_borrowed(&self) -> bool
- Some unit tests, if you're feeling fancy
You should also modify the JsonValue
struct and parse_json
methods as appropriate.
Note, please do not actually implement a full JSON parser, that's just part of the context where such smart pointer could be useful. (Or do, you know, it's your free time after all).
To get started, replace the lib.rs
of your new project with the code below. You can copy the code from the Rust Playground using this link.
use std::collections::HashMap;
pub enum JsonValue {
Object(HashMap<String, JsonValue>),
Array(Vec<JsonValue>),
Value(String),
}
pub fn parse_json(contents: &str) -> JsonValue {
todo!()
}
That's it for today! We hope you found this helpful! If you'd like to see more from the hx Engineering team, consider checking our careers page for our latest openings :)