An `FnPtr` trait for guaranteed static dispatch of function pointers

Hello,

I feel certain that this must have been suggested before, but I've so far not been able to find anything on the subject. Quite frequently I've found myself wanting to reach for the following API.

What?

FnPtr is a trait, which strictly refines the Fn trait, but with the extra guarantee that the functor carries no environment (and, as such, is statically guaranteed to be convertable to a function pointer).

Because implementations of FnPtr carry no environment, they are statically guaranteed to be zero-sized types.

Note: There is already a trait with this name, core::marker::FnPtr. This trait does not do the same thing as this trait, because it is not automatically implemented for all closures without environment captures.

Possible API

// Implemented by all zero-sized closures (i.e: those without any
// environment captures)
trait FnPtr<Args>: Fn<Args> + Sized {
    type Output;

    // Notice that this associated function *does not* take a
    // receiver: it doesn't need to. The type system already
    // carries the information that lets the compiler statically
    // dispatch to the function (or inline directly).
    extern "rust-call" fn call_ptr(args: Args) -> Self::Output;

    // Slightly more iffy on this one: it follows that this *should*
    // be valid syntax, but I don't know if it is. Regardless, the
    // existence of this function is somewhat superfluous: one could
    // just use `F::call_ptr` to get the function pointer.
    fn to_ptr() -> fn<Args> -> Self::Output;
}

Feasibility

The compiler already knows how to do everything required by this trait. In fact, it's trivial to emulate this trait today by simply implementing it for a zero-sized placeholder type, and putting the function contents into the trait implementation (see 'current alternatives' below).

The only change required is for the compiler to automatically implement this trait for all closures that can be cast to function pointers (the compiler already knows which functions this applies to as evidence by our ability to cast such closures to function pointers with as).

Since implementers of FnPtr are required to be zero-sized, the trait never gets implemented by trait objects. The function being statically dispatchable is a requirement.

Motivation

It's often useful to be able to produce function pointers 'out of thin air' when writing wrappers over some low-level interface such as:

  • A callback-like FFI boundary where the callback mechanism doesn't permit a function pointer to be carried over the boundary

  • A trampoline-like function that needs coercing to a function pointer, but that must be generic over some other functor that performs useful work.

Current alternatives

That I know of, there are only two approaches for emulating this behaviour. Both come with significant downsides.

Placeholder type

It's possible to use a zero-sized placeholder type to emulate this today, albeit in a frustratingly verbose manner:

// A placeholder type to represent the function
struct MyFunction;

// A 'function' with a signature akin to `fn(i32, u8) -> bool`
impl FnPtr<(i32, u8)> for MyFunction {
    type Output = bool;
    fn call_ptr((a, b): (i32, u8)) -> Self::Output {
        // Example function logic
        a == b as i32
    }
}

// Now, `MyFunction` can be passed around as a way to get hold
// of a function pointer by invoking `MyFunction::call_ptr` (or
// `F::call_ptr` in some generic context)
my_api_that_accepts_a_fn_ptr(MyFunction);

fn my_api_that_accepts_a_fn_ptr<F: FnPtr<(i32, u8), Output = bool>>(_f: F) {
    // Example usage
    assert_eq!(F::call_ptr((42, 3)), false);
}

Runtime size asserts

Another approach to emulating this is to use the existing Fn trait, coupled with a runtime assertion that the type is zero-sized, allowing us semi-legitimately magic a function pointer out of thin air. This approach is of questionable safety (especially when one considers whether function pointers have provenance).

fn my_api_that_accepts_a_fn_ptr<F: Fn(i32, u8) -> bool>(_f: F) {
    assert_eq!(core::mem::size_of::<F>(), 0);

    // ... other code here ...

    // Safety: we already asserted that `F` is zero-sized and
    // so has the same bit pattern as `()`
    // Safety 2: this might only be safe if we ignore provenance!
    let f = unsafe { core::mem::transmute::<_, &F>(&()) };
    assert_eq!(f(42, 3), false);
}

// Example usage: much nicer than the placeholder type solution
// above, but can potentially panic at runtime! (even though the
// compiler already knows the conditions that produce that panic)
my_api_that_accepts_a_fn_ptr(|a, b| a == b as i32);

I should hope the reasons for this being a deeply ugly solution are immediately obvious.

Summary

I think this would be a relatively cheap (in terms of complexity and maintenance) feature to implement that substantially enhances the expressive power of Rust without needing to defer to unsafe code or ugly, unergonomic surface APIs.

I welcome discussion/criticism/suggestions!

3 Likes

F may have a safety invariant that there only exists a single instance of it, so crrating an arbitrary ZST out of thin air is not safe.

Edit: You get an F as argument already, so in this specific case it should be fine.

F may have a safety invariant that there only exists a single instance of it

Yes, this is true. This sort of potential footgun only adds to the argument that an automatically-implemented FnPtr trait is a good idea, I feel!

The compiler still needs some rule to follow when implementing this trait though. A couple of options would be:

  • implementing Copy, which guarantees you can create multiple copies of the closure and that it doesn't need dropping

  • implementing Default, again as a way to guarantee you can create multiple "copies" of the closure.

On the topic of Default, I wonder if automatically implementing Default for closures, under the same conditions we're discussing for FnPtr, would be an alternative to FnPtr itself. If a closure implements Default then you can call it in a new closure without capturing it by just creating a new default value inside the new closure, thus making the new closure coercible to a fn pointer.

