Pre-RFC: Relax fn pointer lifetimes

This proposes a new amendment to RFC 1214.

General Proposal

Currently, given a function with type fn(T1..Tn) -> T0, where T1: 'a, .., Tn: 'y and T0: 'z, a pointer to said function is bounded by 'fn: 'a + .. + 'z. This RFC proposes to amend RFC 1214 to render this bound only as 'fn: 'z, as the argument bounds are unnecessary for soundness.

This does not propose to add the same relaxation to any other types or traits.

This PR was motivated by rust-lang/rust#80317. A simple example of code that currently fails to compile:

pub fn foo<'a, T: 'a>() {}

pub fn bar<'a, 'b>() {
    foo::<'a, fn(&'b u32)>(); // Errors
}

Soundness

Why is this change sound? An fn pointer that outlives any argument lifetimes cannot be called, as arguments can no longer be constructed / have live references. The lifetime checking of the body is unaffected, so this doesn't induce any changes to the lifetime checking of the function/stateless closure itself.

Open Questions

Can we remove the bound on the return type as well? This seems like it should be sound, as any attempt to call the fn would fail WF check on the use of the returned type. The one situation I'm not sure about is one where the function can be called, but its return is not used. I'm not sure if that would pass the lifetime check, and if it does, then it could execute arbitrary code with unsound lifetimes.

Can this be extended to Fn/FnOnce/FnMut? This would require special-casing these traits, and add limits on possible future extensions to them. Also, as implementers of those traits may have arbitrary extra data attached, they may access state similar to closures. I'm not sure if the borrow checker would handle lifetime checking for that correctly, if changed.

7 Likes

Can we remove the bound on the return type as well? This seems like it should be sound, as any attempt to call the fn would fail WF check on the use of the returned type. The one situation I'm not sure about is one where the function can be called, but its return is not used. I'm not sure if that would pass the lifetime check, and if it does, then it could execute arbitrary code with unsound lifetimes.

I'm not sure that it would be unsound if the output is unused, assuming that the borrow checker allows it. As far as I can tell, for any function fn(T) -> U, its output is going to live as long as its input, simply because there is no way to name a shorter lifetime. Sure, you could do fn<'a, 'b: 'a>(x: &'b u8) -> &'a u8 { x }, but that function can still be called with 'a = 'b. Or you could implicitly convert a fn() pointer to a shorter output lifetime, but again you're just hiding the fact that the return value would really live longer. Since it's impossible to call a function pointer that has outlived its input types it seems like you shouldn't be able to cause problems this way.

Can this be extended to Fn/FnOnce/FnMut ? This would require special-casing these traits, and add limits on possible future extensions to them.

Would it break things to have no special case, and say that dyn Trait<T> is allowed to outlive T, for any trait? Unfortunately, doing the same for associated types would break backwards compatibility because the borrow checker infers <T as Trait>::AssociatedType : 'a if T: 'a, so I don't think it's possible to remove the bound on closures' return types.

Also, as implementers of those traits may have arbitrary extra data attached, they may access state similar to closures. I'm not sure if the borrow checker would handle lifetime checking for that correctly, if changed.

I thought that this was handled by the dyn Trait + 'a syntax, to state the whatever extra data is attached must outlive 'a. And if 'a isn't specified then it defaults to 'static.

Another small weirdness from this is that it creates a situation where a subtype of a type cannot be used in place of the that type. That is, contravariance gives that any fn(&'a u8) is also a fn(&'static u8), but the former is only considered to live for 'a while the latter lives for 'static.

fn check_if_static<T: 'static>(_: T) {}

fn print<'a>(x: &'a u8) {
    println!("{}", *x);
}

pub fn test<'a>() {
    //check_if_static::<fn(&'a u8)>(print); // Errors
    check_if_static::<fn(&'static u8)>(print); // Compiles
}

(Playground)

This would be unsound, because fn pointers are used to enforce variance without losing auto-traits:

  • PhantomData<fn(T) -> T> is used to enforce invariance
  • PhantomData<fn(T)> is used to enforce contravariance
  • PhantomData<fn() -> T> is used to enforce covariance (non-owning)

in a number of crates. Removing this now would break all of these crates by making them unsound. I think we could change this in a new edition though

I don't see how it would break this. A fn(T) -> U would still have to be covariant in U and contravariant in T since you shouldn't be able to pass an input with too short a lifetime or assume that the output has a longer lifetime than it does. The change would just be to the lifetime of fn(T) -> U, not the subtyping relationship.

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