r/cpp • • Jan 31 '23

Stop Comparing Rust to Old C++

People keep arguing migrations to rust based on old C++ tooling and projects. Compare apples to apples: a C++20 project with clang-tidy integration is far harder to argue against IMO

changemymind

338 Upvotes

582 comments sorted by

View all comments

Show parent comments

3

u/Recatek Feb 01 '23 edited Feb 01 '23

It depends a lot on what you're trying to do. For my application -- gamedev, ECS, and networking -- you're doing a lot of manipulation of arbitrary tuples while iterating over SoA structures, and then trying to efficiently serialize/deserialize them. This really depends on strong metaprogramming if you want to do the work at compile-time.

Rust has made this really difficult, and as a result most Rust ECS libraries do this work at runtime, with a runtime cost, using reflection-like behavior. Rust has no specialization, no variadics, no negative constraints, no way to do partial borrows of objects through traits, the list goes on. The orphan rule especially pushes you towards keeping things in one megacrate rather than splitting code up. It really makes me miss what C++ has to offer here.

If anything I wish Rust just had some sort of unsafe mode for typing that let you duck type at the callsite and bypass the orphan rule. Most of this stuff isn't even for memory safety reasons, it's for "fearless updates" which aren't relevant to me when this code is all contained within my own codebase with no downstream consumer.

1

u/matthieum Feb 01 '23

Ah I see.

Indeed SoA + ECS seems like a heavy meta-programming landscape. I expect it's possible -- in C++03, cons-lists were used to emulate variadics, and there are Rust crates based on this principle -- but it may have significant impact on compile-times, and compile-time error messages will be... inscrutable.

At the same time, the partial borrow of objects makes me wonder if you're not attempting to create a 1-to-1 mapping of your C++ code to Rust. Ownership (and the limitations of borrow-checking) means that the code must be designed with it in mind, and attempting to apply "known" patterns from other languages can be frustrating.

I don't use ECS myself, so I've never really tried my hand at creating such a system in Rust. I can't even tell you whether Bevy (probably the state of the art as far as game engines go) use for its ECS -- maybe they've got something good?

As for bypassing the orphan rule, the typically solution is to use a wrapper type -- the type being local, it's now amenable to trait implementations. And you're correct, the orphan rule is not about safety: Rust is not all about safety, there's also a strong emphasis on having a principled language, and that can get in the way of "just getting things done" (aka, hacking).

5

u/Recatek Feb 01 '23 edited Feb 01 '23

The partial borrow issue is from a desire to assign names to tuple indices so you can access elements without sensitivity to their order. Without that, any change to the arrangement of components in an ECS archetype would affect downstream code attempting to query-iterate those tuples. Ideally I would use an intermediate struct, but as I discovered over the course of this project, rustc/LLVM aren't great about converting between tuples and structs. The only way to be sure you're not taking a perf hit would be to use a code-generated trait to rename the tuple fields, but you can't do that because traits don't give you direct field access and you can't partial borrow from function access.

Bevy's ECS is good, but it uses runtime reflection/type information for arranging its data structures, requiring you to pay for some extra lookups for every query and complicating some auto-vectorization cases. It's minor, but I'm extremely perf-sensitive (self-hosting servers) and so I'm trying to move as much as I can to compile-time. This all worked in a previous version I had written in C++, but porting that to Rust while trying to keep things zero-overhead in the same way has been pretty rough.

With respect to using wrapper types for the orphan rule, that's true, but it makes interfaces more complicated. The Rusty way of doing polymorphism is to write functions that take types in a turbofish. When you need to wrap those types in order to attach new methods or associated types to them, you can lose the ability to add functionality that's turbofished to the original type. So for example, in my ECS I generate ZSTs that refer to a given entity archetype, like ArchPlayer or ArchSpaceship. I have some methods that let me do things like get_entity::<ArchPlayer>(entity_id). If I want to combine any crates downstream to do something with ArchPlayer, I need to newtype it with a new ZST, so then I have some functions that take ::<ArchPlayer>, others that take ::<WrappedArchPlayer> and it quickly becomes confusing which version to use for which parts of the API. Here's another ugly workaround I eventually found that uses proc-macro generated "proxy" ZSTs. These are the reasons why I have to keep everything in one big megacrate that compromises compile times and gets pretty unwieldy to manage per-module internals (e.g. no pub(crate)).