I was building a generic data store with Rust and I needed to implement a heterogeneous collection of keys and values. Essentially what I needed was a dictionary, but with values of dynamic type, like both strings and integers at the same time.
Rust is a statically typed language and, due to the memory safety guarantees we are given, all values of some type must have a known, fixed size at compile time, therefore we are not allowed to create a collection of multiple types. However, dynamically sized types also exist, and in this article I’ll show how to use them.
Say we have a HashMap
and we want to add more than one value type to it.
For example:
use std::collections::HashMap;
fn main() {
let mut map = HashMap::new();
map.insert("a", "1");
map.insert("b", "2");
for (key, value) in &map {
println!("{}: {}", key, value);
}
}
This prints:
a: 1
b: 2
In the example above, the type of map
is HashMap<&str, &str>
. In other words, both keys and values are of type &str
. What if we want the values to be of type &str
and, say, i32
?
This won’t work:
use std::collections::HashMap;
fn main() {
let mut map = HashMap::new();
map.insert("a", "1");
map.insert("b", 2);
for (key, value) in &map {
println!("{}: {}", key, value);
}
}
If we try it, we get this compile time error:
error[E0308]: mismatched types
--> src/main.rs
map.insert("b", 2);
^ expected `&str`, found integer
So how do we insert multiple value types in a HashMap
? We have several options, each of them with its own trade-offs.
Option #1: Use an enum
We can define our own enum
to model our value type, and insert that into the hashmap:
use std::collections::HashMap;
#[derive(Debug)]
enum Value {
Str(&'static str),
Int(i32),
}
fn main() {
let mut map = HashMap::new();
map.insert("a", Value::Str("1"));
map.insert("b", Value::Int(2));
for (key, value) in &map {
println!("{}: {:?}", key, value);
}
}
This prints:
a: Str("1")
b: Int(2)
This is similar to a union type. By inserting values of type Value::*
, we are effectively saying that the map can accept types that are either string, integer, or any other composite type we wish to add.
Option #2: Use a Box
We can wrap our types in the Box
struct:
use std::collections::HashMap;
fn main() {
let mut map = HashMap::new();
map.insert("a", Box::new("1"));
map.insert("b", Box::new(2));
for (key, value) in &map {
println!("{}: {}", key, value);
}
}
This doesn’t compile right away. If we try to run this, we get a “mismatched types” error:
error[E0308]: mismatched types
--> src/main.rs
map.insert("b", Box::new(2));
^ expected `&str`, found integer
Luckily we can fix this by explicitly declaring the type of our map:
let mut map: HashMap<&str, Box<dyn Display + 'static>> = HashMap::new();
This works because we are actually storing instances of Box
, not primitive types; dyn Display
means the type of the trait object Display
. In this case, Display
happens to be a common trait between &str
and i32
.
use std::collections::HashMap;
use std::fmt::Display;
fn main() {
let mut map: HashMap<&str, Box<dyn Display + 'static>> = HashMap::new();
map.insert("a", Box::new("1".to_string()));
map.insert("b", Box::new(2));
for (key, value) in &map {
println!("{}: {}", key, value);
}
}
You may wonder what would happen if we were to use the type dyn Display
without the Box
wrapper. If we try that, we’d get this nasty error:
error[E0277]: the size for values of type `(dyn std::fmt::Display + 'static)` cannot be known at compilation time
--> src/main.rs
let mut map: HashMap<&str, (dyn Display + 'static)> = HashMap::new();
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ doesn't have a size known at compile-time
= help: the trait `std::marker::Sized` is not implemented for `(dyn std::fmt::Display + 'static)`
= note: to learn more, visit <https://doc.rust-lang.org/book/ch19-04-advanced-types.html#dynamically-sized-types-and-the-sized-trait>
= note: required by `std::collections::HashMap
This error may be confusing at first, but it actually makes sense. The Rust Programming Language book explains this very well in the Advanced Types chapter:
“Rust needs to know how much memory to allocate for any value of a particular type, and all values of a type must use the same amount of memory.”
The Box<T>
type is a pointer type. It lets us allocate data on the heap rather than the stack, and keeps a reference to the data in the stack in the form of a pointer, which is of fixed size.
(Not an) Option #3: Use separate maps for each type
Here we’re not actually using a HashMap
with separate types, but rather two maps, each with its own type. It’s a bit more verbose and perhaps not the solution you’re looking for, but it’s worth keeping in mind that this works too:
use std::collections::HashMap;
fn main() {
let mut strings_map = HashMap::new();
let mut integers_map = HashMap::new();
strings_map.insert("a", "1");
integers_map.insert("b", 2);
for (key, value) in &strings_map {
println!("{}: {}", key, value);
}
for (key, value) in &integers_map {
println!("{}: {}", key, value);
}
}
It feels much simpler! And the output is naturally the same:
a: 1
b: 2
Conclusion
Rust is very strict when it comes to polymorphic types. As you’ve seen, there are ways to achieve it, but they don’t feel as straightforward as with other dynamic languages such as Ruby or Python. Sometimes though it’s useful to make one step back and look at the actual problem we’re trying to solve. Once I did that, I realized that I didn’t necessarily have to limit myself to a single data structure, so I went for the last option.
I’m still a beginner with Rust, so I might have missed on a better solution. Trait Objects could be one: I’ve experimented with them, but they weren’t quite was I was looking for. If you have any suggestions or know of other possible solutions, feel free to comment below!
Update: @alilleybrinker on Twitter pointed out two caveats to be aware of. One is about the meaning of the 'static
bound: when used on a generic type, any references inside the type must live as long as 'static
. However, by adding 'static
we are also effectively saying that the values inside the Box
won’t contain references. The other caveat is that, when using dyn Display
, the original types are erased, so the available methods are only those known from the Display
trait.