Skip to content
academia.sh

Lesson 03 / 13

Header Files

A header file does not carry a name, it carries that name's text, and the preprocessor copies it separately into every unit that uses it. This makes it possible for the same declaration to enter two units at once and for both to compile; the measurement proves this by running it, and separately shows what the include guard prevents.

Published Updated

In this lesson

  • Distinguishes the difference between a declaration and a definition through the source
  • Shows by running it how a header file carries the same declaration into multiple units
  • Explains with an example which error the include guard prevents
Contents

The previous lesson showed the linker class with only a single call: identity’s declaration was in one file, its definition in another, and stage.h built the bridge between them. This lesson looks at that bridge itself. A header file is neither a unit nor compiled on its own; it only carries text, and that text is copied separately into every unit that #includes it.

This single sentence summarizes a fact that will be recalled often through the rest of the course: in C, a unit is the union of its own source and everything it #includes. What the compiler sees is not the form stage.c takes on screen, but the single piece of text the preprocessor produces after it has resolved every #include. A header file determines where it stands in this union, and this lesson shows why that position must be reserved for declarations alone.

Declaration and Definition

In C, a name has two different appearances in the source. A declaration says that a name exists, its type, and how it is called, but gives no body. A definition gives what that name corresponds to — a function’s body, a variable’s allocated place. A name can be declared in more than one place, but can be defined in only one place; this distinction is the reason header files exist.

A unit does not have to see the definition of every name it calls; seeing only its declaration is enough — this is why a single header file can be shared among dozens of units. To produce a call, the compiler must know the name’s type, how many parameters it takes, and what it returns — all of this is in the declaration. Where the name’s actual counterpart is located is a question that does not concern the compiler; the linker will answer that question. Because of this distinction, a unit can call a definition it has never seen — as long as it has a correct declaration.

// stage.h — declarations of names defined in other units
#ifndef STAGE_H
#define STAGE_H

/* These two functions are defined in another unit: their calls are resolved at link time. */
int identity(void);
const char *unit_name(void);

/* This variable is also defined in another unit. */
extern int unit_counter;

#endif

stage.h declares three names, it defines none of them. identity and unit_name have no body; the extern standing in front of unit_counter says exactly this — “this name is defined elsewhere, it is only declared here.” The definitions themselves stand in the second unit.

// choice.c — the second unit: definitions live here, calls resolve at link time
#include "stage.h"

int unit_counter = 2;

int identity(void) {
    return 7;
}

const char *unit_name(void) {
    return "choice";
}

The third file includes stage.h on its own, unaware of choice.c, and calls all three.

// stage.c — excerpt linked to the second unit through the declarations the header carries
#include <stdio.h>
#include "stage.h"

static void observe(const char *name, const char *stage, const char *evidence) {
    printf("%-30s%-14s%s\n", name, stage, evidence);
}

int main(void) {
    char buffer[64];

    printf("%-30s%-14s%s\n", "observation", "stage", "evidence");

    observe("header text", "preprocessor", "declarations copied into the source as text");

    snprintf(buffer, sizeof buffer, "the call returned %d", identity());
    observe("function in another unit", "linker", buffer);

    snprintf(buffer, sizeof buffer, "unit declared as %s", unit_name());
    observe("name in another unit", "linker", buffer);

    snprintf(buffer, sizeof buffer, "counter %d", unit_counter);
    observe("variable in another unit", "linker", buffer);

    return 0;
}
observation                   stage         evidence
header text                   preprocessor  declarations copied into the source as text
function in another unit      linker        the call returned 7
name in another unit          linker        unit declared as choice
variable in another unit      linker        counter 2

LC6. stage.h itself never appears with the preprocessor label in any observation; what appears in its place, the header text row, is a result, not stage.h itself. What the preprocessor does amounts to nothing more than copying text: the line #include "stage.h" is replaced with the content of stage.h before the compiler ever gets involved. The identity(void) declaration seen in stage.c was therefore never hand-typed into stage.c’s source — it arrived there by being copied. The same line was also copied into choice.c’s source; the two copies are independent of one another, and both became part of their own unit. The #include line seen while editing a file is only a summary of the compiled text — the real source is the expanded text that appears after the preprocessor runs, never seen with the eye.

