The rabbit hole of `HashMap<[String; 2], V>`

I thought HashMap<[String; 2], V> would just work, until I had to get() a value with a [&str; 2] key. Obviously, this wouldn't work. A less stubborn person would just call .to_string() and get done with it, but I figured that maybe I could implement Borrow and make it work.

Thus, my second attempt looked like this:

struct Pair([String; 2]);

impl<'a> Borrow<[&'a str; 2]> for Pair {
    fn borrow(&self) -> &[&'a str; 2] {
        todo!()
    }
}

The whole thing compiled, I could .get() with a [&str; 2], but it became obvious there was no possible implementation for that todo!(). There was no such [&'a str; 2] for me to borrow from &self.

Then, I came up with this third attempt (a small compromise to not have to call .to_string() on the key, I would use one more byte per storage with the enum discriminant):

use std::collections::HashMap;
use std::ops::Index;

#[derive(Eq)]
enum Pair<'a> {
    Owned([String; 2]),
    Borrowed([&'a str; 2]),
}

impl PartialEq for Pair<'_> {
    fn eq(&self, other: &Self) -> bool {
        self[0] == other[0] && self[1] == other[1]
    }
}

impl std::hash::Hash for Pair<'_> {
    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
        self[0].hash(state);
        self[1].hash(state);
    }
}

impl Index<usize> for Pair<'_> {
    type Output = str;

    fn index(&self, idx: usize) -> &str {
        match self {
            Pair::Owned(arr) => arr[idx].as_str(),
            Pair::Borrowed(arr) => arr[idx],
        }
    }
}

// This is a simplified version of how I was using the HashMap.
fn do_something_and_return_key_ref<'a>(map: &'a HashMap<Pair<'static>, i32>, key_a: &str, key_b: &str) -> [&'a str; 2] {
    let (key, value) = map.get_key_value(&Pair::Borrowed([key_a, key_b])).unwrap();
    // [...] do something with value and key
    [&key[0], &key[1]]
}

I thought I had nailed it. Using .get_key_value() would ensure the returned key had a lifetime that matched &map, so it would be fine. Well, unfortunately this doesn't compile:

error[E0621]: explicit lifetime required in the type of `key_a`
  --> src/lib.rs:38:5
   |
35 | fn do_something_and_return_key_ref<'a>(map: &'a HashMap<Pair, i32>, key_a: &str, key_b: &str) -> [&'a str; 2] {
   |                                                                            ---- help: add explicit lifetime `'a` to the type of `key_a`: `&'a str`
...
38 |     [&key[0], &key[1]]
   |     ^^^^^^^^^^^^^^^^^^ lifetime `'a` required

error[E0621]: explicit lifetime required in the type of `key_b`
  --> src/lib.rs:38:5
   |
35 | fn do_something_and_return_key_ref<'a>(map: &'a HashMap<Pair, i32>, key_a: &str, key_b: &str) -> [&'a str; 2] {
   |                                                                                         ---- help: add explicit lifetime `'a` to the type of `key_b`: `&'a str`
...
38 |     [&key[0], &key[1]]
   |     ^^^^^^^^^^^^^^^^^^ lifetime `'a` required

For more information about this error, try `rustc --explain E0621`.

At this point I started wondering if get_key_value() had a bug, because this is one of the explicitly cited uses of the function:

This is potentially useful:

  • [...]
  • for getting a reference to a key with the same lifetime as the collection.

The error message didn't help much either, because it didn't explain why lifetime 'a was required.

Fortunately, I already knew about the role of Borrow, and upon close inspection, I came up with this fourth attempt:

use std::borrow::Borrow;

impl<'a> Borrow<Pair<'a>> for Pair<'static> {
    fn borrow(&self) -> &Pair<'a> {
        self
    }
}

Which, of course, didn't work due to:

error[E0119]: conflicting implementations of trait `Borrow<Pair<'static>>` for type `Pair<'static>`
  --> src/lib.rs:36:1
   |
36 | impl<'a> Borrow<Pair<'a>> for Pair<'static> {
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: conflicting implementation in crate `core`:
           - impl<T> Borrow<T> for T
             where T: ?Sized;

For more information about this error, try `rustc --explain E0119`.

but it led me to the final working solution:

use std::borrow::Borrow;

#[derive(PartialEq, Eq, Hash)]
struct KeyPair(Pair<'static>);

impl<'a> Borrow<Pair<'a>> for KeyPair {
    fn borrow(&self) -> &Pair<'a> {
        &self.0
    }
}

// This is a simplified version of how I was using the HashMap.
fn do_something_and_return_key_ref<'a>(map: &'a HashMap<KeyPair, i32>, key_a: &str, key_b: &str) -> [&'a str; 2] {
    let (key, value) = map.get_key_value(&Pair::Borrowed([key_a, key_b])).unwrap();
    // [...] do something with value and key
    [&key.0[0], &key.0[1]]
}

