3

I'm interested in the options for providing ownership or references to dependencies when doing dependency injection in Rust. I don't mean like a DI framework or anything like that, just the general pattern.

For example let's say we have a trait for a storage layer that stores binary blobs.

pub trait BlobStore {
    fn read_blob(&self, id: &str) -> Result<Vec<u8>, ()>;
}

Now we want to create a decorator blob store that wraps another, adding a caching layer. It needs to accept both a blob store and an in-memory cache.

pub trait Cache {
    fn get(&self, key: &str) -> Option<Vec<u8>>;
    fn set(&self, key: &str, value: Vec<u8>);
}

The question is: what should the decorator look like in terms of how it accepts the wrapped blob store and the cache? Here are the options I see, and their downsides:

  • Perhaps the most straightforward choice is to accept ownership of them:

    pub struct CachingBlobStore<BS, C> {
        wrapped: BS,
        cache: C,
    }
    
    impl<BS: BlobStore, C: Cache> BlobStore for CachingBlobStore<BS, C> {
        fn read_blob(&self, id: &str) -> Result<Vec<u8>, ()> {
            todo!();
        }
    }
    

    This is easy to implement, but it forces the creator to hand over ownership when they might not want to. For example, what if they also want to use the same cache object somewhere else in the program? In order to work around this they'd need to do something like the following:

    // Workaround for wanting to reuse a cache elsewhere.
    impl<C> Cache for Arc<C>
    where
        C: Cache,
    {
        fn get(&self, key: &str) -> Option<Vec<u8>> {
            self.get(key)
        }
    
        fn set(&self, key: &str, value: Vec<u8>) {
            self.set(key, value)
        }
    }
    
    // Create a caching blobstore without needing to hand
    // over ownership of the cache.
    fn create_with_shared_cache<BS: BlobStore, C: Cache>(wrapped: BS, cache: Arc<C>) -> impl BlobStore {
        CachingBlobStore { wrapped, cache }
    }
    

    (Rust playground)

    In fact I don't think they could do this from an outside package. The workaround would need to be even more cumbersome by defining a struct that wraps Arc<C>.

  • Instead we could bake in the fact that we're using Arc, and only require shared ownership via reference counting.

    pub struct CachingBlobStore<BS, C> {
        wrapped: Arc<BS>,
        cache: Arc<C>,
    }
    

    (Rust playground)

    But this requires the user to do a separate allocation for every dependency, and adds indirection that may or may not be an important runtime cost. It also forces the cost of reference counting on the user, even if they don't otherwise need it.

  • We could just require reference to each dependency, and make it the user's problem to figure out the lifetimes.

    pub struct CachingBlobStore<'a, BS, C> {
        wrapped: &'a BS,
        cache: &'a C,
    }
    

    (Rust playground)

    But this forces the user to keep the dependencies alive and in scope even if they just want to hand off ownership. It also adds an indirection cost that may or may not be important at runtime.

Is there any way to have our cake and eat it too? If there some option I haven't thought of here that allows the user to have flexibility in choosing what ownership model they want to use, without the downside of them having to define the relevant trait for a smart pointer wrapper struct if they want to hand over a smart pointer?

2
  • You can "give ownership" of an Arc or a reference, so ownership is generally superior. If references and/or arc's can't implement the trait due to orphan rules then creating a SharedCache and CacheRef<'a> yourself is possible and reasonable. Commented Nov 9 at 2:37
  • Yep, that's the thing I mentioned in the first bullet point. Cool that it works and t's the best way I could come up with, but it's cumbersome so I was wondering if there was some better idea that hadn't occurred to me. Commented Nov 9 at 3:42

1 Answer 1

1

Your first approach is fine.

We can see an example of a similar trait in the standard library: std::os::fd::AsFd. By "similar", here I mean that all of AsFd's methods all take an immutable self parameter. Besides its non-generic implementors, AsFd is also implemented generically for a selection of reference types:

impl<T: AsFd + ?Sized> AsFd for &T { /* */ }
impl<T: AsFd + ?Sized> AsFd for &mut T { /* */ }
impl<T: AsFd + ?Sized> AsFd for Box<T> { /* */ }
impl<T: AsFd + ?Sized> AsFd for Rc<T> { /* */ }
impl<T: AsFd + ?Sized> AsFd for UniqueRc<T> { /* */ }
impl<T: AsFd + ?Sized> AsFd for Arc<T> { /* */ }

While I don't necessarily recommend that you do all of these for your traits (&mut T is not necessary because you can very easily turn &mut T into &T, UniqueRc is unstable, Box is generally covered by the owned implementation), generally speaking, &T, Rc, and Arc represent most, if not all, of the use cases that users of your traits will require. Then, when creating a cached blob store, people will be able to pass in owned caches, referenced caches, or Arc caches:

impl<C: Cache> Cache for &C { /* */ }
impl<C: Cache> Cache for Rc<C> { /* */ }
impl<C: Cache> Cache for Arc<C> { /* */ }

fn create_cached_blob_store<BS: BlobStore, C: Cache>(wrapped: BS, cache: C) -> impl BlobStore {
    CachingBlobStore { wrapped, cache }
}

#[derive(Clone, Copy)]
struct DummyBlobStore;
#[derive(Clone, Copy)]
struct DummyCache;

impl BlobStore for DummyBlobStore { /* */ }

impl Cache for DummyCache { /* */ }

let blobstore = DummyBlobStore;
let cache = DummyCache;

let _ = create_cached_blob_store(blobstore, cache); // owned
let _ = create_cached_blob_store(blobstore, &cache); // referenced
let _ = create_cached_blob_store(blobstore, Arc::new(cache)); // arc'd

Playground

An alternate approach could automatically deal with all the different reference types, by leveraging the Borrow trait:

fn create_cached_blob_store_ref<C: Cache + ?Sized>(
    wrapped: impl BlobStore,
    cache: impl Borrow<C>,
) -> impl BlobStore {
    struct CacheRef<C: ?Sized, CR>(CR, PhantomData<Arc<C>>);
    impl<C, CR> Cache for CacheRef<C, CR>
    where
        C: Cache + ?Sized,
        CR: Borrow<C>,
    {
        fn get(&self, key: &str) -> Option<Vec<u8>> {
            C::get(self.0.borrow(), key)
        }
        fn set(&self, key: &str, value: Vec<u8>) {
            C::set(self.0.borrow(), key, value)
        }
    }

    let cache = CacheRef(cache, PhantomData);
    CachingBlobStore { wrapped, cache }
}

The downside of this approach is that call sites will have to manually specify exact what type to use as the cache, because Rust can no longer infer what trait implementation of Cache to use (theoretically, one could impl Borrow<C1> for CR and impl Borrow<C2> for CR):

let blobstore = DummyBlobStore;
let cache = DummyCache;

let _ = create_cached_blob_store_ref::<DummyCache>(blobstore, cache); // owned
let _ = create_cached_blob_store_ref::<DummyCache>(blobstore, &cache); // referenced
let _ = create_cached_blob_store_ref::<DummyCache>(blobstore, Arc::new(cache)); // arc'd

Playground

Theoretically, this is more flexible, able to take any type that implements Borrow<C>, for a given C: Cache, which would include most, if not all, smart pointer types.

You could also use AsRef<C> instead of Borrow<C>, which has less strict requirements, but this means that the create_cached_blob_store_ref function would not be able to take owned caches at all, leading to a similar problem as your second and third solutions.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.