Feedback for Tutorial about Borrowing & Co

Hi there :crab: :crab: :crab:s!

I have to say, I had a hard time learning Rust and I still have. But after I had already given up an inspiration came to me, a new way to think about ownership and borrowing.

So I wrote a tutorial / article about it, to get clarity for myself and also to help out others who are struggling with it. Feedback is greatly appreciated :wink:

And also: What would be a good place to publish it?

Put it on pastebin for review, it's a bit difficult to read there, sorry.

Comments, in no particular order of importance:

  • It would be useful to clarify that references provide indirection. All this "binds to temporarily" phrasing is quite abstract and while true, I'm not sure if it's helpful for someone trying to understand memory management.

  • I think separating read/write access rules into two cases all the time is more confusing than if you had stated the rule all at once, as it is: mutable references may not alias. This simple fact implies that a new immutable reference can't be created while an existing &mut is active, and vice versa. However, stating this in terms of temporal ordering is more complicated than necessary – it doesn't matter which one you create first (the mutable or the immutable reference), mutable aliasing is disallowed either way.

  • An owner can never access its object directly, even though it may sometimes seem so"

    I don't understand what you mean by this. Are you implying that by-value access doesn't exist? That is not true.

  • This is not true; the code compiles just fine with let access_write. Perhaps you are confusing this with owner_write being declared as mut?

  • And you cannot use them to let different parts of your code know about each other.

    Again, it's not clear what you mean by this. You can absolutely use both ownership and borrowing to let different parts of the code communicate, by e.g. passing a value or a reference from one function into another.

  • Now all these rules create a lot of friction

    I would advise you to stick to facts. Whether one perceives exclusive mutability as "a lot of friction" is highly subjective; for the more experienced, it's pretty natural and desirable. The language and most well-designed APIs are built in a way that the exclusive mutability rule is not a problem in practice; it is a feature, not a bug.

  • Functional Programming: It is its own topic. Simply speaking, you create only functions which will copy or clone their inputs and own their outputs.

    I think you might be confusing functional programming with immutability or even purity. Those are not the same thing.

  • There are pointers with less restrictions: Box, Rc, Ref, RefMut, Arc, Cell, RefCell, Mutex

    Cell, RefCell and Mutex are not pointers. (Also, grammar nitpick: it's "fewer" restrictions, not "less".)

  • To disable compiler warnings, put ![allow(warnings)] (with the "!") at the start of your file.

    You shouldn't recommend turning off warnings. Especially while one is learning, it's overwhelmingly more likely that a compiler warning indicates a logic bug and it should be fixed rather than silenced.

  • And now for the best part, the solution I came up with for letting different parts of your code know about each other, while avoiding any trouble with the borrow checker: Use a string as an ID for every object you create, and store them all in collections!

    While this works, using strings everywhere is unnecessarily resource-intensive. Even though it might not matter most of the time (and it probably doesn't), it's not great to get into the habit of representing everything with strings by default. If one wants opaque IDs, a simple increasing integer is just as good (and more convenient to use since its wrapper type can be Copy).

1 Like
  • immutable & mutable = read-only & read-write = read & write
    Just like file properties.

Better names than "mutable references" (&mut) and "immutable references" (&) would have been "exclusive references" and "shared references". Shared references can have interior mutability, and sometimes the important part about &mut-taking interfaces is the exclusivity guarantee. I see you do cite Matt's "a unique perspective" article later.

a) The owner is created immutable by default, so the object will be unchangable, read-only.

let owner_read_only = vec![0, 5, 15];

b) If it is created mutable, the object will be changable, write.

let mut owner_write = vec![0, 5, 15];
owner_write.push(13);

mut or not in bindings (like let, or function parameters) is pretty much a lint. In particular, it is not part of the type, and not something you cannot change. The owned value -- the Vec in this case -- is not intrinsically different based on being bound to let mut or not. If you have a non-mut binding, you just can't mutate through that particular binding. But you can always just create a new binding to what you own. So this all works:

fn modify<T>(mut v: Vec<T>) -> Vec<T> {
    v.clear();
    v
}

fn main() {
    let read_only = vec![1, 2, 3];
    let mut read_only = read_only;
    read_only.clear();
    println!("Hmm... {read_only:?}");
    
    let read_only = vec![1, 2, 3];
    let still_read_only = modify(read_only);
    println!("Hmm... {still_read_only:?}");
}

This confusion between types &mut T and bindings let mut name = ... seems to persist later on:

// provides read-only access
let access_read_only = &owner_read_only;

// provides write access
let mut access_write = & mut owner_write;
access_write.push(3);

You need the mut on both sides: On the left, you define a named write access; On the right, the owner gives an unnamed write access. The = binds them together.

You don't need access_write to be a mutable binding, unless you're going to assign some different value [1] to it later. This works:

fn modify(_: &mut [i32]) {}

