Strategies for handling (potential) trait objects that contain methods with generics and then subsequently hiding them in another object

I've a general question regarding handling (potential) trait objects that contain methods with generics and then subsequently hiding them in another object. I am aware that traits that contain methods with generics are not object safe and can not be made into a formal trait object. Generally, my strategy for handling this is to just create a generic and restrict the type to those that implement the trait. Where this becomes more burdensome is when this type gets hidden in another object such as a closure. At this point, there's a lifetime problem where it's important that the lifetime of the object not exceed the lifetime of the type. Normally, I handle this in one of three ways: requiring the type to have a static lifetime, matching the lifetime of the type to that of the object, or just requiring the trait itself to be static. My question is whether or not there's a better strategy for handling this. At times, I have quite a few objects where this issue occurs, which leads to quite a few, understandable, lifetime annotations. This leads to complicated and burdensome function calls. As such, I've started more commonly just requiring the trait itself to be static. Is there a better way to handle this?

Here's some code demonstrating the issue and the current resolutions:

// Trait with a method dependent on a generic
trait Foo {
    fn foo<T>(&self, t: T);
}

// An object that implements foo
struct Bar;
impl Foo for Bar {
    fn foo<T>(&self, _t: T) {
        println!("I am a Bar");
    }
}

// Note, functions can't take Foo as a trait object due to foo having a generic
//fn not_trait_object(x : &dyn Foo) {}

// To get around the trait object limitation, we pass in a generic
fn trait_object_workaround<F>(x: F)
where
    F: Foo,
{
    x.foo(0);
}

// If we try to hide a Foo inside of an object that does not depend on a
// generic that requires Foo, we get a lifetime problem
//fn lifetime_issue_hiding_object<F>(x: F) -> Box<dyn Fn() -> ()>
//where
//    F: Foo,
//{
//    Box::new(move || {
//        x.foo(0);
//    })
//}

// One resolution to this is matching the lifetime of the closure to that of
// type F.  This adds another paramter to the function.
fn lifetime_matching_on_type<'a, F>(x: F) -> Box<dyn Fn() -> () + 'a>
where
    F: Foo + 'a,
{
    Box::new(move || {
        x.foo(0);
    })
}

// Alternatively, we could just force F to be static.  This saves the extra
// lifetime parameter.
fn static_requirement_on_type<F>(x: F) -> Box<dyn Fn() -> ()>
where
    F: Foo + 'static,
{
    Box::new(move || {
        x.foo(0);
    })
}

// As a last alternative, we could just add the requirement that anything
// implementing Foo be static.  This makes the trait less general, but saves on
// the lifetime issues that require attention later.
trait FooStatic: 'static {
    fn foo<T>(&self, t: T);
}
impl FooStatic for Bar {
    fn foo<T>(&self, _t: T) {
        println!("I am a Bar static");
    }
}
fn static_on_the_trait<F>(x: F) -> Box<dyn Fn() -> ()>
where
    F: FooStatic,
{
    Box::new(move || {
        x.foo(0);
    })
}

// Test some of the functions
fn main() {
    trait_object_workaround(Bar);
    lifetime_matching_on_type(Bar)();
    static_requirement_on_type(Bar)();
    static_on_the_trait(Bar)();
}

Thanks for the help!

These are essentially the kinds of approaches I know of.

This seems to implicitly say that you’re using the approach of adding a lifetime argument à la “lifetime_matching_on_type”. Which is a good approach IMO because – why not take the most general solution as long it doesn’t really create any problems. Except when it does create problems… which by the sound of “complicated and burdensome” may be the case?

In order to avoid an XY problem, you might want to provide an example of such code with “quite a few lifetime annotations” to better demonstrate how excessive and “burdensome” it really does (or doesn’t) get and to help us determine whether there could be other ways in which the lifetime annotations in places using functions such as “lifetime_matching_on_type” could be reduced, or multiple lifetimes combined, etc… so that it maybe won’t be “burdensome” anymore.

1 Like

Thanks for the reply. Here's a somewhat arbitrary example of where this kind of phenomenon occurs. Essentially, I'll have some data and some operations, which both obey their own traits. The data has its own trait to allow for things like different precisions or underlying data layouts. The operations have their own trait to allow for different algorithms to manipulate the data. Then, say we want to leverage all of this code in some closure, we can lift an element to our data type, manipulate it with our operations, and then strip out the data type. This gives a clean closure modulo dealing with the lifetimes of both the data type and operations type. The situation below isn't that bad, but I do think it would be easier on someone writing the code if they didn't need to remember to match the lifetime of the closure with the that of its types. I will contend that this can get worse than what is presented, but hopefully its indicative of the kind of structure, which might help with any suggestions of a better way to handle this.

// Arbtitrary data type
trait Data: Clone {
    fn from_u16(x: u16) -> Self;
    fn to_u16(&self) -> u16;
}

// One such implementation of the data type
#[derive(Clone)]
struct MyData(u32);
impl Data for MyData {
    fn from_u16(x: u16) -> Self {
        MyData(x as u32)
    }
    fn to_u16(&self) -> u16 {
        self.0 as u16
    }
}

