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.
As a professional coder and c++ expert, I think some of these points are a bit misleading.
You cannot replace all #define macros yet. Those that generate source code from raw tokens don’t have any alternative until c++26 reflection, and even that doesn’t provide 100% replacement coverage for the them yet.
Fair enough. But the vast majority of C code and basically 100% of C++ never used later C’s “generics” to begin with. So its a non-issue
“if constexpr” is NOT anywhere close to a full replacement for #if. It’s only usable in template functions, and cannot be used at all to conditionally define anything- classes, class members, namespaces, and even variables. It also introduces a SCOPE. I’m all for creating a new ‘static if’ or massively extending ‘if constexpr’ to be a full “#if replacement”, but knowing the c++ standards committee some pedantic dweeb will block the obvious & correct solution for 10 years then deliver a mangled 60% of what we actually want sometime around c++ 2038.
As of c++26 variadic templates might finally provide 100% replacement for the old ellipsis; assuming generation of a named struct’s fields based on _VA_ARGS_ is now possible.
std::source_location::current() CAN do what you say. It is also a black magic “function” that must be inlined in order to work the same way (giving the original caller’s line and file, not that any wrapping calls), so the ‘improvement’ over a macro is debatable.
True. Static assert has matured into a full replacement. Now to tackle “#warning”…
Hell no. The C++ committee itself has made this very clear: The future is contacts and “assert” in the language, NOT the current exceptions. Exceptions are on the way out.
STL modules are nice, and worth using by themselves. The biggest problem with fully porting a code base to modules is that in practice, your ENTIRE stack of dependencies has to support them first. Because they really don’t mix well with headers, including the system headers like that somebody in your codebase almost certainly needs. It’s going take windows offering a Win32 replacement as a set of modules, and Linux doing the same, before widespread porting to them becomes practical enough to take off. Well, that and the language having genuine 100% replacement coverage for macros.
Again, reflection should get us eventual replacement for this in the next spec or two. But at the end it’ll synthesize the same code with the same potential downsides.
LikeLike
Hi Zachary,
Thank you for your detailed critique and commentary on this article; it is always refreshing to read a second opinion. Sadly the numbers from your original comment related to points in the article seem to have been erased by my approving it in Jetpack, my apologies for this.
I agree with you 100% on point (7) where `contract_assert()` in C++26 is an improvement over `assert()` and `throw`. For the other points where we differ I am pleased to accept that I was being purposefully over-optimistic about use cases where Modern C++ has (partly) equivalently-performing syntax, in order to encourage new code being written in these styles.
LikeLike