Blog post series: After NLL -- what's next for borrowing and lifetimes?


#101

There is several differences between regions and your proposal:

  • Regions result in a shorter, more clear syntax. They do not overload impl with yet another meaning.
  • Regions are decoupled from field names, with your proposal you make field a part of the public API.
  • Regions can cover several fields.
  • Regions can be quite naturally extended to traits.
  • You can define private and public regions, and this restriction will be enforced by compiler.

#102

There is no overloading here. Struct is a trait, just like any other trait. The impl syntax works with all traits, so of course it works with Struct. It’s not hard-coded or special-cased for Struct.

You can use regular generic syntax if you prefer (though it’s more verbose):

fn foo<A>(this: &mut A) where A: Struct { field1: u32, field2: bool } {
    ...
}

That is true, it is a downside of my proposal. Though the benefit is structural typing (which I don’t consider that important).

So can Struct. Could you explain more about that?


#103

As for verbosity, let’s compare the two approaches. First, the region borrow approach:

struct Vec<T> {
    /// Data belonging to the `Vec`.
    pub region data;
    /// Length of the `Vec`.
    pub region length;
    #[region(data)] ptr: *mut T,
    #[region(length)] len: i32,
}

impl<T> Vec<T> {
    // Needs mutable access to `data`, but doesn't mutate `length`.
    fn as_mut_slice(&region(mut data, length) self) -> &mut [T] {
        do_something_with(self.ptr, self.len)
    }
    // Only needs to access the `length` region.
    fn len(&region(length) self) -> usize {
        self.len
    }
    // This one borrows everything, so no need for special region annotation.
    // This means it borrows all regions.
    fn retain<F>(&mut self, f: F) where F: FnMut(&T) -> bool {
        // ...
    }
}

And now the Struct version:

struct Vec<T> {
    ptr: *mut T,
    len: i32,
}

pub type Data<T> = Struct { ptr: *mut T };
pub type Length = Struct { len: i32 };

impl<T> Vec<T> {
    // Needs mutable access to `data`, but doesn't mutate `length`.
    fn as_mut_slice(self: &mut impl (Data<T> + Length)) -> &mut [T] {
        do_something_with(self.ptr, self.len)
    }
    // Only needs to access the `length` region.
    fn len(self: &impl Length) -> usize {
        self.len
    }
    // This one borrows everything, so no need for special region annotation.
    // This means it borrows all regions.
    fn retain<F>(&mut self, f: F) where F: FnMut(&T) -> bool {
        // ...
    }
}

I actually think the Struct version is much clearer, especially because it reuses standard Rust syntax and idioms (including the trait system and impl syntax).

Some issues I see with Struct:

  • Needing to repeat the fields in Struct is a pain. But that can be solved with a proc macro:

    #[define_regions(pub Data, pub Length)]
    struct Vec<T> {
        #[region(Data)] ptr: *mut T,
        #[region(Length)] len: i32,
    }
    
  • It cannot immutably borrow len while simultaneously mutably borrowing ptr (it has to mutably borrow both).

    This mirrors how &self and &mut self work, but it probably is useful to be able to have individual mutability on fields.

  • The lack of public/private encapsulation. This is a big issue, I need to think about how to fix this.


#104

Ah, I misunderstood your proposal a bit, though I strongly dislike “magical” nature of Struct trait.

I mean region can cover several fields, which usually will be used together:

struct Sphere {
    // region is public but not the fields
    pub region position { x: f64, y: f64, z: f64 }
    region color { r : u8, g: u8, b: u8 }
    pub region size { radius: f64 }
}

In your code you will continue to use fields x, y, etc. as usual and compiler will check if you have borrowed the necessary region. There is an issue with overlapping regions, but I think it will be better to require to use finer grained regions, over making regions functionality more complex.

For me seeing self: &impl Length is really confusing, there is no indication about the fact the it uses the “magic” of Struct trait. Imagine how you will explain this feature to new users. Sometimes it’s better to introduce a new orthogonal and distinct feature, than add a new meanings to existing one.


#105

Regions are also magical. Any new feature will require built-in compiler magic, that’s not limited to just Struct.

I would argue that Struct is much less magical, since it reuses so much of the existing trait features. But I guess it is a matter of personal taste.

Also, Struct is magical in exactly the same way that Fn is magical, so I’m curious: do you strongly dislike Fn as well?

Yes, and I’ve given many examples of doing that with Struct. Most examples use Struct { field1: u32, field2: bool }, which is two fields: field1 and field2. Of course it supports as many fields as you want.

I don’t think that has anything to do with Struct: type aliases can be used to shorten complex types in general (not just Struct). Of course you will have to look up the type alias to know what it does (this also is not specific to Struct).

