API design: moving in and out vs taking (mutable) reference

Hello,

For many numerical algorithms it is natural to consume the (typically large in terms of memory) argument while processing it. For example, the computation of eigenvalues of a matrix, as exposed by the efficient LAPACK library, overwrites the input matrix A.

As a special case of the above, quite often the output has the same type and size as the input. QR decomposition, for example, is exposed by LAPACK such that the input matrix A serves as the output as well.

How should one best expose such algorithms in Rust?

Naively, it seems to me that the most idiomatic solution is moving the input in, consuming it as fit, and then moving the output out. This seems to correspond best to what is actually going and thus should allow the user (and the compiler) to take the best decisions.

However, the popular ndarray-linalg crate, does not quite seem to take this path:

Having three functions for each algorithm seems to me like a lot of unnecessary boilerplate.

Independently, returning a reference to the mutable input reference seems redundant and potentially confusing as well.

I would be grateful if people with insight could shed some light on these questions. Is ndarray-linalg actually following best practices? Would a simpler and more idiomatic API be possible?

Thanks!

If the most convenient and most efficient APIs do not coincide, you should expose both. You shouldn't be making trade-offs on behalf of the user.

If your problem most naturally lends itself to an in-place algorithm, then make that the fundamental implementation. Then, do provide by-value and/or by-reference functions as well, simply implemented in terms of the fundamental one. They are not boilerplate, they remove boilerplate at the call site (you only write your library once, but it will have many users). People don't always want to have to pre-allocate buffers, that's a lot of noise.

The standard library in fact follows this philosophy by eg. providing io::read_to_string() and Read::read_to_string.

6 Likes

rug, which does big-integer arithmetic using gmp, is pretty similar. It exposes several different versions of very similar functions based on whether they move or borrow one of the operands. It can be a little awkward but I think it's a good compromise between readability and efficiency.

1 Like

Is there any established convention for naming in such situations? Would it be uncontroversial to just slap a _ref suffix at the end of the reference variants (or _mut in the case it's a mutable reference)?

Yes, I guess ref or by_mut_ref or even in_place could be sensible suffixes.

2 Likes

Thanks! My confusion stemmed from me wrongly believing that the most idiomatic interface to a function that modifies its argument (for example matrix QR decomposition) would be move-in-move-out.

This is actually not true. It would mean that someone holding only a mutable reference to a value (and not owning it), could not use that function.

It seems that it would be hypothetically possible to remove mutable references from the Rust language. Then functions taking a mutable reference would have to resort to move-in-move-out. But mutable references seem to exist precisely as a less cumbersome alternative to this pattern.
It seems to me that the right way to think about ownership is “who is responsible for destroying the thing”.

Thanks for the example. I understand their difference and the performance vs. convenience tradeoff with regard to the output buffer/string. However, why does io::read_to_string() consume the reader while Read::read_to_string only requires a mutable reference to it? This is not related to the aforementioned tradeoff.

This is not possible — or at least, you would have to invent a new mechanism to replace it — because if:

  1. move out of some place that is not a local variable (is some struct field or similar),
  2. call a function (with the value moved),
  3. and that function then panics,

then you have a problem because the place now contains de-initialized memory — the ownership of the value was transferred to the function which didn't return it. You'd need some way to automatically re-initialize the place, or to always have a placeholder value to put in temporarily (which implies that all types need placeholder values, which weakens the type system).

1 Like

It's not always possible if, for example, function projects from the &mut Struct to &mut Field - in your description it would mean that the function would destroy the struct and return only the field, which is not always possible.

No, not really. Indirection is a fundamental necessity for all sorts of data structures and algorithms. How do you propose a subrange of an array would be overwritten, for example? You can't just move out of the middle of the array (not only because you'd need to replace a reference-to-slice, a DST, which is not possible today by value, but also because dynamic partial moves like this would almost always be impossible to keep track at compile-time, exactly because they are dynamic).

If R: Read then &mut R: Read as well. Therefore io::read_to_string() doesn't (need to) consume the reader. Here, taking the reader by-value makes the API simpler and more general. If that function took a mutable reference, then only references could ever be passed. With the actual signature, references and non-reference values can be passed just as well.

In contrast, the io::Read::read_to_string() method needs to take a mutable reference because most of the time, you don't want to transfer ownership of a writer; instead, you want to reuse it. Making the trait method accept a mutable reference makes the auto-referencing mechanism of method call syntax kick in, providing a convenient and correct default. Had the method been declared as read_to_string(self) -> io::Result<String>, the user would usually be forced to take an explicit reference and write (&mut r).read_to_string(), which is just plain ugly.

You seem to be confused about generic type notation. A generic type variable R can stand in for any type. It doesn't mean "a non-reference type". Just like a variable x in algebra can stand in for "any number". If it's a result of adding two other numbers, it's still just a number and can be denoted with x, you don't have to write it as a + b.

2 Likes

Indeed. I only had calling of functions with local variables and temporary values in mind. For that application, I guess that move-in-move-out is semantically equivalent to taking a mutable reference.

Thanks for clarifying this. I have a lot of experience with C++ templates (from the time when these were untyped, i.e. before concepts), but still very little with Rust generics. Reading R: Read indeed tricked me into believing that R may not be a reference.

You mean "you don't want to transfer ownership of a reader", right?

I must be confused again: had the trait method been defined to take self (read_to_string(self) -> io::Result<String>) and not a reference, how would it be possible to evoke it for a reference: (&mut r).read_to_string()?

In the implementation of Read for &mut R where R: Read, Self (which is the type of a self receiver) is &mut R.

So if you have some owned reader: R and a method that takes self, you need to pass &mut reader and not reader to avoid giving away ownership. Given how method resolution works, you would need the unergomatic (&mut reader).method(...) (or to not use method call syntax).

It works in the same way as C++ templates. If you have a template<typename T>, you can make T a pointer or a non-pointer. "All types" include pointers.

Yes.

Again, because R: Read implies &mut R: Read. Substitute Self = &mut R and now self has type &mut R. It's exactly the same thing. self is simply sugar for self: Self where Self is once again a type variable denoting an arbitrary type (the implementor).

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.