Can I add function params by generic parameter?

I have a type like this one below. It’s a version of the event/observer design pattern. You can register listener callbacks and fire the event/signal to deliver info to all listeners.

Now I want some signals that take an extra “context” parameter.

type InternalsPtr<D, P> = Arc<Mutex<SignalInternals<D, P>>>;

#[derive(Clone)]
pub struct Signal<D, TTM>
    where TTM: ThreadTransferMechanism, D: 'static
{
    inner: InternalsPtr<D, TTM>,
}
struct SignalInternals<D, TTM> {
    listeners: Vec<Box<dyn Fn(&D) + Send + Sync + 'static>>,
    delivery_scheduled: bool,
    firing: Vec<D>,
    thread_transfer_mechanism: TTM
}

So, I want a variation where the listeners take another argument:

listeners: Vec<Box<dyn Fn(&D, &mut Context) + Send + Sync + 'static>>,

Am I correct that Rust has no way to do that all under the same type? I guess that would be “variadic generics”?

You can add another generic type parameter but give it a default:

#[derive(Clone)]
pub struct Signal<D, TTM, CTX=()>
    where TTM: ThreadTransferMechanism, D: 'static
{
    inner: InternalsPtr<D, TTM>,
}
struct SignalInternals<D, TTM, CTX> {
    listeners: Vec<Box<dyn Fn(&D, CTX) + Send + Sync + 'static>>,
    delivery_scheduled: bool,
    firing: Vec<D>,
    thread_transfer_mechanism: TTM
}
1 Like

If I understood correctly what you're after, tou should be able to make it work by introducing your own trait and writing blanket impls for types with relevant Fn impls:

trait Listener<D> {
    fn notify(&self, d: &D, ctx: &mut Context);
}

impl<F: Fn(&D), D> Listener<D> for F {
   fn notify(d: &D, _: &mut Context) {
       self(d); // ignore context
   }
}
impl<F: Fn(&D, &mut Context), D> Listener<D> for F {
   fn notify(d: &D, ctx: &mut Context) {
       self(d, ctx); // pass context
   }
}

You should also be able to add Send + Sync + 'static as supertraits of Listener, tidying up your code a bit.

I think the best approach depends on how the Context gets used (who supplies it, mostly generic usage or custom usage for each Context, is "none" the only special case in terms of listeners signature and other type differences, closed set or open set...).

dyn Fn(&D, &mut Context) seems a little odd offhand, you only have shared access to the D and the dyn Fn (which is in a Mutex), but exclusive access to Context? But I don't have the larger picture, so just a vibe.

I had some time so I took another pass at this.

This may still allow you to be ergonomic for non-context signals with a bit of indirection and scaffolding. Another goal of mine would be to avoid the need for bounds on the struct.[1]

We always have a &mut Context parameter internally, and then use a not-Sized marker type to indicate the no-context case:

    type Listener<D, Context> = Box<dyn Fn(&D, &mut Context) + Send + Sync>;
    pub enum NoContext {}

    #[derive(Clone)]
    pub struct Signal<D, TTM, Context: ?Sized = [NoContext]> {
        inner: InternalsPtr<D, TTM, Context>,
    }

    struct SignalInternals<D, TTM, Context: ?Sized = [NoContext]> {
        listeners: Vec<Listener<D, Context>>,
        delivery_scheduled: bool,
        firing: Vec<D>,
        thread_transfer_mechanism: TTM,
    }

Then we put common code under a Context: ?Sized implementation. The hope is most your code is here to avoid duplication. They can be publically Context shape agnostic, or internal for dispatching from the Context shape sensitive impls:

    // Common code
    impl<D, TTM, Context: ?Sized> Signal<D, TTM, Context> {
        // Publically `Context` agnostic
        pub fn schedule(self) { ... }

        // Used internally after normalizing
        fn add_listener_direct(&self, l: Listener<D, Context>) { ... }

        // This one could be public maybe, dunno your use case.
        // Public would allow weird looking calls downstream :-)
        // So I'd probably only allow it on the with-context API
        fn do_stuff_with(&self, ctx: &mut Context) { ... }
    }

And then the rest that's necessary to have context and context-free API facades. We use the Sized or not dichotomy to give both cases the same method names.[2]

    // Context specific stuff.  Note: `Ctx: Sized` so no overlap w/`[NoContext]`.
    impl<D, TTM, Ctx> Signal<D, TTM, Ctx> {
        pub fn add_listener<F>(&self, f: F)
            where F: Fn(&D, &mut Ctx) + Send + Sync + 'static,
        {
            self.add_listener_direct(Box::new(f));
        }

        pub fn do_stuff(&self, mut ctx: Ctx) {
            self.do_stuff_with(&mut ctx)
        }
    }

    // No context specific stuff
    // We normalize by passing/discarding a dummy arg
    impl<D, TTM> Signal<D, TTM> {
        pub fn add_listener<F>(&self, f: F)
            where F: Fn(&D) + Send + Sync + 'static,
        {
            self.add_listener_direct(Box::new(move |d, _| f(d)));
        }

        pub fn do_stuff(&self) {
            self.do_stuff_with(&mut [])
        }
    }

So it's ergonomic downstream.

pub fn is_this_usable_1<D, TTM>(s: Signal<D, TTM, String>) {
    s.add_listener(|_, s| s.push('?'));
    s.do_stuff(String::new());
}

pub fn is_this_usable_2<D, TTM>(s: Signal<D, TTM>) {
    s.add_listener(|_| {});
    s.do_stuff();
}

The errors when you mess up the arity also seem reasonable.

The reliance on Sized for the generic-with-context case means that any not-Sized context needs its own facade. On the other hand, multiple no-context marker types are possible, so long as they are not Sized.


Usable for you? I'm still unsure, but was happy enough with how it worked out to share.


  1. The ones in the OP aren't needed. Another approach is to use an associated type for the Fn signature difference, but then you need bounds on the struct. ↩︎

  2. Possible because Sized is a fundamental trait: we're allowed to assume [NoContext] will never be Sized for coherence. ↩︎

1 Like

Followup thought: using [NoContext] rules out a generic [T] facade, but there's no generic dyn Trait to worry about, so an alternative is:

    pub trait NoContext: private::Sealed {}
    pub struct Signal<D, TTM, Context: ?Sized = dyn NoContext> { ... }

    // For making `&mut dyn NoContext` internally
    struct NoContextImpl;
    mod private { pub trait Sealed {} }
    impl private::Sealed for NoContextImpl {}
    impl NoContext for NoContextImpl {}

(And downstream can't make &mut dyn NoContext in case you accidentally expose such an API... unless you also expose a &mut dyn NoContext yourself.[1])


  1. I still wouldn't purposely expose such an API. ↩︎

Your Rust-fu is too advanced for me here. I’ll need to study Sized vs not Sized and come back to this. I don’t see the point of the [NoContext] stuff yet.

I understand much more about Rust than I did a year ago, but I’m still clueless on some fronts.

Thanks for your posts.

1 Like

A similar approach with less fu:

  1. Start with always having a Context. Consumers who don't want it can use () for their Context.

  2. Write a wrapper around that with Context = (). Functions can dispatch to the inner type. Make the API mostly the same, but more ergonomic by omitting Context arguments etc. Consumers who don't want a Context can use that instead now.

  3. If there's a lot of duplication in the wrapper that doesn't change the API...

    pub fn method(&self) -> Thing { self.0.method() }
    

    ...consider implementing Deref to the inner type so you don't have to write those.

It would probably be about the same experience for the consumer.