Pre-pre RFC: Generic effect system for functions

I've been thinking off-and-on about the various "effect system"-like ideas people have proposed for various function behaviors, like "this code won't panic", "this code won't block the async runtime", etc. Recently, I was reading about how the restrictions on Send and Sync are enforced through the library functions imposing requirements, not language magic, and it got me thinking about if one could do a similar thing with relatively minimal compiler support, and I've come to this idea:

All functions and methods can by annotated with effects through a syntax like:

#[effects(no_unwind, no_panic)]
fn wrapping_add(a: usize, b: usize) -> usize { .. }

Where the #[effects(..)] attribute is just a list of identifiers (feel free to bikeshed syntax and keywords).

From a language design perspective, the main requirement is that a function that uses an effect can only call other functions which also use the same effect. So, for example, this would be rejected:

// This function doesn't supply the attribute.
fn panics_if_given_zero(num: usize) {
    // note that the actual `panic!` in here is irrelevant to this example,
    // the compiler only looks at the lack of an attribute on the function.
    if num == 0 { panic!() }
}

#[effects(no_panic)]
fn wrong_effects(num: usize) {
    // Compile error: `no_panic` effect is required, but `panics_if_given_zero` doesn't have it. 
    panics_if_given_zero(num);
}

As an escape hatch, there's a new block hold_my_beer(..) (feel free to bikeshed the name and syntax, I think this would need to be a keyword) that allows you to call functions which are missing specific attributes:

#[effects(no_panic)]
fn will_not_panic(num: NonZero<usize>) {
    // `num` is non-zero, so this call can't actually panic.
    hold_my_beer(no_panic) {
        panics_if_given_zero(num.into())
    };
}

(this block is conceptually similar to unsafe in that it disables certain checks, but probably shouldn't be called unsafe since this isn't a safety requirement).

The escape hatch is meant to let you come up with whatever effects you want, and then you can still use other libraries that weren't written with that in mind (including using stdlib with some invented effect of your own), albeit with some extra effort.

Stdlib can annotate all of its methods with commonly-wanted effects, like no_panic, nonblocking, etc. And, since you can work around your dependencies not using an effect you want, we could always let the ecosystem experiment and annotate a new effect when we see demand for it rising in the ecosystem (would there be a demand for no_alloc? idk, but we could see what people do and figure it out).

I'm sure there's plenty of edge cases to figure out, like function pointers and traits. It already seems hard to me to figure out how to annotate all the methods on Result, since a lot of them would inherit effects supplied by one or more function arguments, which is why I marked this as a pre-pre-rfc. I'm wondering if anyone else has thought about doing this before (I feel like this is natural enough that I can't be the only one to have thought of it, though I haven't seen an approach like this discussed elsewhere), and what edge cases there might be that I'm missing that might make this harder than I think to implement, before I try to make a sketch of how it would interact with every feature in the language.

1 Like

You got it backwards. Effects are generally considered a capability you give to the function (e.g. the ability to unwind or panic, not the absence), and the requirement is that functions with effects are callable only from other functions with effects or from a top level enabler (e.g. for the async effect this would be an async runtime)

Lack of unwinding/panic can be a safety requirements for certain usecases (e.g. replace_with/take_mut).

In other cases there's no simple way to implement such escape hatch (see e.g. the async effect).


Honestly this just feels like an annotation system for linting rather than an effect system, since effectful functions have no way to actually perform their capabilities, so any more complex effect is not even implementable this way.

It's also missing the "hard part", that is polymorphism (handling generics/traits/function pointers/conditional effects/etc etc)

That's a good point. I thought orchestrating it this way would be better for the backwards-compatible introduction of these effects (e.g. annotation-free functions are currently allowed to panic, so that must be the default in the new system).

Yeah, maybe I should have been more explicit that this doesn't work for every effect, just ones that involve maybe or maybe not having access to something that normal Rust functions have by default.

Fair point, maybe I was overly optimistic in my name for this. In my view, this is controlling access to effects which are default-enabled (hence my use of names like no_panic, no_unwind, and no_alloc). So this doesn't work for effects which add abilities to default functions, like async fn.