fn main() {
    let mut v = vec![];
    let non_mutable_binding_to_mutable_reference = &mut v;
    non_mutable_binding_to_mutable_reference.push(0);
    modify(non_mutable_binding_to_mutable_reference);
}

And in fact, if you make the latter binding let mut, the compiler will warn you that you don't need it... provided you have not turned off warnings :slight_smile:

An owner can never access its object directly, even though it may sometimes seem so. In the last line above, the push method is given write access for the duration of its call.

I'm not sure what that means either. This is fine and involves no references.

#[derive(Default)]
struct S {
    a: String,
    b: String,
}

fn main() {
    let mut s = S::default();
    s.a = String::new();
    s.b = String::new();
}

Or even simpler,

fn main() {
    let mut i = 0;
    i = 1;
    i = 2;
}

Every example above has its own scope through the use of {...} and all accecces created within will end when the bracket is closed again.

Borrow lifetimes don't have to be limited to the blocks where you create or assign them. In your example, the variables are defined in each block and aren't returned out of it. And the borrows you create aren't used or otherwise bound to be longer than the variables. Therefore the borrows will be limited to the blocks in your example... but this is not true of all borrows.

Nor do borrows have to extend to the end of the block they're created in. Rust use to work this way (before "NLL" -- non-lexical lifetimes), but that's no longer the case and hasn't been for years now. If you no longer use a borrow, the borrow may end before the variable that holds the borrow goes out of scope. (Sometimes it does seem borrow must extend to the end of a scope when you hold it in a type that has a destructor, as running the destructor usually counts as using the borrow.)

Funny enough, the last two do compile. But as soon as you would use the wrong access, you will get an error.

And therefore, if you never use the first borrows in your example, they will effectively end immediately, so there's no conflict in creating the &mut owner_write borrow.

Or as an alternative way to look at it, creating the &mut owner_write effectively ended all preceding borrows (due to the exclusivity guarantee), and thus compiles so long as you don't try to use the borrows that have ended. Using the owned value itself would have a similar effect.

  • Object Orientation: Only use methods to create access to an object. Every getter method returns a copy of the data it retrieves.

I would say:

  • Don't use getters and setters within the module that defines an object; just access fields directly.
  • If you have a &mut SomeType getter to a SomeType field, consider just making that field public.
  • If you have a getter and setter for some field and you're not upholding some variant in the setter, similarly consider just making that field public.
  • If you have a &SomeCloneableType getter causing you borrow problems, an alternative to changing the getter to return a clone is to just clone at the call site.
    • let value = obj.getter().clone();

Here's a related article you may find illuminating.


  1. that is, assign a different &mut to access_write itself, in contrast with modifying through the &mut held in access_write ↩︎

1 Like

Thank you two very much for your detailed feedback. I clearly had some misconceptions about mutability and other things.

I have updated the tutorial a bit and removed the mistakes, but right now it is unfinished WIP. I decided that I will need to deepen my understanding of the finer details of Rust first and then attempt a rewrite.

I will keep it simpler and focus only on understanding borrowing and references and leave out most ways to deal with it. Although storing objects/values in a hashmap and access them by ID is a good pattern for beginners, I think. It leaves out the added complexity of multiple ownership and internal mutability, while achieving something similiar.

One question: Is assigning a value to an owning variable the only way to change its content without borrowing?

What other way do you have in mind?

As far as I understand, owning variables do not access their values directly, except for value assignment with the '=' operator.

Since I am not used to pointers/references as a seperate concept from variables, this is an interesting fact for me.

Well, a pointer/reference is a value, just like any other value. It has a type, it can be stored in a variable, it can be created as a temporary, etc. The concepts of pointers and variables are really orthogonal.

Well, if you own a value, you can also borrow it. So if a variable contains a T, you can get a &T or &mut T out of it. But "owning" still means "having access by value". The fact that you can borrow a value doesn't mean that it will necessarily be used via a reference.

To clarify, I meant write access. Because read access is as simple as putting the variable into an expression, like

some_variable * 5

Arguably even assigning involves borrowing, typically. E.g. if a type implements Drop then a &mut self reference is passed to the Drop implementation of the old value. On the other hand, some operations that modify a value are built-in and don’t ever produce any &mut … reference. Depending on what you mean by whether or not a value “is borrowed” this would mean there’s no borrowing going on in this case!? Operators like += for primitive types (integers, floats) are built-in, so they operate directly on the value just like = does; similarly for modifying an array at an index, e.g. foo[42] = bar or foo[42] += 1, or modifying a struct’s field, like foo.x = 1 doesn’t involve borrowing. (To clarify: In general for most types indexing, or += does involve borrowing since then traits like IndexMut or AddAssign get involved; it’s just certain built-in types that don’t and that’s also for the most part an implementation detail anyways, though it might have some consequences when dereferencing raw pointers in unsafe code.)

1 Like