// Another implementation of some data
#[derive(Clone)]
struct MyOther<'a> {
    data: u16,
    offset: Option<&'a u16>,
}
impl<'a> Data for MyOther<'a> {
    fn from_u16(x: u16) -> Self {
        MyOther {
            data: x,
            offset: None,
        }
    }
    fn to_u16(&self) -> u16 {
        self.data
    }
}

// Operations on slices of the datatype
trait Operations<D>: Clone
where
    D: Data,
{
    fn combine<F>(&self, f: F, xs: &[D], ys: &[D]) -> Vec<D>
    where
        F: Fn(&D, &D) -> D;
}

// Serial implementation of the operations.  May want parallel later.
#[derive(Clone)]
struct Serial;
impl<D> Operations<D> for Serial
where
    D: Data,
{
    fn combine<F>(&self, f: F, xs: &[D], ys: &[D]) -> Vec<D>
    where
        F: Fn(&D, &D) -> D,
    {
        xs.iter().zip(ys.iter()).map(|(x, y)| f(x, y)).collect()
    }
}

// Function that wants to use both Data and Ops, but return an object that does
// not depend on them explicitly
fn foo<'a, D, O>(ops: O, offset: D) -> Box<dyn Fn(&[u16]) -> u16 + 'a>
where
    D: Data + 'a,
    O: Operations<D> + 'a,
{
    Box::new(move |xs: &[u16]| {
        // Project into data
        let xs: Vec<D> = xs.iter().cloned().map(|x| D::from_u16(x)).collect();

        // Use the operations on our new vectors
        let zs = ops.combine(
            |x, y| D::from_u16(x.to_u16() + y.to_u16()),
            xs.as_slice(),
            xs.as_slice(),
        );

        // Return the result
        zs[0].to_u16() + offset.to_u16()
    })
}

// Test some of the functions
fn main() {
    println!(
        "{}",
        foo::<'_, MyData, Serial>(Serial, MyData::from_u16(2))(
            vec![1, 2].as_slice()
        )
    );
    println!(
        "{}",
        foo::<'_, MyOther, Serial>(Serial, MyOther::from_u16(2))(
            vec![1, 2].as_slice()
        )
    );
}

Honestly, this usage of lifetimes in foo doesn't look too bad to me. Note that users of such a function that just care about the case where D: 'static, O:' static can still do so, the option to support shorter lifetimes just makes functions like foo more general. It's probably also an easy refractor to turn a function using : 'static bounds into one that's generic over the lifetime.

OTOH, following YAGNI, if your trait is such that it does - for some reason - make little sense in practice or at least isn't too useful to ever implement it for types that don't fulfill 'static, then - sure - the : 'static supertrait bound on the trait can make a lot of sense. And even that one could easily be refactored later as long as your trait isn't part of a stable external API yet.

1 Like

One thing I would personally have been curious to see in Rust is to get "lifetime extractor"s macros or operators.

That is, if we take the fn identity<T> (it: T) -> impl '? + Sized { it }, we currently have to do what you mention:

fn identity<'T, T : 'T> (it: T) -> impl 'T + Sized { it }
  • More generally, it is impossible in Rust to erase the "minimum lifetime" of usability, be it through impl pseudo-erasure, or dyn erasure.

What I suggest is that, when 'Name is not defined / in scope (which it shouldn't be in practice, thanks to non_snake_case), that it magically resolve to a new free lifetime parameter "upper-bounded" by Name, assuming a <Name> generic was introduced.

That is, that the following definition be equivalent to the above:

fn identity<T> (it: T) -> impl 'T + Sized { it }

A slightly less terse / magical and thus explicit approach would be, if macros were allowed to expand to lifetimes, to be able to write:

fn identity<T> (it: T)
  -> impl min_lifetime_of!(T) + Sized
{
    it
}

With such a tool, it would be possible to write your lifetime_matching_on_type as:

fn lifetime_matching_on_type<F : Foo> (x: F)
  -> Box<dyn 'F + Fn()>
{
    Box::new(move || x.foo(0))
}
  • which I find quite readable :slightly_smiling_face:

or:

fn lifetime_matching_on_type<F : Foo> (x: F)
  -> Box<dyn min_lifetime_of!(F) + Fn()>
{
    Box::new(move || x.foo(0))
}
  • which is not too bad, is it? :sweat_smile:

That being said, I believe we'd need a ˆ binary operator on lifetimes, to signify the intersection / minimum of two lifetimes, or, again, the min_lifetime_of! macro being overloaded1 to do that as well:

fn foo<D : Data, O : Operations> (
    ops: O,
    offset: D,
) -> Box<dyn min_lifetime_of!(O, D) + Fn(&[u16]) -> u16>
  • And with the ˆ operator (I'll admit it does look a bit alien :laughing:):

    fn foo<D : Data, Ops : Operations> (
        ops: Ops,
        offset: D,
    ) -> Box<dyn ('D ^ 'Ops) + Fn(&[u16]) -> u16>
    

1 The overload is not strictly necessary per se, thanks to tuple types:
min_lifetime_of![T, U, V] = min_lifetime_of![ (T, U, V) ]

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.