The 3d mental borrow checker breakdown

Having all the elements in place it is time to put them in your backpack and travel with it. That is the point where I break. I cannot see how to gather the elements in an 'App' and centralize the functionality. Nothing is working because the compiler cannot see what I am doing.

I already - against my will - splitted some variables unlogically because of these problems.

Are there some tutorials how to centralize things? Not little borrow examples (I can handle these) but 'putting it all together in Rust'. It is very very different from other languages.

After all you need an 'App' where everything is gathered. Isn't it?

This is long and rambling, and I'm not entirely sure what I'm trying to get out. Honestly, I think I solve this problem "by feel", which is not a particularly communicable approach. So... here's a jumbled mess that will maybe be of some limited help?

TLDR: I think about satisfying the borrow checker at the beginning, not when I'm trying to bring stuff together. The easiest way I've found is to focus first on what the code is doing, rather than how the data is "logically" structured. The structure the data needs to take will (hopefully) mostly fall out as a consequence of where and how you need to borrow data to feed into functions.

Original rambling:

I think this is really hard to answer because it depends massively on what you're doing. I don't know of any tutorials, as any example large enough to show you want you want is probably just going to be an entire full-sized codebase.

I ported a previous project to Rust several years ago. Had to redesign more or less the entire thing to work in Rust. One of the key mental changes I made was that instead of thinking about the design in terms of classes and objects, I thought about it in terms of execution. That is, I cared more about how the imperative code was structured and let that guide how the data was structured, rather than thinking about how the data should be "logically" structured and letting that dictate how the imperative code was structured.

So I did almost no model design. Instead, I started by writing the top level of the simulation loop. That code had all the necessary, blanked-out data structures owned as stack variables. Then I start drilling down into the implementation of the code, splitting up and borrowing data so I could pass it to sub-functions as necessary. How the data was structured mostly fell out of how it needed to be structured in order for the code to pass the borrow checker. It's not that I had no idea what data I needed or how I wanted it to be structured, but rather that I let those ideas sit in the background until I was forced to realise them to continue writing the code.

Another way I thought about it: all the data needed to be structured in a big tree. Rust likes trees. That tree is comprised of both abstract types and the state stored in the stack. I actively avoided introducing references between different parts of the tree. If I needed one part of the tree to be able to see a different part, that told me I needed to move that target branch up the tree so that I could reach "up" rather than "across". That's equivalent to passing a borrow down the call stack, which Rust is fine with. Reaching "across" quickly turns into dynamically managed shared access, which means RC pointers and locks, and it all starts to go wrong. Don't do it unless you really need to.

At the end of the day, writing in Rust really just comes down to designing your code in a way the borrow checker will understand. You need to be thinking about this from the beginning. If you don't, then you will eventually find yourself having to throw out your code and write it again to make it work. I know. I've had to do it on multiple occasions.

7 Likes

No idea, I have never written an "App" in my life. I write programs.

More seriously, your question is very vague. If you are asking how to design software in general there have been billions of words written on the subject. Just type "books software design" into google search to see. There are endless conferences and such on the subject. See YouTube.

Perhaps it would help people help you if you could describe exactly what it is your program is to do. Perhaps show that data you were force to split and say why?

I don't see that "putting it all together" is any harder in Rust than most other languages I have used. Perhaps describe what you found hard there.

Perhaps I should not admit it but often when I have some new problem and not much idea how to architect it I just start coding. Staring with some little functionality that I know I will need and building and testing that. Then moving on to the next part. Eventually I have a pile of parts and I have to figure out how I'm going to get them all working together. But by then I will have gotten a feel of how it will look.

1 Like

This is really good advice. I just wanted to add a perspective. This approach may seem unnatural or difficult, compared to creating the sort of "logical model" that you may be accustomed to, that is designed without considering Rust's constraints on ownership.

But there is an advantage to these constraints once you are familiar with them, which does take time! The advantage is that they guide the design and implementation, making design decisions easier in that sense. And because they force single ownership, the end result is a more straightforward implementation.

What may be considered a drawback is that you will have to refactor when changes in ownership are needed. With other languages you may be able to get away without refactoring in more situations, because the compiler isn't forcing single ownership. But that is also how "technical debt" is sometimes created, since the refactoring is really just postponed. So this can also be considered an advantage of Rust.

3 Likes

Thanks for the comments. I seriously will read them may times.

