C++ Programming and Brain RAM
I have a tricky relationship with C++. There is a narrow subset of the language that, when properly used, I find to be a strict improvement over C. Specifically, careful use of namespaces, RAII, some pieces of the STL (such as
std::unique_ptr), and very small bit of light templating can actually simplify a lot of common C patterns, while making it a lot harder to shoot yourself in the foot via macros and memory leaks.
That said, C++ faces a choking combination of wanting to simultaneously maintain backwards compatibility and also extend the language to be more powerful and flexible. I’ve recently been reading the final draft of Effective Modern C++ by Scott Meyers. It is an excellently written book, and it does a superb job covering what new features have been introduced in the last couple of C++ versions, and how to make your code base properly take advantage of them.
And a lot of the new stuff in C++ is awesome. I had a chance to start taking advantage of the new features when I was working at Fog Creek on [MESSAGE REDACTED], and I was actually really pleasantly surprised by how much of an improvement the additions made in my day-to-day coding. In fact, I was so pleasantly surprised that I actually gave a whole presentation on how C++ didn’t have to be awful.
But reading through Scott’s book the last few days has also reminded me why I was somewhat relieved to effectively abandon C++ when I joined Knewton.
Take move semantics, one of the hallmark features of C++11/14. Previously, in C++, you always had to either pass around pointers to objects, or copy them around. This is a problem, because pointers aren’t easily amenable to C+±style RAII-based garbage collection, yet copies are very expensive. In order to get your memory safety and your speed, you end up having a lot of code where you semantically want something like
std::vector<something> generate_somethings(); ... std::vector<something> foo = generate_somethings();
but, for performance reasons, you have to actually write something closer to
void generate_somethings(std::vector<something> &empty_vector); ... std::vector<something> foo; generate_somethings(foo);
As much as C++ developers rapidly acclimate to this pattern, I think we can safely agree that it’s much less clear than the far-less-efficient first variant. You can’t even tell, simply by looking at the call site, that
foo is mutated. You can infer it, certainly, but you have to actually find the prototype to be sure.
In theory, move semantics (also known as rvalue references) allow C++ to explicitly acknowledge when a value is “dead” in a specific context, which allows for much greater efficiency and clarity. The reason it’s called “move semantics” comes from the idea that you can move the contents of the old object to the new one, rather than copying them, since you know that the old object can no longer be referenced. For example, if you’re moving a
std::string from one variable to another, you could simply assign the underlying
char * and length, rather than making a full-blown copy of the underlying buffer, even if neither
string is a
const. The original can’t be accessed anymore, so it’s fine if you mutate memory that the original owned to your heart’s content.
In practice, though, things aren’t that simple. Scott Meyers helpfully notes that
std::movedoesn’t move anything, for example […]. Move operations aren’t always cheaper than copying; when they are, they’re not always as cheap as you’d expect; and they’re not always called in a context where moving is valid. The construct
type&&doesn’t always represent an rvalue reference.
In fact, Scott’s point is obvious if you understand how C++ gets realized under the hood. For example, when returning from a function, anything on the stack that’s returned via rvalue reference is going to have to be copied, so you’re only going to win if the object has enough data on the heap that moving actually saves copies. But understanding that requires you already bring a lot of C++ knowledge to the table.
This is a fractal issue with modern C++. Congratulations, you get type inference via
auto type inference works via template type inference, so make sure you understand that first. This comes up in especially fun situations, like
Foo &&bar = quux() being an rvalue reference,
auto&& bar = quux() not being one.
Or to quit picking on rvalue references, how about special member generation—those freebies like default constructors and copy constructors that the compiler will write on your behalf if you don’t write them? There are two new ones in C++, the move constructor and the move assignment operator, and the compiler will write them for you!..unless you wrote one of the two, in which case, unlike all the other special members, you have to write both. But at least that’ll be a compile-time issue, whereas, if you have an explicit copy constructor, you actually won’t get either move-related special members autogenerated; you’ll have to write both yourself if you want them. This isn’t purely academic: if you add a copy constructor and forget this fact, you may get a chance to enjoy an “unexplained” slowdown in your code when your silently generated move constructor vanishes.
To be clear again, these rules are emphatically not arbitrary. They make complete sense if you take a step back and think about why the standard would have mandated things work this way. But it’s not immediately transparent; you have to think.
And this is why I find it so amazingly hard to write code productively in C++. My brain has a limited amount of working memory. When I’m writing in a language with a simple runtime and syntax, such as C, Go, Python, Smalltalk, or (to an arguably slightly lesser extent) OCaml, then I need dedicate relatively little space in my brain to nuances of the language. I can spend nearly all of my working space on solving the actual problem at hand.
When I write in C++, by contrast, I find that I’m constantly having to dedicate large amount of thought to what the underlying C++ is actually going to do. Is this a template, a macro, or an inline function? Was that the right choice? How many copies of this templated class am I actually generating in the compiled code? If I switch this container to have
const members, is that going to speed things up, or slow them down? Is this class used in a DLL for some silly reason? If so, how can I make this change without altering the vtable? Is this function supposed to be called from C? Do I even need to care in this instance?
It’s not that I can’t do this. I did it for years, and, as I noted, I was voluntarily, intentionally working in C++ for the last couple of months I was at Fog Creek. Sometimes, at least for now, C++ is unquestionably the right tool, and that project was one of those times. But as happy as I am that C++ is getting a lot of love, and that working with it is increasingly less painful, I can’t help but feel that the amount of baggage it’s dragging around at this point means that I have to spend far too much of my brain on the language, not the problem at hand. My brain RAM ends up being all about C++; most of the problem gets swapped to disk.
C++ still has a place in my toolbox, but I’m very, very glad that improvements elsewhere in the ecosystem are ever shrinking the tasks that require it. I’m optimistic that languages like Rust may shrink its uses even further, and that I may live to see when the answer to “when is C++ the best tool for the job?” can finally genuinely be “never.” In the meantime, if you have to write C++, go buy Effective Modern C++.
I’m oversimplifying slightly, mostly by omitting things like
boost::scoped_ptr, but they don’t really change my point. ↩︎
Effective Modern C++, pp. 355. ↩︎
Did you know templates had type inference? No? Me neither. I somehow was able to work in C++ for several years without learning this fact, and am now scared to look back at my old code and figure out how dumb some of it is. ↩︎
Want to comment on this post? Join the discussion! Email my public inbox.