1. constexpr instead of macro constants
When you first see that #define MAX_USERS 100 “kind of works”, the temptation appears: “Oh, you can do everything like this!”. And at that moment somewhere in the distance a compiler cries, because macros are not C++ entities but textual substitution. They do not respect scopes, have no type, and can change the meaning of an expression because of operator precedence. In other words, macros often look like a very powerful spell: the effect is impressive, but the price of a mistake is an unexpected fireball hitting your own code.
The main idea of this lecture is simple: if a task can be expressed with proper C++ mechanisms (constants, functions, templates), then almost always it is better to do so. Leave macros for cases when the preprocessor is truly necessary.
When we write an object-like macro, it looks like a “constant”:
#define MAX_TASKS 200
But this is not a constant. It's a rule: “when you see MAX_TASKS — substitute the text 200”. No type, no scope, diagnostics can be strange.
What constexpr gives as a project constant
The nicest thing: constexpr is almost as short, but it is a proper C++ entity. It has a type, it obeys C++ visibility rules, and the compiler can give clearer diagnostics.
Imagine we maintain our console sample app TodoLite (a toy todo list). We need a configuration: the maximum number of tasks we allow to keep in memory.
Let's make a header include/app/config.hpp:
#pragma once
namespace app::config {
inline constexpr int MaxTasks = 200;
}
There are two points here.
First, constexpr int is a real integer, not text.
Second, we added inline so this constant can be safely put into a header and included in multiple .cpp files (we won't dive deep into linkage theory now; just remember a practical rule: “if it's in a header — think about inline”).
Now in any .cpp:
#include <iostream>
#include "app/config.hpp"
int main() {
std::cout << app::config::MaxTasks << '\n'; // 200
}
This is no longer “textual substitution” magic but a normal typed constant living in namespace app::config.
Why this matters for a beginner
At first it seems: “What difference does it make, 200 and 200”. But the difference appears when you make a mistake.
If you accidentally write this:
#define MAX_TASKS "200"
The compiler may give you an error far from the point you expect, because somewhere a string literal will be substituted instead of a number.
With constexpr this won't happen:
namespace app::config {
inline constexpr int MaxTasks = "200"; // compilation error: wrong type
}
The compiler will complain right at the point: “cannot assign a string to int”. This is one of the rare situations where the compiler behaves like a helpful teacher rather than a character from horror.
3. Functions and templates instead of function-like macros
With function-like macros things get even more interesting, because they look like functions but are not:
#define SQUARE(x) ((x) * (x))
In the previous lecture we already discussed that parentheses are mandatory here. But even with parentheses there is a problem that cannot be “fixed with parentheses”: the argument may be evaluated multiple times.
The repeated-evaluation problem
#include <iostream>
#define SQUARE(x) ((x) * (x))
int main() {
int i = 2;
int y = SQUARE(i++); // i++ will be substituted twice
std::cout << "i=" << i << " y=" << y << '\n'; // i=4 y=6 (or other “magic”)
}
It doesn't matter exactly which result you get (it can be unexpected and depend on details). The important thing is that the expression's meaning becomes “slippery”: i++ unexpectedly runs twice.
Replacement: a constexpr function
Now let's do it properly:
constexpr int square(int x) {
return x * x;
}
And use it:
#include <iostream>
constexpr int square(int x) {
return x * x;
}
int main() {
int i = 2;
int y = square(i++); // i++ is evaluated once
std::cout << "i=" << i << " y=" << y << '\n'; // i=3 y=4
}
The benefit here is not even constexpr itself. The key is that the function guarantees the argument is evaluated once. That's a huge win over “textual magic”.
Example from the TodoLite mini-project: clamping a range
Now let's make a slightly more practical snippet for TodoLite to show that we're not just playing with squares but actually improving project code.
Assume a task priority ranges from 1 to 5, and we want to guard: if the user entered 999 — we neatly clamp the value to 5.
A beginner often writes a macro:
#define CLAMP(x, lo, hi) ((x) < (lo) ? (lo) : ((x) > (hi) ? (hi) : (x)))
It looks powerful but is dangerous: arguments can be evaluated multiple times (for example, if they are function calls), and it's hard to read.
Replacement for the macro: clamp as a function template
When you want a “generic thing”, the macro seems convenient: it's just text. But here C++ provides a proper tool — a function template.
Let's make the header include/app/util.hpp:
#pragma once
namespace app {
template <typename T>
constexpr T clamp(T x, T lo, T hi) {
if (x < lo) return lo;
if (x > hi) return hi;
return x;
}
} // namespace app
Note two important points.
- First, it reads like normal code: if, return, without nested ternary operators.
- Second, arguments are evaluated once, like in any function.
Now in TodoLite code:
#include <iostream>
#include "app/util.hpp"
int main() {
int rawPriority = 999;
int priority = app::clamp(rawPriority, 1, 5);
std::cout << priority << '\n'; // 5
}
It's still compact but much safer than the macro.
A template is part of the C++ language. It participates in type checking, respects scopes, and provides clearer errors than “after textual substitution”.
Yes, template error messages can sometimes intimidate beginners. But in this topic we use templates in the simplest form: one function, one typename T. That's enough to start.
4. inline: how to live with headers without macros
When code becomes multi-file, a typical situation arises: “I want to define something in a header so it is available everywhere”. Macros “solve” this easily: define it in a .hpp and it gets substituted in every .cpp.
But macros pay a price for that: they leak through the project like water through a bad roof. Today it's fine in one file, tomorrow you include another header — and suddenly MAX conflicts with someone else's MAX.
Practical pattern: inline constexpr for settings in config.hpp
For TodoLite we'll put a few settings in one place:
#pragma once
namespace app::config {
inline constexpr int MaxTasks = 200;
inline constexpr int MinPriority = 1;
inline constexpr int MaxPriority = 5;
}
And in the task-adding logic:
#include "app/config.hpp"
#include "app/util.hpp"
int normalizePriority(int raw) {
return app::clamp(raw, app::config::MinPriority, app::config::MaxPriority);
}
Now everything is typed, in a namespace, and under control.
inline functions in headers
If a function is small and you want to keep it in a header (for example, a formatting utility), it's common to do:
#pragma once
#include <string>
namespace app {
inline std::string yesNo(bool value) {
return value ? "yes" : "no";
}
} // namespace app
Here inline means: “this function can be defined in a header included by many .cpp files”. We won't dive into formal rules; the practical advice is: small utility functions in headers — yes, but make them inline.
5. When a macro is still justified
There are tasks where a macro is actually needed because it works at the call site, not at the function's definition site.
The classic example is logging with file and line information: __FILE__ and __LINE__. These are predefined macros substituted by the preprocessor and provide information about where the call was written. This behavior is standardized and supported by all mainstream compilers.
Hybrid: macro captures the call site, the rest is done by a function
This is a good compromise: minimize the macro's role to what it does best — “inject file and line”. The rest of the logic is handed to a C++ function.
For example, include/app/log.hpp:
#pragma once
#include <iostream>
namespace app {
inline void logLine(const char* file, int line, const char* msg) {
std::cout << file << ":" << line << " " << msg << '\n';
}
} // namespace app
#define APP_LOG(msg) ::app::logLine(__FILE__, __LINE__, (msg))
The macro here is small and “dumb”: it only forwards __FILE__ and __LINE__. Everything else is normal C++.
Usage:
#include "app/log.hpp"
int main() {
APP_LOG("start"); // main.cpp:5 start (example)
APP_LOG("finish"); // main.cpp:6 finish (example)
}
Yes, this is still a macro. But it is a controlled macro: it does not try to be a function, does not perform computations, and does not contain complex logic.
6. Cheat sheet: what to replace a macro with
Sometimes a beginner needs not philosophy but a quick answer: “I was about to write #define, what should I use instead?”. Let's make a short table to keep in mind.
| What you wanted to do with a macro | Bad option | Good C++ option | Why it's better |
|---|---|---|---|
| Constant (number/limit) | |
|
Has a type, scope, and better diagnostics |
| Simple computation | |
|
Argument is evaluated once |
| Generic computation “for any type” | |
|
Types are checked by the compiler |
| Multiline “operator” | complex macro | regular function / several functions | Readability and debugging |
| Need to know the call site | “no alternatives” | small macro + function | Macro captures __FILE__/__LINE__, function does the work |
And now a selection diagram — a mini flowchart:
flowchart TD
A[I want to write #define] --> B{Is it a constant?}
B -->|Yes| C[inline constexpr / constexpr]
B -->|No| D{Is it a computation?}
D -->|Yes| E{Need to support different types?}
E -->|No| F[constexpr/inline function]
E -->|Yes| G[template function]
D -->|No| H{Need call-site info? __FILE__/__LINE__}
H -->|Yes| I[small macro -> calls inline function]
H -->|No| J[Most likely, you don't need a macro]
7. Common mistakes
Mistake #1: replace a macro with const and then “get a surprise” in a multi-file project.
A beginner removes #define, writes in a header const int MaxTasks = 200;, includes this header in several .cpp files and is surprised by linker errors or odd behavior. The practical cure at our level: for config constants in headers use inline constexpr and keep them in a separate config.hpp.
Mistake #2: write a function instead of a macro but forget that sometimes a macro captures the call site.
When you want to log “where it happened”, a simple function log("msg") won't know the caller's file and line by itself: it will see only where the function is defined. Therefore for __FILE__/__LINE__ a hybrid is justified: a small macro forwarding metadata into a regular function.
Mistake #3: try to “do it like in C” and write complex logic in a macro because it's shorter.
A one-line macro easily becomes a three-line macro, then ten lines, then you are afraid to change it and in a month you cannot even read it. If the macro contains branches, loops, multiple actions, and especially container handling, it is almost surely inappropriate: you lose types, scope, and reasonable debugging.
Mistake #4: use macros as “global names”, not realizing they ignore namespace.
You can carefully write namespace app::config { ... }, and then one #define MaxTasks ... accidentally breaks everything because the macro substitutes anywhere in the subsequent text, even inside someone else's namespace. The simple rule: name macros conspicuously (usually ALL_CAPS) and keep their influence minimal, and better — replace them with constexpr/inline/template.
Mistake #5: replace a macro with a template and then be surprised by half-page compilation errors.
Yes, templates sometimes produce verbose diagnostics. But in this lecture we use them gently: one template <typename T> and simple logic. If an error still scares you, simplify the call and check argument types: the template clamp expects a type that supports < and >, and you may have accidentally passed incompatible types or swapped argument order.
GO TO FULL VERSION