Try   HackMD

Effect as bounds on behavior

Niko's proposal made me rethink how I conceive of effects. Allow me to riff on a mental model focused on subtractive effects like const, nopanic, noalloc, nothreads etc.

Frame

Effects, like lifetimes, could be inferred whole-program; we choose not to. A signature is therefore a contract: by writing const fn, I:

  • commit (compiler-checked) to never do a runtime-only operation in my function;
  • which allows you to use my function in const context.

const impl is similar: I commit to a bound on this impl and future changes of it, which you can therefore rely on.

Traits

Without traits, all is easy. The fun starts with traits:

  1. A trait declaration may want effect bounds on some or all methods (const fn method(););
  2. A trait implementation may want a stricter bound than the declaration (const impl Default for u32);
  3. A trait declaration may add future default methods, hence must commit to a bound on these methods' effects;
    • An impl thus cannot declare a stricter bound than this since it may unknowingly carry these default methods in the future.
  4. A function may want to bound the effects of a trait parameter (T: const Default);
  5. A function may have behavior conditional on its trait bound methods' behaviors, because it intends to call those methods (~const fn foo<T: ~const Default>());
  6. Drop is normally implicit but we must be able to talk about its runtime behavior; T: const Destruct and T: ~const Destruct serve this purpose.

Design decision: trait bounds cannot talk about the behaviors of individual methods (RTN-like), they can only talk about a global bound on all method behaviors.

Nightly behavior in this model

(I think!)

  • a const fn can have T: ~const Trait bounds, and declares it will never do runtime behavior outside of the behavior of the trait methods on such Ts;
  • a T: const Trait bound requires an impl that declared none of its methods would ever do runtime operations;
    • this implicitly requires the trait declaration to have declared that future default methods would also not do runtime operations.
  • a trait can individually bound each of its methods to be const, just like normal fns. This is orthogonal to constness of the trait itself;
  • a const trait Trait can have T: ~const OtherTrait bounds, and declares that none of its default methods will ever do runtime behavior outside of the behavior of the methods from the ~const OtherTrait bounds.
    • note that this is only a commitment on default methods;
    • this is necessary for impls of this trait to commit to a bound on its methods.
  • const impl Trait for X can have T: ~const OtherTrait bounds, and declares that none of its methods will ever do runtime behavior outside of the behavior of the methods from the ~const OtherTrait bounds.
    • because of future default methods, this requires the trait to be declared const trait.

Niko's proposal in this model

Niko's proposal doesn't quite fit this model, because it allows methods to opt-out of the bound on the whole trait/impl.

Why ~const fn?

This raises a question: what's the point of writing ~const fn in Niko's proposal?

Can we somehow make const fn be a usefully stronger commitment than ~const fn? (Note that this model in this doc does not allow for "function that is only callable at compile-time").

Why const trait?

const trait is the most confusing part of this. One may think it constrains impls or methods but no! It's only about default methods. I 100% expect users to assume that "const trait => the methods are const".

Even this shuold be fine:

const trait Trait {
    // The default body is `const`, but implementors may provide a non-const implementation
    fn method() -> u32 {
        42
    }
}

More effect bounds

nopanic would be awesome for unsafe code, the ability to temporarily move out of &mut T, and linear types.

I can imagine other bounds being useful for unsafe code in general, very curious if y'all have ideas. See Zulip thread.

Notation for conditional trait bounds

The real question is:

nopanic fn foo<T: Default>() -> T;

Should nopanic extend to the trait bound? Is it ok for this function to panic if default() panics? Is it ok if this function can't call default()?? Is this like imperfect derive???

impl<T: Default> Option<T> {
    // Here the API is clearly that the "Default" impl is something external, so I naturally understand `nopanic` to mean "as far as the implementation of this simple function is concerned".
    pub nopanic fn unwrap_or_default(self) -> T { ... }
}

-> maybe intuition pump with nopanic Trait<T>, do we expect propagation to bounds on T?

-> maybe the real question is what happens with multiple effects:

pub nopanic noalloc noloop const unwrap_or_default<T: Default>(self) -> T

would be madness to replicate the bounds.

In which cases to I actually want the unconstrained bound? If I'm not calling a method.