The problem I am encountering, using winit and drawing (as first experiment softbuffer).
winit needs an event loop on a mutable self.
Thus i think I have to put everything involving the program inside some 'self' lets call is 'App' (which is the applicationhandler) to name a few:
-the window
-the gamelogic data (it must be somewhere isn't it?)
-a lot of cached data.

Putting these together in one mutable self gives obviously borrow problems. At least I do not see how to solve that.
I believe my problem is not finding the correct execution path of code. I just don't know where to put my (global) variables, or actually any variable :slight_smile:
I cannot say in my redraw() for example:

fn redraw(&mut self)
{
    let pixels = self.buffer.buffer_mut() // etc.
    let game = self.game; // nope
    // do stuff with game and pixels.
}

However: I must say that the functionality which already works works perfectly, even after a big refactor. In other languages everything would have miserable after that (you do not really have to be afraid something is not initialized for example).
So I know I am a little bit on the right track in some area's.

When reading 'Rust likes trees': I would like a little more explanation on that one...

If you want to post the errors (full message) and more of the code (structs at least) then we can try to help.

:+1:

A tree is a graph in which every node has at most one incoming edge. This fits Rust's ownership model perfectly: if you want mutation, then each value needs exactly one incoming "edge", where an edge can be a mutable reference or direct ownership. You can also think about code as being part of this tree. Code at the top of the main loop is "near" the values that are easily accessible. So redraw has easy access to buffer and game. But if you had a method defined on game, it wouldn't have easy access to buffer unless redraw passed it down as a separate argument.

Funny you should mention winit. I've recently been messing around with a toy project using winit and wgpu, and I've had it on hold for a few days because I need to refactor it. See, I have textures that are currently owned by the rendering pipeline that uses them (because this is "logical"). The pipeline is what knows which texture to load and when to use it.

However, I also want live reloading: any change to an on-disk file should immediately be reflected in the running program. Currently, this means keeping all the live reload code mixed in with the rendering code, which is ugly and not clean. So I very much want to move it out into its own thing that runs in a separate pass between frames.

But with the current structure, that would mean the reload code needing to "reach in" to the inside of the render pipelines to modify the textures. And I don't expect textures to stay owned by render pipelines (that's just a convenience while experimenting). In an OO language, I would just give both the reload code and the render code references to the same texture object, but I can't do that in Rust without involving locks and shared ownership, which I make a point of avoiding wherever I can.

Basically: everything is currently a tree, but the reload code would require code running at one part of the tree to "reach down" through other systems to get at the data it needs. This is messy and hard to maintain. I want the data and code to be "near" each other in the tree, with easy and uniform access.

So the solution I'm currently avoiding sitting down and doing is ripping all the textures out of the pipeline, and "moving them up the tree" so they're next to all the winit and wgpu state. Then, the reload code can directly access the texture state without touching anything else. The render pipeline will then need the top level redraw function to borrow and pass down the texture state so it can do things like actually create and look up the contents of textures when it needs to.

This is kind of what I mean by the data structure "coming out" of the code. I didn't really nail down how I wanted to do live reload, so there came a point where what that code needed was at odds with how I'd structured the data. In an OO language, I would "solve" this by throwing more references at the problem. In Rust, I have to actually solve it by redesigning the data layout. It sucks, but I do find that, in practice, you end up with cleaner and simpler code.

An anecdote: the project I ported to Rust was (before the port) borderline impossible to understand. Individual parts were pretty straightforward, but how they interacted was just a complete mess. Porting to Rust forced me to clarify everything, and the final product was code that was slightly more complex on the individual level (individual components had to deal with borrows being passed in and some occasionally large argument lists), but significantly less complex overall. The actual flow of data was almost universally: "borrow some stuff you have access to and pass it down".

