Modern C: Function-like macros
In Chapter 17: Function-like macros of Jens Gustedt’s Modern C: A Guide to the C23 Standard, the book moves into Level 3: Experience. While macros can easily obfuscate source code or introduce subtle side-effect bugs if used carelessly, C23 leverages function-like macros to build type-safe, expressive, and flexible interfaces that functions alone cannot easily achieve.
Below is a detailed breakdown of the core concepts, expansion mechanics, variadic utilities, and key takeaways from Chapter 17 across its five main sections:
1. General Principles: Macros vs. Inline Functions (Section 17)
The primary rule of modern C macro design is to use function-like macros only when standard C functions are insufficient.
- Prefer
inlineFunctions:- Takeaway 17 #1: Whenever possible, prefer an
inlinefunction to a functional macro. - Functions evaluate arguments exactly once, whereas a macro can evaluate an argument expression with side effects multiple times (e.g.,
SQUARE(count())expands tocount() * count(), triggering two invocations). Aninlinefunction eliminates call overhead while maintaining strict call-by-value evaluation.
- Takeaway 17 #1: Whenever possible, prefer an
- Macro Purpose:
- Takeaway 17 #2: A functional macro shall provide a simple interface to a complex task.
- Function-like macros excel at compile-time argument checking, context inspection (file/line logging), variadic parameter lists, type-generic selection, and default argument handling.
Example
Standard C functions evaluate arguments strictly once by value. Functional macros perform raw text substitution, which can evaluate arguments with side effects multiple times (e.g. count() * count()), introducing subtle runtime bugs.
- Takeaway 17 #1: Whenever possible, prefer an
inlinefunction to a functional macro. - Takeaway 17 #2: A functional macro shall provide a simple interface to a complex task.
#include <stdio.h>
static int call_counter = 0;
static inline int increment_counter(void) {
return ++call_counter;
}
// DANGEROUS MACRO: Evaluates argument 'x' TWICE in replacement text
#define BAD_SQUARE(x) ((x) * (x))
// SAFE INLINE FUNCTION: Evaluates argument 'x' EXACTLY ONCE
static inline int safe_square(int x) {
return x * x;
}
void demo_macro_vs_inline(void) {
// 1. MACRO SIDE-EFFECT BUG:
// BAD_SQUARE(increment_counter()) expands to:
// ((increment_counter()) * (increment_counter()))
// Function increment_counter() is executed TWICE!
call_counter = 0;
int bad_res = BAD_SQUARE(increment_counter());
printf("BAD_SQUARE result: %d (Function call count: %d)\n", bad_res, call_counter); // Yields count = 2
// 2. SAFE INLINE EVALUATION:
// Arguments are evaluated once before entering safe_square()
call_counter = 0;
int safe_res = safe_square(increment_counter());
printf("safe_square result: %d (Function call count: %d)\n", safe_res, call_counter); // Yields count = 1
}2. How Function-Like Macros Work (Section 17.1)
Macros operate purely via textual replacement during preprocessing, before the compiler assigns grammatical or type semantics to tokens.
- Early Phase Token Replacement:
- Takeaway 17.1 #1: Macro replacement is done in an early translation phase, before any other interpretation is given to the tokens that compose the program.
- Expansion Rules:
- The macro definition is temporarily disabled during its own replacement to prevent infinite recursion.
- Arguments inside the outer parentheses
()are split by unnested commas. - Arguments are recursively expanded for other macros.
- Parameter occurrences in the replacement text are substituted with the expanded argument fragments.
- The resulting replacement text is rescanned for further macro expansions.
- Macro Retention:
- Takeaway 17.1 #2 (Macro Retention): If a functional macro is not followed by
(), it is not expanded. - This allows a function and a macro to share the exact same name. Enclosing a function declaration name in parentheses—e.g.,
char const* (string_literal)(char const str[static 1])—suppresses macro expansion during function declaration. Passing a function name without()decays it to a function pointer without expanding the macro.
- Takeaway 17.1 #2 (Macro Retention): If a functional macro is not followed by
Example
Macro replacement occurs during early translation phases prior to compiler type checking. If an identifier that names a functional macro is not followed by opening parentheses (), the preprocessor retains the identifier unexpanded. This allows a function and a macro to safely share the exact same name.
- Takeaway 17.1 #1: Macro replacement is done in an early translation phase.
- Takeaway 17.1 #2 (Macro Retention): If a functional macro is not followed by
(), it is not expanded.
#include <stdio.h>
#include <string.h>
// Functional macro wrapping strlen for compile-time logging
#define string_len(s) (printf("Logging length query for '%s'\n", (s)), strlen(s))
// Suppressing macro expansion on function declaration by wrapping function name in ()
size_t (string_len)(char const s[static 1]) {
return strlen(s);
}
void demo_macro_retention(void) {
char const text[] = "Modern C23";
// 1. Followed by '()': Expands the FUNCTION-LIKE MACRO
size_t len1 = string_len(text);
// 2. NOT followed by '()': Macro expansion is SUPPRESSED (Takeaway 17.1 #2)
// Decays to a pointer to the actual underlying FUNCTION (string_len)
size_t (*func_ptr)(char const*) = string_len;
size_t len2 = func_ptr(text);
printf("Lengths measured: %zu, %zu\n", len1, len2);
}3. Argument Checking & Enforcing Constraints (Section 17.2)
C’s type system cannot natively enforce constraints such as requiring an argument to be a true string literal or validating array sizes at compile time. Function-like macros can enforce these constraints prior to calling internal functions.
- String Literal Enforcement: Using token concatenation with empty strings (e.g.,
"" F "\n") forces the preprocessor to verify thatFis a literal string, issuing a compile-time syntax error if a rawchar*pointer is passed instead. - Semicolon Safety: Wrapping multi-statement macros inside
do { ... } while (false)ensures that the macro syntactically behaves like a singlevoidfunction call and cleanly integrates intoif-elseblocks requiring a trailing semicolon.
Example
Function-like macros can enforce compile-time constraints that functions cannot natively check, such as ensuring an argument is a true string literal or guaranteeing that a multi-statement macro requires a trailing semicolon inside if-else blocks.
#include <stdio.h>
#include <stdbool.h>
// STRING LITERAL ENFORCEMENT:
// Concatenating "" with F forces a syntax compile error if 'F' is a raw 'char*' pointer
// rather than a genuine literal string "..."
#define REQUIRE_LITERAL_STRING(F) ("" F)
// SEMICOLON-SAFE MULTI-STATEMENT MACRO:
// Wrapping statements inside do { ... } while (false) ensures the macro acts as a
// single block requiring a trailing semicolon after calling it.
#define SAFE_LOG(msg, val) \
do { \
printf("[LOG] %s: ", (msg)); \
printf("%d\n", (val)); \
} while (false)
void demo_constraint_enforcement(void) {
int score = 100;
// do-while(false) allows safe inclusion inside if-else constructs:
if (score >= 90)
SAFE_LOG("High score achieved", score); // Trailing semicolon cleanly terminates block
else
puts("Standard score");
// Literal string validation:
char const* valid_lit = REQUIRE_LITERAL_STRING("C23 Standard");
printf("Literal string verified: %s\n", valid_lit);
}4. Accessing the Context of Invocation (Section 17.3)
Macros can inspect the caller’s execution environment using predefined macros: __FILE__ (source filename), __LINE__ (line number), and __func__ (enclosing function name).
- Line Number Mismatches:
- Takeaway 17.3 #1: The line number in
__LINE__may not fit into anint. - Takeaway 17.3 #2: Using
__LINE__is inherently dangerous.
- Takeaway 17.3 #1: The line number in
- Stringification Operator (
#):- Placing
#before a macro parameter converts the raw textual argument directly into a string literal. - Takeaway 17.3 #3: Stringification with the operator
#does not expand macros in its argument. Writing# __LINE__produces the string"__LINE__"rather than"25". - Takeaway 17.3 #4: Nested macro definitions may expand macro arguments several times. To stringify
__LINE__as a numeric text literal, code must use a two-level nested macro:#define STRINGIFY(X) #X #define STRGY(X) STRINGIFY(X) // Expands __LINE__ to '25' before stringifying to "25"
- Placing
- Token Concatenation (
##): Glues adjacent preprocessing tokens together to generate new identifier names dynamically.
Example
Macros inspect the caller’s context using __FILE__, __LINE__, and __func__. Because the stringification operator # prevents internal macro expansion, evaluating macros like __LINE__ as a numeric text literal requires two-level nested stringification.
- Takeaway 17.3 #3: Stringification with the operator
#does not expand macros in its argument. - Takeaway 17.3 #4: Nested macro definitions may expand macro arguments several times.
#include <stdio.h>
// Level 1: Converts argument directly into string without expanding macro arguments
#define STRINGIFY_DIRECT(x) #x
// Level 2: Forces expansion of macro argument 'x' BEFORE stringifying
#define STRINGIFY_EXPAND(x) STRINGIFY_DIRECT(x)
// Token Concatenation (##): Glues tokens together to construct variable names dynamically
#define CREATE_VAR_NAME(prefix, id) prefix ## _ ## id
void demo_stringification_and_context(void) {
// 1. Direct vs. Two-Level Stringification (Takeaway 17.3 #3 & #4)
char const* s1 = STRINGIFY_DIRECT(__LINE__); // Produces literal "__LINE__"
char const* s2 = STRINGIFY_EXPAND(__LINE__); // Produces literal "42" (or current line number)
printf("Direct stringification: %s\n", s1);
printf("Two-level stringification: %s\n", s2);
// 2. Token Concatenation (##):
int CREATE_VAR_NAME(sensor, 101) = 99; // Expands to: int sensor_101 = 99;
printf("Dynamically created variable 'sensor_101' = %d\n", sensor_101);
}5. Variable-Length Argument Lists & C23 __VA_OPT__ (Section 17.4)
Variadic Macros (Section 17.4.1)
Variadic macros accept arbitrary parameter counts using ... and expand them via __VA_ARGS__.
- C23
__VA_OPT__Feature: In older C standards, omitting trailing variadic arguments caused syntax errors due to left-over trailing commas in functions likefprintf(stderr, format, __VA_ARGS__). - C23 introduces
__VA_OPT__(tokens), which expands its enclosedtokens(such as a comma,) only if__VA_ARGS__is non-empty:#define TRACE_PRINT(F, ...) \ fprintf(stderr, F __VA_OPT__(,) __VA_ARGS__)
Variadic Functions & Why to Avoid Them (Section 17.4.2)
Variadic functions (declared with ... and accessed via <stdarg.h>) are prone to runtime bugs due to a lack of type safety.
- Default Argument Promotions:
- Takeaway 17.4.2 #1: When passed to a variadic parameter, all arithmetic types are converted as for arithmetic operations, with the exception of
floatarguments, which are converted todouble. Narrow integer types (char,short) promote toint.
- Takeaway 17.4.2 #1: When passed to a variadic parameter, all arithmetic types are converted as for arithmetic operations, with the exception of
- Type Safety & Portability:
- Takeaway 17.4.2 #2 & #3: A variadic function has to receive valid information about the type of each argument in the variadic list, and using variadic functions is not portable unless each argument is forced to a specific type.
- Takeaway 17.4.2 #4: Avoid variadic functions for new interfaces. Instead, prefer variadic macros that pack arguments into compound literal array initializers (e.g.,
(long double const[NARGS]){ __VA_ARGS__ }) where conversions are verified at compile time.
va_listLimitations:- Takeaway 17.4.2 #5 & #6: The
va_argmechanism doesn’t give access to the length of theva_list, and a variadic function needs a specific convention for the length of the list. - C23
<stdarg.h>Update: In C23,va_start(ap)no longer requires the second parameter specifying the last named argument.
- Takeaway 17.4.2 #5 & #6: The
Example
Variadic functions (... via <stdarg.h>) lose array length information, lack type safety, and trigger default argument promotions (e.g. float converts to double). Modern C prefers variadic macros combined with C23’s __VA_OPT__, which inserts a comma only if variadic arguments are supplied.
- Takeaway 17.4.2 #1: Variadic function parameter arithmetic types undergo default argument promotions.
- Takeaway 17.4.2 #4: Avoid variadic functions for new interfaces.
#include <stdio.h>
// C23 __VA_OPT__(tokens): Inserts 'tokens' (e.g. ',') ONLY IF __VA_ARGS__ is non-empty.
// Prevents syntax errors caused by leftover trailing commas when no extra arguments are passed.
#define LOG_TRACE(format, ...) \
fprintf(stderr, "[TRACE] " format __VA_OPT__(,) __VA_ARGS__)
// PREFERRED ALTERNATIVE TO VARIADIC FUNCTIONS (Takeaway 17.4.2 #4):
// Variadic macro packing arguments into a type-safe compound literal array!
#define SUM_DOUBLES(...) \
sum_double_array(sizeof((double[]){ __VA_ARGS__ }) / sizeof(double), (double[]){ __VA_ARGS__ })
static double sum_double_array(size_t len, double const arr[len]) {
double total = 0.0;
for (size_t i = 0; i < len; ++i) {
total += arr[i];
}
return total;
}
void demo_variadic_macros(void) {
// 1. C23 __VA_OPT__ Handling empty variadic argument list safely:
LOG_TRACE("Simple log message with no arguments.\n");
LOG_TRACE("Formatted log: Value = %d, Status = %s\n", 42, "OK");
// 2. Type-Safe Variadic Array Summation (no dangerous va_list required!):
double total = SUM_DOUBLES(1.1, 2.2, 3.3, 4.4);
printf("Sum computed via type-safe variadic macro: %g\n", total);
}6. Providing Default Arguments (Section 17.5)
C functions do not natively support default parameter values. However, macro wrappers combined with __VA_OPT__ can supply fallback default values when optional parameters are omitted.
- Overloading C Library Calls: By wrapping library functions like
strtoul(...)with helper macros (CALL3/DEFAULT3), calls omitting theendptrandbasearguments automatically supply"0",nullptr, and0as defaults:// Calling strtoul(argv) cleanly defaults endptr to nullptr and base to 0 #define strtoul(...) CALL3(strtoul, "0", nullptr, 0, __VA_ARGS__)
Example
C functions do not natively support default parameter values. However, macro wrappers combining __VA_ARGS__ with helper macros can supply default values when optional parameters are omitted.
#include <stdio.h>
#include <stdlib.h>
// Internal helper functions enforcing exact parameter counts
static inline unsigned long parse_str_base(char const* s, char** endptr, int base) {
return strtoul(s, endptr, base);
}
// Overloading macro: Selects fallback default arguments when parameters are omitted.
// Default values: endptr = nullptr, base = 0 (auto-detect hex/octal/decimal)
#define SMART_STRTOUL(str, ...) \
strtoul((str), __VA_OPT__(__VA_ARGS__ + 0 *) nullptr, 0)
void demo_default_arguments(void) {
char const* hex_input = "0x1F";
// Standard call requires all 3 arguments: strtoul(hex_input, nullptr, 0)
// Macro wrapper supplies missing defaults:
unsigned long val = SMART_STRTOUL(hex_input);
printf("Parsed string '%s' with default arguments: %lu\n", hex_input, val);
}