Why I Think Rust Is The Way Forward
Introduction
I want to begin by making it clear that what I’m about to write is significantly an opinion piece. I have a lot of experience and data (see references below) to back up my thoughts but the how/why/when of all of this for any given situation is still an open question. However, I don’t want to write a document that is full of “well except sometimes”, and “but don’t overdo this” and so forth. That would be tedious. Everything I’m about to say, even when it’s not qualified, is actually qualified and never should it be used to do something that’s flatly dumb. That said, I’ve tried to be balanced in my opinions despite having a clear position. Your particulars for how to execute on a change like this will be tricky to say the least.
Context
I have no particular preference in programming languages generally. I make choices on the basis of fitness. In particular I like to use my own notion “Pit of Success” to judge things. Meaning simply: Were we to go in a particular direction are we likely to succeed? Are we likely to make things better? And, specifically can we defeat the null hypothesis : “These languages are all pretty much equally horrible for systems programming hence we’re going to face the same old junk-ola in this imagined new world of yours.”
It’s a pretty simple null hypothesis.
So I want to consider things from these perspectives:
- Which patterns give fewer bugs?
- Which patterns give better code quality?
- How hard is it to achieve these outcomes?
Importantly we really have to add #3. It is trivially true that there is no code anyone can write in the language of their choice that I can’t also write in vanilla C
if I'm prepared to work hard enough. So on the first two axes the best you can ever hope for is a tie. However #3 matters. A lot. We do not want it to be the case that you have to know everything about machine architectures, memory models, and inline assembly in order to be successful. We care a lot more about what the results look like when you use idiomatic patterns.
These three notions will pervade discussion that follows.
So, let’s review some past choices!
Tech 1: C
or light C++
with explicit cleanup
By “light C++
" I mean use of C++
where adoption of C++
features is very limited. Maybe advanced constants, no EH
, limited polymorphism, and very limited RAII
. This code often looks something like this:
HRESULT Stumble(/*out*/ Jumble **jumble)
{
HRESULT hr;
Mumble *mumble = NULL;
Bumble *bumble = NULL;
*jumble = NULL;
hr = CreateMumble(&mumble, ...);
if (ERROR(hr)) {
ErrorTrace(hr);
goto Error;
}
hr = CreateBumble(&bumble, ...);
if (ERROR(hr)) {
ErrorTrace(hr);
goto Error;
}
hr = MumbleTheBumbleIntoAJumble(bumble, mumble, jumble);
if (ERROR(hr)) {
ErrorTrace(hr);
goto Error;
}
hr = S_OK;
Cleanup:
// at this point we clean up things that always need cleaning
// in success or failure.
// jumble needs bumble to initialize but doesn't own it
if (bumble) {
ReleaseBumble(bumble);
}
return hr;
Error:
Invariant(*jumble == NULL);
// jumble owns the mumble, so only free in case of error
if (mumble) {
ReleaseMumble(mumble);
}
// For reasons lost to, time the Windows pattern is always
// "error: goto cleanup"
goto Cleanup;
}
Sometimes there are macros that hide the if
/goto
/Trace
but it's basically the above pattern.
So what’s wrong with this? Well sometimes people have an adverse reaction to that goto
but really this kind of goto
is pretty clean. You get one clear section that needs to deal with any locals that may or may not be initialized. It's very obvious where your cleanup code goes and how to write it. Sometimes people complain about the verbosity but this in and of itself doesn't cause significant problems. The chief problem here isn't lack of clarity -- it's the propensity to forget to do your cleanup.
And yet, I can tell you with no doubt that the cleanest C++
code-bases I've encountered, and the ones with the fewest bugs, all used this pattern or one like it. Why? Because you can't just use this pattern by itself, you also have to test. There is a strong presumption here that you will write unit tests to cover every line of code. Given that assumption consider:
- there are no sneaky weird lines of auto generated bizarreness that are hiding
- it’s on you to cover them all
- if you do, and your tests run in a harness that detects leaks (by e.g. counting allocations/frees) then you are immune to leaks as long as you test all your code
- testing all your code is invaluable for lots of other reasons
Notably, I haven’t shown any kind of error handling and/or recovery in this simple example but, even without showing it, it’s pretty clear how you would wire it in and how cleanup would happen. This kind of pattern strongly encourages coding that looks like this:
- acquire all the things
- do all the things
- cleanup the things
The strength of the pattern is that once you’ve learned it, it’s broadly applicable, and easily practiced. A key weakness is that it is somewhat unnatural to beginners, and, especially in the C++
case it hobbles the language in the name of simplicity. You're using C++
but you can't use it. For instance, you're always wondering if your world is going to be rocked by one misplaced thrown exception in some code you're calling. This pattern composes well with itself, but only with itself.
From an “economy of generated code” perspective, this is probably the best pattern. You have to manually decide what cleanup is necessary and hence it is the minimal cleanup. You can fold cleanup ops into simple helper functions and the pattern encourages you to do so. Ah hoc cleanups are extra tedious, hence developers are not likely to create them.
The thing is, this kind of code looks nothing like what you see in the language manuals. Even if you were sticking strictly with C
this looks nothing like idiomatic K&R C
code. To be successful you have to avoid tons of "normal" C
structures like internal pointers and whatnot. It's pages of "don't do this", "don't do that", and "whatever you do, test everything" or you're likely screwed.
Tech 2: Use more of C++
like say more RAII
This option is the source of the worst code-bases I have ever seen, without question. Note, this is a statement about my observations, hence it is true by construction, but your experience may vary.
What is it that goes wrong? Well, three things broadly:
- All this
RAII
isn't free and it's often sub-optimal to say the least - The code soon becomes impenetrable
- Lifetimes are actually pretty complicated in reality
Now looking at original sample, I added some complexity on purpose, it’s not a lot, but I made it so that bumble
is a temporary object whereas mumble
becomes part of the jumble
. How does this look in RAII
?
You might be tempted to use something like the following:
// Here we avoid STL for brevity and because it's just one
// possible choice -- there is no law that says you have to use STL.
// There is an obvious mapping to std::* for this code.
HRESULT Stumble(Ptr<Jumble> &jumble)
{
HRESULT hr;
Ptr<Mumble> mumble;
Ptr<Bumble> bumble;
jumble = nullptr;
hr = CreateMumble(mumble, ...);
if (ERROR(hr)) {
ErrorTrace(hr);
return hr;
}
hr = CreateBumble(bumble, ...);
if (ERROR(hr)) {
ErrorTrace(hr);
return hr;
}
hr = MumbleTheBumbleIntoAJumble(bumble, mumble, jumble);
if (ERROR(hr)) {
ErrorTrace(hr);
return hr;
}
}
But we have a problem now don’t we? We can’t really use the same Ptr<Bumble>
can we? There was an ownership transfer. This stuff happens all the time, not just for memory but for even bigger stuff like file handles and whatnot. The simple rule of using a pointer/handle wrapper doesn't work. Sometimes we solve this problem like so:
hr = MumbleTheBumbleIntoAJumble(bumble, mumble, jumble);
if (ERROR(hr)) {
ErrorTrace(hr);
return hr;
}
// This means we no longer own the bumble memory, or file,
// or whatever its holding...
bumble.detach();
But now we have the reverse RAII
problem. We have to be sure to disarm the RAII
in all the right places and this detach
op is extra code we have to get just right. In the binary you still have all the same cleanup as before, but you don't have to look at it at least. On the other hand if you're profiling or something, it's not just that you don't have to look at the cleanup code, you can't look at it! A profiler can't clearly show you that you have a hot path in cleanup code -- especially if it's inlined. A leak checker can't show you the line of the leak. It all gets charged to the that magic "}" at the end of the function. You really want to make sure to keep that stuff small and cheap. And though intentions always start well, the endgame for these codebases is often quite bad indeed.
You might try to avoid this detach
business by making something like SharedPtr<Bumble>
and letting reference counts do the work for you. This is fair, but now both functions need counter management code and the situation above is actually a linear transfer, not sharing. You'll pay a binary size cost for the count management and if you used std::shared_ptr<foo>
, or something like it, you'll also pay for interlocked increments and decrements that you don't need. These can be as much as 100x more expensive than normal instructions. Also, the pointer management code will be maybe 5x as big in bytes. If you do this everywhere as your main pattern that's big-time bloat.
Nobody can afford a 5x hit on all their pointer management in their systems code.
The big problem with this pattern is that every problem drives you to make your code and framework more complex, more bloated, and more difficult to understand. If I go one step further:
Ptr<Jumble> Stumble()
{
SharedPtr<Mumble> mumble = CreateMumble(...); // maybe throws
Ptr<Bumble> bumble = CreateBumble(...); // maybe throws
return MumbleTheBumbleIntoAJumble(bumble, mumble); // maybe throws
}
Now I’m using C++
EH for the error cases. The code is much cleaner.
However:
- all that cleanup code is still there, you just don’t have to, and actually, can’t see it
- you get a bunch of EH related metadata associated with the various places an exception could happen
- you’ve inflicted other partial teardown cases on the compiler that previously folded
- for instance you can’t assume that the
bumble
pointer is null if themumble
fails, that's going to create a unique exit path
At this point I could ask 100 people what code was generated for the above and perhaps one could answer correctly. And this is a trivial example. The apparent economy of this pattern is a lie. Letting this evolve naturally, what actually happens is that you see people creating classes that look like this:
class Jumble {
...
SharedPtr<Mumble> p1;
SharedPtr<Bumble> p2;
SharedPtr<Rumble> p3;
SharedPtr<Fumble> p4;
SharedPtr<Humble> p5;
SharedPtr<Stumble> p6;
};
And then you ask, “Really? You have six interior objects all of which have independent lifetime with shared ownership? Really? Do you know that you just spend about 5kB on constructors and destructors and your teardown time will be increased by something like 3000 clocks for no reason?”
And to this you will get a shrug.
And all we did at this point was admit RAII
(!!!). That isn't even one of the most expensive patterns!
In STL
these same lifetime issues result in rampant use of std::string
to avoid bugs which:
- is huge at 32 bytes a pop
- always has its own copy of the text
- is mutable!
It’s quite common to see patterns like:
DoSomething("the args"s)
Which results in the creation of a std::string
so that we can pass it to DoSomething
which of course can't do anything real with a std::string
so at some point it makes a system call to something that uses an actual string so it has to call c_str
so that it can pass a const char *
to an API which would have been just as happy to have the original string literal. So we allocate, free, basically invisibly, for no reason at all.
There are things like this everywhere in the modern C++
. They can be avoided, but the more often you use idiomatic C++
patterns the more likely you are to run into this kind of junk.
RAII
is nothing like a panacea, it's just another set of tradeoffs. My experience with C++
is really very simple to explain: The more of it (C++
) that you use the worse your codebase gets. It's sad that it comes to that. The best C++
code bases were very careful to opt in lightly. This is very hard to explain to newcomers who just want to use the language the way it appears in the books.
Could we possibly have a language where using it like it says in the book is a good idea?
Could we possibly have a language where if you forget to test every line of code there’s still a decent chance that you’re not screwed?
Tech 3: Rust
and why I think it's the future of systems programming
I said future, but frankly maybe it’s already the present.
Let’s look at our sample again, in Rust this time, this time we even include a stub for mumble_the_bumble_into_a_jumble
which we didn't do previously.
fn mumble_the_bumble_into_a_jumble(bumble: &Bumble, mumble: Mumble)
-> Jumble
{
// Process the borrowed Bumble and owned Mumble
// Create a new Jumble using the data from Bumble and Mumble
let jumble = Jumble {
// Jumble initialization with data from Bumble and Mumble
};
// Perform any necessary operations with Bumble and Mumble
jumble // Return the Jumble
}
// our previous examples were just this bit
fn stumble() -> Jumble
{
let bumble = create_bumble();
let mumble = create_mumble();
mumble_the_bumble_into_a_jumble(&bumble, mumble);
}
Wow it looks like the book!
The ownership is clear.
There are no ridiculous template types for basic notions like a regular boring pointer.
The code-gen will be great.
Oh but you say I cheated, where are all those error cases? Let’s add those back in. Brace yourself for the code explosion.
fn mumble_the_bumble_into_a_jumble(bumble: &Bumble, mumble: Mumble)
-> Result<Jumble, String>
{
// Process the borrowed Bumble and owned Mumble
// Create a new Jumble using the data from Bumble and Mumble
let jumble = Jumble {
mumble,
// Jumble initialization with data from Bumble and Mumble
};
// Perform any necessary operations with Bumble and Mumble
// maybe return Err(something) in here
Ok(jumble) // Return the Jumble as a Result
}
// our previous examples were just this bit
fn stumble() -> Result<Jumble, String>
{
let bumble = create_bumble()?;
let mumble = create_mumble()?;
mumble_the_bumble_into_a_jumble(&bumble, mumble)
}
Did you catch the explosion? There are now two ?
characters that are required.
Once again, I read the book, do the book things, and I get good code.
Importantly, the error transmission is clear, and I have no wonky invisible EH overhead. I could throw
if I wanted to but I don't have to. I have clear semantics around borrowing that I cannot get wrong so it's very clear which function frees what. The cleanup code will be present in exactly one place.
If I was using this same pattern with strings or collections I can easily do the most common operations such a borrowing slices of strings or collections generally and I get no weird leaky memory situations or use-after-free situations because I have to declare the borrow and it can be enforced at compile time.
And I still get great code.
In fact, I get great code because I had to specify this stuff, so the point at which cleanup is needed is deterministic. I don’t have to avoid patterns in the book. I use the book. The book works.
The only remaining problem I really have here is that there is a lot of invisible destruction and nearly invisible control flow (e.g. the ?
operator) but that's a small price to pay for this level of predictability. I can't really have it both ways and most people I think don't want to look at all that cleanup code.
Mistakes I can’t make in this world include:
- I can’t forget to check the error codes before using my values
- I can’t forget to free my resources
- I can’t keep using data I no longer have the right to use
- I can’t loan someone my data in such a way that they can continue to use it after it’s dead
And best of all I have a clear and simple story about which code can mutate data and which can’t, and hence my locking and sharing can be much simpler. I didn’t include a sharing sample in the interest of brevity, but suffice to say “do what the book says” works great.
A Brief Word on Correctness and Secure Code
I don’t have to get too far into this. Anyone who has worked on a large codebase in C
or C++
knows that even if you give the best engineers you can hire the best tools we know how to make, those engineers will create use-after-free exploits faster than they can fix them. This isn't simply my observation, you can just look at CVE data for the last few years and make up your own mind.
The situation doesn’t really improve much with Modern C++
although there is tremendous bluster about it. The only thing I can say with certainty about Modern constructs is that they bloat your code and make it harder for both newbies and experts to understand what is actually going on. If you doubt this hypothesis, simply ask any developer you like how big std::function
is, or what the cost of a std::shared_ptr
assignment is. For the answers, see the references below.
It is very easy to delude yourself into thinking you have null-safety with modern pointer types but you don’t. The same data-aliasing problems persist and they are controlled by the same underlying lifetime assumptions we had to make in regular C
code. See the teardown on pointer hazards below for more information.
Conclusions
Opinions might vary on the strength of these arguments but probably the strongest argument of all is this: C
programmers, especially programmers that can successfully use C
code at the system level, are a dying breed. I should know, I'm one of them. Modern C++
has so many hazards and is so costly that I can't but view it as anything less than a total failure in the systems space. I can't begin to tell you how disappointing this is to me. Whether it's suitable for your problems is entirely up to you, but I'm convinced my problems are not helped by the use of Modern C++
.
In contrast, Rust
is lean, also modern, and squarely addresses the most important weaknesses that were in C
without turning into a meta-programming hell-scape. By providing solutions to problems of data sharing and lifetime generally and creating solutions that are both readily practicable and performant they've done more to advance the state of the in systems programming than nearly four decades of C++
. When I was in college, we used to refer to COBOL
as an aging dinosaur. C++
is older now than COBOL
was then, and it's far less safe to use. Nobody complains about use-after-free in COBOL
.
I don’t see a future where C++
continues to be the systems language of choice and frankly I couldn't be happier about it. I'm very tired of telling developers not to use their tools the way the manual describes them. I'm tired of writing alternate "This actually works" manuals.
We can have a more “Pit of Success” correctness and performance story. We still have a responsibility to test everything, but we can start on a much better foundation.