C++ Makes Too Many Poor Design Decisions

C++ Makes Too Many Poor Design Decisions

C++ Makes Too Many Poor Design Decisions

Author | Jimmy Hartzell

Planning | Yun Zhao

The author of this article, Jimmy Hartzell, is an expert who teaches advanced C++ courses within his company. However, after returning to “modern” C++, he is very disappointed with the improvements made to this language. In this article, the author will focus on various “warts” of C++, which make him feel a sense of “disappointment” regarding the design decisions.

The author also has extensive experience with Rust but does not compare C++ with Rust or other programming languages; instead, he explores whether these seemingly technical “warts” make sense from the perspective of C++.

C++ Makes Too Many Poor Design Decisions

“Tricks” Are Not Impressive and Not Worth Showing Off

Returning to C++ development, I was filled with confidence and anticipation: I still possess various “quirky” skills in C++ and can still work efficiently. Moreover, I have now been using a more modern programming language, so there should be fewer legacy issues. However, the reality is that C++ brings more pain.

There are many features in Rust that I miss, which could easily be added to C++, such as obvious safety features and first-class support for sum types (known as enums in Rust) or tuples. (Clarification: std::tuple and std::variant are not first-class support; these two are actually quite cumbersome.)

However, before discussing the quirky “paper-cut” tricks, I want to address one of the main defensive “phrases” I see regarding C++, which I find particularly confusing:

C++ is a great programming language. These complaints come from those who are incompetent. If they were better programmers, they would appreciate how C++ works and wouldn’t need their help. Languages like Rust are not helpful for true professionals.

Clearly, this phrasing is a bit “dramatic,” but I have seen this attitude many times. My most charitable interpretation of it is that the difficulties of C++ are a hallmark of its power and a natural cost of using a powerful programming language. However, in many cases, it feels like a form of elitism: making things easier for “weak” programmers is a meaningless populist idea, and great programmers do not benefit from making things easier.

As someone who has spent most of my career doing professional C++ programming and teaching (internally) advanced C++ courses, this is nonsense to me. I do know how to navigate many of C++’s “paper-cut and lightning-avoidance” tricks and am happy to do so when dealing with C++ codebases. But despite my experience, they still slow me down and distract me, diverting my attention from the actual problems I am trying to solve and leading to poorer code maintainability.

As for the benefits, I do not see these tricks in C++ as impressive. Unless it’s a legacy codebase or optimizations available only in specific compilers that happen not to support Rust, most of these tricks deal with other issues unrelated to the actual design of the programming language.

While I take pride in my C++ skills, I worry that these seemingly “better techniques” can make them partially obsolete. Even with features that make it easier, I do not appreciate it. In most cases, these “tricks” do not help C++ solve more work problems for me; instead, C++ creates unnecessary additional work problems because using these so-called tricks distracts you from the purpose of your work—don’t do that. Don’t get me started on header files!

C++ Makes Too Many Poor Design Decisions

Pointless “paper-cut” tricks with little significance

I also wish my programming language were beginner-friendly. I always work with other programmers of various skill levels, and I would rather not have to correct my colleagues’ mistakes—or my earlier, more foolish versions of mistakes. While I do not agree with sacrificing functionality to make a programming language more beginner-friendly, many, if not most, C++ features that are unfriendly to beginners (and annoy experts) do not actually make the language more powerful.

That said, here are the biggest pointless “paper-cut” tricks I noticed when returning to C++ development from Rust.

C++ Makes Too Many Poor Design Decisions

const Is Not Default

When const can mark parameters, it’s easy to forget to mark parameters. You might just forget to enter the keyword. This is especially true for this, which is an implicit parameter: you don’t have time to explicitly enter the this parameter, so if it isn’t marked appropriately, it doesn’t look interesting.

If C++ had the opposite default, where every value, reference, and pointer were mutable unless explicitly declared const, we would be more likely to correctly declare each parameter based on whether the function needs to change it. If someone includes the mutable keyword, it’s because they know they need it. If they need it but forget, the compiler error would remind them.