(That said, I did cheat on a handful of things. Resolving IDs to names ended up getting a magic global so I didn't have to pass the table down in almost every function in the codebase. Debug logging in some places used a log behind a shared mutex, since I didn't care too much about its performance. However, these were both things that were hard to do because I didn't plan for them, and refactoring for them would have been extraordinarily painful. Sometimes you have to live with imperfection...)

2 Likes

Aha aha. Yes I saw one post of you fighting with winit and pixels. I discarded pixels for now because it used 1 second to initialize (not sure if i was doing it 100% right but anyway... my program should start within max 100 ms). And there was a version problem between winit and pixels (winit being newer / refactored). So I moved to softbuffer for now.

The actual flow of data was almost universally: "borrow some stuff you have access to and pass it down".

Yes that is pretty much the strategy I am following. I will check how to avoid "accessing something in a random place in the tree".
I would like to put it online but the mess is still too big :slight_smile:

the project I ported to Rust was (before the port) borderline impossible to understand.

I recognize this as well. The current state of my program is better than the old Delphi version. And much better than the experimental c# version.

If you want to post the errors (full message) and more of the code (structs at least) then we can try to help.

I will try and post the 'basic' problem. need some time for that (edit: not able to reproduce the problem in short code. maybe it has to do with the access to the softbuffer pixels as well as my game).
I also would like the gui (winit + softbuffer) to be as agnostic as possible, just calling updategame() and render() and redirecting all OS events down the tree.

Ok It seems less dramatic than I thought at first. The code works (I was so surprised again. I believe I encountered it earlier) if I "write it out for the compiler".

I ran into this most unfortunate feature that little helper functions are not possible.

Just to simplify inner functions I would like to use (for example)

let pixels = self.pixels_mut();
let game = self.game_mut();
// do something with pixels and game

instead of having to write:

let pixels = self.surface.buffer_mut().unwrap().Deref();
let game = self.game.as_mut().unwrap();
// do something with pixels and game

But the compiler disagrees on that when having 2 or more of these little helper functions, which can be more complicated of course than this.

It's a partial fix, but that particular issue can be handled by separating your structures into "bundles" (sets of public members, with no (reference returning, at least) methods) and "wrappers", that contain only one logical value helps a lot with that issue.

Another is finding and separating your mutable state from your immutable state, lots of things fall naturally out of that, another is delegating RefCell etc, access, eg returning a wrapper so that you can separate selecting the cell from actually borrowing it later on, reducing the chance of an overlapping request.

There's lots of these little tricks, but in short it does just suck at least a little bit, you're not really missing anything major.

1 Like

I think I've managed to find a macro way to get your field access helpers to work. Hopefully it still sparks joy.

I acknowledge the limitations of Rust here, and hope that the borrow checker can improve. I'm not the only one: the borrow checker within.

Got it. Thanks! There are several solutions yes to do the trick.
My feeling is that structs mess it up if you are not extremely careful designing them.
In these articles I see a lot of "cannot", "limitation", "falls short".
What is the 'state' of the borrow checker?
Perfect? Not perfect yet? Not ready?

It's always going to have some blind spots, since it only allows what it can prove to be correct. Improvements like the already implemented non-lexical lifetimes (NLL), the Polonius project, and the ideas from the article keep making it more permissive. It's just complex, as I understand it.

2 Likes

It's not that the borrow checker isn't ready yet.
It's fully production ready and is basically fantastic and successful.

It changes the way a lot of developers write programs and this is both an enlightening and frustrating experience.

It could be less frustrating, if it allowed more things "in the spirit of rust" (mutation xor sharing). So I guess not perfect yet, though perhaps perfection is not really the aim for practical reasons. Rust has always tried to be a practical language, it's a balance.

The borrow check may not be perfect. We know at least one way to create memory misuse errors that it does not catch, but that requires writing some very convoluted, unrealistic code (Sorry can't find a link to it). It does seem that there are cases where using lifetime annotations is not required, life times could be elided.

I suspect perfection is not even possible given the program analysis that would be required to achieve that. However given that Rust is used successfully in all kind of application areas from embedded systems to compilers and databases I'm very confident that Rust and it's borrow checker are ready.

Certainly the robustness of the code one creates in Rust resulting from the freedom from silly memory misuse and type misuse errors is orders of magnitude above what is achievable in other compiled languages.

Perfection has been proven impossible for a while now, even before work started on Rust's borrow checker. There will always be either false positives (valid programs that are rejected) or false negatives (invalid programs that are accepted) or both.

The practical compromise that was made in Rust's design was for the borrow checker to never let through an invalid program. Given that this means there will be correct programs that it rejects, an escape hatch was provided to let you bypass the automatic analysis— unsafe. This lets you use algorithms that are correct despite being rejected by the borrow checker, as long as you take on the burden to ensure that they are actually correct.

3 Likes

Certainly the robustness of the code one creates in Rust resulting from the freedom from silly memory misuse and type misuse errors is orders of magnitude above what is achievable in other compiled languages.

Yes, absolutely.

It is also quite logical, actually, that the compiler cannot check my get_a / get_b example.
At least I think I know now a little bit how to setup things in a Rusty way and solve the problem I encountered.

Also see here: TWiR quote of the week - #1579 by ZiCog
People have been scouring Rust for theoretical inconsistencies.

1 Like

To add to my previous response, the basic issue in this case is that we can't yet express a partial borrow in a function signature. That's what the borrow checker within that was mentioned earlier suggests a possible solution for with its view types. The borrow checker (and type checker) will only look at the function signature and not what's inside the function (ignoring -> impl Trait subtleties for a moment). That means that if we say &mut self, it has to assume that the function may borrow the whole object.

If we could express a partial borrow, the compiler could know that they are disjoint, but we have to expose some internal details to the caller. That makes it a possible breaking change to later change the set of borrowed values. The new set could overlap with an other set.

I only skimmed through The Borrow Checker Within. Looking only at the functions signature is logical.
Would it not be insanely complicated to do otherwise? Anyway I need to really read it first...

Returning (2 variables in brackets from one function) is possible as well.
(or if wished a dedicated 'viewstruct').