Rant: Entity systems and the Rust borrow checker ... or something.

preview_player
Показать описание
Commentary on the closing keynote from RustConf 2018, which you can view here:
Рекомендации по теме
Комментарии
Автор

I've worked roughly 16 months fulltime in Rust now. And I have to say - I do agree with most of the points you made. In 30k lines, the borrow checker has saved my ass 3 - 10 times in a way where I in hindsight knew that those bugs would have been very hard to catch. The other times it was more or less just annoying because I knew that some things were safe, but the borrow checker was overly restrictive or didn't "get" what I wanted to express, so I had to refactor my code just to make it pass the checker - but that doesn't mean that the checker "forced me to write good code", no, the borrow checker was just too dumb to get what I mean and I had to write more syntax to make it understand.

I also don't hit the borrow checker problems that much anymore, but yesterday was an exception for example where I had a really nasty borrowing problem. I've come to realize that Rust takes a long-ass time to write but once it's written, it's pretty solid and you don't run into That is both a good and a bad thing. On the one hand, it allows you to schedule and plan things somewhat easily. On the other hand, productivity in Rust is pretty low, because many libraries are simply missing because good code, which Rust forces you to write, ( *not necessarily courtesy of the borrow checker* ) takes long to write and there is a lot of friction. Also compile times are abysmal, because of the whole type-checking (yes, lots of generics and macros take time to type-check), which is simply something that decreases the productivity.

I did not use Rust because of the borrow checker though, I find it nice and certainly useful (esp. in regards to preventing iterator invalidation), and it's a new, unique feature in PL design (at least I am not aware of any other language that has a borrow checker) but people give it too much credit. There are other features that Rust has that prevent large swaths of bugs - discriminated unions and exhaustive pattern matching (versus switch on a number with fallthrough default case), or Option / Result error handling - those things are great.

On the other hand, I don't write game engines, but I knew that I have to possibly maintain my code for decades. Which is one of the reasons I chose Rust for a project. In hindsight, not the best move, because programming is in the end, a business and you need to ship on time. In hindsight, I'd rather deal with debugging a few NullPointerExceptions rather than spending 6 months writing a certain library that doesn't exist in Rust. So I think that if you want to get your language off the ground, libraries (esp. if game-focused) are a huge deal.

There is certainly truth to the argument that Rusts way of preventing mistakes via borrow checking is just *one* way, not *the* way, to prevent logical mistakes. Because in the end, you don't want to prevent pointer mistakes, you want to prevent logic mistakes (which express themselves as pointer misuse). For example, a bug I had in Rust was that I forgot to update a cache after reloading a file - the borrow checker passed fine, but it didn't know about my application logic or the fact that I had to update my cache. You *can* have two mutable pointers to some memory just fine, it's just that it's a common logic mistake to update one and forget that you have another pointer. But that's, at its core, a logic mistake and Rusts way of mutability-checking is just one way to prevent against one class of bugs.

However, Rust gives me good tools to at least try to prevent these mistakes, mostly by leveraging the type system. For example, if I have a function B that needs a struct C, and the only way to get a struct C is by calling function A first - then I have a logical "chain" where I can't forget to call function A before function B (Rusts move semantics help a bit here). Otherwise it wouldn't compile. Or (in the example) I want to render something, but need an "UpdateKey" struct first and the only way to get that is by calling the generational-update function. This is how at least I have learned to build APIs that are essentially fool-proof, so that you shouldn't be able to forget to update something before you call something else.

But overall, I still think Rust is a decent language that at least tries to push PL research forward and this video highlighted a good argument, so thanks for this video. I am hopeful to see how Jai pans out.

fschutt
Автор

Jonathan lives in The Witcher's universe; he went from day-time to night-time in 35 minutes!

DigitalDuo
Автор

The problem in the example is the indexing. What the Rust borrow checker validates is references e.g. "pointers". The borrow checker guarantees that no memory is freed as long as there are still references to it, and it also guarantees that there are never ever multiple writing references or both writing and reading references to the same memory at the same time. There is no way to access invalid memory or corrupt memory using "safe" (standard) Rust. The problem with the indexing is, that the implementations in the standard libraries just intentionally crash when directly indexing an array with an index out of bounds. So it is definitely still possible to try to access invalid memory and Rust will let it slide and you will crash. However, those functions are always documented to panic in specific conditions, and for indexing, there are alternatives that don't panic, but return an Option<T> type, which you manually have to unwrap at the call site. That is the point where the programmer has to decide how he will handle the case where the memory he is requesting is not available. By returning a type which reflects the possible unavailability of the requested memory, the programmer is forced to acknowledge this possible failure and must implement a solution, or explicitly ignore any errors and let the program crash.

distrologic
Автор