choice.c also includes stage.h, and the same three declarations are copied into its source too. Both units compile with these declarations, and both compile without a warning — the same declaration entered two separate places and neither was troubled by it, because a declaration makes a promise, it does not reserve a place. The definition, on the other hand, exists only once, in choice.c; stage.c uses all three names but carries the definition of none of them, borrowing the definitions at the linking stage.

The four rows in the output show how this borrowing works. The header text row is an observation about the source itself, because it proves that a single piece of text carries the declaration — but what matters is that stage.c can use these three names. The function in another unit and name in another unit rows reach choice.c’s function definitions; the variable in another unit row reaches a variable definition. All three use the same bridge: the declaration in stage.h tells stage.c “this name exists, here is its type”; its actual counterpart arrives only at linking time. While stage.c compiles on its own, it does not even know that unit_counter’s value is 2 — all it knows is that some integer like this exists somewhere.

What a Header Shares, What It Does Not

stage.h carrying only declarations is not a choice, it is a necessity. Since a header file can be included in multiple units, if a function definition — together with its body — were put into it, that body would be copied separately into every unit that includes it. Both stage.c and choice.c include stage.h; if identity’s body stood there, both units would have defined the same name, and this would produce a “same name defined twice” conflict at linking. This is why the definition must stand only in one unit, not in the header — the include guard prevents this within a single unit, but it does not prevent a definition from spreading across multiple units; what prevents that is never putting the definition in the header at all.

This also explains why the line extern int unit_counter; is not written as int unit_counter = 2;. The extern keyword tells the compiler “do not reserve a place here, this name already exists elsewhere”; a variable written without it is actually a definition and reserves a place. Had a variable without extern been put in the header, every unit that includes it would reserve its own copy — stage.c and choice.c would end up with two separate unit_counters carrying the same name but standing at different addresses, and this would produce either a conflict or a silent inconsistency at linking.

This rule does not apply directly to function declarations; a function declaration does not reserve a place on its own to begin with, it only plays the same role as an extern variable declaration. The essence of the rule is this: every line put in a header must be interpreted the same way by every unit that reads it. A declaration naturally satisfies this condition, because it makes the same promise everywhere it is copied; a definition does not, because it demands a separate place everywhere it is copied.

The Include Guard

A header file can enter the same unit through more than one path — directly, or from inside another header file. This does not have to be as obvious a mistake as including a single file twice; it often arises on its own when two separate headers both depend on a third, shared header at the same time. When a unit includes both of them, the shared header ends up entering that unit twice. The #ifndef / #define pair at the top of stage.h exists exactly to cover this case. The small example below shows this with a header independent of stage.h.

// boundary.h — a small header declaring one type; carries an include guard
#ifndef BOUNDARY_H
#define BOUNDARY_H

typedef struct {
    int row;
    int column;
} Position;

#endif
// entry.h — an intermediate header pulling in boundary.h through one path
#include "boundary.h"
// exit.h — a second intermediate header pulling in boundary.h through another path
#include "boundary.h"
// context.c — boundary.h enters the same unit through two separate paths
#include <stdio.h>
#include "entry.h"
#include "exit.h"

int main(void) {
    Position p = {3, 7};
    printf("position: row %d column %d\n", p.row, p.column);
    printf("header pulled in through two paths, type defined once\n");
    return 0;
}
position: row 3 column 7
header pulled in through two paths, type defined once

LC7. context.c never includes boundary.h directly — it pulls it in indirectly, through two separate paths, via entry.h and exit.h. The preprocessor processes both #includes in order: in the first, since BOUNDARY_H is not yet defined, the Position type is copied into the source and BOUNDARY_H is defined; in the second, #ifndef BOUNDARY_H is now false, and everything up to the #endif is skipped — Position is not copied again. Without the guard, Position would be defined twice in the same unit, and this would not compile. The struct here is untagged: C counts every untagged struct declaration as a separate type, while a typedef name can only be rebound to the same type. The second copy tries to bind Position to a second, different anonymous type, and the rule is violated. A definition, unlike a declaration, must be singular.

