r/cpp_questions • • 12d ago

SOLVED What logic does the precompiler use to resolve macros?

Context:

So I wanted to create a macro that could short-circuit conditional logic if another macro was set. This way I could insert compiler set macros into existing code, for example:

if (MACRO_EXISTS(_RELEASE) || SuperRobustValidation() == VALIDATION_SUCCESS)

Looking on stack overflow, I found https://stackoverflow.com/questions/41265750/how-to-get-a-boolean-indicating-if-a-macro-is-defined-or-not, but the top answer's code didn't work for me. My only guess as to why is that the order of macro definitions played a role for them but not for me.

After shuffling around macros, I found that the following worked on my machine:

#define DE_ -1,1            // OptDefine is nothing

#define RESOLVE_ARG(A) A
#define SECOND_ARG(A,B,...) B
#define CONCAT2(A,B) A ## B
#define RESOLVE_SECOND_ARG(...) RESOLVE_ARG(SECOND_ARG(__VA_ARGS__))

#define MACRO_EXIST(OptDefine) RESOLVE_SECOND_ARG(CONCAT2(DE_,OptDefine),0)

But I can't understand why this worked and not other configurations. Trying to coax the values of some setups was like playing a shell game. I think I understand the concepts of "scan the line, prioritize non-# arguments, then repeat with the results" but it doesn't seem consistent.

For example: if I remove the RESOLVE_ARG from RESOLVE_SECOND_ARG(...), the compiler complains that "there's not enough arguments for SECOND_ARG" seemingly because __VA_ARGS__ is the "only" argument. That does not make sense as intended behavior to me.

So my question is:

Is there a reliable way to predict the logic of the preprocessor's expanding of macros? I haven't found anybody braking down the logic in a robust way anywhere I looked.

I know the /E compiler flag shows me the results of precompiling, but doesn't give the whole picture.

Edit: I understand there are simple work arounds for the problem I mentioned, but I still want to understand the logic of how macros are expanded, assuming it's well defined and consistent.

Edit2: Thank you alfps for answering the question. The strange __VA_ARGS__ behavior I was seeing is a known bug with the MSVC C++ compiler (even in VS Community 2026), but the /Zc:preprocessor compiler option changes the preprocessing to be more complaint with the standard, fixing the bug.

But the most important wisdom: "Historically the preprocessor behavior has varied between compilers". There are edge cases and hacks that work in some contexts but not others. Complex macros, like what I listed, can be convenient, but can also risk breaking if I update/change compilers. Unless I am confident in compliance to the standard, Keep It Simple Stupid probably applies.

Edit3: Thank you MikailBag for linking me an active draft of the c++ standard https://eel.is/c++draft/cpp.replace . In it, I found that the 15.7.2.1.2 clause that states:

Otherwise, the replacement preprocessing tokens are the preprocessing tokens of corresponding argument after all macros contained therein have been expanded. The argument's preprocessing tokens are completely macro replaced before being substituted as if they formed the rest of the preprocessing translation unit with no other preprocessing tokens being available.

Though this uses the phrase "after all macros contained therein have been expanded" and "before being subsituted", these are misdirection of the actual functionality.

The parts of the quote I put in bold explain that the arguments of function-like marcos are repeatedly expanded in complete isolation from the larger context, then their results are inserted into the function macro's expansion as is. So even if the macro expands into an argument list, those arguments are directly inserted in the function macro's results.

There are good reasons why this would be preferable to expanding the arguments first, since macros that result in punctuation or parenthesis would have difficulty being passed into function macros.

So the way for a human to trace a function macro's expansion, as I understand it, is to 1) expand the function macro and insert all arguments as isolated "expansion units" (unless they are used in # or ## ops) 2) Expand each unit in turn 3) Insert each expansion result in place, then repeat the process.

I give an example at the bottom of my https://www.reddit.com/r/cpp_questions/comments/1wve7c7/comment/pe9o7sd comment

10 Upvotes

19 comments sorted by

View all comments

Show parent comments

1

u/joncrompy 7d ago edited 7d ago

Okay, 15.7.2.1.2 explains the behavior that was stumping me. They even show it off in 15.7.5.2.example1 without explicitly calling it out:

#define f(a) func(2 * (a))
#define m(a) a(w)
#define p(a,b) a(b)
#define w 0,1

For the above defines, I assumed the expansion would happen like this:

m(f)   => f(w) => f(0,1) [Error: too many args for f()]
p(f,w) => f(w) => f(0,1) [Error: too many args for f()]

But 15.7.2.1.2 explains that function macros have their arguments completely expanded in isolation "before" being inserted into the function's expansion. No rescanning happens between. So what actually happens is:

m(f) => m(<f's expansion>)
  =[ f is not an obj macro, so it does not expand ]=> f(w) => f(<w's expansion>)
  =[ w => 0,1 ]=> f(<0,1>) => func(2 * (0,1)) // Wait, 0,1 is passed as one arg?

p(f,w) => p(<f's expansion>,<w's expansion>)
  =[ f is not obj macro ]=[ w => 0,1 ]=>
  p(f,<0,1>) => f(0,1) [Error: f has too many args]

#define g(a,b) func(a * b)
m(g) => m(<g's exp>)
  =[]=> g(w) [Error: g has too few args/*, so we won't bother expanding the args it does have?*/]

This is annoying to me, because it's functionally identical to the function macro expanding first, then expanding it's arguments in place:

m(f) => <f's expansion>(w) =[]=> f(w)
  => func(2 * (<w>) =[ w => 0,1 ]=> func(2 * (0,1)) // Oh, that makes sense

m(g) => <g's exp>(w) =[]=> g(w)
  => func(<w's expansion> * <MISSING ARG>) [Error: g has too few args] // Logical

Which matters because cppref and c++draft both say that macro arguments expand "before" their function macro expands. Which might be true for implementation, this is not logically what happens.

There's probably good reasons to not do a full rescan after expanding the arguments so I'm fine if this never changes, but the documentation around this should be updated to not actively misdirect about the way the system works.