Lending both a reader and a writer at once

I've started trying to translate one of my more popular Python modules into Rust, and I've hit a problem with multiple mutable borrows. The high-level goal is to allow reading from one file and writing to another file at the same time while enabling cleanup of both files together. Initially, I wanted the Rust version to be used like this:

let mut inp: InPlaceFile = InPlace::new("file.txt").open()?;
let reader: &mut InPlaceReader = inp.reader();
let writer: &mut InPlaceWriter = inp.writer();
for line in reader {
    writeln!(writer, "{}", some_modification(line?))?;

but that's invalid due to multiple mutable borrows on inp coexisting, so obviously I have to rethink the API.

My next idea was to have InPlaceFile implement BufRead and Write itself, with the implementations delegating to the appropriate inner files, but that would preclude doing desirable things like the above where BufRead::lines() (or another consuming method) is called on the reader side while also writing out to the writer side.

Idea number three was to have InPlace::open() return a pair of a reader and writer, but then the call to save() would require passing both objects back (as it needs to close both filehandles), which seems like bad ergonomics and also just asking for trouble. I can't simply rely on Drop in place of save(), as I also need to support a discard() call as an alternative to save(), so at least one of those needs to be explicit.

Idea number four (actually idea number zero, before I looked into how Rust database libraries handle transactions for ideas) is for InPlace::open() to take a FnOnce(InPlaceReader, InPlaceWriter) -> Result<T, E> closure to which the reader & writer (or mutable references to them?) are passed, and then when the closure returns, InPlace does either save() or discard() depending on whether the Result was Ok or not. This would likely mean that open()'s return value would have to be Result<T, InPlaceError<T, E>>, where InPlaceError wraps all the ways that opening, saving, and discarding could go wrong. I'm not sure how ergonomic or idiomatic this idea is.

Are there any other strategies for implementing this that I've overlooked? What would be the best approach?

The high-level goal is to allow reading from one file and writing to another file at the same time while enabling cleanup of both files together.

I could be missing something here, but it looks like your code is attempting to read from and write to the same file simultaneously, which is not going to work (aside from any problems with multiple borrows).

Also, is there any reason why you couldn't just use io::copy from the standard library if you are indeed writing to a different file from the source?

Edit: Oh, it looks like you want to make modifications before writing, so io::copy wouldn't work.

It's not reading & writing to the same file; it's reading from one file and writing to a temp file that then replaces the original at the end.

I see, you've wrapped up all the in-place file modification functionality into your struct. It's been a long day, forgive me. :grinning:

Again, not sure if I'm on the right track, but are you looking to split a borrow on your InPlaceFile so that the reader and writer can be moved about separately?

1 Like

So making the reader and writer fields public instead of exposing methods and then doing this?

let mut inp = InPlace::new("file.txt").open()?;
let reader = &mut inp.reader;
let writer = &mut inp.writer;
for line in reader {
    writeln!(writer, "{}", some_modification(line?))?;

That works. I'm not entirely sure how much I like publicly exposing the fields, though, since I don't want users modifying them. (Though since I'm planning to give InPlaceFile a Drop implementation, that'll mean that readers & writers can't be moved out of one, so there will be nowhere for a user to get a different value to assign, so maybe it all works out.)

If you hand out a mutable reference to them, then the user can do whatever s/he pleases with them anyway. A getter returning a &mut reference to a field is effectively equivalent with the field being public. It can even be moved from/into using mem::swap(), mem::replace(), and similar functions.


Not sure if this helps in making a design choice, but I would like to point out that the Rust standard library decided to make read and write access to a file only require a shared reference to the file. See std::io::Read implementors and std::io::Write implementors. (There is an impl Read for &File and an impl Write for &File.) This means you can create a shared reference to a file and copy that reference and then use one copy as reader and one as writer.

1 Like

That's a good point, but I'm currently inclined towards wrapping the files in a BufReader and BufWriter before providing them to the user, and those require mutability. I suspect that most users are going to want to use at least a BufReader (largely for the lines() method), and doing the wrapping in the library seems more convenient.

Is there a consensus on whether file-opening libraries should apply buffering or leave it to the user?