The C Language has a heavy reliance on the C Preprocessor (CPP)–a preprocessor is a program that runs over a set of text files replacing specific patterns as it goes. The more I use C, the more I’ve become convinced that the CPP should be used only very rarely in .c files (though limited use in a header file doesn’t bother me). A major exception to this is the use of the ‘#include’ statement to pull header files into .c files.
I have a couple main reasons to avoid the preprocessor in .c files:
- It’s easy to add preprocessor statements, but hard to take them out. They are the ultimate quick fix, but the longer you use them, the more of them you’ll need to fix the next problem. Eventually they will take your code hostage, and the project descends into Preprocessor Hell — a place where it’s hard to determine what code is active and, if it is, what it does.
- I don’t normally have a compelling reason to put macro definitions inside .c files. Adding them there clutters up the more important aspects of my project. I end up putting these in one of my various header files.
I want to point out one good way to avoid using preprocessor statements in .c files when dealing with multiple platforms. First, here’s a program that uses the preprocessor to differentiate between platform implementations:
In this example we can see that there’s common code in each of the three functions, but there are also sections specific to each of PLATFORM_A and PLATFORM_B. It’s not too hard to see how this becomes unmanageable with more than 2 platforms or more than a few functions.
Instead of using conditionals in .c files, I propose using a single header file that defines all of the interfaces each platform must implement. It might look something like this:
Once we have this interface defined, we just need to implement it once for each different platform. Here’s the first implementation:
… and here’s the second:
Notice that the compiler will prevent us from compiling both implementations at once since the symbol names would clash. The preprocessor prevents this with a #error statement in the #else branch, but what if this piece was forgotten? I think it’s safer and cleaner to push this logic into the build system in order to simplify the code. This isn’t hard to do especially if each of the platforms have a directory for platform specific code.
Using a common interface with multiple platform implementations has several advantages:
- The compiler will help us catch missing portions or duplicate portions of the interface. A preprocessor-only implementation will not reliably help us catch these bugs.
- The C files don’t need special analysis to figure out what code is active and what code isn’t active. This makes the code more maintainable in the future.
- We completely avoid #ifdef hell.
- The common interface header file serves as a template for adding new platforms.
- Using different files for the platform-dependent code gives certain guarantees about independence. We can be confident we haven’t accidentally modified the code for the wrong platform.
I’ve put together a complete example of this anti-CPP-pattern in this GitHub gist. It consists of the following files:
- main.c – the entry point which calls all three of the functions
- common.h – some code that’s common to both platforms
- common.c – implementation of common.h
- interface.h – the interface file that defines the functions required by each platform
- platform_a.c – the implementation of the interface for Platform A
- platform_b.c – the implementation of the interface for Platform B
- bad_way.c – both platforms implemented in a single file using the preprocessor to select which code to include
If you have GCC installed and in your path…
You can build the code for Platform A by running:
gcc main.c common.c platform_a.c -o platform_a
Platform B can be built with:
gcc main.c common.c platform_b.c -o platform_b
I’ve found build system and architectural changes preferable to the use of preprocessor statements in .c files.
What other ways are there to avoid overusing the preprocessor?