Maybe Rust should have function references, too

fn (i32) -> i32 is a function pointer. But this syntax is a little exceptional, because we expect pointers to have *.

What if fn (i32) -> i32 was the type of the function itself (whatever that means outside the type system), and we had function pointers and function references:

  • *fn (i32) -> i32: this is a function pointer. As a pointer, it needs unsafe to be dereferenced, and have pointer stuff, like the fmt::Debug trait.
  • &fn (i32) -> i32: this is a function reference. As a reference, it has a lifetime that can be smaller than 'static (e.g. a dynamically loaded function whose lifetime is bound to the library it came from), and don't need unsafe to be used (unless the function itself is unsafe).

This idea was prompted by this issue a colleague in my team found (and exploited) in the libloading crate: `Deref` in `Symbol` can leak a raw function pointer. · Issue #158 · nagisa/rust_libloading · GitHub

10 Likes

I think lack of a lifetime on fn() is an oversight in Rust's design, and it would be nice to fix it.

It would likely need an edition or some alternative syntax to avoid mixing up pointer-to-pointer &fn() with pointer-to-function &fn().

16 Likes

It's called a function pointer, but it's closer to a function reference, because it cannot be null and is assumed to be valid. fn (i32) -> i32 in current Rust should probably be &'static fn (i32) -> i32 to be consistent with references/pointers to all other types.

AFAIK one of the reasons why function pointers have the syntax they currently have, was it wasn't fully clear how a type of the function itself should behave. It probably should be an extern type, an opaque "truly unsized type".

I also think it would be nice to rework function pointers in a future edition, but that probably won't happen before "extern types" are stabilized and the open questions for these are solved.

11 Likes

there is some merit to treating function pointers differently, as they are actually represented differently on Harvard architectures, which wasm is an example of.

however, functions with non-static lifetimes do come up occasionally, such as in dynasm.

perhaps fn (i32) -> i32 + 'a is a possible syntax for these functions?

2 Likes

there is some merit to treating function pointers differently, as they are actually represented differently on Harvard architectures, which wasm is an example of.

I don't see how that justifies a different syntax. References to ?Sized objects, like str, Path and slices are not plain memory pointers, either, nor are references to trait objects. But they still use the &'lifetime syntax.

4 Likes

the existance of &fn() would imply &mut fn() can exist. on many embedded platforms, functions are stored in ROM, and cannot be modified due to hardware limitations.

perhaps &'a fn() would be the ideal syntax if we were designing a language from scratch, but we aren't. i think fn() + 'a is a resonable alternative that avoids completly overhauling rust's syntax across an edition boundary. it also has some pleasing symmetry with Fn() + 'a.

7 Likes

Except note that we need to be careful again of the (subtle) detail that is the difference between Fn() + 'a and Fn() + use<'a>. (The former is an outlives requirement, whereas the latter is a captures / invalidated-with bound.) The two are often very similar to the point of being interchangable (mostly, when it's a single covariant lifetime), but it matters in other cases (mostly, when also capturing another generic/lifetime without a strong outlives relation between the two).

I think the lifetime of a function reference falls into the case where this distinction doesn't matter, but I'm not confident in that. If it does, though, I agree that this ends up being the most practical option to avoid needlessly invalidating existing code/resources that use fn(). There's no great reason for Generic<fn()> to be possible for not-a-reference extern type fn().

While WASM is a Harvard architecture, it's a bit more interesting than that, making it perhaps not the best example (although certainly the most actually used example). Specifically, a wasm.core ref.function can't be stored in linear memory, so if Rust fn() were it directly, we couldn't make &fn(). Instead, we make fn() a table index (usize) so it acts much more like "real" pointers do, except for the fact that they're in a different "address space", only take one address per referenced function, and "alias" linear memory space. (In Rust terms, *fn() acts like a ZST with meaningful address.)

But perhaps more relevant, if wasm modules gain the ability to marshal function pointers directly, the Rust runtime will need to dynamically manage the table to avoid unbounded table growth, which would require the usage of lifetime scoped function references.

3 Likes

can you elaborate on this or link to an explanation? i would understand T + 'a to mean "T must live for at least 'a", is that incorrect?

That's not a problem. There's &mut str, but strings can be in the ROM too.

7 Likes

on a pure Harvard architecture (eg. wasm), strings are not in the same address space as functions.

in terms of the rust Abstract Machine, there is no way to convert between a data address and a code address. under the current conventional memory model, code is always immutable and eternal, while data is sometimes transient and mutable.

techniques like self-modifying code and dynamic unloading are generally outside of the scope of portable abstractions, as they are not possible on all platforms.

See the latest Rust blog post on RPIT for the context.

In short, remember that &'a T carries an implication that T: 'a. + use<'a> doesn't require that T: 'a.

We're trying to mirror &'a T, so + 'a seems correct for function items.

in short, i propose we extend rust syntax to make the following valid:

struct DynFn<'a> {
    f: fn() + 'a,
}