Now, you might think this isn’t important because you can avoid using const or not have functions with unnecessary features—but sometimes you have to accept these things in C++. If you get parameters by non-reference const, the caller can only use lvalues to call your function. But if you get parameters by reference const, the caller can use either lvalues or rvalues. Therefore, to use certain functions naturally, you must get their parameters by const reference.

Once you have a const reference, you can only (easily) call functions that accept const references, so if any of those functions forget to declare parameters const, you must include const_cast—or change the function later to correctly accept const.

In case you think this is just a careless beginner mistake, note that many functions in the standard library must be updated to replace const_iterator or supplement iterator when they correctly discover that functions like const_iterator: erase make sense. It turns out that for functions like erase, the collection must be mutable, not the iterator—the maintainers of the C++ library made a mistake from the start.

C++ Makes Too Many Poor Design Decisions

Forced Copy

In C++, objects are default privileged ways of object behavior. If you do not want an object to be copyable, and all its fields are copyable, you usually have to mark the copy constructor and copy assignment operator as = delete. By default, the compiler writes code for you—code that may not be correct.

However, if you do make your class move-only, be careful, as this means you cannot use it in some cases. In C++11, there was no ergonomic way to capture by move in a lambda—which is often how I want to capture a variable into a closure.

This has been “fixed” in C++14—because when you want to use defaults from the start, you can now use extremely clumsy move capture syntax.

Even so, good luck using lambdas. If you want to put it into a std::function, then to this day you are still out of luck. std::function expects the objects it manages to be copyable, and if your closure object is move-only, it won’t compile.

This issue will be resolved in C++23 with std::move_only_function, but in the meantime, I am forced to write classes with copy constructors that throw some kind of runtime logic exception. Even in C++23, copyable functions will still be the default assumption.

Strangely, because most complex objects, especially closures, should never be copied. In general, copying complex data structures is a mistake—missing & or missing std::move. But this is an error with no warnings, and there are no obvious signs in the code indicating that complex operations requiring significant allocations are being performed. This is an early lesson for new C++ developers—do not pass non-primitive types by value—but even experienced developers can mess this up from time to time, and once it enters the codebase, it’s easy to miss.

C++ Makes Too Many Poor Design Decisions

Getting Parameters by Reference: Unnatural Design

Returning multiple values in C++ by tuple is unnatural. std::tie can do this, but calls to std::make_tuple are verbose and distracting, not to mention you will write it uncomfortably, which is always bad for those reading and debugging your code.

Side note: Someone commented about structured bindings as if this solved the problem. Structured bindings are a great example of a half-hearted attempt that modern C++ supporters love to reference. Structured bindings help some, but if you think they make returning by tuple ergonomic, you are wrong. You still need to write std::pair or std::tuple in the function return statement or in the function’s return type. This isn’t the worst, but it’s still not as lightweight as complete first-class tuple support, and it’s not enough to convince people not to use parameters, which is my real complaint. std::make_tupleEven so, it’s not that output parameters (or input-output parameters) are bad; it’s that they are bad in C++ because there’s no good way to express them.

So what should we do? The clumsiness of tuples leads people to turn to parameters. To use output parameters, you end up getting parameters by non-reference const, which means the function should modify that parameter.

The problem is that this is only marked in the function signature. If you have a function that gets parameters by reference, that parameter looks the same at the call site as a by-value parameter:

// Return false on failure. Modify size with actual message size,
// decreasing it if it contains more than one message.
bool got_message(const char *void mesg, size_t &size);

size_t size = buff.size();
got_message(buff.data(), size);
buff.resize(size);

If you quickly read the calling code, that call might look like resize is redundant, but that’s not the case. size is being modified in got_message, and the only way to know it’s being modified is to look at the function signature, which is often in another file.

For this reason, some prefer to pass out parameters and in-out parameters by pointer:

bool got_message(const char *void mesg, size_t *size);

size_t size = buff.size();
got_message(buff.data(), &size);
buff.resize(size);

If pointers are non-nullable, that would be great. What does nullptr mean in this case? Does it trigger undefined behavior? What happens if the caller passes their pointer to it? Developers often forget to document how functions use null pointers.

