Contravariance in function pointers

Hey folks,

can maybe somebody explain me why I can do this:

fn foo<'a, 'b, T>(_: &'a &'b (), v: &'b T) -> &'a T {
    v
}

fn bar<T>() { 
    let fp1: for<'a, 'b> fn(&'a &'b (), &'b T) -> &'a T = foo;
    let fp2: for<'a, 'b> fn(&'a &'static (), &'b T) -> &'a T = fp1;
}

but not this:

fn foo<'a, 'b, T>(_: &'a mut &'b mut (), v: &'b mut T) -> &'a mut T {
    v
}

fn bar<T>() { 
    let fp1: for<'a, 'b> fn(&'a mut &'b mut (), &'b mut T) -> &'a mut T = foo;
    let fp2: for<'a, 'b> fn(&'a mut &'static mut (), &'b mut T) -> &'a mut T = fp1;
}

?

Maybe this has something to do with the invariant behaviour of mutable borrows but my assumption was that due to the contravariance in the input of fn-pointers I should be able to make the coercion also with mutable borrows when it works with immutable borrows...

Hint: This code came up while I was playing around with #25860, so maybe there is some buggy behaviour involved here too?

Regards
keks

fp2's signature fn(&'a mut &'static mut (), ... implies that

  • after the function is called, the pointed-to location will contain an &'static mut (), and
  • the function can swap it for any other &'static mut () if it likes

but fp1 can swap in an &'b mut () (perhaps reborrowed from the &'b mut T somehow) which, if done with fp2's signature, would leave the location with something that lives only for 'b, not 'static. It’s difficult to demonstrate this with () and T, but here is a version with concrete types, using transmute to substitute for the disallowed function pointer cast, that is flagged as UB by Miri:

#[derive(Debug)]
struct St(i32);

fn foo<'a, 'b>(p: &'a mut &'b mut St, mut v: &'b mut St) -> &'a mut St {
    std::mem::swap(p, &mut v);
    v
}

fn main() { 
    let fp1: for<'a, 'b> fn(&'a mut &'b mut St, &'b mut St) -> &'a mut St = foo;
    let fp2: for<'a, 'b> fn(&'a mut &'static mut St, &'b mut St) -> &'a mut St =
        // SAFETY: No. This demonstrates a cast that is prohibited.
        unsafe { std::mem::transmute(fp1) };
    
    let mut static1: &'static mut St = Box::leak(Box::new(St(1)));
    {
        let mut place2 = St(2);
        fp2(&mut static1, &mut place2);
    }
    dbg!(static1); // UB: dereferences dangling pointer to place2
}

Of course, in your original code, () is zero bytes, so it would be impossible to cause this particular UB, but the subtyping/variance rules have no exceptions that would make them more permissive just because the referent is a zero-sized type.

1 Like

It is due to invariance. Invariance in a contravariant (or covariant) position results in invariance, below which you can't "escape" invariance.

//  ______________________________________________________________________
// |         __________________________                                   |
// |        |         ________________ |  _________________               |
// |        |        |        _______ || |         _______ |              |
// | covar  | contra | invar | invar ||| | contra | invar ||              |
         fn( &'a mut  &'b mut    _     ,  &'b mut     _     ) -> &'a mut _

I don't know where your intuition came from or how much of the issue you've read, but note that contravariance is not required for the unsoundness.

2 Likes

Thank you for your answers and explanations. :slightly_smiling_face:

@quinedot
Yes, I've read the blog post from lcnr where he shows that you can construct the unsoundness also through covariance. :+1: The actual problem was my understanding of variance in this context. Due to contravariance in T in fn(T) -> U I naively assumed that you can just coerce T to something which is "more", but as you showed you also have to respect the inner variances of T.

EDIT:

But this works:

fn foo<'a, 'b>(_: &'a mut &'b mut ())  {
}

fn bar<T>() { 
    let fp1: for<'a, 'b> fn(&'a mut &'b mut ()) = foo;
    let fp2: for<'a> fn(&'a mut &'static mut ()) = fp1;
}

Might this be the case because I reduced 'b here from a higher-ranked lifetime to a regular lifetime? In the initial example 'b still exists as higher-ranked lifetime for fp2. I'm confused. :exploding_head:

You can see this as being connected: when you change a lifetime in an invariant position, the resulting type is “more” in one way and “less” in another way, so it ends up being neither a sub- nor supertype of the original type.

In this case you are substituting 'static for all occurrences of 'b, so this is just narrowing for<'b> to a single concrete case out of the infinite set of cases for<'b> refers to, which is valid. Similarly, we can substitute all occurrences of 'b in your original example and get one that compiles:

fn bar<T: 'static>() { 
    let fp1: for<'a, 'b> fn(&'a &'b (), &'b T) -> &'a T = foo;
    let fp2: for<'a, 'b> fn(&'a &'static (), &'static T) -> &'a T = fp1;
}
2 Likes

Thanks! Ok just as I suspected. This works also with the version with mutable borrows but in the immutable version you mentioned you can just coerce one higher-ranked 'b to 'static

fn foo<'a, 'b, T>(_: &'a &'b (), v: &'b T) -> &'a T {
    v
}

fn bar<T>() { 
    let fp1: for<'a, 'b> fn(&'a &'b (), &'b T) -> &'a T = foo;
    let fp2: for<'a, 'b> fn(&'a &'static (), &'b T) -> &'a T = fp1; // works!
}

... and it works.

That is/was one of the main reasons of my confusion but as far as I understand it now it should work because of the covariance in the shared references when the reason that this:

fn foo<'a, 'b, T>(_: &'a mut &'b mut (), v: &'b mut T) -> &'a mut T {
    v
}

fn bar<T>() { 
    let fp1: for<'a, 'b> fn(&'a mut &'b mut (), &'b mut T) -> &'a mut T = foo;
    let fp2: for<'a, 'b> fn(&'a mut &'static mut (), &'b mut T) -> &'a mut T = fp1; // error
}

... doesn't work is the invariance in the exclusive references.

Is that right? If so, I think I got it.

Yes, that all sounds right.

1 Like