Yes, hence why I called it a "pre-pre RFC", I figured my lack of having thought through the harder parts yet meant that it wouldn't deserve full "pre-RFC" standing. Edge cases that I can think that this would need to handle to be useful are:

  • Trait definitions can require their implementors use some effect (not usable for existing stdlib traits, unless we do more work to make it only required over an edition).
  • Trait implementations can opt in to effects their definitions don't require (like #[refine], but for these effects)
  • Tracking effects on function pointers (and letting functions require them on function pointers they take as input arguments)
  • Functions generic over a trait might have some effects depending on whether or not methods from their generics have those effects (including the simple case of Fn and its variants, but also other traits for full effect), for cases where the trait doesn't require it but some implementation might have it.
  • Interactions with dynamic trait objects

Note that const already works like this: a const fn is an fn without the “runtime” effect.

This is almost enough for enforced async signal safety.

I wish that could be done using auto-traits, though that may lead to a bunch of issues, especially in regards to Edition boundaries, see Changing the rules of Rust.

You'd still want some kind of #[effects(no_panic)] to mark in the function signature that making the function panic (for example) is considered a breaking change (as it would remove the Auto-trait).

Unless I'm mistaken most functions would ideally be generic over effects/keywords (async, can_panic, ...), either with an opt-in (async+await) or an opt-out (no_panic attribute/keyword). Though that could negatively impact compile times.

Wouldn't the addition of auto-traits to these effects solve a bunch of issues:

  • Adding a Const marker trait could allow const functions to be generic over other functions: const fn(inner: impl FnOnce() + Const) (though there may be other things preventing this)
  • Adding an Async marker trait could solve the MaybeAsync issue, at least in terms of generics + passing in closures/functions.
  • CanPanic
  • Leak
  • ...

This would require an autotrait to be able to change the signature of a method on another trait (e.g. F: Const would allow <F as FnOnce()>::call_once to be const). This is a pretty big change!

Moreover the Async/CanPanic ones are even more problematic: if F: FnOnce() -> T then you can use the call value of type F and get back a value of type T, or pass it to functions expecting F: FnOnce() -> T, but if F: Async this no longer works, meaning that adding a trait bound would be able to change the capabilities of a type. The only case where this currently happens is ?Sized, which is not a trait but instead an opt-out of a default trait bound, and at least in that case it only removes capabilities while in your case you're changing them.

Overall IMO autotraits are the wrong way to go here because they are fundamentally properties of types, while what you want here is to influence the properties of a trait implementation/bound.

Maybe Async isn't the right name. My intention was a CanBeCompiledAsAsyncStateMachine. Not that it is compiled that way. To pass it as an argument (or store it in a struct) the compiler has to decide if it needs the async-state-machine compiled function or a "normal" function at this position (you can't mix them of course).

Adding the trait bound thus wouldn't restrict but only extend what you can do with it, so I'm not sure if this "change of capabilities" is relevant.

Aren't functions (at least from the users perspective, when passed to an impl FnOnce()) basically a type? They implement the Fn* traits, they can be Send (depends on which types a closure captures), they can have a lifetime (e.g. closures that borrow), they can contain data (e.g. closures). I'd argue that whether or not a function can panic or by compiled to an async function/state machine is a property of this single function (which behaves like a type). A property which can then be used in a trait bound to restrict which functions can be used, similar to how they restrict which types can be used for "normal"/other generics.

I agree that there is a difference between struct/enum types and functions, but as FnOnce shows: Functions can already have trait impls (or at least something that looks like them in trait bounds). So why shouldn't their properties be usable in trait bounds? They might be handled differently by the compiler (e.g. as bit flags; if actually having it as a trait doesn't make sense), and they are probably relevant at a different time during compilation, but (without knowledge of compiler internals) I don't see a reason against using the syntax from traits like Send: impl FnOnce() + SomeProperty.

Am I missing something?

https://github.com/rust-lang/rfcs/pull/3762

1 Like