Procedural Programming with C · Intermediate · Week 28

The Preprocessor and Modular Programming

Everything so far has lived in one file. This week splits a program across several, which raises the two questions that define C's module system: what does a header expose, and what stays invisible. The growable array of week 19 becomes a real module by the end.

By the end of this week you can
  • Write macros that behave correctly under every argument, and say when to use a function instead.
  • Use conditional compilation and include guards.
  • Explain internal and external linkage, and fix a multiple-definition error.
  • Split a program into header and source files with a deliberate public interface.
  • Hide an implementation completely behind an opaque pointer.

1The preprocessor is a text tool

Week 2 established that the preprocessor runs before the compiler and knows nothing about C. It obeys the # directives and hands the result on as text.

#define MAX_ITEMS 100          /* object-like macro */
#define SQUARE(x) ((x) * (x))  /* function-like macro */
#include <stdio.h>             /* paste in a file */
#ifdef DEBUG … #endif          /* conditional compilation */

Macro pitfalls

Parenthesize everything. Twice: the whole body, and every use of a parameter.

#define SQUARE(x) x * x            /* broken */
SQUARE(2 + 3)   ->  2 + 3 * 2 + 3  ->  11

#define SQUARE(x) ((x) * (x))      /* correct */
SQUARE(2 + 3)   ->  ((2 + 3) * (2 + 3))  ->  25

Beware double evaluation. A parameter appearing twice in the body is evaluated twice:

#define MAX(a, b) ((a) > (b) ? (a) : (b))

MAX(i++, j)     /* i is incremented once or twice, unpredictably */
MAX(f(), g())   /* one of the calls happens twice */

There is no general fix in standard C. This is the strongest argument for using a function: a function evaluates each argument exactly once, and any compiler will inline a small one at -O2.

Prefer the alternatives. For a constant, const or an enum — week 5. For a small operation, a static inline function — week 38. Macros remain necessary for conditional compilation, for header guards, for stringification, and for generic code that must work across types (week 30).

Stringify and paste

#define STRINGIFY(x) #x
#define CONCAT(a, b) a ## b

STRINGIFY(hello)     /* becomes "hello" */
CONCAT(my_, var)     /* becomes my_var  */

The common practical use is a debug macro that reports the expression it evaluated:

