Getting Started with Rust Using Rustlings — Part 10: HashMaps

Jesse Verbruggen
10 min readMar 7, 2023
Part 10: HashMaps

Welcome back to my miniseries on learning Rust using Rustlings! This article will cover HashMaps, a data structure for storing key-value pairs. With HashMaps, you can quickly look up values by their associated keys, making them ideal for tasks like caching or building simple databases. I will show you how to create and manipulate hashmaps in Rust. So, let’s dive into the world of hashmaps and see what we can learn!

This is part 10 of my miniseries on learning Rust. If you want to read the previous article, you can get to it by following the link below.

In this series, I am going over my process of learning the syntax of Rust with Rustlings. In Part 1, I explain how to install Rust and Rustlings. So if you haven’t yet done that, click the link below.

HashMaps

So what’s a HashMap? Let’s break this down into the two concepts that make up a HashMap. First, let’s look at what a Map is and then what a Hash is. A Map is a structure with a set of keys tied together to values. This is useful for looking up values using the key of a value. These keys and values can be of any type, with the downside here being that keys of complex types are harder to compare. A Map requires that all keys in a Map are unique.

In Rust, a regular Map does not exist as a collection. Instead, we can use HashMaps to create a collection. A HashMap is a Map with one additional feature leveraged to make looking up values easier for a computer to find. What happens is that the key we set in a HashMap gets hashed into a number. This uses a hashing function that will always return a unique value that will be the same whenever it gets called with the same parameters. This makes it very easy for a computer to find a value associated with a key, as this hash we get as a result is easy to compare.

Rust’s HashMap, by default, uses the SipHash hashing function. This function isn’t the fastest hashing function, but it is resistant to Denial of Service (DoS) attacks. This tradeoff of speed for security purposes can be overwritten, but generally, I would not recommend this unless your specific use case requires this.

Let’s look at how we can represent my cats in a HashMap.

use std::collections::HashMap;

#[derive(Debug)]
pub struct Cat {
pub name: String,
pub age: u8,
pub color: String,
}

fn main() {
let cleo = Cat {
name: "Cleo".to_string(),
color: "grey".to_string(),
age: 2,
};

let jules = Cat {
name: "Jules".to_string(),
color: "black".to_string(),
age: 3,
};

let toulouse = Cat {
name: "Toulouse".to_string(),
color: "ginger".to_string(),
age: 4,
};

let mut map_of_cats = HashMap::new();
map_of_cats.insert("cleo", cleo);
map_of_cats.insert("jules", jules);
map_of_cats.insert("toulouse", toulouse);
// The hashmap takes ownership of the values, so we can't use them again

introduce_cat(&map_of_cats["cleo"]);
// We can find a cat by its key

// We can iterate over the hashmap
for (key, cat) in map_of_cats {
println!("Say hello {}!", key);
introduce_cat(&cat);
}
}

fn introduce_cat(cat: &Cat) {
println!("Hello, my name is {} and I am {} years old.", cat.name, cat.age);
}

This will output the following.

Hello, my name is Cleo and I am 2 years old.
Say hello jules!
Hello, my name is Jules and I am 3 years old.
Say hello cleo!
Hello, my name is Cleo and I am 2 years old.
Say hello toulouse!
Hello, my name is Toulouse and I am 4 years old.

Notice that the order of items is not stored in the order that we inserted them. We can lookup cleo using her name and loop over all objects in our HashMap. We can use the key and value stored in the map in our loop.

What if we look up a key in our HashMap that does not exist?

let mut map_of_cats = HashMap::new();
map_of_cats.insert("cleo", cleo);
map_of_cats.insert("jules", jules);
map_of_cats.insert("toulouse", toulouse);
// The hashmap takes ownership of the values, so we can't use them again

introduce_cat(&map_of_cats["garfield"]);
thread 'main' panicked at 'no entry found for key', src/main.rs:35:20

