Keymaps

Keymaps

This is an important chapter for Rubyists. In Ruby, we use Hashes a lot. It’s our lookup table. It’s our key map. In Rust, similar functionality is called a Hash Map. It’s not used as much in Rust but as a Rubyist you will want to know how they compare.

The concept is the same. According to the Rust book, Hash maps are useful when you want to look up data not by using an index, as you can with vectors, but by using a key that can be of any type.

Let’s jump in.

Let’s use the Ruby project we created back in the Getting Started chapter and have made additions to since. We have the following code:

class User
  attr_accessor :kinds

  def initialize
	  @kinds = %w[singer guitarist]
  end
end

Rather than hardcoding the kinds of users in the constructor, let’s use a Hash as a lookup table and give the kinds of users a description.

class User
	KINDS = {
	  singer: 'leads the band, main vocalist, like Robert Smith',
	  guitarist: 'leads the rhythm, plays guitar, like Johnny Marr'
	}

	attr_accessor :kind

	def initialize(kind)
	  @kind = KINDS.fetch(kind, nil)
	end
end

user = User.new(:singer)
puts user.kind #=> "leads the band..."

KINDS in the above example is a common site in Ruby code. In this instance, we are declaring a constant with a Hash value. We then use the Hash to look up the value for a given key correlated to our User’s kind. This allows us to print out the value of the “kind” in the last line.

We can do something similar in Rust. Let’s see what that looks like.

use std::collections::HashMap;

pub struct User {
	kind: String,
}

impl User {
	pub fn new() -> Self {
		let binding = User::user_kinds();
    
		let kind = binding.get("singer").unwrap().to_string();

		Self { kind }
	}

	fn user_kinds() -> HashMap<String, String> {
		let mut kinds = HashMap::new();

		kinds.insert(
			String::from("singer"), 
			String::from("leads the band, main vocalist, like Robert Smith"));
		kinds.insert(
			String::from("guitarist"), 
			String::from("leads the rhythm, plays guitar, like Johnny Marr"));

		kinds
	}
}

let user = User::new();
println!("user kind: {}", user.kind);

There is a fair amount to unpack here. We tried to have a Rust example that mirrors the Ruby example, but Ruby makes it more convenient to use Hashes. Let’s break this down.

First, we have to import HashMap from the standard collections. Next, we declare our User Struct. Like the Ruby class, it has a single attribute named kind as a String data type.

Now we implement the methods on our User Struct. First is the new method. It’s pretty similar to the constructor in Ruby. It allows us to create an instance of our Struct. It returns the instance. While defining the instance, we assign the kind attribute to a value from our HashMap.

Now, the big difference between the Ruby example and the “trying really hard to be apples to apples” Rust example is how the HashMap is declared. In Ruby, we can declare the Hash as a constant in our class. There is no const constructor for a HashMap in Rust, due to the way the current implementation protects against HashDoS attacks. So our alternative is to declare the HashMap in an associated function.

  • reminder that an associated function in Rust is like a class method in Ruby.

With our associated function, our new method can reference the HashMap and we can assign our User’s kind attribute. We can see that the kind gets assigned a value in the last line where we print out the value.

In the above example, we use the get method of HashMap to retrieve the value for a specific key.

Let’s see what happens when the key doesn’t exist. Let’s change the line in the new method to look for a key other than singer.

let kind = binding.get("drummer").unwrap().to_string();

Because we have not defined a “drummer” key in our HashMap, if we run our program with cargo run we will receive a panic. This is probably not our desired result. We need to be more fault tolerant.

In Ruby, when working with Hashes we can set a default return if a key is not found. We did this in our constructor in the example above.

@kind = KINDS.fetch(kind, nil)

When using the fetch method to retrieve a value from a Hash by key, we can specify a second value that will be returned if the key being used is not found. In this case, we return nil. Rust is kind of doing the same thing.

When we use the get method in Rust, it returns an Option. We covered Option in the Enums chapter. Essentially, Option is an Enum that contains a Some or None value. Some means the key was found and returns the value. None means the key was not found. So why are we panicking?

Our panic is the result of our call to unwrap the result of our get call. In this case, we are looking for the key “drummer” which doesn’t exist and we receive an Option of None. We can’t unwrap none. But like in Ruby, we can make unwrap more fault tolerant.

We can be more fault tolerant to receiving None. If that happens we can unwrap or map the Some value or return a default value. Just like in Ruby. To do this, let’s change the line where we are looking up a value for “drummer” to the following:

let kind = binding.get("drummer").map_or("not found", |v| v).to_string();

Now when we run the program with cargo run we’ll receive “not found” for the user kind.

That gives us a decent comparison for using HashMaps in Rust compared to our heavy use of Hash in Ruby.

But we’re not done. Rust also provides a HashSet collection type. HashSet and HashMaps both implement the Hash trait, but they have different use cases which we’ll explore in the next chapter.