This can be solved with non-nullable pointers, but few programmers actually do this in practice. When something is not a default value, it often isn’t used in the right place. The sustainable answer to this is to change the defaults rather than engage in a heroic attempt to fight against human nature.

C++ Makes Too Many Poor Design Decisions

Method Implementations Can Be Contradictory

In C++, every time you write a class (especially lower-level classes), you have the responsibility to make decisions about certain methods that hold special semantic significance in the programming language:

  • Constructor (copy): X(const X&)
  • Constructor (move): X(X&&)
  • Assignment (copy): operator=(const X&)
  • Assignment (move): operator=(X&&)
  • Destructor: ~X()

For many classes, the default implementations are sufficient, and you should rely on them if possible. Whether this is viable depends on whether simply copying all fields is a wise way to copy the entire object, which is easy to forget to consider.

However, if you need a custom implementation for one of them, then you need to write all of these. This is known as the “Rule of Five.” You must write all of these even if the correct behavior of both assignment operators can be fully determined by the appropriate constructors and destructors. The compiler can default the assignment operators to reference these other functions and thus always be correct, but that is not the case. Implementing them correctly is tricky and requires techniques such as explicitly preventing self-assignment or swapping with by-value parameters. In any case, they are boilerplate, and they are another thing that can go wrong in programming languages with a lot of such content.

Side note: Indeed, many classes can use = default for all of these methods. However, if you customize the copy constructor or move constructor, you must also customize the assignment operator to match, even if the default implementation might be correct (if the language were defined more intelligently).The Rule of Five makes this clear, basically stating this. The complete rule is explained in the CPP reference. If you customize the copy or move constructor, the corresponding = default assignment operator will be erroneous. Beware! Note how the example code does not use = default assignment operators even though the assignment operator contains no logic.

C++ Makes Too Many Poor Design DecisionsC++ Has Made Too Many Poor Decisions

After seeing comments on Hacker News, I felt it necessary to add this section. Whenever someone complains about any issue in C++, someone will mention a newer version of C++ that fixes that issue. These “fixes” are often not that good and only feel like fixes when you get used to everything being a bit awkward.

The reasons are as follows:

  • The default way is still the old, bad way. For example, lambda capturing by move should be the default, while std::move_only_function should be the default for lambdas coming in C++23.

  • For this reason, and because the old, bad way never enabled warnings, even new programmers will continue to do things the bad way.

Of course, I understand that this is important for backward compatibility. But this is the whole problem: C++ has accumulated too many poor decisions. Why should the default be passing collections by copy, let alone lambda captures? I know historical reasons, but that doesn’t mean modern programming languages should work this way.

Even C++11 cannot eliminate the fact that raw pointers and C-style arrays have good syntax, while smart pointers look bad. Even C++11 cannot clarify that it is working around a language designed without the need for moves.

C++ Makes Too Many Poor Design Decisions

In Conclusion: C++ Ailments Are Hard to Cure

Unfortunately, I am very aware of why these decisions were made, and that is one of the reasons: compatibility with legacy code. C++ does not have a versioning system and cannot deprecate core language features. If a new version of C++ were created, it would no longer be C++—though I support efforts to convert C++ to new syntax and clean some of this up.

This is also the only benefit: continuity with history. While I can see the value in that, its value is very limited and narrow. However, if you ignore backward compatibility and existing large codebases, these “paper-cut” tricks do not make the programming language more powerful or better, they only make it harder to use. I have seen arguments supporting “manually maintaining header files,” which surprised me, telling me what benefits C++’s design choices on these issues have.

Some may say these things are trivial, but they all slow down programmers while also frustrating them. If you have enough experience, your subconscious might be adept at navigating these “tricks,” but imagine what those subconscious should have been paying attention to.

Imagine how quickly you could spot these mistakes in a junior colleague’s code review? How much longer would it take if you were a strict reviewer? If these issues were resolved, developers would become more efficient, more effective, and happier. Programming would also become both fun and quick.

Original link:

https://www.thecodedmessage.com/posts/c++-papercuts/

C++ Makes Too Many Poor Design Decisions

Leave a Comment