This code will still compile, but as we’re trying to find a value that does not exist, it will crash our program. We can handle this scenario with the Option enum, using the HashMap.get function.

let garfield = map_of_cats.get("garfield");

match garfield {
Some(cat) => introduce_cat(cat),
None => println!("No cat found"),
}
No cat found

With this approach, it’s easy to handle cases where we want to search for a key that may or may not exist.

Updating a HashMap

We need to consider some things when updating values in a HashMap. Do we want to overwrite a value? Do we want to create it if it does not exist? Or do we combine it with the new value to add more information? Let’s look at how we can implement all of these different scenarios.

Overwriting values

If we want to overwrite a certain value in a HashMap, we need to use the insert method for the same key, which will overwrite the existing value at this key. Let’s say that my cat got a year older; then, we can write the following to update the values.


let mut map_of_cats = HashMap::new();
map_of_cats.insert("cleo", Cat {
name: "Cleo".to_string(),
color: "grey".to_string(),
age: 2,
});

introduce_cat(&map_of_cats["cleo"]);

map_of_cats.insert("cleo", Cat {
name: "Cleo".to_string(),
color: "grey".to_string(),
age: 3,
});

introduce_cat(&map_of_cats["cleo"]);

// Program Output:
// Hello, my name is Cleo and I am 2 years old.
// Hello, my name is Cleo and I am 3 years old.

Now there’s a drawback to this that you need to be aware of. What if we don’t want to update a value if it’s already there? In this case, the value will replace the previously stored data, and we will lose that info. We can, however, check if an entry already exists and only, if it doesn’t yet, add this value.

Adding values safely

To do this, we will chain the methods entry(key) and or_insert(value) to ensure we’re safely adding new values only. We can use this for both existing and non-existing values. Let’s take the previous example and change it up a little because Cleo hasn’t yet aged. We can use this syntax everywhere to ensure this is working as expected.

let mut map_of_cats = HashMap::new();
map_of_cats.entry("cleo").or_insert(Cat {
name: "Cleo".to_string(),
color: "grey".to_string(),
age: 2,
});

introduce_cat(&map_of_cats["cleo"]);

map_of_cats.entry("cleo").or_insert(Cat {
name: "Cleo".to_string(),
color: "grey".to_string(),
age: 3,
});

introduce_cat(&map_of_cats["cleo"]);

// Program Output:
// Hello, my name is Cleo and I am 2 years old.
// Hello, my name is Cleo and I am 2 years old.

We also get a reference to our object in the HashMap, which we can use to update information with the information available to us. Let’s explore this option.

Updating values with the reference

let mut map_of_cats: HashMap<&str, Cat> = HashMap::new();
let cleo = map_of_cats.entry("cleo").or_insert(Cat {
name: "Cleo".to_string(),
color: "grey".to_string(),
age: 2,
});

introduce_cat(&cleo);

*cleo = Cat {
name: "Cleo".to_string(),
color: "grey".to_string(),
age: cleo.age + 1,
};

introduce_cat(&cleo);

// Program Output:
// Hello, my name is Cleo and I am 2 years old.
// Hello, my name is Cleo and I am 3 years old.

In this case, I used the value of Cleo's age and aged her by one year. Notice that I had to use the * syntax to dereference cleo to assign back to it. This is because we only borrow this value, so the variable cleo only holds a reference.

With all that explained, let’s move on to the exercises and practice what we’ve learned.

Exercises

hashmaps1.rs

use std::collections::HashMap;

fn fruit_basket() -> HashMap<String, u32> {
let mut basket = // TODO: declare your hash map here.

// Two bananas are already given for you :)
basket.insert(String::from("banana"), 2);

// TODO: Put more fruits in your basket here.

basket
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn at_least_three_types_of_fruits() {
let basket = fruit_basket();
assert!(basket.len() >= 3);
}

#[test]
fn at_least_five_fruits() {
let basket = fruit_basket();
assert!(basket.values().sum::<u32>() >= 5);
}
}

