Trait with a callback: How to model argument reference lifetime?

27 views Asked by At

Let's consider the following example of a trait involving an FnOnce callback (but I guess it's the same for FnMut or Fn):

use std::cell::RefCell;
use std::rc::Rc;

struct Holder<T>(Rc<RefCell<T>>);

trait Callback<A> {
    fn callback(&self, f: impl FnOnce(&A));
}

impl<A> Callback<A> for Holder<A> {
    fn callback(&self, f: impl FnOnce(&A)) {
        f(&self.0.borrow())
    }
}

So far this example allows to write code like this:

let holder_a = Holder(Rc::new(RefCell::new(42)));
holder_a.callback(|a| println!("{a}"));

My goal now is to generalize this pattern over tuples of Holder, i.e., I'd like to be able to do:

let holder_a = Holder(Rc::new(RefCell::new(42)));
let holder_b = Holder(Rc::new(RefCell::new(1.0)));
(holder_a, holder_b).callback(|(a, b)| println!("{a} {b}"));

and later extend it to larger tuples (perhaps with the typical limit of 12). So the general challenge here is that we have e.g. an (Holder<A>, Holder<B>, Holder<C>) and for the callback signature we have to provide a (&A, &B, &C) instance (I don't want to rely on Copy or Clone). Essentially the question is how to model the lifetime of these & in the elements?

Attempt 1

Here are a few naive attempts. In the implementation above, Callback uses A as the generic argument, while the FnOnce uses &A. One idea would be to turn this around, i.e., don't introduce the & in the trait already, but "pass it in" via the generic argument. This would lead to:

trait Callback<A> {
    fn callback(&self, f: impl FnOnce(A));
}

impl<A> Callback<&A> for Holder<A> {
    fn callback(&self, f: impl FnOnce(&A)) {
        f(&self.0.borrow())
    }
}

impl<A, B> Callback<(&A, &B)> for (Holder<A>, Holder<B>) {
    fn callback(&self, f: impl FnOnce((&A, &B))) {
        f((&self.0.0.borrow(), &self.1.0.borrow()))
    }
}

Basically the signatures look right, but the compiler complains: impl has extra requirement for<'a> impl FnOnce(&A): FnOnce(&'a A). It sound like this is not allowed because the trait itself has no lifetime associated to the FnOnce, but the impls do.

Attempt 2

My next attempt was to go even more generic, and also pass in the lifetime:

trait Callback<'a, Args> {
    fn callback(&'a self, f: impl FnOnce(Args));
}

impl<'a, A> Callback<'a, &'a A> for Holder<A> {
    fn callback(&'a self, f: impl FnOnce(&'a A)) {
        f(&self.0.borrow())
    }
}

impl<'a, A, B> Callback<'a, (&'a A, &'a B)> for (Holder<A>, Holder<B>) {
    fn callback(&'a self, f: impl FnOnce((&'a A, &'a B))) {
        f((&self.0.0.borrow(), &self.1.0.borrow()))
    }
}

Almost: This time the relation between the trait and the impls is fine, and also the intended usage compiles. But, the compiler rightfully complains that the lifetime of the borrow doesn't last long enough (creates a temporary value which is freed while still in use). Indeed, I guess now 'a corresponds to the lifetime of &'a self and the borrow is more short lived than that.


The question remains: How can I model these lifetimes (&'a A, &'a B, ...) correctly in such a case? Is there a neat trick leveraging GATs or HRTBs?

2

There are 2 answers

1
Chayim Friedman On BEST ANSWER

You can do that using both GAT and HRTB, but it won't necessarily be convenient:

trait Callback {
    type Param<'a>
    where
        Self: 'a;
    fn callback(&self, f: impl for<'a> FnOnce(Self::Param<'a>));
}

impl<A> Callback for Holder<A> {
    type Param<'a> = &'a A where Self: 'a;
    fn callback(&self, f: impl FnOnce(&A)) {
        f(&self.0.borrow())
    }
}

impl<A, B> Callback for (Holder<A>, Holder<B>) {
    type Param<'a> = (&'a A, &'a B)
    where Self: 'a;
    fn callback(&self, f: impl for<'a> FnOnce((&'a A, &'a B))) {
        f((&self.0 .0.borrow(), &self.1 .0.borrow()))
    }
}
1
bluenote10 On

Looking at the problem with fresh eyes, I noticed that it should be possible to pull the entire callback out into a generic argument, i.e.:

trait Callback<F> {
    fn callback(&self, f: F);
}

impl<A, F> Callback<F> for Holder<A>
where
    F: FnOnce(&A),
{
    fn callback(&self, f: F) {
        f(&self.0.borrow())
    }
}

impl<A, B, F> Callback<F> for (Holder<A>, Holder<B>)
where
    F: FnOnce((&A, &B)),
{
    fn callback(&self, f: F) {
        f((&self.0.0.borrow(), &self.1.0.borrow()))
    }
}

This makes everything compile. In a sense this circumvents the challenge of specifying these lifetimes, because the lifetimes only get instantiated on the impl level.

I'm not entirely sure of all implications of doing it like that though.