Replacing the Preprocessor in Modern C++

Back in the early days when the original C++ compiler compiled into C, it seemed natural to use the existing C preprocessor to add C (and C++) headers to each source file in order to create a coherent compilation unit. With the Standard Library available as a module in the C++23 standard, it’s time we tried to use other features of Modern C++ to fulfil tasks we’ve previously relied on the C preprocessor for. This article aims to cover all of these main features.

1. Don’t use #define

All of the former uses of #define can be achieved with more modern syntax. Use constexpr (or const) to create named, compile-time constant values, inline functions to replace utility macros and lambdas (possibly generic) to replace code-generating macros.

constexpr double e = exp(1.0);  // #define e 2.718281828
inline int times_two(int n) { return 2 * n; }  // #define times_two(n) (2 * (n))

auto print_out = [](auto item){ std::cout << "Item: " << item << '\n'; }; 
// #define print_out(item) { std::cout << "Item: " << item << '\n'; }

2. Use templates instead of type parameters

It is possible to pass types as parameters to #define but almost all uses have been replaced by use of C++ generics. Simple cases (say two or three different types) can be handled by function overloading, if full control over usage is desired.

template <typename T>
T square(T n) { return n * n; }  // #define square(t, n) t square(t n) { return n * n; }

//  Alternatively only allow double, long long and int
//  (and types which implicitly convert to these):
double square(double n) { return n * n; }
long long square(long long n) { return n * n; }
int square(int n) { return n * n; }

3. Utilize constexpr if and SFINAE instead of #if

It is possible to conditionally compile code using if constexpr within functions and std::enable_if to control instantiation of template functions. These powerful constructs are radicalizing the way C++ is written by many programmers.

template <typename T>
void processValue(T value) {
    if constexpr (std::is_integral_v<T>) {
        std::cout << "Integral value: " << value << std::endl;
    } else if constexpr (std::is_floating_point_v<T>) {
        std::cout << "Floating-point value: " << value << std::endl;
    } else {
        static_assert(std::is_same_v<T, std::string>, "Unsupported type");
        std::cout << "String value: " << value << std::endl;
    }
}  // Only one branch appears in the compiled code

template <typename T>
std::enable_if_t<std::is_integral_v<T>, T> processIntegral(T value) {
    // Code specific to integral types
}  // Function processIntegral() is "switched" on or off

4. Prefer variadic templates over macro ellipsis

It is possible to create generic types with named “parameter packs”, which expand at compile-time. They can also be passed (unexpanded) using std::forward, and unpacked using “folding expressions” (covered in a previous article).

template <typename... Args>
void printValues(Args&&... args) {
    ((std::cout << args << " "), ...);
}
// #define printValues(format, ...) printf(format, __VA_ARGS__)

5. Use library source location facilities

The built-in macros __FILE__ and __LINE__ are useful for locating code using logging messages or similar, but there are better ways available these days. Simply instantiate a std::source_location object at the call site and then your logging routine can provide as much detail as required from the available member functions.

6. Prefer static_assert() over #error

It is possible to signal conditional compile-time errors with entities that can be checked in an #if directive, followed by an #error directive inside. The modern approach is to use the built-in static_assert(), which is another compile-time construct, taking a condition test and an optional error message to output as a compiler error.

static_assert(sizeof(int) == 4, "Needs 32-bit int");

// Replaces:
#if SIZEOF_INT != 4  // SIZEOF_INT set by configure script or build system
  #error Needs 32-bit int
#endif

7. Use exceptions instead of assert()

While not specifically using the preprocessor (aside from the fact that assert() is often a macro), using throw and catch rather than unconditionally terminating the program, allows for stacktraces to be printed from within the running program.

class TracedException {
public:
    TracedException(
                std::string_view msg = {},
                std::stacktrace trc = std::stacktrace::current()) {
        if (!msg.empty()) {
            std::cerr << msg << '\n';
        }
        std::cerr << trc;
    }
};

void f() {
    // ...
    throw TracedException("Full stacktrace");
}

It is also possible to write a C++ function which replaces all uses of assert() and itself conditionally throws an exception.

8. Modules in C++23

As of C++23 we have available standardized module syntax, and the whole of the Standard Library is available using import std;. It is anticipated that this will be quicker to compile than using a single #include such as <iostream>. This should be a game-changer for how we write and compile C++ programs going forward; the advantages include cleaner interfaces, fewer bugs and no more obscure error messages related to template instantiation problems. Finally we can properly retire the preprocessor from its original purpose of creating compilation units.

9. No replacement for #code and ##

By way of a footnote I should point out that there is no way I know of to write either of the following in Modern C++ without using the preprocessor:

#define log_assert(condition, message) \
    if (!condition) \
        printf("Condition %s failed: %s\n", #condition, message);
#define concatenate(a, b) a##b

With the type-naming capabilities of C++ the latter is not really an issue, and for the former maybe you should ask the question: “Do you want your clients to see your actual source code in run-time error messages?”

That’s all for this article. We have seen several different areas of use of the C preprocessor that have been both superseded and improved upon, by either or both of language and library support. Of course all new code should be written without using preprocessor directives where possible, and legacy code can often be refactored to a more modern style.

Leave a comment