Abstracting over the underlying implementation on a per type basis

I have a struct that has an underlying implementation that is abstracted on a per type basis. For example:

Playground

struct UseCase<'a, S: ?Sized>
where
    S: Support<'a>
{
    inner: S::Impl
}

Where this would be implemented for a type like the following

trait Thing<'a, S: ?Sized> {
    fn new(t: &'a S) -> Self;
    // more methods here ...
}

trait Support<'a> {
    type Impl: Thing<'a, Self>;
}

// Implementation for [T]

struct ActualThing<'a, S: ?Sized> { s: &'a S }

impl<'a, S: ?Sized> Thing<'a, S> for ActualThing<'a, S> {
    fn new(s: &'a S) -> Self {
        Self { s } 
    }
}

impl<'a, T: 'a> Support<'a> for [T] {
    type Impl = ActualThing<'a, Self>;
}

The problem

When implementing this for a type that is generic over another type (like the above), it requires a T: 'a bound. Which wouldn't normally be necessary if there wasn't a separate underlying implementation (the associated type).

impl<'a, T: 'a> AsRef<[T]> for UseCase<'a, [T]> {
    fn as_ref(&self) -> &[T] {
        unimplemented!()
    }
}

My questions are:

  • Would this limit the way UseCase<'a, [T]> could be used? Compared to something like UseCase<'a, str>.
  • Is there a way to do what I want to do, without requiring the T: 'a? Perhaps changing the way the Support and Thing traits are setup?

When you have &'a [T] then it's always true that T: 'a, otherwise you'd have a dangling pointer. In a few places Rust will add T: 'a implicitly for you. But it's just not safe to ever have T that doesn't really outlive it being borrowed.


But in general you can't abstract away memory management in Rust.

In languages with a garbage collector there's no difference between owned and borrowed values, and object lifetimes are automatically extended to be correct for every use.

In Rust, the memory management has to be done by the code that uses the values, so every single use of a value needs to know whether it's dealing with a borrowed or owned value, and have enough information to ensure that it's not borrowed for too long. The compiler has to generate physically different code depending on whether you use Vec<u8> or &[u8].

If you want to make either borrowed or owned type usable behind an abstraction boundary, then the compiler will have to assume the worst, most restrictive subset of both. It can't let you keep the abstract object as if it was owned value in case it was actually borrowed. It can't let you freely copy around shared references, just in case the abstract object was actually a unique owner.

In some cases like Cow it's possible to replace lifetimes with 'static to support only the owned case. But usually you have to decide whether your trait is for owning, or borrowing objects, or shared and owning at the same time (Arc), and make that explicit.

1 Like

You add the constraint to all implementers of Support and it will be implicit elsewhere:

-trait Support<'a> {
+trait Support<'a>: 'a {
 // ...
-impl<'a, T: 'a> AsRef<[T]> for UseCase<'a, [T]> {
+impl<'a, T> AsRef<[T]> for UseCase<'a, [T]> {

This is actually more constraining, not less, but it may work out fine in practice.

1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.