-std=c++pain

A few years ago (~2021) I was creating a little project that made use of the BGFX rendering library. The details of that project aren't particularly relevant here, outside of the fact that most of my motivation to use BGFX over something like Vulkan or OpenGL directly was because it felt to be the "least bad" option. Through looking at the author's github profile, I found a manifesto of sorts: "Orthodox C++"

At the time I read this, I had gotten rather annoyed at the article. It makes several assertions about what makes good C++ code and only backs up half of them (like what's the problem with using <cstdio> over <stdio.h>?), and it does so with an apparent attitude that comes from on high. It doesn't help that I had quite a bit of difficulty setting up BGFX for my own project, so I was already inclined to dismiss his suggestions.

For a while I had taken an attitude that C++ isn't actually that hard. Segfaults can be trivially traced with any debugger. Pointers exist in other "not hard" languages, and they just hide that fact from you. Constructors & destructors are just called according to scope, and RAII takes care of most of the brainpower required there anyways. Etc.

The perspective that I have now is that I had formed that opinion from writing my own code for my own purposes with my own requirements, mostly by myself. I had formed habits which were good enough to dodge a lot of the pitfalls of C++. After having to write C++ code according to others' requirements (largely in university for a professor/TA), I now see a bunch of foot guns that I had simply never encountered before, and while I still disagree with some of the Orthodox C++ mandates, how and why people argue their C++ is the only good C++ makes a lot more sense to me. And yes, my C++ is better than yours /s.

The Impossible Sum Type

One of the projects we were assigned in university was somewhat simple: we'd be given a few files that contained a list of bank transactions (deposits, withdrawals, transfers, opening/closing accounts, etc.). We needed to write a program that would parse this list of transactions and then print the state of all the bank accounts and their funds if all those transactions were executed.

My immediate thought when hearing these requirements is that the tool to use here is std::variant. I could easily create some statement like using Transaction = std::variant<WithdrawalTransaction, DepositTransaction, ...>;, and have it all work pretty seamlessly. This, in fact, worked quite well. At least, until... just before submitting I tested compiling with the -std=c++11 flag as required for the assignment. As it turns out, std::variant was added in C++17. No problem, I thought, I could easily swap out the type with my own custom-made one (I was already using using after all).

It was not easy.

Typically when I need to create a sum type in plain C, the way I do it is something like this:


typedef ... MySumType_A;
typedef ... MySumType_B;
typedef ... MySumType_C;

typedef struct {
    enum {
        kMySumType_A,
        kMySumType_B,
        kMySumType_C,
    } which;
    union {
        MySumType_A a;
        MySumType_B b;
        MySumType_C c;
    } what;
} MySumType;

I simply use which to keep track of the underlying type. Of course, the C++ version doesn't need all the typedefs, but the idea is the same. Or, unfortunately, should be the same.

You see, one of the "transactions" we needed to handle was creating a new account. This would consist of an account ID and a first/last name. This may seem trivial to implement, and by all rights should be, but I was storing the name as a std::string. Due to having a non-trivial constructor & destructor, this removed the implicit constructor & destructor for the transaction type overall. Reason being: a union can be any one of its subtypes, but determining which one is application-defined (in my case with which). The language throws up it's hands and says "you deal with this" because it cannot know which constructors and destructors to call. Similar logic also applies to the assignment operator.

This is mildly frustrating, but I can easily implement that logic. It shouldn't be too bad. When I tried to implement this, however, it would always break when I tried assigning a transaction to an account creation transaction. What gives?

Well, if the right side of the assignment was a CreateAccountTransaction, then it would call operator=(CreateAccountTransaction const &). This would just set which = kCreateAccount and what.openAccount = rhs. See the problem?HintWhat does what contain, and which operator= is called?

What was happening is that what would get filled with whatever (either from being uninitialized or being filled with another transaction). Then, it would reach what.openAccount = rhs which has a CreateAccountTransaction on both sides. This would call the operator= for the transaction and the strings inside, which because it's an assignment operator would assume the left side of the operand had already been initialized. It would see data that looked like a valid pointer, and attempt to free it despite not actually being valid, causing the allocator to segfault.

The solution I wrote for this problem is absolutely terrible:


Transaction& operator=(CreateAccountTransaction const & that) {
    if (this->which == kCreateAccount) {
        this->what.createAccount = that;
    } else {
        this->which = kCreateAccount;
        // What the hell
        // Also NOTE: doesn't properly destruct old contents for other types. 
        // Fine here because all other types have trivial destructors.
        new(&this->what.createAccount) CreateAccountTransaction(that);
    }
}
Why don't you just use inheritance?

Okay. Deep breath.

Yes, that would work. If I were to go back and do this again I'd probably do it that way, particularly because it's the only tool given in C++11 that can really handle this. However I will note that we weren't expected to know how to do that in this class (we were for future classes though). If that response is satisfactory I might recommend closing this detail section unless you want a lot of angry opinions below.

Inheritance has a few niche use cases, and I think this is one only insofar as there's no "real" sum type in the language (like here) and/or you make some assumptions about how the code is going to be used that in this case don't practically apply here (though may in a large organization). Besides this, it's a tool that forces complexity throughout the code and makes debugging applications needlessly complex.

It Assumes Code is Immutable

The assumption I am referring to is that you have some people who are writing library code that handles a large but not huge variety of types with a lot of different particular contracts, and other people writing application code who have a tenuous at best communication channel to the former people. In other words, it helps you work with the constraints of Conway's law.

The argument in favor of inheritance over using a sum type (like std::variant) is that it allows you to add new types with similar behavior without having to modify the original type. To which I'd ask: what's the problem with modifying the original type?

This is what I mean when I say it's to work with the constraint's of Conway's law. I simply see no other valid reason that would behoove you to not update your code for your new requirements. For this code, who would be tasked with adding a new Transaction, yet be unable to update the Transaction and the code that uses it? If that is indeed a problem, that sounds like a failure of the person or organization, not the code itself.

If inheritance were only the "least good" option, I'd just use it and move on. Problem is that it has a number of drawbacks, some of them I've never seen discussed.

It Confuses Control Flow

The obvious drawback is that if you call a method on an object, you have no guarantee where it will go unless you know the exact type. It doesn't help that using inheritance encourages putting similar bits of functionality across the codebase. Combined, this means that attempting to follow the control flow in a debugger inevitably navigates through several different objects, each with internal logic and state that is a mix of common inherited and bespoke behavior, each pretending to be a black box. All to do something simple like checking if the next character on a stream is whitespace. Even if you never find yourself using a debugger, it makes "Go to definition" utterly unusable.

That isn't just a problem for navigating and understanding the code as a programmer, it makes it harder as the compiler. If the compiler doesn't know the exact type, then it has to assume the worst: most method calls have to be treated as though they could make any modification to any relevant state, because even if you might intuit the scope it will mutate, there can theoretically be a method that violates that intuition.

It Requires Complex Memory Logic

What I just described above is a fairly common complaint of inheritance, but there's another thing about it that I don't see talked about. It will require that I back up a bit to explain though.

Usually, it's best to default to stack allocation for your objects. This is for a variety of reasons; it's super cheap to allocate (a single add & subtract per function call), it avoids cluttering the heap, it helps the compiler handle lifetimes for you, etc. If you heap allocate something, then you aught to have some reason that requires you to do so. In fact, you can pretty easily enumerate the reasons:

  1. You don't know the size of an object until runtime (size-unbound)
  2. You don't know how long an object will live until runtime (time-unbound)
  3. Your object is large enough to cause problems in the stacknote This one isn't super relevant here but I kept it for the sake of completeness. With large enough objects on the stack you can more easily run into stack overflows (esp. if the stack is small), and zeroing out a large block of memory is going to be more efficient from something like calloc.

Point 1 is by far the most common. Most obviously any kind of ArrayList, std::vector, etc. fits this bill. Point 2 is one that is a lot less common but still vital to understand as a programmer. It can show up a lot in game engines where entities are created and destroyed a lot over the lifetime of the program, and even more when a reader and writer are operating on their own schedules (such as with async code).

What does this have to do with inheritance? Well, as described earlier, inheritance requires us to treat every object as though it could be basically anything. The effect is that nearly every non-primitive in a program must be treated as size-unbound, because we need to handle the possibility that the object we're handling has extra fields we're unaware of.

That on it's own is mostly a non-issue. You're just passing references and pointers around now, right?

Mostly... The actual issue is that the heap and C++ as a language are agnostic to whether we're handling space-unbound or time-unbound objects, and the latter is very mentally taxing to work with without a garbage collector. The result is that in our attempt to get polymorphism we have now littered the code with news and deletes and std::shared_ptrs and all the rest.

This is where I start to get a little conspiratorial because I am unfortunately too young to have been there when C++ gained traction and see Java/C# replace a large chunk of it. I suspect that this issue has had a huge influence on how software developers treat languages with manual memory management.

Using new for dozens of different objects that only tangentially need it is doing memory management on nightmare difficulty. Of course, if that's what you're doing, pointers are hard. Memory is hard. The language is hard. And if a language comes along and tells you you'll never have to write another delete in your life you'd only ever look back at the "old" way with contempt.

C++ has tried reducing this issue by adding std::unique_ptr and std::shared_ptr. These are better than nothing, but are still falling into the trap of treating space-unbound and time-unbound objects the same (more details in a future article?).

But seriously, a lot of my "C++ isn't that hard" attitude came from just not using new and delete. As soon as I was given no choice I saw all the flaws with the language, because it requires hand consideration of the lifetimes of each and every object.

I ended up losing my evening to this problem. But hey, at least I got a 4.0 for the class.