For example, there is an Executor01Future type alias in the futures crate. You generally don’t need to worry about it (you just use it), but if you are curious about the actual implementation it shows you right in the docs (it’s Compat<UnitError<FutureObj<'static, ()>>>)

Similarly, if you had a Length type alias, that would show up in the docs, so you could just click on it and it will show you the Struct definition.

As for teaching, that’s not hard: you just say that Struct is a trait which means “the type has these fields with these types”. That’s it. It’s very simple because it reuses the existing trait system (which has to be taught regardless of whether Struct exists or not).


#106

I think it will be better if you write your proposal as a pre-RFC and we continue the discussion there.


#107

As a minor step, I wonder if making in-signature destructuring track disjoint borrows would be helpful?

struct S {
    a: u32,
    pub b: u32,
    c: u32,
}

impl S {
    fn ac(Self { a, c, .. }: &Self) -> (&u32, &u32) {
        (a, c)
    }
}

fn main() {
    let mut s = S{a:1,b:2,c:3};
    let b = S::ac(&s);
    s.b += 3;
    println!("{:?}", b);
}

Today’s error:

error[E0506]: cannot assign to `s.b` because it is borrowed
  --> src/main.rs:16:5
   |
15 |     let b = S::ac(&s);
   |                   -- borrow of `s.b` occurs here
16 |     s.b += 3;
   |     ^^^^^^^^ assignment to borrowed `s.b` occurs here
17 |     println!("{:?}", b);
   |                      - borrow later used here

(A way of doing this destructuring and retaining method call syntax would be needed to make this truly useful.)

(EDIT: obviously, it’d still have to be opt-in, sadly, as current semantics borrows the whole struct and unsafe code could rely on that. But other than opt-in, this would allow experimentation without surface syntax expansion.)


#108

This discussions sounds like it is converging on something like PIT. Personally I don’t like it, I think it is too verbose and is too much new syntax and features for such a niche problem.


#109

“Modest proposal”:

I agree that adding more ways to ensure disjoint borrows would be nice. But it seems like they’ll inevitably be verbose and, more importantly, not usable at all in many situations (where you can’t cleanly split regions of code by which fields they access). Plus, if the borrow checker is extended in the future to support things like self-reference and parent pointers, it’ll become even harder to ensure disjointness.

So why not also look at making non-disjoint borrows more ergonomic?

In particular, I think Cell is a powerful tool, with the potential to make the borrow checker feel less restrictive by making immutability less “all or nothing”, that’s currently held back by ugly syntax and a relative lack of supporting APIs. I think it would be very interesting to turn it into a first-class language feature, in some form.


#110

I don’t think that turning Cell into a first class feature would work, because Cell depends on the fact that something is Copy to work, so it wouldn’t work for any Non-Copy type.

Yes, I think this is very important, especially about self-references (I think this is already possible, or will be very soon due to how generators work).


#111

The more I read about it, the more I think the solution for you is some combination of unsafe and/or different architecture.

About unsafe: You have a very particular case, where you want to fight for every % of performance speedup. There’s nothing wrong with resorting to unsafe in such cases.

About architecture: When I see a class like this: https://gitlab.com/jix/varisat/blob/master/varisat/src/cdcl.rs#L34 then I know there will be trouble. I would just not have a struct like this in my code. I would rather pass all the values separately to a bunch of free standing functions. IMO, it’s a OO-ism to try to group everything by abstractions that don’t fit the actual data model and then fight hard to have logic in methods on them.

If Cdcl is a user-facing concept, I would make a a “datastore” of some form, and make Cdcl contain references to it (as Ids) or Arc everything etc. Generally I would avoid lifetimes rigorously, especially lifetimes on struct fields.

Nvmd. Just see my next comment. :slight_smile:


#112

I am of opinion that most problems with lifetimes are a result of stubborn OO-ism.

Generally people are indoctrinated from the time they start to learn programming, that the right way to code is to have classes, that abstract logical concepts, and then methods on them. They want to think in terms of objects, they want their IDEs to give them method autocompletion, and they want to completely ignore the actual data model they need.

Eg. a 3d gfx library will have:

struct RenderingCtx {
  surface: &mut Sufrace,
  shaders: &mut Vec<Shaders>,
  vertices: &Vec<3d>,
  control_pipeline: &mut Pipeline,
  ... // obviously everything is made up
}

This struct is not a real data-struct. It’s just a logical grouping, or a abstract concept that does not correspond to the actual data model. The data in this struct does not live side-by-side, and because of this it should not be in a same “data-structure”. Even if it’s convenient to think in terms of “rendering context”, it doesn’t exist in your program, so no wonder that it’s hard to operate on it or represent it. Lifetime is just one of the problems you will encounter. (Another funny and common problem is: if Player shoots Monster where does the actual hit-point decrement happens, which in combination with many small classes and inheritance often leads to what I call “Thank you Mario! But our logic is in another class!” and having to jump in your IDE furiously to get to the point where the actual data is modified).)

