Building a toy key-value store with GATs

Jan 23, 2023

Generic associated types (GATs) are a great mechanism to add more flexibility to your traits. I didn't really grok them until tonight. GATs were stabilized near the end of last year, but its purpose eluded me - I wasn't able to work out what I could actually use it for or how.

This evening I was working on an interface for an in-memory key-value store. It needed to have two associated functions, one for fetching stored data, and one for inserting data into the store. The trait I started with was roughly like:

trait Store<K, V> {
    fn get(&self, key: &K) -> Option<V>;
    fn insert(&mut self, key: K, value: V);
}

Then I can use the trait without worrying about the implementation:

fn insert_and_get<S, K, V>(store: &mut S, key: K, value: V) -> Option<V>
where
    K: Clone,
    S: Store<K, V>,
{
    store.insert(key.clone(), value);
    store.get(&key)
}

An implementation of Store might look like this:

struct CloningStore<K, V> {
    store: HashMap<K, V>,
}

impl<K, V> CloningStore<K, V> {
    fn new() -> Self {
        Self {
            store: HashMap::new(),
        }
    }
}

impl<K, V> Store<K, V> for CloningStore<K, V>
where
    K: Hash + Eq,
    V: Clone,
{
    fn get<'store>(&'store self, key: &K) -> Option<V> {
        self.store.get(key).cloned()
    }

    fn insert(&mut self, key: K, value: V) {
        self.store.insert(key, value);
    }
}

Here I'm wrapping a HashMap and implementing the Store traits items to manipulate the map. The get function will clone the value, so I've added the Clone bound to V.

Putting it all together, I can now instantiate a concrete type, and pass it to my insert_and_get function:

fn main() {
    #[derive(Clone, Debug)]
    struct MyData(usize);

    let mut cloning_store: CloningStore<usize, MyData> = CloningStore::new();
    let my_data = insert_and_get(&mut cloning_store, 1, MyData(1)); // Option<MyData>
    println!("{my_data:?}"); // Some(MyData(1))
}

Cloning is expensive and I'd prefer to avoid doing that. So I'd like to have the option of an in-memory store which returns references to the data it's holding. The CloningStore could be switched out for this new one. I can just change the return type:

struct RefStore<K, V> {
    store: HashMap<K, V>,
}

impl<K, V> Store<K, V> for RefStore<K, V>
where
    K: Hash + Eq,
{
    fn get(&self, key: &K) -> Option<&V> {
        self.store.get(key)
    }

    fn insert(&mut self, key: K, value: V) {
        self.store.insert(key, value);
    }
}

Nope!

cargo run --bin no_ref
   Compiling gats v0.1.0 (...)
error[E0053]: method `get` has an incompatible type for trait
  --> src/bin/no_ref.rs:62:31
   |
58 | impl<K, V> Store<K, V> for RefStore<K, V>
   |         - this type parameter
...
62 |     fn get(&self, key: &K) -> Option<&V> {
   |                               ^^^^^^^^^^
   |                               |
   |                               expected type parameter `V`, found `&V`
   |                               help: change the output type to match the trait: `Option<V>`
   |

So the trait needs to be a bit more flexible such that I can set the output types per implementation. I can add the Output associated type to the Store trait and update the get function to return Option<Self::Output>, and then implement that on both CloningStore and RefStore:

trait Store<K, V> {
    type Output;
    fn get(&self, key: &K) -> Option<Self::Output>;
    fn insert(&mut self, key: K, value: V);
}

impl<K, V> Store<K, V> for CloningStore<K, V>
where
    K: Hash + Eq,
    V: Clone,
{
    type Output = V;
    fn get<'store>(&'store self, key: &K) -> Option<Self::Output> {
        self.store.get(key).cloned()
    }

    // fn insert()
}

impl<K, V> Store<K, V> for RefStore<K, V>
where
    K: Hash + Eq,
{
    type Output = &V;
    fn get(&self, key: &K) -> Option<Self::Output> {
        self.store.get(key)
    }

    // fn insert()
}

The compiler is happy... with CloningStore. For RefStore, rustc is noticeably unhappy:

cargo run --bin no_ref
   Compiling gats v0.1.0 (...)
error[E0637]: `&` without an explicit lifetime name cannot be used here
  --> src/bin/no_ref.rs:64:19
   |
64 |     type Output = &V;
   |                   ^ explicit lifetime name needed here

error[E0310]: the parameter type `V` may not live long enough
  --> src/bin/no_ref.rs:64:19
   |
64 |     type Output = &V;
   |                   ^^ ...so that the reference type `&'static V` does not outlive the data it points at
   |