#define TRACE(expr) \
    fprintf(stderr, "%s:%d: %s = %d\n", __FILE__, __LINE__, #expr, (expr))
Predefined macroExpands to
__FILE__Current source file name
__LINE__Current line number
__func__Enclosing function name (C99; an identifier, not a macro)
__DATE__, __TIME__Compilation date and time
__STDC_VERSION__201710L for C17, 202311L for C23

2Conditional compilation

#ifdef DEBUG
    fprintf(stderr, "state = %d\n", state);
#endif

#if defined(__linux__)
    …
#elif defined(_WIN32)
    …
#else
#   error "unsupported platform"
#endif

Enable from the command line with -DDEBUG or -DDEBUG=1. This is how a debug build differs from a release build without maintaining two copies of the source.

One caution: heavily conditional code is compiled in only one configuration at a time, so the branches you are not building are never checked by the compiler. A block inside #ifdef _WIN32 can contain syntax errors that a Linux build never reports. Keep the conditional regions small, and prefer a runtime if when the cost allows it — an if (debug_enabled) is type-checked in every build.

3Headers and include guards

Without protection, including a header twice defines its types twice:

/* buffer.h */
#ifndef BUFFER_H
#define BUFFER_H

…declarations…

#endif /* BUFFER_H */

The first inclusion defines BUFFER_H; every later one skips the body. #pragma once does the same in one line and is supported by every mainstream compiler, though it is not in the standard. Both are fine; be consistent within a project.

What belongs in a header

Header (.h)Source (.c)
Function declarationsFunction definitions
typedefs and struct definitions callers needStructs only the implementation uses
#defined constants that are part of the interfaceInternal constants
extern declarations of shared variablesThe one definition of each
Documentation of the contractstatic helper functions

A header should include only what its own declarations require, and a source file should include what it uses. Headers that include other headers "just in case" create build-time coupling that is painful to unpick later.

4Linkage and the one-definition rule

DeclarationLinkageVisible to
int counter; at file scopeExternalEvery translation unit
static int counter; at file scopeInternalThis file only
extern int counter;External — a declaration, not a definitionRefers to a definition elsewhere
void f(void)ExternalEvery translation unit
static void f(void)InternalThis file only
Anything inside a functionNoneThat block

The classic error, and its fix:

/* config.h — WRONG */
int verbose = 0;            /* a DEFINITION in a header */
/usr/bin/ld: multiple definition of `verbose'

Every source file that includes the header gets its own definition, and the linker finds several. The correct split:

/* config.h */
extern int verbose;         /* declaration: this exists somewhere */

/* config.c */
int verbose = 0;            /* the single definition */

Mark everything static that is not part of the interface. It prevents accidental name collisions at link time, lets the compiler optimize more aggressively, and documents intent. A source file whose only non-static names are the ones in its header is a well-formed module.

5Information hiding and opaque pointers

A header that exposes a struct definition lets callers reach inside it — and once they do, you can never change the layout. The opaque pointer prevents that entirely:

/* buffer.h — the caller sees a name, not a layout */
typedef struct Buffer Buffer;      /* incomplete type */

Buffer *buffer_create(size_t capacity);
void    buffer_destroy(Buffer *b);
bool    buffer_append(Buffer *b, int value);
size_t  buffer_count(const Buffer *b);
/* buffer.c — the definition lives here and nowhere else */
struct Buffer {
    int    *data;
    size_t  count;
    size_t  capacity;
};

Callers can hold a Buffer * and pass it around, but cannot declare a Buffer, cannot see the members, and cannot depend on the size. You may change the implementation freely — add a field, switch to a linked structure — and every caller still compiles without modification.

Exposed structOpaque pointer
Caller can allocate on the stackMust call create
Direct member access; fastAccessor functions
Layout is frozen foreverFree to change
Recompile all callers on any changeCallers unaffected

Use the opaque form for anything with an invariant to protect or a future you cannot predict — which week 33 turns into the standard way to build an abstract data type, and week 45 shows is essential for a library whose ABI must stay stable.

6Worked example: the growable array, as a module

Three files. This is the structure week 29 compiles into a library.

intarray.h — the public interface

/* intarray.h — a growable array of int.
 *
 * Error contract:
 *   Functions returning bool give true on success, false on failure;
 *   on failure the array is left unchanged and usable.
 *
 * Ownership:
 *   intarray_create returns a handle the caller must pass to
 *   intarray_destroy exactly once. No other function frees anything.
 */
#ifndef INTARRAY_H
#define INTARRAY_H

#include <stddef.h>
#include <stdbool.h>

typedef struct IntArray IntArray;     /* opaque: no layout exposed */

IntArray *intarray_create(size_t initial_capacity);
void      intarray_destroy(IntArray *a);

bool      intarray_push(IntArray *a, int value);
bool      intarray_get(const IntArray *a, size_t index, int *out);
size_t    intarray_count(const IntArray *a);
size_t    intarray_capacity(const IntArray *a);
void      intarray_clear(IntArray *a);

#endif /* INTARRAY_H */

intarray.c — the implementation

#include "intarray.h"

#include <stdlib.h>
#include <assert.h>

#define DEFAULT_CAPACITY 4          /* internal: not in the header */

struct IntArray {                   /* the definition lives only here */
    int    *data;
    size_t  count;
    size_t  capacity;
};

/* static: invisible outside this file, no link-time collisions */
static bool grow(IntArray *a)
{
    size_t bigger = a->capacity * 2;
    int *moved = realloc(a->data, bigger * sizeof *moved);
    if (moved == NULL) {
        return false;               /* a->data still valid */
    }
    a->data = moved;
    a->capacity = bigger;
    return true;
}

IntArray *intarray_create(size_t initial_capacity)
{
    if (initial_capacity == 0) {
        initial_capacity = DEFAULT_CAPACITY;
    }

    IntArray *a = malloc(sizeof *a);
    if (a == NULL) {
        return NULL;
    }
    a->data = malloc(initial_capacity * sizeof *a->data);
    if (a->data == NULL) {
        free(a);                    /* release what we got */
        return NULL;
    }
    a->count = 0;
    a->capacity = initial_capacity;
    return a;
}

void intarray_destroy(IntArray *a)
{
    if (a == NULL) {
        return;                     /* destroy(NULL) is safe, like free */
    }
    free(a->data);
    free(a);
}

bool intarray_push(IntArray *a, int value)
{
    assert(a != NULL);
    if (a->count == a->capacity && !grow(a)) {
        return false;
    }
    a->data[a->count++] = value;
    return true;
}

bool intarray_get(const IntArray *a, size_t index, int *out)
{
    assert(a != NULL && out != NULL);
    if (index >= a->count) {
        return false;
    }
    *out = a->data[index];
    return true;
}

size_t intarray_count(const IntArray *a)    { assert(a); return a->count; }
size_t intarray_capacity(const IntArray *a) { assert(a); return a->capacity; }
void   intarray_clear(IntArray *a)          { assert(a); a->count = 0; }

main.c — a caller that cannot see inside

#include "intarray.h"

#include <stdio.h>
#include <stdlib.h>

/* A debug macro that reports the expression it evaluated. */
#define TRACE(expr) \
    fprintf(stderr, "%s:%d: %s = %zu\n", __FILE__, __LINE__, #expr, (size_t)(expr))

int main(void)
{
    printf("compiled against C standard %ld\n", __STDC_VERSION__);

    IntArray *a = intarray_create(2);
    if (a == NULL) {
        fprintf(stderr, "out of memory\n");
        return EXIT_FAILURE;
    }

    for (int i = 1; i <= 9; i++) {
        if (!intarray_push(a, i * i)) {
            fprintf(stderr, "push failed\n");
            intarray_destroy(a);
            return EXIT_FAILURE;
        }
    }

    printf("count = %zu, capacity = %zu\n",
           intarray_count(a), intarray_capacity(a));

    printf("contents:");
    for (size_t i = 0; i < intarray_count(a); i++) {
        int value;
        if (intarray_get(a, i, &value)) {
            printf(" %d", value);
        }
    }
    putchar('\n');

    int value;
    printf("index 99 -> %s\n",
           intarray_get(a, 99, &value) ? "value" : "refused");

    TRACE(intarray_count(a));

    /* Neither of these compiles — and that is the point:
     *   IntArray local;          // incomplete type
     *   printf("%zu", a->count); // dereferencing an incomplete type
     */

    intarray_destroy(a);
    intarray_destroy(NULL);        /* documented as safe */
    return EXIT_SUCCESS;
}

Building it

gcc -std=c17 -Wall -Wextra -g -fsanitize=address -c intarray.c -o intarray.o
gcc -std=c17 -Wall -Wextra -g -fsanitize=address -c main.c     -o main.o
gcc -fsanitize=address intarray.o main.o -o app
./app

Two compilations, then a link — week 2's model, now with something to link. Change only main.c and you recompile one file instead of the whole program, which is the practical reason projects are split at all. Week 29 automates this with a Makefile.

Prove the encapsulation

Add either of the commented lines in main.c:

IntArray local;
error: storage size of 'local' isn't known
printf("%zu", a->count);
error: invalid use of incomplete typedef 'IntArray'

The caller genuinely cannot reach inside. Now change the implementation — replace the array with a linked list, or add a field — and rebuild intarray.c only. main.o does not even need recompiling, because nothing it depends on changed.

Provoke the multiple-definition error

Add int shared_counter = 0; to intarray.h and rebuild:

/usr/bin/ld: main.o:(.bss+0x0): multiple definition of `shared_counter';
             intarray.o:(.bss+0x0): first defined here

Both translation units included the header and both defined the variable. Change the header to extern int shared_counter; and put int shared_counter = 0; in intarray.c — one declaration everywhere, one definition in exactly one place.

See the macro pitfall

#define SQUARE_BAD(x)  x * x
#define SQUARE_OK(x)  ((x) * (x))

printf("bad:  %d\n", SQUARE_BAD(2 + 3));   /* 11 */
printf("good: %d\n", SQUARE_OK(2 + 3));    /* 25 */

int i = 3;
printf("%d\n", SQUARE_OK(i++));            /* i incremented twice */
printf("i is now %d\n", i);                /* 5, not 4 */

Run the preprocessor alone to see exactly what the compiler received:

gcc -E main.c | tail -40

7Common mistakes

MistakeWhat happensFix
Unparenthesized macro body or parameterPrecedence changes the meaningParenthesize both; prefer a function.
A macro parameter used twiceDouble evaluation of side effectsUse a static inline function.
A definition in a headermultiple definition at link timeextern in the header, definition in one .c.
Missing include guardRedefinition errors#ifndef/#define/#endif, or #pragma once.
No static on helpersNames leak; collisions across filesAnything not in the header is static.
Exposing a struct that callers then depend onLayout frozen; every change recompiles the worldOpaque pointer.
Semicolon after a macro definitionIt becomes part of the expansion#define N 10, not #define N 10;.
Large #ifdef blocksUnbuilt branches never compile-checkedKeep them small; prefer runtime if.

8Check yourself

Why is #define SQUARE(x) ((x) * (x)) still not equivalent to a function?

Because the parameter appears twice, so any side effect in the argument happens twice — SQUARE(i++) increments i twice. Parentheses fix precedence but not evaluation count. A static inline function evaluates each argument once and is inlined just as effectively at -O2.

Why does putting int verbose = 0; in a header cause a link error?

Because the preprocessor pastes the header into every source file that includes it, so each translation unit defines its own verbose and the linker finds several definitions of one external name. Headers declare; exactly one source file defines. Use extern int verbose; in the header.

What does static do to a file-scope function, and why use it?

It gives the name internal linkage, so no other translation unit can refer to it. That prevents accidental collisions at link time, documents that the function is an implementation detail, and lets the compiler optimize more aggressively because it can see every call. Everything not in the header should be static.

What does an opaque pointer buy you, and what does it cost?

It hides the structure's layout completely, so callers cannot depend on it and you may change the implementation without recompiling them — essential for a library with a stable interface. The costs are that callers must allocate through a create function rather than on the stack, and that every access goes through an accessor rather than reading a member directly.

Why keep #ifdef regions small?

Because only one configuration is compiled at a time, so code inside a branch you are not building is never seen by the compiler — it can contain syntax errors, unused variables, or stale API calls that surface only on another platform. Small regions limit the exposure, and a runtime if is type-checked in every build.

9Where this leads

That completes the Intermediate level. You have a module with a public header, a hidden implementation, an error contract, and no leaks. Week 29 opens the Advanced level by building it properly: a Makefile, then CMake, then the same code as a static and a shared library — which is the form week 45 packages and week 46 calls from Python.