Chapter 13: Modular Programming
As programs grow, maintaining all code in a single file becomes impractical. Modular programming divides functionality into logical units that can be developed, compiled, and reused independently. In C, this modularity is achieved by combining header files for declarations, source files for definitions, and linkage specifications that control visibility between translation units. Understanding these principles makes projects cleaner, more maintainable, and easier to scale.
Splitting programs into multiple source files
Breaking a program into several .c files allows you to organize related functions and data together. Each file can be compiled separately into an object file, then linked to form the final program. This separation shortens build times and encourages clear boundaries between components.
/* file: util.c */
#include <stdio.h>
void greet(const char *name) {
printf("Hello, %s!\n", name);
}
/* file: main.c */
#include "util.h"
int main(void) {
greet("world");
return 0;
}
/* file: util.h */
#ifndef UTIL_H_INCLUDED
#define UTIL_H_INCLUDED
void greet(const char *name);
#endif
Each source file includes its own header to ensure declarations stay synchronized with definitions. Compilation then proceeds independently, producing object files that are later linked.
gcc -c util.c -o util.o
gcc -c main.c -o main.o
gcc main.o util.o -o app
.c file. This ensures missing prototypes or mismatched declarations cause compiler errors early.
Managing dependencies with Makefiles
Once a project has several modules, a Makefile simplifies builds by tracking dependencies. Each target defines how to produce an object file or executable from its sources. When you change a file, only affected parts rebuild.
# Makefile
app: main.o util.o
gcc main.o util.o -o app
main.o: main.c util.h
gcc -c main.c
util.o: util.c util.h
gcc -c util.c
clean:
rm -f *.o app
Designing clear and reliable header files
Headers describe the interface a module presents to other files. They contain type definitions, macros, and function declarations but never define variables or allocate storage. This separation allows multiple translation units to include the same header safely.
/* file: vector.h */
#ifndef VECTOR_H_INCLUDED
#define VECTOR_H_INCLUDED
#include <stddef.h>
typedef struct {
double *data;
size_t length;
} Vector;
void vector_init(Vector *v, size_t n);
void vector_free(Vector *v);
double vector_dot(const Vector *a, const Vector *b);
#endif
Keeping headers minimal and self-contained
Each header should include everything it needs to compile independently. Use forward declarations rather than full includes where possible, and limit exposure of internal structures. If only a pointer to a type is required, forward declare it instead of including its full definition.
/* file: connection.h */
#ifndef CONNECTION_H_INCLUDED
#define CONNECTION_H_INCLUDED
struct Server; /* forward declaration */
int connect_to(struct Server *srv);
#endif
Controlling visibility with static and extern linkage
Linkage determines whether identifiers in one translation unit are visible to others. By default, functions and variables at file scope have external linkage, meaning they can be used across source files. Marking them static restricts them to the current file. Use extern for declarations that refer to definitions elsewhere.
/* file: counter.c */
#include <stdio.h>
static int count = 0; /* visible only in this file */
void increment(void) {
++count;
printf("Count = %d\n", count);
}
/* file: main.c */
void increment(void); /* or include a header declaring it */
int main(void) {
increment();
increment();
return 0;
}
In this example, count remains private to counter.c while increment() is accessible externally. To share global variables across modules, you declare them with extern in a header and define them once in a single source file.
/* file: globals.h */
#ifndef GLOBALS_H_INCLUDED
#define GLOBALS_H_INCLUDED
extern int global_flag;
#endif
/* file: globals.c */
int global_flag = 1;
Using static for internal helper functions
Static functions are ideal for internal utilities that should not be visible outside their source file. They also enable certain compiler optimizations because the compiler knows the function cannot be called externally.
static void log_message(const char *msg) {
fprintf(stderr, "log: %s\n", msg);
}
Building and linking libraries
Libraries package multiple object files so that you can reuse them without recompiling the source each time. There are two common types: static libraries (.a or .lib) and shared libraries (.so or .dll).
Creating and using a static library
Compile your modules into object files, then archive them into a library with ar. Link against it like any other object file.
# build a static library
gcc -c util.c mathx.c
ar rcs libmylib.a util.o mathx.o
# use the library
gcc main.c -L. -lmylib -o app
Static libraries are copied into the final binary during linking, which makes distribution simple but increases file size.
Building and using a shared library
Shared libraries are loaded dynamically at runtime. They reduce duplication across programs and can be updated independently of the executable. On Unix-like systems they use the .so extension, while Windows uses .dll.
# build a shared library
gcc -fPIC -c util.c mathx.c
gcc -shared -o libmylib.so util.o mathx.o
# link program dynamically
gcc main.c -L. -lmylib -o app
export LD_LIBRARY_PATH=.
./app
Understanding symbol visibility in shared libraries
When exporting functions from shared libraries, you can control which symbols remain public using compiler attributes or link scripts. Reducing exported symbols minimizes conflicts and keeps your library interface clean.
#ifdef _WIN32
#define API __declspec(dllexport)
#else
#define API __attribute__((visibility("default")))
#endif
API void greet(const char *name);
At link time, only the exported functions are available to other programs, helping maintain a clear separation between internal helpers and the official API surface.