help: consider adding an explicit lifetime bound...
   |
60 | impl<K, V: 'static> Store<K, V> for RefStore<K, V>
   |          +++++++++

Focusing on the first error: rustc requires a lifetime parameter for the associated type. Sure, I'll add a lifetime parameter to RefStore:

struct RefStore<'store, K, V> {
    store: HashMap<K, V>,
}

impl<'store, K, V> Store<K, V> for RefStore<'store, K, V>
where
    V: 'store
    K: Hash + Eq,
{
    type Output = &'store V;

    fn get(&self, key: &K) -> Option<Self::Output> {
        self.store.get(key)
    }

    fn insert(&mut self, key: K, value: V) {
        self.store.insert(key, value);
    }
}

Nope again.

 cargo run --bin ref
   Compiling gats v0.1.0 (...)
error[E0392]: parameter `'store` is never used
  --> src/bin/ref.rs:45:17
   |
45 | struct RefStore<'store, K, V> {
   |                 ^^^^^^ unused parameter
   |
   = help: consider removing `'store`, referring to it in a field, or using a marker such as `PhantomData`

PhantomData it is:

struct RefStore<'store, K, V> {
    store: HashMap<K, V>,
    _phantom: PhantomData<&'store ()>,
}

Nope.

cargo run --bin ref
   Compiling gats v0.1.0 (...)
error: lifetime may not live long enough
  --> src/bin/ref.rs:57:9
   |
50 | impl<'store, K, V: 'store> Store<K, V> for RefStore<'store, K, V>
   |      ------ lifetime `'store` defined here
...
56 |     fn get(&self, key: &K) -> Option<Self::Output> {
   |            - let's call the lifetime of this reference `'1`
57 |         self.store.get(key)
   |         ^^^^^^^^^^^^^^^^^^^ associated function was supposed to return data with lifetime `'store` but it is returning data with lifetime `'1`

Ah. I need 'store to live as long as '1, the elided lifetime of &self. I can't update the function so that self is bound to 'store (&'store self) because that would break the trait implementation.

Alright, so maybe I can specify lifetimes on the associated type, i.e. generic associated types. That means that the trait will need to be updated:

trait Store<K, V> {
    type Output<'store>
    where
        Self: 'store;

    // fn get()
    // fn insert()
}

Self should live as long as any value of Output's lifetime 'store. Therefore the get function also needs to be updated to reflect that:

trait Store<K, V> {
    type Output<'store>
    where
        Self: 'store;

    fn get<'store>(&'store self, key: &K) -> Option<Self::Output<'store>>;
    fn insert(&mut self, key: K, value: V);
}

rustc allows the lifetime to be elided too so there's less boilerplate:

fn get(&self, key: &K) -> Option<Self::Output<'_>>;

Now CloningStore's Store implementation can be updated. It's get function doesn't return a reference, but the type still needs to include the lifetime:

impl<K, V> Store<K, V> for CloningStore<K, V>
where
    K: Hash + Eq,
    V: Clone,
{
    type Output<'store> = V where Self: 'store;

    fn get(&self, key: &K) -> Option<Self::Output<'_>> {
        self.store.get(key).cloned()
    }

    // fn insert()
}

rustc is happy! Next up is RefStore. RefStore returns a reference, and because there is a named lifetime parameter, I can use that to bind &V to the lifetime of Self:

impl<K, V> Store<K, V> for RefStore<K, V>
where
    K: Hash + Eq,
{
    type Output<'store> = &'store V where Self: 'store;

    fn get(&self, key: &K) -> Option<Self::Output<'_>> {
        self.store.get(key)
    }

    // fn insert()
}

The compiler is still happy. Finally, the insert_and_get function from earlier can be updated so that it's return type is that of of Store::Output:

fn insert_and_get<S, K, V>(store: &mut S, key: K, value: V) -> Option<S::Output<'_>>
where
    K: Clone,
    S: Store<K, V>,
{
    store.insert(key.clone(), value);
    store.get(&key)
}

The requirements of Store::Output are applied to this too so that any value of S::Output must live as long as the lifetime of &mut S.

And done.

fn main() {
    #[derive(Clone, Debug)]
    struct MyData(usize);

    let mut store: CloningStore<usize, MyData> = CloningStore::new();
    let my_data = insert_and_get(&mut store, 1, MyData(1)); // Option<MyData>
    println!("{my_data:?}"); // Some(MyData(1))

    let mut store: RefStore<usize, MyData> = RefStore::new();
    let my_data = insert_and_get(&mut store, 1, MyData(1)); // Option<&MyData>
    println!("{my_data:?}"); // Some(MyData(1))
}