Let us also not forget that array[i] is just syntactic sugar for *(&array+i) in basically any language, which is itself just pointer arithmetic. By using this syntax you may be allowing some compiler warnings about out-of-bounds accesses, but only if your compiler supports it and it's turned on (eg, clang likely won't complain unless you're using clang -analyze).

Be_Captain
Автор

"crashes are not as bad as you think",
yes, i often try to design my code so that it clearly crash if something is wrong, saves me à lot of debugging time.

ChaotikmindSrc
Автор

I think Catherine's main point was that borrow checker right from the beginning showed all the problems of the OO design, which can be implemented in C++ without any complaint from the compiler but causing various problems down the road. It shows that using C++ developers were able to spot those problems by means of very careful thoughtful implementation/design or serious debugging afterwards. However with Rust's borrow checker these problems are obvious compilation errors, which allows to try some other designs with less efforts without affecting correctness.


And yes, the ECS solution she proposed mostly works around borrow checker and replaces pointers with "manual index allocation in Vec", however the borrow checker would still prevent the consumers from keeping the references temporarily obtained from systems for later usage.

RumataEstor
Автор

I feel that your comment at roughly 39:00 is kind of what I experienced from learning Rust this summer. Because the C++ compiler would let me do something that the Rust borrow checker would not, I was forced to reevaluate past habits or patterns and it changed how I code C++ as well to be more mindful.

AndrewRogers
Автор

This is the first time Ive listened to Jonathan Blow talk about programming and actually understood what he was talking about.

WIImotionmasher
Автор

There's a lot of great points in this video that I really like, but I do have a gripe that there's some misunderstanding of what the word "safe" is intended to mean. "safe" in rust is a very specific and well-defined term that is exclusively intended to refer to rust without unsafe and the things which that prevents you from doing, namely mutable pointer aliasing and use-after-free. Rust code may be "safe" without being correct.

keldwikchaldain
Автор

reminds of the original MacOS of 1984 which tried to run in 128K of memory. The heap memory manager allowed memory compaction and so instead of a direct pointer to memory objects, one got a pointer to a pointer (a handle). This made possible for memory blocks to be moved for heap compaction and also made it possible to deal with when the memory object no longer existed in the heap. This was good for managing read-only resources which could always be reloaded from the executable file if had been flushed.

TheSulross
Автор


I think how she uses the term "safe" may have misled you. Saying something is "safe" has a very specific meaning in Rust: no race-conditions, no double-frees, and no use-after-frees; most else is still fair game. That's why when she says it's "safe" but isn't great, she means it compiles (doesn't violate ownership, lifetimes, etc.), but that doesn't mean it's good (the code may still have serious logical flaws). I think it's really interesting to consider this almost a step backwards (C++ programs very well may segfault in debug builds due to these logical errors, whereas Rust won't), it seems like all higher-level languages would suffer from that too.

At 39:00, when you say the borrow checker may have helped by forcing the developer to explore other options, I think that's exactly what Catherine is referring to when she says the borrow checker helps. It's turned a memory access error into a smaller possible set of logic errors. With Rust lifetimes, it wouldn't be possible to double-free or use-after-free an entity, only a pseudo-use-after-free bug is possible (using the index after it's been freed and been re-allocated). The way Vecs are implemented in Rust, it isn't possible for an index to point to a deallocated object (either you have a Vec of optionals, or when you remove an item the Vec shifts later items to the left). I think this naturally forces you to consider this use-after-free logical error, which would naturally lead you towards the generational solution (or some other solution, or at least into a wall) when developing.

One last nitpick, I dislike how you're pulling apart code that in the context of the presentation is shown as code with problems in it. If you're going to do a rant on code I think you should at least wait till she shows the best examples, so you don't end up fighting a straw-man.

Enjoyed the discussion though overall, it's really educational engaging with C++ programmers as someone who's only written lower-level code in Rust. Hopefully others more knowledgeable about Rust can chime in, I'm definitely no Rust expert

hjdbfpi
Автор

The same problem you described manifests itself in Super Mario 64 (as the cloning glitch) and Ocarina of Time (as stale reference manipulation) because both games use raw pointers to entities that get deleted, but references to them aren't updated in some cases.

camthesaxman
Автор

I love hearing ideas on technical stuff from someone who has already proven his expertise by making a full game. Thanks for the upload.

schwererziehbar
Автор

After looking up a component, in let's say the Vec<Option<PhysicsComponent>>, the borrow checker makes it impossible for you to accidentally keep or transfer the address of the component. This is automatically enforced at compile time so there's no run-time cost, and you don't need any explicit company policy on that matter.

Without this check you might accidentally keep a pointer to a component that gets destroyed somewhere along the way.

borkborkas
Автор