In the end, I think Borrow is not the best trait to use to compare the HashMap key, because it requires a reference to be returned as the object for comparison. It would be more flexible if the trait allowed the return of any arbitrary object, which I think would also cover all the cases covered by Borrow. E.g.

pub trait ComparesWith<T> {
    fn get_comparer(&self) -> T;
}

// I didn't try to compile, but it looks like it can cover all cases where Borrow is implemented:
impl <Borrowed, T: Borrow<Borrowed>> CompareWith<&Borrowed> for T {
    fn get_comparer(&self) -> &Borrowed {
        self.borrow()
    }
}

Such a trait would have allowed something closer to my second attempt to work, because the object returned wouldn't need to be a reference taken from &self.

2 Likes

Indeed. See this issue that discusses this. You can just use hashbrown directly instead of std::collections::HashMap. std uses hashbrown, so this won't add any additional dependencies than you already need. There are additional parts of the hashbrown API that I have found useful too.

7 Likes

Here's another workaround for when you don't have a choice about using Borrow.

It's still a separate dependency with distinct types that compiles with different code due to not using unstable, std specific features.[1]

(Which is fine! It's still better in this respect, maintained by team members, etc. But it still is a separate depenency.)


  1. As one example, switch to the std version here to make the error go away. â†Šī¸Ž

6 Likes

It's possible the iddqd crate can help. Is this something you've evaluated?

After some thought I question whether a real issue here is:

The Borrow trait assigns lifetime of returned reference to lifetime of reference to self

pub trait Borrow<Borrowed>
where
    Borrowed: ?Sized,
{
    // Required method
    fn borrow(&'1 self) -> &'1 Borrowed;
}

However when Borrowed is set to a reference type &'2 T
is has no way to clarify the relationship between '1 and '2.

Thus implementing this trait is impossible because '1 must outlive '2.

Here is how one can get from [String;2] to &[&str; 2] but I have no clue about lifetime annotations:

let a: [String; 2] = [String::from("Love"), String::from("Rust")];
let b: &[String; 2] = &a;
let c: [&String; 2] = b.each_ref();
let d: [&str; 2] = c.map(|v| v.as_str());
let e: &[&str; 2] = &d;

I'm not an expert on this in any way, so take this with a grain of salt.

The return type has to continue to borrow *self for the desired functionality (it has to contain the lifetime on &self reference, which denotes how long *self is borrowed). But the API is limited because the method signature makes you return a reference (&Borrowed) specifically. Without unsafe, this means you have to have a Borrowed (or &Borrowed, etc) somewhere in your type. And even with unsafe, there might not be a way to utilize this trait directly.

So for example, in the OP, it is exactly as the OP said: There is no [&'a str; 2] in Pair:

// Layout is not guaranteed but it's going to look something like this

Pair: two `String`s consisting of a pointer, a length, and a capacity
+---+---+---+---+---+---+---+---+---+---+---+---+
| S1 ptr| S1 len| S1 cap| S2 ptr| S2 len| S2 cap|
+---+---+---+---+---+---+---+---+---+---+---+---+

[&str; 2]: Two `&str`s consisting of a pointer and a length (no capacity)
+---+---+---+---+---+---+---+---+
| S1 ptr| S1 len| S2 ptr| S2 len|
+---+---+---+---+---+---+---+---+

So you can't return a reference to a [&str; 2] as you don't have one.

And you can't create one locally in Borrow::borrow and then return a reference to that for the same reason this doesn't work: locals are destroyed (or moved) by the time the method returns.

The trait would have to look different -- not require returning a reference specifically -- to support the use case directly. For example:

trait BorWow<'a, Borrowed> {
    fn bor_wow(&'a self) -> Borrowed;
}

impl<'a> BorWow<'a, [&'a str; 2]> for Pair {
    fn bor_wow(&'a self) -> [&'a str; 2] {
        [&self.0[0], &self.0[1]]
    }
}

impl<'a, T, B> BorWow<'a, &'a B> for T
where
    T: std::borrow::Borrow<B>,
    B: ?Sized,
{
    fn bor_wow(&self) -> &B {
        <Self as std::borrow::Borrow<B>>::borrow(self)
    }
}

(hashbrowns Equivalent is a more targeted fix for HashMap/HashSet specifically.)

3 Likes

Borrowing only gives access to view a value that already exists. It fundamentally has no way of creating anything new.

String has a shape of {&str, capacity} so it can give access to the &str part, but [String; 2] is [{&str, capacity}, {&str, capacity}], so there are no two consecutive &strs in there to give access to.

Unfortunately, it's just an overly restrictive design of the HashMap.

1 Like