existing fn() will be equivalent to fn() + 'static in most contexts, but in function paramater contexts, it will be equivelent to fn() + '_. this mirrors the behavior of dyn Fn() vs dyn Fn() + 'a.

this will make it possible to more soundly use runtime code generation.

(Replies are going fast! Please attempt to combine multiple replies into a single post with appropriate quotes to minimize clutter. The "replying to" icon for any specific post doesn't matter if you use quotes. This is a forum with forum etiquette, not a chat/IRC.)

You're probably correct but I'm not 100% positively convinced; the difference is quite subtle and not straightforward to predict what will actually break from too-strong bounds until you run into unexpected outlives requirements. I'm pointing this out as something to keep in mind, rather than an absolute deal breaker.

If this is the case, it should be the case for impl Trait and dyn Trait lifetime elision both as well. I never can remember when dyn Trait + '_ is/isn't needed.

1 Like

You seem to expect that existence of &T implies that Rust must give you fully functional &mut T and T, but that's absolutely not required in Rust.

&str is the example of that – you can't mutate the string literals, and you can't dereference them since they're unsized. Additionally if T is not Copy and doesn't have UnsafeCell, then there's no legal way to get a mutable copy of it from &T or move it to any other address, and there's no reason why function bodies would need to be made copyable or mutable. Types also don't need to give any guarantees about ability to be transmuted, especially unsized types.

They can remain completely opaque immutable unmovable unsized objects, and function identifiers resolving to &'static fn() could be identical to what fn() is today.

You're also not allowed to do pointer arithmetic with &, so a separate address space for functions is not a problem. Rust has pointer provenance, so even if the addresses cast to integers were overlapping with the data address space doesn't mean anything.

8 Likes

strictly speaking, rust isn't "required" to do anything, since there's no spec.
however, in practice, it should generally follow the principle of least surprise.
having &T but no &mut T would certainly be suprising.

it is not. &mut str is perfectly funtional and usable type through as_bytes_mut. of course you cannot mutate string literals, string literals have a type of &str.

strictly speaking, this is unrelated to provenance. a pointer is made up of 3 parts:

  1. address
  2. provenance
  3. address space

even ignoring provenance, the address space is still separate from the address.

Strictly speaking, Rust is required to do some things, by the 1.0 stability promise and supporting documentation. Namely, barring specific carve-outs, std/alloc/core will continue to behave as per their documentation, and any pure safe code that cleanly compiles will continue to compile (although maybe not cleanly and with the usual API evolution caveats around type inference and name resolution) and do the same thing (for a sufficiently weak "same" once anything less strictly defined in officially endorsed resources than control flow, structure composition, and integer arithmetic).

&mut str had no safely constructable values for a decent period of Rust's history. What would be wrong about function items being &'static fn(), and not exposing any way to construct/use &mut fn()? You'd still be able to name the &mut fn() type.

Provenance is unfortunately an overloaded term — it both is a catch-all for "extra shadow state on pointers that is inherited from the source pointer/place, and is typically invisible context lost when casting a pointer to intptr_t or equivalent" (which includes even exotic things like alternative address spaces and long/short pointer dimorphism) or to talk specifically about the state which governs whether a pointer is still allowed to access the object living at the matching machine address.

The "Rust has pointer provenance" decision was very vague on what provenance Rust pointers have, just that pointers do have some sort of provenance, and that pointers comparing equal doesn't mean that both are necessarily equally valid to dereference.

You can argue that address space is part of the Rust type, thus shouldn't be considered as provenance; I might even agree with you. "Provenance isn't a thing on the concrete machine" is another axiom in some definitions of provenance. But asserting that it must not be provenance and must be a secret third thing is, strictly speaking (:cheeky:), not true.

IIRC Miri uses "provenance" for "the bit which doesn't exist if you disable runtime borrow checking" and will track "function address" versus "data address." (But Miri still isn't the arbiter of what is/isn't provenance; if anyone is, it'd be the authors of the provenance paper for C.)

5 Likes

there wouldn't be anything wrong with it, i guess. if that was the only downside to that approach, i probably wouldn't be talking about it, but there's also the tiny issue of

&mut fn() already has a meaning in current rust

namely:

fn noop() {}

fn main() {
    let mut f: fn() = noop;
    let fp: &mut fn() = &mut f;
}

if we're going to completely upend current semantics, we better make sure that the new semantics are absolutely ideal... and i would argue that because of the weirdness of &mut fn(), they are not.

While I'm actually in agreement that the "function reference is now spelled &'static fn() isn't an ideal solution, I still fail to see how &mut fn() is such an important sticking point for you. (As opposed to, say, fn() on its own changing meaning, or even &fn().)

Let's imagine the hypothetical Rust for a moment. In edition 20XX, fn() is an unsized type implementing Pointee, allowing code to hold &fn() or &mut fn(), but it doesn't implement any of the rest of the default bound hierarchy, so e.g. size_of_val::<fn()> is not a well-formed generic instantiation. It is impossible to have a fn() value which is not behind a reference or pointer. When code is written that mentions fn() not behind a reference, causing an error due to missing bound on the type, a structured, machine applicable suggestion is provided to use &'static fn() instead. (The assumption being that most code doesn't care about supporting unloading, and if a different lifetime should be used, that should be clear at the call site.) The path expression naming function items produces a place with a zero-sized existential impl Copy + Fn() + CoerceInto<&'static fn()> type. &fn(): Fn(), and JIT libraries can hand out &'_ mut fn() references when they compile a function, to indicate the now-unique ownership over that function's machine code region. &[mut] fn() isn't a particularly useful reference type, since the only allowed things to do with dereferenced fn() places is to ignore them (bind to _) or simply immediately reference them again, but it does allow generic code that works with references to also work with function references; some JITs to instead hand out Handle<fn()> and reference count the function bodies and system, since proper support for variadic generics is still deferred to the indeterminate future.

My complaint about fn()-not-pointer is that making everyone write references has no realized benefit (as compared to attaching a lifetime another way). The one benefit I have actually seen argued for is a vague sense of uniformity and allowing FFI code to use raw pointers (and thus nullable semantics matching the C API more directly) instead of Option<fn()>. There's no benefit to other code that can't be accomplished equally with using fn()-as-reference in generic API.

None of this complaint is about &mut fn(), which is exactly the type you would expect it to be in such a world — a unique reference to the machine code, but unsafe to manipulate in any way due to the crazy reality of writing machine code at runtime, especially without knowing a slice length instead of attempting to somehow infer it by looking at the machine code. (E.g. is it a local jump or a tail call? Who knows!) And on Harvard architectures, the uniqueness can still be meaningful, but an unsafe Oxford cast extra bogus.


TL;DR and an answer to the title question:

TL;DR: the issue isn't &mut fn(), it's the lack of use case for unsized fn() that isn't behind a reference. So why bother breaking code for no actual benefit.

3 Likes