A basket of fruits in the form of a hash map needs to be defined. The key represents the name of the fruit and the value represents how many of that particular fruit is in the basket. You have to put at least three different types of fruits (e.g apple, banana, mango) in the basket and the total count of all the fruits should be at least five.

This looks fairly simple. All we have to do is to add two new types of fruit, and at least 3 pieces of fruit in total need to be added. Let’s see what that looks like.

fn fruit_basket() -> HashMap<String, u32> {
let mut basket = HashMap::new();

// Two bananas are already given for you :)
basket.insert(String::from("banana"), 2);
// We add two apples and one orange to the basket
basket.insert(String::from("apple"), 2);
basket.insert(String::from("orange"), 1);

basket
}

hashmaps2.rs

use std::collections::HashMap;

#[derive(Hash, PartialEq, Eq)]
enum Fruit {
Apple,
Banana,
Mango,
Lychee,
Pineapple,
}

fn fruit_basket(basket: &mut HashMap<Fruit, u32>) {
let fruit_kinds = vec![
Fruit::Apple,
Fruit::Banana,
Fruit::Mango,
Fruit::Lychee,
Fruit::Pineapple,
];

for fruit in fruit_kinds {
// TODO: Put new fruits if not already present. Note that you
// are not allowed to put any type of fruit that's already
// present!
}
}

#[cfg(test)]
mod tests {
use super::*;

fn get_fruit_basket() -> HashMap<Fruit, u32> {
let mut basket = HashMap::<Fruit, u32>::new();
basket.insert(Fruit::Apple, 4);
basket.insert(Fruit::Mango, 2);
basket.insert(Fruit::Lychee, 5);

basket
}

#[test]
fn test_given_fruits_are_not_modified() {
let mut basket = get_fruit_basket();
fruit_basket(&mut basket);
assert_eq!(*basket.get(&Fruit::Apple).unwrap(), 4);
assert_eq!(*basket.get(&Fruit::Mango).unwrap(), 2);
assert_eq!(*basket.get(&Fruit::Lychee).unwrap(), 5);
}

#[test]
fn at_least_five_types_of_fruits() {
let mut basket = get_fruit_basket();
fruit_basket(&mut basket);
let count_fruit_kinds = basket.len();
assert!(count_fruit_kinds >= 5);
}

#[test]
fn greater_than_eleven_fruits() {
let mut basket = get_fruit_basket();
fruit_basket(&mut basket);
let count = basket.values().sum::<u32>();
assert!(count > 11);
}
}

A basket of fruits in the form of a hash map is given. The key represents the name of the fruit and the value represents how many of that particular fruit is in the basket. You have to put MORE THAN 11 fruits in the basket. Three types of fruits — Apple (4), Mango (2) and Lychee (5) are already given in the basket. You are not allowed to insert any more of these fruits!

As we already have 11 fruits in total and need to add at least one of each type to the basket, we can add one for both fruits. We’ll use this existing loop, check if a certain type of fruit is already present, and if it is, we will skip it.

fn fruit_basket(basket: &mut HashMap<Fruit, u32>) {
let fruit_kinds = vec![
Fruit::Apple,
Fruit::Banana,
Fruit::Mango,
Fruit::Lychee,
Fruit::Pineapple,
];

for fruit in fruit_kinds {
if !basket.contains_key(&fruit) {
basket.insert(fruit, 1);
}
}
}

hashmaps3.rs

use std::collections::HashMap;

// A structure to store team name and its goal details.
struct Team {
name: String,
goals_scored: u8,
goals_conceded: u8,
}

fn build_scores_table(results: String) -> HashMap<String, Team> {
// The name of the team is the key and its associated struct is the value.
let mut scores: HashMap<String, Team> = HashMap::new();

for r in results.lines() {
let v: Vec<&str> = r.split(',').collect();
let team_1_name = v[0].to_string();
let team_1_score: u8 = v[2].parse().unwrap();
let team_2_name = v[1].to_string();
let team_2_score: u8 = v[3].parse().unwrap();
// TODO: Populate the scores table with details extracted from the
// current line. Keep in mind that goals scored by team_1
// will be the number of goals conceded from team_2, and similarly
// goals scored by team_2 will be the number of goals conceded by
// team_1.
}
scores
}

