My personal view of this is that, in a “mathematical function” interpretation sense, a &mut T
parameter to a function ought to be interpreted as passing a T
in and getting a new T
out. And in practice, it is almost equivalent, you can adapt either way, one of the adapters requiring a helper function as defined e.g. by the replace_with
crate:
fn foo<T>(r: &mut T) { todo!() }
fn foo_adapted<T>(x: T) -> T {
let mut y = x;
foo(&mut y);
y
}
fn bar<T>(x: T) -> T { todo!() }
fn bar_adapted<T>(r: &mut T) {
replace_with::replace_with_or_abort(r, bar)
}
Rust Explorer
The only actual difference between the two signatures is the behavior when panicking. The &mut T
will leave the T
intact (though possibly in a weird / unexpected state) whilst in the T -> T
version, the T
would be dropped on panic. Since panics are often not caught (or only caught on a much higher level where the T
would have been dropped either way), this distinction commonly doesn’t matter all that much.
Of course, &mut T
return types are a bit harder to assign mathematical meaning [in terms of “pure” mathematical functions] to (though I’m certain someone has thought about how that might be possible). In fact, I believe that one should consider all rust functions that only do mutation through &mut T
reference (or to local variables) as pure functions without side-effects. In my view, side-effects only come into play once IO is involved, or once “interior mutability” primitives are used.
Note that the behavior on panicking is really the only semantic difference for fn(&mut T)
vs fn(T) -> T
in Rust. This is unlike other languages like C++. It’s essential that &mut T
promises exclusive access to its target which allows us to interpret it as a side-effect-free operation. We don’t modify anything that anyone else could observe before the function returns, and this exclusive access is proven by static analysis… so, long story short, reasoning about &mut T
parameters in Rust is just as easy as reasoning about passing around immutable values. In languages like C++, passing a pointer or reference will however always immediately introduce at least the possibility of shared mutable access, side-effects affecting far-away parts of out program, it only stays easy to reason above if you yourself figure out that there was unique (un-shared) mutable access, but you’ll need to analyze your program yourself to come to that conclusion, and the compiler won’t help you. Which is a shame, because in practice, you quite often do have exclusive access so reasoning about programs becomes harder unnecessarily in such cases, just by the fact that the compiler doesn’t give you any certainty about whether or not your in the “easy to reason about” case. Maybe writing good comments in such cases can rectify the situation, though that’s then more effort on the writing the program part, though probably it’s worth it.
The case of a &mut …
argument being a buffer to be filled fits the easy case to interpret case of &mut T
being a parameter, not a return type, so the question that arises is: Why not pass a buffer in, and return it back modified? And maybe the first question before that, why pass in an empty buffer in the first place? The last question is easily answered: Performance reasons. By passing in an existing buffer, one can possibly use existing capacity in the buffer (e.g. if it’s a String
or a Vec<T>
) and avoid re-allocations, particularly in case the buffer is cleared and re-used for multiple calls.
Why not pass it in and back out? I’d say there are four aspects I can come up with:
- moving has some small overhead, too;
&mut T
is simply (slightly) more efficient than passing in and back out an owned T
- convenience: mutable references are convenient to work with, especially when your functions are written in an imperative style and local variables shall be mutated. Then it’s convenient to be able to – say – call
v.push(…)
on your v: Vec<T>
variable instead of needing some kind of v = v.push(…)
- generality: there are the two alternatives
T -> T
and &mut T
in the language; with that as a given, as you can witness above, it’s more straightforward (especially if you don’t like the possibility of aborting your program) to apply a fn(&mut T)
in a situation where you need a T -> T
transform than the other way. So API tends to use &mut T
to be more generally useful
- why have
&mut T
at all? As noted above, &mut T
return types give functionality that’s hard to express differently. As far as I’m aware, in Haskell the lens
package is able to provide somewhat comparable functionality to something like a &mut S -> &mut T
function in Rust (and – admittedly – lots of other functionality that mutable references in Rust don’t provide), but that package is infamously hard to understand, so I believe the capabilities that mutable references are useful and easy to understand, so I’m glad we have them and use them. Examples for &mut S -> &mut T
are e.g. accessor functions to private fields. Or e.g. array indexing (where an additional index argument is involved, too). I suppose, the simplests equivalent of the fn(&mut Vec<T>, usize) -> &mut T
indexing of Rust for a hypothetical Vec
type in Haskell would need to look like Int -> (t -> t) -> Vec t -> Vec t
? Ah wait, no, that one doesn't allow us to just read a value at that index, unlike the Rust function… guess I’ll have to re-read a lenses tutorial to freshen up on how properly offer such an abstraction in Haskell….
There is certainly more aspects to how &mut T
is useful, but I don’t have time to think them through entirely right now. One thing that sometimes comes up, and in particular often with the case of “buffers” you mentioned, is a parameter type like &mut [u8]
, which is a reference to an unsized type, so [u8] -> [u8]
by-value is not even an option.