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 a throw 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:

  1. the catch site has maximum control over which errors are caught, and
  2. 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:

  1. event handling is controlled by the consumer of the API (or, as Robert puts it, unnecessary widening of component's responsibility is avoided), and
  2. 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.