My C++ defaults
21 January 2025
Every programmer has heard that C++ is complicated (its ISO standard is well over 2000 pages long). Some even claim that "C++ is a horrible language". I'd like to share a few heuristics that should make the language less complicated and horrible.
I've grown up on modern C++ (from C++11 onward) and it has been my main programming language for about 10 years. It has served me well for my university assignments, computer science PhD research work, and in various professional environments. It truly is a general purpose programming language.
General
The first thing I think about when starting a project is a build system. CMake is the closest thing to a standard build system for C++ so I gravitate towards it. I've written about how I use it in my previous blog post.
When choosing a C++ language standard, I usually stick to C++17 for now. Why not C++20 or something event newer? If there are no more restrictive requirements, I aim to support the latest Debian version because it (or something derived from it like Ubuntu) is a common choice for many systems. This allows me to rely on compilers and dependencies provided by the system package manager (e.g. apt) which eases the pain of deployment and setting up CI.
At the time of writing, the latest Debian version is 12 which supports GCC
version 12 which means that not all C++20 features are supported. In particular,
std::format
, calendar and time zone utilities, and modules are not supported.
That means that I would have to limit myself to a subset of C++20 that is
supported by a particular compiler version. I do not like that because it
confuses new people:
We are compiling with C++20, but the code written today is using
std::ostringstream
to format text... Why?
To avoid confusion, I just make the cut at the latest fully-supported C++ standard by the latest Debian version, which is C++17 at the time of writing. This limitation maintains portability of code, knowledge, and simplifies deployment.
Modifiers and attributes
I use modifiers and attributes consistently and liberally. Compile-time
constants are always static constexpr
instead of using #define
. Every
run-time variable that is not changing in a particular scope is marked const
.
I make an exception for the by-value function arguments since that is the style
I've take a while ago, but I'll likely change that habit since I have no other
reason.
For noexcept
, I follow this core guideline:
Use
noexcept
when exiting a function because of athrow
is impossible or unacceptable.
This means that even if some function can throw an exception, but I'm sure
that I will not handle that exception gracefully, I'll mark the function
noexcept
.
I mark all classes final
if they are not intended to be inherited from - which
means most classes. Additionally, all virtual functions that are not intended to
be to be overridden are marked final
.
As per this guideline, single-argument constructors are marked explicit
to avoid unintended conversion.
Almost all non-void
functions are marked with [[nodiscard]]
attribute so
that a warning is emitted if a return value is ignored. Another useful attribute
is [[maybe_unused]]
that suppresses warnings on unused variables. This is most
useful with used with conditionally compiled code, such as assert
:
[[nodiscard]] int parse_int(std::string_view str) noexcept {
int result;
[[maybe_unused]] const auto [ptr, ec]
= std::from_chars(str.data(), str.data() + str.size(), result);
assert(ec == std::errc{});
return result;
}
Assertions and error handling
Assertions are debug-only checks that I use liberally to verify preconditions
and postconditions of function calls. They are a very useful tool for catching
bugs during testing. I use the standard assert()
macro.
For error handling, I use exceptions in most cases. They are a standard way of error handling in C++ that always works and does not hurt performance on the happy path. Typically, I write a class for each possible error for two reasons:
- the
catch
site has maximum control over which errors are caught, and - error message formatting code is tucked away nicely into the class private function.
For example, if the above code for parsing an integer threw an error instead of
asserting, the error check and throw
would look like this:
if (ec != std::errc{}) { throw IntegerParsingError{str, ec}; }
A minimal exception class that only contains a what()
message could look
something akin to this:
class IntegerParsingError final : public std::runtime_error {
public:
IntegerParsingError(std::string_view str, std::errc ec) noexcept
: std::runtime_error{format_what_message(str, std::errc ec)}
{}
private:
[[nodiscard]] static std::string
format_what_message(std::string_view str, std::errc ec) noexcept {
std::ostringstream oss;
oss << "failed to parse string " << std::quoted(str)
<< " as an integer: " << std::make_error_code(ec).message();
return oss.str()
}
};
static_assert(std::is_nothrow_copy_constructible_v<IntegerParsingError>);
This exception does not transport any other data to the catch
site. If needed,
the exception class can contain member variables, but the class should remain
nothrow
copyable (see this guideline). If a member variable is not
nothrow
copyable, I wrap it into a std::shared_ptr
.
Wrapping C
In C you manually clean-up resources and in C++ you rely on RAII so you cannot forget to clean-up after yourself. When coding C++, you often have to interact with C APIs. C APIs are great since they offer ABI stability and interoperability with any programming language. Those APIs often have a function for creating and freeing objects of some type so a C usage would look like this:
Foo* const foo = foo_create();
// do something with foo...
foo_delete(foo);
In C++, I don't want to have to call foo_delete()
manually so I would create
something like this when consuming a C library:
struct FooDeleter final {
void operator()(Foo* ptr) const noexcept {
foo_free(ptr);
}
};
using FooPtr = std::unique_ptr<Foo, FooDeleter>;
With such setup, the usage is simplified to:
const FooPtr foo{foo_create()};
// do something with foo...
FooPtr
is a standard unique pointer so all ownership semantics of any code
that uses it is clear. This setup reduces the pain of consuming C APIs so much
that I often prefer using a C API if a library does not provide a good official
C++ wrapper. This means I'm not relying on some half-baked C++ wrapper and I
have access to the original documentation.
Logging
In this CppCon talk, Robert Leahy argues that most components should emit events instead of logging to the standard output. I agree, and so my code typically hides logging behind one level of indirection - either static or dynamic polymorphism.
Here is an example with dynamic polymorphism, where FooProcessor::process()
function needs to log when it has started and finished processing an object of
type Foo
. Instead of logging, an optional FooProcessor::Observer
object is
notified using the appropriate virtual function.
class FooProcessor final {
public:
class Observer {
public:
virtual ~Observer() noexcept = default;
virtual void on_processing_started(const Foo& foo) noexcept = 0;
virtual void on_processing_finished(const Foo& foo) noexcept = 0;
protected:
Observer() noexcept = default;
Observer(const Observer& other) = default;
Observer& operator=(const Observer& other) = default;
Observer(Observer&& other) noexcept = default;
Observer& operator=(Observer&& other) noexcept = default;
};
private:
Observer* observer;
public:
explicit FooProcessor(Observer* observer = nullptr) noexcept
: observer{observer}
{}
void process(Foo foo) const {
if (observer) { observer->on_processing_started(foo); }
// process foo...
if (observer) { observer->on_processing_finished(foo); }
}
};
The main benefits of this indirection are:
- event handling is controlled by the consumer of the API (or, as Robert puts it, unnecessary widening of component's responsibility is avoided), and
- if events are logged, the logging:
- is separated from the business logic, and
- can be centralized so a consistent style can more easily be maintained and a logging library can more easily be swapped out.
Conclusion
As you may notice, I didn't come up with some revolutionary C++ paradigm. All I do is follow the advice of people that have designed the language and as a result, I have a tool suitable for rapid research programming and stable production systems with various performance requirements.
Finally, if want to giggle a bit, just search the web for something like: "Why
is C++ difficult?" and open up some popular tech blog. You'll find claims that
C++ is hard to learn because of "multiple inheritance", "making a choice
between std::vector
and std::list
" or some other inconsequential thing that
you encounter once per decade.