OOP is typically at odds with any methodology that is data-oriented. Eg. why we have Object-relational impedance mismatch, or why game engines don’t do OO and use ECS’s or more generally: any software that needs to be super-performant has to abandon OO. It’s also why we have lifetimes issues on borrowing Self (not that almost all examples people discuss are about borrowing Self, in struct that are just some logical grouping of bunch of borrows).

So personally, most of my problems with lifetimes were gone after Rust made me realize it, and I’ve just unlearned OOP, and went back to writing my code in more data-first way. More like C, less like Java. There are some “tactical” problems with lifetimes still, but nothing fundamental.

Now, to get into something more constructive

I wish Rust made it easier to operate on functions with a lot of arguments, so living without artificial grouping like RenderingCtx is just more convenient, so people don’t have to pretend it’s a self-contained data-structure.

Eg. if I do have the above ctx : RenderingCtx instance, and would like to call a function:

fn do_something( surface: &mut Sufrace,  shaders: &mut Vec<Shaders>,  control_pipeline: &mut Pipeline) {
 /// ...
}

couldn’t I just somehow:

ctx.do_something(...); // sugar for: do_something(ctx.surface, ctx.shaders, ctx.control_pipeline)

or something like this (exact syntax a subject for bike-shedding)?

I mean: why can’t the compiler just unpack the arguments “by type” for me, and make my life easier, so I don’t have to get this convenience by artificially inventing methods on RenderingCtx, just so I don’t have to pass everything separately all the time? Also, this should allow autocompleter to suggest do_something as a kind-of-method on RenderingCtx, so that people can still get their IDE suggestions right.

Wasn’t something like that part of Rust in early days? structural types?.

To rephrase: instead of inventing some weird ways for borrowing particular fields on structs, just make using normal, free-standing functions that take a lot of fine-grained arguments easier, more natural and looking more like what people used to OO are happy with.

Side-note

I should have said “OOP-ism” everywhere. I believe that in the original Smalltalk-like OO, the objects were actually more like what we call “actors” now, erlang style. So one wouldn’t have “an object” with only references to other objects, because it was impossible. I am not a great CS historian, but I blame Java for inventing modern OO( P ).


#113

Heh :slight_smile: I wish I wouldn’t have either. This is only this way because Rust doesn’t allow me to write what I actually want. It’s not about making it easier to work with structs like this, that’s already working these days. It’s about not having to use structs like this but also not having to pass all references individually (we might just disagree on whether that’s an option, but that’s ok).

In my case the data actually does live mostly side by side, just in another struct that’s partially borrowed when the CDCL routines are invoked. There doesn’t exist a partition of that struct that’s disjoint for the whole program, otherwise I’d just rearrange the struct fields and borrow a contained struct instead of this mess.

This is something I’ve wanted previously too, when the data that is passed along isn’t actually part of the same struct. I’d welcome change there, and I agree they’d make the workarounds to my main problem less painful.

But there is another issue with this and also with the view struct workaround that I’m currently using. Because the functions are only ever invoked with references to the same structure, but we’re hiding the fact from the compiler (unless it does whole program analysis specially for this) and thus might force it to generate worse code. I hope I get around to benchmark this soon, because I have no idea how much of an issue that is.

Also my background isn’t really OOP, so I doubt that’s why I’m having problems here.


#114

If you are using that many fields inside a function, chances are you should already break it up into multiple functions, even if borrow checking rules permitted all this code to be called on just &mut self.

I realize, and that wasn’t my point.


#115

Splitting the function doesn’t reduce the number of parameters if they are needed not because the function is long, but because downstream functions transitively called from it need them.

I find splitting functions actually makes this problem worse, so sometimes I have to leave code in a single function to make the borrow checker happy even though I would prefer to split it.


#116

That only means the factoring problem is not in the immediate callee but lower in the call stack, so this doesn’t seem like a good argument in favor of not doing so.


#117

Consider the pathological example of bunch of functions whose call graph is a binary tree. Each leaf only needs a single reference but a different one. Each inner function is pretty small calling just two other functions. If it was any smaller i.e. calling only one function, you could just remove it comlpetely. Nevertheless the root would require all references as parameters.

Of course real code isn’t as bad as that, but it can be close and in that case splitting functions doesn’t help at all. Or am I misunderstanding what you’re suggesting?


#118

Write up with another approach:


#119

@nikomatsakis, not sure if you’re still following this thread, but I think a couple of recent threads in this forum remind us that there’re paper cuts/bugs/deficiencies in existing lifetime-related code. The threads are:

I’m sure you’re aware of these issues (and you/others are possibly working on them already), but in a lot of ways I’d love for these (and similar) holes to be plugged before we consider new bolder enhancements, such as the ones discussed in this thread.


Pls help to explain (weird error with closure lifetime inference)