Two mutable references to the same variable and their lifetimes

Hello Rustaceans,

I'm in need of some help understanding a case where two mutable references to a mutable variable are passed to two different functions, in main (the whole code is at the bottom):

    const TEST_PATH: &str = "./mnist_test_data/mnist_test.csv";

    let mut data_rdr_0 = data_reader(TEST_PATH);

    let mut data_rcrds = data_records(&mut data_rdr_0);

    let n_rows = data_number_rows(data_rcrds);

    let n_cols = data_number_columns(&mut data_rdr_0);

;

I'm new to Rust and I come from Python, but it's my understanding that I can make any number of references I want to an immutable variable (&), but can only make one mutable reference to a mutable variable (&mut).

I searched for an explanation of why it was letting me pass &mut data_rdr_0 to the functions data_records and data_number_columns; meaning that the code is compiled.

I asked GPT-3 and its response was the following:

"In this code snippet, you are not actually making two mutable references to the same mutable variable. The data_reader() function is returning a Reader instance which is owned by the data_rdr_0 variable. The data_records() function is taking a mutable reference to that same Reader instance, while the data_number_columns() function is also taking a mutable reference to the same Reader instance. This is allowed by Rust because the lifetimes of the two references are distinct. The first reference (to the Reader instance) will be valid until the data_records() function returns, while the second reference (to the Reader instance) will be valid until the data_number_columns() function returns."

If this is true, it is my understanding that the function data_records returns at the line where it's assigned to the mutable variable data_rcrds and thus, for the lines below this declaration, I would be allowed to make a new mutable reference, right?

It seems that way because I've done it on the last declaration in main. However, I cannot switch the last two declarations, let n_rows = ... and let n_cols = ..., as it tells me what I already knew:

  • "cannot borrow data_rdr_0 as mutable more than once at a time"

Can anyone tell me where my reasoning fails? It seems that the let n_rows = ... declaration is the key behind my lack of understanding.

(For context, I'm trying to program a neural network using the MNIST data base. I did it in Python, but I want to do it in Rust for better perfomance.)

Thanks in advance!

Full code:

#![allow(unused)]

use csv::{Reader, StringRecordsIter, Error};
use std::fs::File;

fn data_reader(path: &str) -> Reader<File> {
    return csv::Reader::from_path(path).unwrap();
}

fn data_records(reader: &mut Reader<File>) -> StringRecordsIter<File> {
    return reader.records();
}

fn data_number_rows(records: StringRecordsIter<File>) -> usize {
    return records.count();
}

fn data_number_columns(reader: &mut Reader<File>) -> usize {
    return reader.headers().unwrap().len();
}

fn main() {

    const TEST_PATH: &str = "./mnist_test_data/mnist_test.csv";

    let mut data_rdr_0 = data_reader(TEST_PATH);

    let mut data_rcrds = data_records(&mut data_rdr_0);

    let n_rows = data_number_rows(data_rcrds);

    let n_cols = data_number_columns(&mut data_rdr_0);

    println!("{} by {}", n_rows, n_cols);
}

Lifetimes aren't in a 1:1 correspondence with scopes. The first mutable borrow ends when data_number_rows() returns (as its return value has no relation to the input lifetime – I guess this is the key observation you were missing), so on the next line there's no outstanding mutable loan anymore.

Generally, the borrow checker tries to be smart with lifetimes within a single function, and it tries to make your code compile to the best of its ability. Such inference is (a) purely intra-procedural, and (2) is not able to influence/move where variables are declared, but by tweaking the expansion and contraction of the possible liveness regions of each borrow, the compiler still has some headroom to make code compile that wouldn't if it purely relied on literal scopes.

2 Likes

GPT-3 is best treated as a plausible nonsense generator, and that's what you have received as a response in this case as well. In particular, the following sentences are incorrect:

In this code snippet, you are not actually making two mutable references to the same mutable variable.

You are clearly doing that.

This is allowed by Rust because the lifetimes of the two references are distinct.

It is not enough for the lifetimes to be "distinct". The relevant property is whether the compiler can prove that mutable borrows do not overlap with any other borrows.

The first reference (to the Reader instance) will be valid until the data_records() function returns, while the second reference (to the Reader instance) will be valid until the data_number_columns() function returns.

This first part of the sentence is incorrect. data_records returns a StringRecordsIter which borrows the reader, and therefore the reader must remain borrowed at least as long as the return value (data_rcrds) exists.

As the lines are ordered in your post, the borrows do not overlap, but if you reorder them, they do overlap.

When the lines are ordered the way they are in your post:

    // `data_rcrds `mutably  borrows `data_rdr_0`
    let mut data_rcrds = data_records(&mut data_rdr_0);

    let n_rows = data_number_rows(data_rcrds);
    // `data_rcrds ` no longer used after this point,
    // so it's okay for something else to mutably borrow `data_rdr_0` now

    // `n_cols ` borrows mutably `data_rdr_0`,
    // no problem since `data_rcrds` no longer needs it
    let n_cols = data_number_columns(&mut data_rdr_0);

If you swap the two rows, however:

    // `data_rcrds ` borrows `data_rdr_0`
    let mut data_rcrds = data_records(&mut data_rdr_0);

    // `n_cols ` borrows `data_rdr_0`,
    // but it's still borrowed by `data_rcrds` which needs it later
    let n_cols = data_number_columns(&mut data_rdr_0);

    // `data_rcrds ` used here, so we can't release the borrow before this point
    let n_rows = data_number_rows(data_rcrds);
3 Likes

That clears it up. I suspected that somehow the let n_rows = ... declaration was the reason behind my confusion, because it was taking ownership of data_rcrds. Now that I re-read what I wrote in main, it makes sense.

Nonetheless, I should learn more about references and lifetimes.

Thank you!

No. Taking ownership of data_rcrds is not relevant; it could just as well have been kept around and intact.

The key is that it's not used anywhere after that particular line – either directly or indirectly (which would be the case if data_number_rows() returned something with the same lifetime annotation).

Here's a fully-desugared, lifetime-annotated version of the code. The executable part in main() abuses block labels (which syntactically mimic lifetime annotations) to show where the inferred liveness regions of both borrows start and end.

2 Likes

First of all, thank you for the detailed feedback.

As well as H2CO3's reply, your reply clears up my mistaken understanding of what was going on.

This thread will be a reference point in my Rust learning process.

Again, thank you!

I will need to study the concepts of references, lifetimes, ownership in more depth.

I cannot say right now that I fully understand everything you laid out, but that version of the code you've made will surely help.