Confusion over different behaviors of mut/immut references' lifetime

The following code does not compile

use std::{
    fs::File,
    os::fd::{AsFd, BorrowedFd},
};

pub struct Foo<'fd> {
    _fd: std::marker::PhantomData<BorrowedFd<'fd>>,
}

impl<'fd> Foo<'fd> {
    pub fn new() -> Foo<'fd> {
        Foo {
            _fd: std::marker::PhantomData,
        }
    }

    pub fn insert1(&mut self, fd: BorrowedFd<'fd>) {}

    pub fn insert2(&self, fd: BorrowedFd<'fd>) {}
}

fn main() {
    let file = File::open("Cargo.toml").unwrap();
    let mut foo = Foo::new();
    foo.insert1(file.as_fd());  // line 1, foo.insert2(file.as_fd()); is OK
    drop(file);
    drop(foo);
}

Error message is

error[E0505]: cannot move out of `file` because it is borrowed
  --> src/main.rs:26:10
   |
23 |     let file = File::open("Cargo.toml").unwrap();
   |         ---- binding `file` declared here
24 |     let mut foo = Foo::new();
25 |     foo.insert1(file.as_fd());
   |                 ---- borrow of `file` occurs here
26 |     drop(file);
   |          ^^^^ move out of `file` occurs here
27 |     drop(foo);
   |          --- borrow later used here

However, if line 1 changes to

foo.insert2(file.as_fd());

it compiles. I don't understand this behavior and I expect neither should compile. The relevant borrow is about file variable, and both insert1 and insert2 assign the borrow of file as long as 'fd. Why changing self 's mutability changes file's borrow's lifetime?

It's variance.

A mutable reference allows bidirectional data flow: you can both read from and write to it. This means that it has to exactly preserve any lifetime parameters of its referent; it's invariant.

In contrast, an immutable reference is covariant: wherever a &T<'short> is expected, you are allowed to pass a &T<'long>, as you can only ever read from the reference.

Your lifetime declarations on the impl block, the type, and the signature of the insert1() method together mean that insert1() called on a &mut Foo<'fd> can only ever accept a BorrowedFd<'fd>. In contrast, insert2() called on a &Foo<'shorter_than_fd> can also be passed a BorrowedFd<'fd>. This means that the mutable version must borrow the receiver for exactly as long as the BorrowedFd is valid, whereas the immutable version is allowed to borrow the Foo for a shorter scope.

2 Likes

Thanks but I am not sure how it explains the error. The error seems to be the borrow of file spans from line 25 to line 27, so at line 26 moving file is an error.

That insert2 compiles means the borrow of file stops before line 26 even when the signature of insert2 makes the lifetime of borrow of file as long as 'fd.

I am not sure how lifetime of borrow of foo is related to the error.

While it feels that lifetimes act differently that's not the case. Lifetimes are not different. References are different.

Note the difference:

    let mut x = 42;
    let r1: &i32 = &x;
    let r2: &i32 = Clone::clone(&r1);
    println!("{x} {r1} {r2}");
}

Works.

pub fn main() {
    let mut x = 42;
    let r1: &mut i32 = &mut x;
    let r2: &mut i32 = Clone::clone(&r1);
    println!("{x} {r1} {r2}");
}

Doesn't work.

It's about uniqueness, not mutability.

Because you can take shared reference and create another shared reference, with different lifetime it makes sense to short-circuit that process and pretend that when you pass shared reference around you may shorten it's lifetime (extending it wouldn't be sound, of course).

But unique mutable references… they are unique! You can not clone them! And that is why they couldn't change their lifetime quietly, behind the scenes. That wouldn't be sound.

What is fd in your mind? When you try to unwing such cases it's usually best to first write expanded version and then reason about it:

fn main() {
    let file = File::open("Cargo.toml").unwrap();
    let mut foo = Foo::<'_/*a*/>::new();
    Foo::<'_/*b*/>::insert1(&mut foo, file.as_fd());  // line 1, foo.insert2(file.as_fd()); is OK
    drop(file);
    drop(foo);
}

Note that there are not one lifetime fd, but many. And if you used insert2 lifetimes a and b may be different. Lifetime a goes from definition of foo to drop(foo), lifetime b goes from insert2 to drop(file) and there are no conflicts.

Yes, that's exactly where variance kicks in. Shared references being invariant means that behind the borrow automatically created by the compiler when calling insert2(), the lifetime parameter of Foo can be shorter than 'fd. A temporary borrow is thus created and subtyping makes the compiler coerce &'tmp Foo<'fd> to &'tmp Foo<'shorter_than_fd>.

In contrast, taking a mutable reference means that the reference created by the compiler must have type &'tmp Foo<'fd>, implying that 'fd is at least as long or longer than the borrow of foo. (This is an implicit constraint – a reference must never be held for longer than its referent, because that would be the very definition of a dangling reference.) Furthermore, since the signature of File::as_fd() also ties the lifetime parameter of the returned BorrowedFd to its receiver of type &File, this in turn also implies that the file must be valid and borrowed for at least as long as the foo exists, which is exactly what your code violates by dropping the file before the foo.

1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.