This shows that the #ifndef family solves a problem tied not to declarations but to definitions. identity‘s declaration could be copied into two units, because a declaration can be duplicated. Position‘s definition cannot be copied twice into the same unit, because a definition is singular — just like a function’s body. The include guard protects a header’s own definitions under this singularity rule.

LC8. The name BOUNDARY_H itself has no special meaning; it only serves as a marker answering the question “has this header already been copied.” What the preprocessor sees is not a file name or a path, only whether this name is defined — from the moment the line #define BOUNDARY_H runs, every subsequent #ifndef BOUNDARY_H in the same unit gives a false result. The guard therefore rests not on the file itself but on this single mark left in the source; if another header with a colliding name used the same mark, the guard would still work, but this time it would leave out the wrong header. Making the name unique and specific to its header is therefore the programmer’s responsibility, not a guarantee of the language.

entry.h and exit.h themselves carry no new declaration; both are only bridges to boundary.h. This mirrors, at small scale, a pattern common in real header trees: a header includes whatever other headers it needs, and the unit that uses it does not have to be aware of these internal dependencies. context.c reached the Position type without even knowing boundary.h existed — both entry.h and exit.h carried this dependency on their own.

The Bounding Measurement

A header file is not a contract. The declaration in stage.h matches the definition in choice.c exactly because, in this core, the two were kept consistent by hand — but the language itself does not check this consistency. If stage.h mistakenly declared int identity(int); instead of int identity(void);, the definition in choice.c would still be int identity(void) { return 7; }. The compiler compiling stage.c looks only at the declaration in stage.h and produces the call accordingly; the compiler compiling choice.c looks only at its own definition. Each is consistent within its own unit, each compiles on its own without a warning — because the compiler cannot see two units at once, a bound known since this course’s first lesson.

A mismatch can only surface once the two units come together, that is, during linking. But a classic C linker checks not with which signature a name was declared, only whether the name exists — it looks up the name identity in the symbol table, finds it, matches the address. Whether the signature matches is often something the linker cannot see either; the error is not caught there either, it falls into undefined behavior. In the core, the situation is not like this: the declaration in stage.h and the definition in choice.c match exactly, and both runs in this lesson have already proven it — both the identity call and the Position struct gave the expected values. A header file does not provide this consistency, it only carries it if it has already been provided by hand.

This bound is another appearance of the previous lesson’s finding, “the stages do not reverse.” The preprocessor, while copying a header, does not ask whether the text is correct, it only copies. The compiler says nothing as long as it finds the copied text consistent within its own unit. The inconsistency becomes visible only once the two units are linked — and even then, only in some cases. This is why a header file’s correctness is not something the language checks, it is something its writer maintains through their own discipline: when the declaration changes, the definition must change too, even if the two stand in separate files.

How this discipline is maintained is a separate subject — build systems and dependency tracking are the subject of later curricula — but this lesson’s bound says this: identity’s signature does not stay correct automatically just because stage.h exists, it can only be changed from a single place. What a header file actually earns is not a guarantee of correctness, but this singularity — that when a declaration changes, every unit that uses it sees the same text.

Summary

  • A name appears in the source in two separate forms: a declaration only says it exists, a definition gives its body or its place.
  • A declaration can be copied into multiple units; a definition can exist in only one unit.
  • A header file is not a unit; it only carries text, and it is copied separately into every unit that includes it.
  • The include guard (#ifndef / #define) prevents the same header from entering a unit through more than one path and copying a definition twice.
  • A header file is not a contract: if the declaration and the definition disagree, the compiler cannot see it, because it looks at only one unit at a time; the linker, too, looks only for the name, not the signature.
  • Putting a declaration rather than a definition in a header is a necessity more than a style: if the same definition were copied into every unit that includes it, a conflict would arise when these units are linked.

Next Step

This lesson opened up the mechanism behind the header text row: the preprocessor copies a file, but the rest of the source has no way of knowing that this copying happened. The difference between the #include "stage.h" line standing in the source and the expanded text the compiler sees is the shared property of all of the preprocessor’s decisions: none of them leaves a trace in the source, each exists only through its result. The next lesson returns to the preprocessor’s remaining three decisions — macro value, conditional body, and token pasting — and shows, with a deeper look than the previous lesson, why each of them works entirely independently of the compiler at the text level.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close