The C Path — learn C, visually

🎩 The Preprocessor

Function-like macros: power tools with no guard

⏱ 14 min · free interactive lesson · quizzes, visualizations & a real compiler

▶ Open the interactive lesson — free, no signup
Why you're learning this

A macro that squares a number can quietly turn SQUARE(a + 1) into 9 instead of 25 — a fifty-year-old trap that still bites professional codebases today. This lesson shows you exactly why, then hands you the defenses: the parenthesizing rules, the do { } while (0) trick the Linux kernel swears by, and the secret behind every "print the expression AND its value" debugging helper C programmers reach for.

Add parentheses right after the macro name — no space! — and #define takes arguments, making a function-like macro: #define SQUARE(x) x * x. Now SQUARE(7) becomes 7 * 7. It looks like a function, but remember the mantra: it's still just text. Arguments aren't worked out to a value and handed over the way a function's are — they're pasted, character for character.

The classic disaster, in three acts

square1.c — naive version
#include <stdio.h>

#define SQUARE(x) x * x

int main(void) {
    int a = 4;
    printf("%d\n", SQUARE(3));      /* fine: 3 * 3 = 9   */
    printf("%d\n", SQUARE(a + 1));  /* ...uh oh          */
    return 0;
}

🤔 SQUARE(a + 1) with a = 4 — what prints?

Think first

9, not 25! The paste is literal: SQUARE(a + 1)a + 1 * a + 14 + (1×4) + 1 = 9. The macro never saw the value 5 — it saw the three tokens a, +, 1 and dropped them into a precedence minefield.

The repaired version parenthesizes each parameter: #define SQUARE(x) (x) * (x). But that's still only half the armor:

🤔 With #define SQUARE(x) (x) * (x), what does 100 / SQUARE(5) give?

Think first

100, not 4. Expansion: 100 / (5) * (5). Division and multiplication associate left-to-right: (100/5) × 5 = 100. The parameters were protected but the whole expression was not — it needs an outer set: ((x) * (x)).

Hence the full parenthesization rule: parentheses around every parameter use AND around the whole replacement:

square-final.c — the armored version
/* every parameter use wrapped + the whole body wrapped */
#define SQUARE(x) ((x) * (x))

/* the same discipline, always: */
#define MIN(a, b) ((a) < (b) ? (a) : (b))
#define ABS(x)    ((x) < 0 ? -(x) : (x))

🧠 Checkpoint: Why must a macro body like (x) * (x) get one MORE pair of parentheses around the whole thing?

  • Style guides require it
  • Operators outside the macro can still tear the expression apart, e.g. 100 / SQUARE(5)
  • The preprocessor rejects unparenthesized bodies
  • To make the macro evaluate arguments once
Show answer

Operators outside the macro can still tear the expression apart, e.g. 100 / SQUARE(5) — Inner parens protect against precedence inside the arguments; the outer pair protects against precedence at the call site. Neither can fix double evaluation — that is inherent to pasting.

And even the perfect version has one unfixable flaw — arguments that get pasted twice are evaluated twice:

🤔 int i = 4; int r = SQUARE(i++); — what are r and i afterwards?

Danger — think first

Trick question: anything at all. The expansion ((i++) * (i++)) modifies i twice with no sequence point between — that is undefined behavior per the standard. You might see r=16, i=6 today, and something else after a compiler upgrade. UB means all bets are off, not "probably 20".

💀

Never pass expressions with side effects to a macro (i++, function calls, assignments). SQUARE(i++) expands to ((i++) * (i++)) — two unsequenced modifications of i, which is undefined behavior, not merely "incremented twice". A real function evaluates its argument exactly once; a macro makes no such promise.

Multi-statement macros: the do { } while (0) idiom

Suppose a macro needs two statements. Wrapping them in bare braces seems reasonable — until the macro meets an if:

swap-bad.c — braces are not enough
#define SWAP(a, b) { int t = (a); (a) = (b); (b) = t; }

void order(int x, int y) {
    if (x > y)
        SWAP(x, y);      /* expands to { ... } ;   */
    else                 /* error: 'else' without a previous 'if' */
        x = y;
}

Expansion of the bad version: the if owns the { ... } block, then the user's semicolon becomes an empty statement, and the else is left dangling with nothing to attach to — a syntax error (or worse, silently wrong pairing in nested ifs). The cure is a loop that runs exactly once:

swap-good.c — the do/while(0) idiom
#define SWAP(a, b) do { int t = (a); (a) = (b); (b) = t; } while (0)

void order(int x, int y) {
    if (x > y)
        SWAP(x, y);      /* one statement + required ';' — perfect */
    else
        x = y;           /* compiles, and pairs correctly */
}

Why it works: do { ... } while (0) is a single statement that demands a semicolon — so SWAP(a, b); behaves exactly like a function call in every grammatical position, including between if and else. Every serious C codebase (Linux kernel included) uses this idiom.

🧠 Checkpoint: What problem does do { ... } while (0) solve in a macro?

  • It makes the body run faster
  • It lets the macro return a value
  • It turns multiple statements into ONE statement that needs a trailing semicolon, so if/else around the macro parses correctly
  • It prevents double evaluation of arguments
Show answer

It turns multiple statements into ONE statement that needs a trailing semicolon, so if/else around the macro parses correctly — A brace block plus the user’s semicolon breaks if/else pairing; do/while(0) is a single statement that eats the semicolon naturally. It does nothing about double evaluation.

The # and ## operators

Two operators exist only inside macro replacement text:

Stringify powers every debugging macro ever written — print the expression and its value without typing it twice:

dump.c — # stringify in action
#include <stdio.h>

#define DUMP(expr) printf(#expr " = %d\n", (expr))

int main(void) {
    int score = 40, bonus = 2;
    DUMP(score);
    DUMP(score + bonus);
    DUMP(score * bonus + 1);
    return 0;
}
terminal
$ gcc dump.c -o dump && ./dump
score = 40
score + bonus = 42
score * bonus + 1 = 81
# the expression text AND its value — typed only once

Note the trick: #expr " = %d\n" works because adjacent string literals are concatenated. Token pasting shines in code generators — one macro stamping out families of declarations: DECLARE_LIST(int) can mint list_int_push, list_int_pop, and friends via list_ ## T ## _push.

Variadic macros: __VA_ARGS__

Since C99, a macro's parameter list may end in ..., and __VA_ARGS__ expands to whatever extra arguments were passed — tailor-made for wrapping printf-style functions:

log.c — variadic macro
#include <stdio.h>

#define LOG(fmt, ...) \
    fprintf(stderr, "[%s:%d] " fmt "\n", \
            __FILE__, __LINE__, __VA_ARGS__)

int main(void) {
    int users = 3;
    LOG("startup ok, %d users", users);
    LOG("temp=%d limit=%d", 71, 80);
    return 0;
}
terminal
$ gcc log.c -o log && ./log
[log.c:9] startup ok, 3 users
[log.c:10] temp=71 limit=80

One wrinkle: with the C99 rules, LOG("boot") — no extra args — leaves a dangling comma after __LINE__,. C23 fixes this cleanly with __VA_OPT__(x), which expands to x only if variadic arguments are present: __LINE__ __VA_OPT__(,) __VA_ARGS__. (Before C23, GCC and Clang offered , ## __VA_ARGS__ as an extension that swallows the comma.)

🧠 Checkpoint: What does C23’s __VA_OPT__(,) do in a variadic macro?

  • Expands to a comma only when variadic arguments were actually passed
  • Counts the variadic arguments
  • Stringifies the variadic arguments
  • Makes the comma operator sequence the arguments
Show answer

Expands to a comma only when variadic arguments were actually passed — It solves the dangling-comma problem: LOG("boot") with zero extra args would otherwise leave "..., __LINE__," hanging. __VA_OPT__ emits its content only if __VA_ARGS__ is non-empty.

Macro or function? A field guide

macrofunction
type checkingnone — textfull
argument evaluated0, 1, or many timesexactly once
works on any typeyes ("generic" for free)one signature
can use sizeof/types/#yesno
debugger & error messagessee the expansion, ouchclean
address can be takennoyes

Modern advice: prefer real functions (the compiler inlines them beautifully — see the inline lesson), and reserve macros for what functions cannot do: stringifying, token pasting, using __FILE__/__LINE__ at the call site, and type-generic tricks.

Try it

This spot has an interactive editor widget — open the interactive lesson to play with it.

You now command substitution; next comes the preprocessor's other superpower — making whole regions of code appear or vanish with #if.

▶ Practice this lesson interactively (with live gcc)