How much do you think about borrowing

Rust is the first language I've used that has such a strong concept of ownership. In python or scala, I would generally write the code and then start fixing the compilation errors, which where often syntax errors (forgot semi-colons or an argument). With rust, many of the errors involve satisfying the borrow checker. In this case, fixing the compilation errors frequently involves adding/removing a bunch of *, &, .clone(), .copy() and maybe Box and (A)rc until the code compiles.

This often feels like I'm mindlessly filling out bureaucratic paperwork to do something that in other languages, the compiler is able to figure out on it's own. (The other languages can probably figure it out much easier since they lack the strong ownership model and its benefits.).

Is there more that I should be actively thinking about here? Is there some clear benefit when I go back later to read the code? Or is this just some mindless overhead that I need to put up with to get the benefits of the ownership model?

Borrowing is definitely not bureaucratic paperwork. It's the bread and butter of memory safety. It is the system of rules that lets you use indirection safely, by guaranteeing that pointers always point to valid data, and that mistakes related to shared mutability (such as iterator invalidation) are not made.

Other languages definitely don't "figure out" borrowing on their own. Instead, they simply don't care about or ignore these kind of errors.

Most mainstream high-level languages are garbage collected, meaning that you don't need to worry about lifetimes, because everything is always heap-allocated (with its omnipresent overhead). Few other languages such as C are not garbage collected and simply allow incorrect programs to compile without any notice if you screw up.

In addition, most non-pure languages ignore the problem of shared mutability altogether, resulting in runtime exceptions (try to mutate an Objective-C NSMutableArray while iterating through it) or, again, Undefined Behavior (if you do the same thing with a C++ std::vector).

5 Likes

This fighting with the compiler mostly goes away with practice. It took me a while to "get" it, but borrowing is not a chore any more. I can anticipate what will work and what won't work.

I don't "think" about it in a sense of "oh no, it doesn't work, what am I going to do now!?", but rather something straightforward like "I know another thread is going to need this data, so it's an Arc".

It's sort-of like "thinking" about balancing a bicycle. It may be hard when you start, and you can't ride a bicycle without balance, but once you get it, you only think where you want to go, and execution comes naturally.

You can't write Rust without getting borrowing and ownership correctly. But once you internalize it, and use appropriate programming patterns and data structures, it stops being a chore.

For example, if you don't know about self-referential struct limitation you can waste a whole day on it. If you do, you either avoid this pattern entirely, or know to use Arc or some other workaround, and get on with it.

There are exceptions:

  • once in a while I get surprised that some code doesn't compile. It's usually because the compiler sees a mistake in my logic (e.g. I wanted to use shared data without locking, or a long-lived thread could access to a temporary borrow). That's a good thing!

  • The hard cases are typically in generic code. Getting lifetimes right in higher-order abstractions is much harder than in regular code. This can be a chore and a puzzle.

17 Likes

Nowadays, whenever I write C or C++ code I'll get anxious about lifetimes and wonder how we ever survived without something so fundamental.

I had to dig through the WASM3 source code (a big C library implementing a WebAssembly interpreter) this evening and was reminded just how wild it is to have a massive web of raw pointers throughout your code. Especially when there aren't many comments or other indicators about whether something is owned memory or borrowed or whether one value must outlive another.

I've written my fair share of C, but even then it felt like I'd need to learn half the codebase just to make a PR that doesn't also introduce an obscure segfault.

TL;DR: Without explicit lifetime annotations and the borrow checker guiding your design, it's really hard to understand a program's ownership story. It's not pointless bureaucracy, it's a conscious part of API design that just isn't made explicit in other languages with manual memory management.

9 Likes

I definitely think about borrowing at the design and signature stage. Honestly, I have to do that in languages like C# too: Is this shared? Is it ok if I modify this object? Should I make this class immutable so I don't need to worry about it? Can I reuse that Stream?

In many ways it's easier in Rust because I can actually make those things explicit in the code. I don't ever need to pass leaveOpen: true because that's phrased as ownership instead, for example. (And for extra fun, you have to be careful that you don't accidentally pass that for append instead -- yay overloading.)

Then in the method body, I certainly forget .copied() on iterators or miss mut in places or whatever sometimes, but I don't really worry about those. I think of those like missing semicolons, where the compiler helps me out if I was inconsistent somewhere.

Admittedly, this does rely on adapting your brain to think of these things for the signature. But sometimes the difficult borrowing errors are a sign that the signature was where the problem was -- there was a nice example of that in Return a reference from the function - #6 by scottmcm

I think the important part is that it's just ignoring it in other languages. If I pass an array to another function in Scala, does it modify it? I don't know. If I'm a class, can I give out an array to a caller? If they might modify it, maybe I can't without copying it.

That leads to either defensive copying -- either from the cache or the user, or worst both -- or the occasional weird error where the value in the cache got updated and now some things

So there's absolutely an advantage from all this, even if it's not immediately obvious. As quoted in https://manishearth.github.io/blog/2015/05/17/the-problem-with-shared-mutability/,

My intuition is that code far away from my code might as well be in another thread , for all I can reason about what it will do to shared mutable state.

3 Likes

I very much doubt that. An enormous amount of thought and care has gone into the design of the language. It's true that sometimes the compiler almost surely knows what you really meant ( when you omit a mut or & somewhere for example ), but I trust the people who designed the language knew what they were doing. Mostly the compiler tells you what you left out.

1 Like

I would (and do) argue that ownership and borrowing exist in other languages. Here is an explanation I gave at strange_loop:

I think this is a stage most people (myself included) go through. It is the stage where the Rust borrow checker violently beats terrible variable-{lifetime, aliasing} habits out of programmers. One of the most horrifying experiences in learning Rust was realizing that most of my old C++ code had situations where I was iterating through a collection while modifying the collection.

I don't see this as 'mindless' paperwork. 99.9% of the time, after studying the issue, I end up agreeing with the Rustc compiler -- that I indeed was sloppy and I should fix my code.

I think the main benefit is all the memory corruption bugs one no longer has to deal with. Back in my C++ days, valgrind was my best friend. In using Rust, I don't recall using valgrind a single time.

4 Likes

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.