#[cfg(test)]
mod tests {
use super::*;

fn get_results() -> String {
let results = "".to_string()
+ "England,France,4,2\n"
+ "France,Italy,3,1\n"
+ "Poland,Spain,2,0\n"
+ "Germany,England,2,1\n";
results
}

#[test]
fn build_scores() {
let scores = build_scores_table(get_results());

let mut keys: Vec<&String> = scores.keys().collect();
keys.sort();
assert_eq!(
keys,
vec!["England", "France", "Germany", "Italy", "Poland", "Spain"]
);
}

#[test]
fn validate_team_score_1() {
let scores = build_scores_table(get_results());
let team = scores.get("England").unwrap();
assert_eq!(team.goals_scored, 5);
assert_eq!(team.goals_conceded, 4);
}

#[test]
fn validate_team_score_2() {
let scores = build_scores_table(get_results());
let team = scores.get("Spain").unwrap();
assert_eq!(team.goals_scored, 0);
assert_eq!(team.goals_conceded, 2);
}
}

You have to build a scores table containing the name of the team, goals the team scored, and goals the team conceded. One approach to build the scores table is to use a Hashmap. The solution is partially written to use a Hashmap, complete it to pass the test.

In our loop, we are passing over the string result from get_results. This is passed into our function, and the loop gets the correct values from this string, line per line. We have a Team struct which we can use to keep track of the goals. In this exercise, France and England play multiple matches, so we must update the existing value. Let’s take a closer look at my implementation for this to see how we solve this exercise.

fn build_scores_table(results: String) -> HashMap<String, Team> {
// The name of the team is the key and its associated struct is the value.
let mut scores: HashMap<String, Team> = HashMap::new();

for r in results.lines() {
let v: Vec<&str> = r.split(',').collect();
let team_1_name = v[0].to_string();
let team_1_score: u8 = v[2].parse().unwrap();
let team_2_name = v[1].to_string();
let team_2_score: u8 = v[3].parse().unwrap();

// Get or create the home team
let team_1 = scores.entry(team_1_name.clone()).or_insert(Team {
name: team_1_name.clone(),
goals_scored: 0,
goals_conceded: 0,
});

// Record the scores of this match for the home team
*team_1 = Team {
name: team_1_name.clone(),
goals_scored: team_1.goals_scored + team_1_score,
goals_conceded: team_1.goals_conceded + team_2_score,
};

// Get or create the visitor team
let team_2 = scores.entry(team_2_name.clone()).or_insert(Team {
name: team_2_name.clone(),
goals_scored: 0,
goals_conceded: 0,
});

// Record the scores of this match for the visiting team
*team_2 = Team {
name: team_2_name.clone(),
goals_scored: team_2.goals_scored + team_2_score,
goals_conceded: team_2.goals_conceded + team_1_score,
};
}

scores
}

We are using the approach of updating the value through the reference. We need to do this for both teams, and that solves our exercise. To reuse the team name we parsed from the string, I have created a clone of this string wherever we used it. And that’s it; the following exercise is quiz2.rs, which I will leave up to you to solve to test your skills. I will solve this myself, but my next chapter will not review it. Good luck, and, most importantly, have fun!

I hope this article has helped you understand what HashMaps are and how they work in Rust. Using the methods and syntax I explained here, you should be able to handle HashMaps easily. Remember always to handle missing keys safely and to think about how you want to handle updates before implementing them. With this knowledge, you can use HashMaps in your Rust projects effectively.

If I helped you learn more about Rust or you found this interesting, please clap 👏 and share this article to help more people find these articles.

Let’s help each other learn and grow! Peace out✌️

--

--