I really like Rust, and I have been making small games in it in my spare time. Johnathan is right, what is presented does not solve the problem, it solves the crash symptom. The real problem will be the same no matter the language: pointers, gc, borrow checker or something new. Entities might die, and you have to handle that, in a safe and reliable way.

But this critique, of the struct of vecs, does not disqualify Rust as a great language for game dev (or other things). You can still use Rust to solve the real problem, and Rusts borrow checker is going to help you in other ways (concurrency being one).

One of the major advantages or rust is the memory safety, but a memory security flaw in a game is probably not nearly as critical as one in a database, web server or a operation system. So it comes down to personal preference. I personally like the strictness of the Rust compiler, but that is what I value, others might value the flexibility of a language like c or even python. You do you.

To sum up : Jonathan says "this is not a silver bullet" and he is right, but that doesn't mean that Rust is a bad language or tool for the job.

philipkristoff
Автор

At 33:00 he sums up the arguument that the borrow checker does not provide generational indexes out of the box. If you store your entities in a Vec<Option<Entity>> with fixed size and use integers for indexes, then you will eventually need to reuse slots to save memory, and that requires you to add a generation-id to distinguish between the old inhabitant of the slot and the new inhabitant of the slot.

AFAICS the project named generational-arena on github provides a nice container type with generational indexes. I haven't tried it but it seems to work. But Jonathans point is that you could have writtten the same code in C++.

The usercomment at 49:10 sums up the benefits of rusts borrow checker. You get some aliasing guarantees and some promises regarding parallel code. Jonathan is probably right about productivity. If you need to build your game engine from scratch then rust will provide some friction and force some redesigns before you get it right, but if you use a premade container type such as generational-arena or an ECS-library such as specs or if you copy the design from Catherine's video, then you will experience less friction. So in short rustbecomes more productive when you use existing libraries that enforce nice programming patterns.

anotherelvis
Автор

Hia, C++ guy here.

Reference-counted smart pointers, like std::shared_ptr and it's weak-pointer counterpart std::weak_ptr, don't have to inform all the other pointers that point to the resource when it is destructed. Destruction simply decrements the reference count, which is just a uint that each smart pointer accesses atomically by reference (ie, thread-safe, and they don't have their own copy of the uint). So it's essentially a lazy-check (which we love, since we only pay for the operation when we actually need it): weak_ptrs won't keep resources alive, and in order to even dereference them you must lock them. If the lock fails, it means the resource no longer exists (ie, all the shared_ptrs that pointed to it have gone away, even if weak_ptrs still exist). The danger in all this then boils straight down to whether the programmer is checking for null after locking a weak_ptr, which is programming 101.

Be_Captain
Автор

On the subject of weak pointers, what you describe isnt how C++ weak pointers work. In fact, C++ weak_ptr solves this exact problem you're describing... which is why that's the preferred solution for game objects to point to other objects. C++ shared_ptr/weak_ptr work with a shared block of memory that they point to that contains 2 reference counts. One tells you how many strong references are (those that keep the object alive) and the other tells you how many weak references. You dont need any large lists of pointers to objects pointing at your target object, that's totally unneeded. It all works because each smart pointer just goes to the single control object to update/check the references in an atomic way. Also, there's no ambiguity about when to check for null... a weak_ptr must be locked before using it. The process of locking it gives you a shared_ptr, which you then check for null. If it's not null, it will remain valid until it's destroyed. The whole thing works really well and all you have to do is make sure you dont store shared_ptrs/unique_ptrs unless you own that object and want to keep it alive. Anyone who is just observing an object should store a weak_ptr.

DeusExAstra
Автор

for the purpose of learning where your problems are in development, crashes are very good, but nothing is better than failing to compile.

teslainvestah
Автор

I realize I’m 5 years too late to this thread, and maybe somebody else has already pointed this out, but the difference between using a generational index and just “turning the borrow checker off” is this:

1) the indexes cannot be used like pointers in that they cannot be dereferenced at any time, but only when we are being given access to the underlying storage

2) this means that the system can be designed such that you have complete control over when an entity has read or write access to the underlying storage

3) for example, an entity could be allowed to mutate itself in some kind of update method, but be passed a context that provides read only access to other entities that it has keys for, but some kind of larger system could be passed a mutable context that allows read write access to any entities

This may not be immediately obvious for a game engine, but for other kinds of similar “object soup” applications where, for example, mutation can only happen in the context of a command architecture, the ability to prevent some subsystem from “hiding away” a pointer to an object and mutating it whenever it wants or reading it when the domain logic implies it has an invalid value, this is an architectural win.

My experience is largely in the kind of world where we do this kind of thing with automatic reference counting and weak pointers, and the problems described above are very common pitfalls.

realSimonPeter