2 Likes

I think at one point it was possible to have a const FN: fn(Foo, Bar) -> Baz generic parameter with the right nightly features enabled. I think that would be the best solution to this, since it doesn't require expanding the standard library and would be most widely applicable (I've wanted to use this in generics of a newtype struct where adding a PhantomData field was not an option).

The compiler still needs some rule to follow when implementing this trait though.

It already has such a rule: any closure for which you can use as fn(...) -> ... to coerce it to a function pointer should implement this trait.

implementing Copy, which guarantees you can create multiple copies of the closure and that it doesn't need dropping

Ah, I think you might have misunderstood the purpose of the trait. The goal here is not to capture 'all functors that are trivially copyable' (that's already easy to capture, with a + Copy bound). It's to capture 'all functors that can be represented directly as a regular function pointer' (the conditions for this is that they (a) have no captures and (b) are statically dispatchable).

I wonder if automatically implementing Default for closures, under the same conditions we're discussing for FnPtr, would be an alternative to FnPtr itself.

I think this would be a satisfactory alternative for all cases that I can think of right now, yes. Perhaps there are minor ergonomic issues surrounding constness, but those relate more to existing problems for Default.

I think at one point it was possible to have a const FN: fn(Foo, Bar) -> Baz generic parameter

Although this would exhibit the correct behaviour on the callee side (it's possible to pull the function pointer 'out of thin air' if it's a constant value), I'm not sure how it would work on the caller side for closure-like syntax given that closure types cannot be named. For example:

// Callee
fn api_that_accepts_an_fn_ptr<const F: fn(i32)>(f: /*???*/) {
    // Example usage
    (FN)(42)
}

// Caller
api_that_accepts_an_fn_ptr(|x| println!("{x}"));

It's not immediately clear to me how one could ergonomically derive the required const from the closure parameter. A way to make this workable might be:

// Callee
fn api_that_accepts_an_fn_ptr<const F: fn(i32)>() {
    // Example usage
    (FN)(42)
}

// Caller
fn my_func(x: i32) { println!("{x}") }

api_that_accepts_an_fn_ptr::<my_func>();

But again: we're almost back to the really unergonomic setup of existing alternative solutions, and for no discernable reasons other than arbitrary limitations of the language.

I've seen the idea to use Default for this somewhere before. I don't recall where exactly, if it was a forum post or maybe even RFC..

turning F: FnOnce(Args…) -> R into a function pointer is a simple as (|args…| F::default()(args…)) as fn(Args…) -> R, and for unnameable closure types, you can do this in a helper function that receives (and doesn't use any further besides for type inference) a value of the relevant closure/function type.

As a way to convince folks that this serves a real need, here's an example of a concrete case where I'm currently butting up against the need for this trait.

I'm implementing a threaded interpreter. In a threaded interpreter, the original code gets 'compiled' into a list of function pointers that are sequentially executed.

A very common optimisation is to make each function invoke the next function in the sequence within its own body, resulting in a tail call (and hence, substantially better performance). I'd like to abstract away this tail-calling behaviour because it's annoying to write out for every possible instruction function: so I'd like a way of taking an impl FnPtr, which does the useful logic of the instruction, and wraps it in the tail-calling and argument-extracting logic (akin to instruction decoding, but in software).

Note that I cannot simply pass either an impl Fn or a fn(...) for the inner logic function: the final wrapped function must be representable as a single function pointer to be executed by the interpreter.

My current solution makes use of the 'runtime size asserts' technique mentioned in my original post: needless to say, this is painful and error-prone: if the user of the API has their logic function accidentally captures anything from the enclosing scope, they end up with a hidden runtime error (despite the fact that the compiler should be trivially capable of understanding that this is a problem).

I've seen the idea to use Default for this somewhere before.

Yep, I think Default would suffice for this case too.

I want to note that I got confused by calling this “function pointer”. A function pointer fn(…) -> R is not a ZST and is not statically dispatched. The thing that has a unique type is a function item…but that terminology doesn’t include captureless closures.

5 Likes

Agreed, this is a confusing name, but what is the correct name for items of function types that are ZSTs (e.g. functions and capture less closures, and presumably any custom ZST type manually implementing Fn using nightly features).

For what it's worth, you can probably move this size_of check into a post-mono error.

I want to note that I got confused by calling this “function pointer”. A function pointer fn(…) -> R is not a ZST and is not statically dispatched.

Agreed. I also considered FnZero as a possible name since that's more an accurate description of the implementer (as opposed to the capabilities the trait provides). That said, I think the name is mostly a bikeshedding issue though: I'm more interested to hear what appetite there is for the functionality itself.

I know that auto-implementing Default has been attempted before (although I have a lot of disagreements for the reasoning behind the PR's closing), so perhaps a dedicated trait is the way to go instead?

2 Likes

Nitpick: Should function pointer types implement FnPtr? They have no environment (for some definition of "environment") and can be converted to function pointers (trivially), but they're not zero-sized types.

2 Likes

Function pointers can't implement the trait as defined in the first post because it requires them to be callable without an instance of themself. In other words the type has to unique identify the function being called, while function pointers identify the function by their value.

Yep, this is correct. The point here is to outright avoid needing to carry around any sort of runtime representation of the function until the moment at which it is invoked (or otherwise needs turning into a function pointer).

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.