Maintainable code through the use of modern C++ features
Author: Dominik Berner, bbv Software Services
Contribution – Embedded Software Engineering Congress 2018
The new standards have significantly modernized the C++ programming language and, in some cases, introduced entirely new programming paradigms into the world of C++.
The "major" changes, such as variadic templates, auto, move semantics, lambda expressions, and others, have generated much discussion and are therefore widely known. Besides the language features, the standard library has also undergone significant expansion, and many concepts from libraries like Boost have been standardized. In addition to these very noticeable (and sometimes controversial) features, there are a number of small but important language enhancements that are often less well-known or overlooked.
Precisely because these features are often very small and sometimes almost invisible, they have great potential to simplify the daily work of programmers and to gently modernize code without major interventions. Often, when working with existing code, one doesn't have the option of making large structural or externally visible changes, but this is exactly where these "small features" can help keep code up-to-date and maintainable.
Modern, maintainable code
Maintainability, readability, and code quality are indispensable aspects of modern software development. Software's advantage over hardware lies in its relative ease of adaptation and revision, and this is often a conscious and recurring process, especially in agile development environments. This inherent volatility further underscores the importance of these quality attributes, as poor code negates the benefit of easy maintenance. While concepts like clean code, the SOLID principle, and paradigms such as low coupling and strong cohesion are crucial, quality begins with the language itself. Utilizing the language's built-in features and functions helps clarify the intent behind the code and often facilitates the automatic verification of that intent. Furthermore, this can often reduce the amount of code written, reinforcing the principle of "less code means fewer bugs.".
An example to illustrate
A simple algorithm can be very complicated to understand if the notation doesn't match expectations or if the author has come up with a particularly clever optimization hack. For example, swapping two variables x and y can be written as follows (see figure in the...). PDF).
While this XOR swap is memory-efficient and has its place in very specific cases, the operation is not intuitively readable. Even with a code comment, this simple example forces unnecessary thought on the reader. In contrast, the following example reads much more easily (see figure in the...). PDF).
The following 10 small features and extensions, mainly from modern C++ standards, help to keep code compact and readable, thus improving code quality.
Control inheritance with override and final
Inheritance is both a blessing and a curse for many programmers. On the one hand, it often helps to avoid code duplication; on the other hand, there are many pitfalls—especially in C++—that must be considered. Particularly when refactoring base classes, it frequently happens that dependent classes are forgotten, and this is only noticed at runtime. The `override` keyword has provided a solution since C++11. Whenever a function in an inheritance tree is overridden, `override` should be used. This automatically makes the overridden function virtual, and the compiler is given the opportunity to verify whether a method is actually being overridden and whether the overridden method is indeed virtual. (See figure in the...) PDF)
Even more control over the inheritance tree is gained by completely preventing inheritance from a certain point onward. The `final` specifier indicates that a class or virtual function cannot be overridden. While this doesn't reduce the amount of code written, it clearly communicates the intention behind a piece of code: that no further inheritance is desired. The compiler even helps with this by failing compilation if an attempt is made to override inheritance. (See figure in the diagram.) PDF)
using declarations and constructor inheritance
Duplicating code is a programmer's worst nightmare, even if it involves generated code. Using declarations allow programmers to "import" a symbol from one declarative region, such as namespaces, classes, and structures, into another without generating additional code. With classes, this is particularly useful for directly inheriting constructors from base classes without having to rewrite all the variants. Another example is to explicitly define covariant implementations in derived classes. This clearly signals to the reader that a "foreign" implementation is being used, one that has not undergone any functional modification. (See figure in the...) PDF)
This has worked for classes and structures for some time; since C++17, inheriting symbols also works for (nested) namespaces: (see figure in the PDF)
Forwarding constructors
Other high-level programming languages have long supported constructor chaining, and since C++11, this is finally possible in C++ as well. The advantages of less duplicated code, resulting in easier readability and thus better maintainability, are obvious. This is particularly helpful for constructors that perform complex internal initializations and/or checks, and it promotes the implementation of the RAII (Resource Allocation is Initialization) paradigm. (See figure in the...) PDF)
In conjunction with the use of the aforementioned constructor inheritance with `using`, code can be compressed even further. (See figure in the...) PDF)
= delete – Deleting functions
Less code means fewer bugs, even in generated code. So let's make it easier for the compiler to generate code we don't want or need. The `delete` keyword for function declarations—not to be confused with the corresponding expression for deleting objects—is another very powerful extension in C++11, allowing a programmer to not only signal an intention but also enforce it through the compiler. Using `= delete` explicitly ensures that certain operations, such as copying an object, are not permitted. Of course, the "Rule of Five" should also be observed when deleting functions. (See diagram in the...) PDF)
Guaranteed prevention of copies
Guaranteed copy elision is usually invisible to the programmer, but it holds great potential for smaller and cleaner code. This elision prevents unnecessary copies of temporary objects from being created when they are immediately assigned to a new symbol after creation. While some compilers like gcc have supported this for some time, with C++17, copy elision was incorporated into the standard as guaranteed behavior. Besides generating less code, it allows the programmer to enforce their intention that an object should not be copied or moved with even greater consistency. This can be expressed very clearly using the `= delete` command mentioned above. (See figure in the diagram.) PDF)
Structured Bindings
Classes and structures are not the only way to structure data handling. The standard library also provides a whole host of data containers for precisely this purpose. With `std::tuple` and `std::array`, C++11 introduced two data structures with compile-time known sizes. While `std::array` represents a relatively simple modernization of C arrays, `std::tuple` created a generic way to conveniently pass heterogeneous data around within a program without requiring the programmer to create pure data classes or structs.
Since C++17, accessing the contents of these data structures has been very lightweight thanks to structured bindings (see figure in the PDF).
It's important to note that all variables here have the same const-ness and are read either by reference or by value. Structured bindings also work with classes, but this is somewhat problematic because the semantics of class members don't impose a strong order on them. There are ways to reimplement this semantics, but this is comparatively complex.
Strongly typified enums
One of the most frequently used methods for creating custom data types with clearly defined value ranges, even in C, was the use of enums, and they remain popular today. A common complaint is that type safety is often insufficiently ensured when using enums. In the past, it was possible to assign a value of one enum type to a variable of another enum type. With the new standards, this is no longer possible if used correctly. Adding the keyword `class` or `struct` to an enum definition makes it a strongly typed data type, and using it with a different enum type will result in a warning or a compilation error, depending on the configuration. As an added bonus, since C++11, the underlying data type of an enum can also be explicitly specified, which improves code portability. (See figure in the...) PDF)
Time literals with
A very common use of data with clear, but not always linear, value ranges, especially in applications with strict timing requirements, is of course time itself. Handling time units is a nightmare for many programmers. The reasons are manifold, ranging from the non-linear division of seconds, minutes, and hours to the fact that confusion can quickly arise regarding the time unit in a call like `sleep(100)`. Are we talking about seconds? Milliseconds? With the introduction of `std::chrono` in C++11 and the addition of time literals, handling becomes considerably easier. These literals allow time values to be declared in the code with a simple suffix, specifying a fixed unit or resolution. It delivers everything from microseconds to hours. By using the time units provided by std::chrono, time values can be converted at compile time, eliminating the need for tedious manual conversion at runtime. (see figure in the diagram) PDF)
Branches with initialization
At first glance, the introduction of direct initialization in if and switch statements in C++17 seems like a way to write code a little more compactly. Another, somewhat hidden advantage is that programmers can more clearly express their intention that a symbol should only be used within a branch. Having the initialization directly next to or within the condition also prevents the risk of it being (unintentionally) separated from the branch during refactoring. (See figure in the...) PDF)
Direct initialization can be used very elegantly in conjunction with the structured bindings mentioned above. The following example attempts to overwrite an existing value in a `std::map`. The return value of the `insert` operation is directly unpacked into an iterator, along with a flag indicating whether the operation was successful, and can therefore be used directly within the query.
(see illustration in the PDF)
Standard attributes
Whenever a programmer makes an assumption, it should be documented in the code. Standard attributes allow some such assumptions to be documented with minimal effort. Attributes have been known for some time across various compilers, although the notation often differed between them. Since C++17, this has been standardized as `[[ attribute ]]`, which improves code readability. Furthermore, several standard attributes, supported by all compilers, have been introduced, allowing programmers to explicitly state their intentions for certain constructs.
- [[noreturn]]
Indicates that a function does not return, e.g. because it always throws an exception. - [[deprecated]] / [[deprecated(„reason“)]]
Indicates that the use of this class, function, or variable is permitted, but no longer recommended. - [[fallthrough]]
Used in switch statements to indicate that a case: block intentionally does not include a break statement. - [[nodiscard]]
Produces a compiler warning if a return value marked as such is not used. - [[maybe_unused]]
Suppresses compiler warnings for unused variables, e.g., in debug code.
Conclusion
These 10 small features and functions are, of course, only a small part of what modern C++ is all about. But by consistently applying them, code can be made more readable and easier to understand with relatively little effort, without having to rewrite the entire structure of an existing codebase.
Summary
The introduction of the new C++11/14/17 standards has significantly modernized C++. Besides major language features like smart pointers, move semantics, and varaidic templates, there are also a number of smaller extensions that often fly under the radar. But these features, in particular, can help to considerably simplify C++ code and make it more maintainable. This, coupled with new features in the STL, can help prevent many small errors right from the start of code writing. The fact that the code also becomes easier to read and more stable are further welcome side effects.
author
Dominik Berner is a senior software engineer at bbv Software Services AG with a passion for modern C++. For him, code maintainability is not a side effect, but a primary quality criterion essential for developing long-lasting software. As a blogger and speaker at conferences and meetups, he knows how to present content in a way that provides added value for the audience.
Implementation – our training & coaching
Do you want to bring yourself up to date with the latest technology?
Then find out more here MircoConsult offers training courses/seminars/workshops and individual coaching on the topic of implementation/embedded and real-time software development.
Training & coaching on the other topics in our portfolio can be found here. here.
Implementation – Expertise
Valuable expertise in the field of implementation/embedded and real-time software development is available. here Available for you to download free of charge.
You can find expertise on other topics in our portfolio here. here.
