CodeGym /Courses /C++ SELF /Conditional compilation — #if/#ifdef for platforms and fe...

Conditional compilation — #if/#ifdef for platforms and features

C++ SELF
Level 28, Lesson 2
Available

1. Conditional compilation and why it is not a if

Conditional compilation sounds like “one more if”, but it's a different beast. if chooses a branch at runtime, when the program is already running, and both branches must be valid C++ (the compiler has to parse them). #if/#ifdef choose a branch before compilation, at the preprocessor stage: the unselected branch can be literally removed from the text, and the compiler never sees it.

This is powerful but dangerous: you can accidentally keep “dead” code for years and not notice that it no longer compiles.

Let's fix the model in a small diagram:

flowchart TD
    A[Source .cpp/.hpp] --> B[Preprocessor: include/define/if]
    B --> C[Resulting text]
    C --> D[C++ compiler]
    D --> E[Object files]
    E --> F[Linker]

So #if controls what text reaches the compiler, while if controls which path will execute in the already compiled program.

2. Mini-dictionary of directives: #ifdef, #ifndef, #if, #elif, #else, #endif

First it's useful to “lay out the syntax” so it doesn't look like magical runes. Conditional compilation in C++ is a family of preprocessor directives — lines that start with # and don't require a ;.

These directives form blocks similar to if/else, but at the text level: open a condition, write a chunk of text, close with #endif. In real projects such blocks are common, especially when you need to support different platforms or different build modes.

A small reminder table:

Directive Idea Condition
#ifdef NAME
“If the macro is defined” the macro exists (regardless of its value)
#ifndef NAME
“If the macro is NOT defined” the macro does not exist
#if EXPR
“If the expression is true” EXPR — an integer preprocessor expression
#elif EXPR
“Else if…” like #if, but for a chain
#else
“Otherwise…” alternative branch
#endif
“Close block” end of the conditional block

3. #ifdef and #ifndef: “flag exists or not”

Often you simply need to enable or disable a piece of functionality. The “macro defined or not” model fits perfectly. Here #ifdef reads literally as if defined.

Suppose we continue developing our tutorial console app (let's call it TaskBook): it prints a greeting and sometimes wants to output debug messages so we can see what's happening.

Let's make a file config.hpp:

// config.hpp
#pragma once

#define APP_DEBUG

And in main.cpp:

#include <iostream>
#include "config.hpp"

int main() {
#ifdef APP_DEBUG
    std::cout << "[debug] start\n";  // [debug] start
#endif
    std::cout << "TaskBook is running\n"; // TaskBook is running
}

Note: if you remove the #define APP_DEBUG line, the debug line simply disappears from the resulting text. The compiler won't see it at all.

4. #if: when you need modes, not just on/off

Sometimes two states are not enough. For example, you may want not just “debug on/off” but multiple verbosity levels: 0 — silent, 1 — important messages, 2 — very verbose.

In that situation #ifdef starts to look like a hack, because you'd have to invent several macros, ensure they don't conflict, and suffer. For this it's better to use #if, since it checks a numeric expression on the preprocessor side.

Make a numeric mode in config.hpp:

// config.hpp
#pragma once

#define APP_LOG_LEVEL 2

And use it in code:

#include <iostream>
#include "config.hpp"

int main() {
#if APP_LOG_LEVEL >= 2
    std::cout << "[trace] entering main\n"; // [trace] entering main
#endif

#if APP_LOG_LEVEL >= 1
    std::cout << "[info] TaskBook started\n"; // [info] TaskBook started
#endif
}

Now you can change a single number and get a different amount of logs. This is often more readable than a “forest” of #ifdef.

5. Compound conditions: #elif and defined()

When there are multiple conditions, there's a temptation to nest #ifdef inside #ifdef, and then cover that with an outer #if. At some point the file becomes a puzzle. To stay sane, it's useful to know two techniques: “ladders” via #elif and the defined(...) operator.

Ladder of conditions via #elif

Imagine TaskBook prints “which platform we are on” (just for example). The logic may look like this:

#include <iostream>

int main() {
#if defined(_WIN32)
    std::cout << "Platform: Windows\n"; // Platform: Windows
#elif defined(__APPLE__)
    std::cout << "Platform: macOS\n";   // Platform: macOS
#elif defined(__linux__)
    std::cout << "Platform: Linux\n";   // Platform: Linux
#else
    std::cout << "Platform: Unknown\n"; // Platform: Unknown
#endif
}

defined(NAME): readable compound conditions

Sometimes you want to write a condition like “if feature A is enabled and feature B is disabled”. With bare #ifdef this quickly becomes nested blocks.

The defined(NAME) operator lets you write such conditions inside #if, as if it's logic, but on the preprocessor side:

#include <iostream>

#define FEATURE_A
// #define FEATURE_B

int main() {
#if defined(FEATURE_A) && !defined(FEATURE_B)
    std::cout << "A on, B off\n"; // A on, B off
#endif
}

It's important to remember that defined works only in preprocessor conditions (#if, #elif). It's not a C++ function or operator of the C++ language.

6. Platforms and features: how to configure the build

Platform macros may look like a secret code, but they solve a boring (thus real) problem: the same code sometimes must compile on different systems where paths, directory separators, available APIs and even headers differ.

In an ideal world we'd write “one clean C++”, but in reality the world likes to throw particulars. That's when conditional compilation by platform and by feature appears.

Platforms: _WIN32, __linux__, __APPLE__

A compiler on Windows usually defines _WIN32, on Linux — __linux__, on macOS — __APPLE__. We use these to choose small behavior differences.

Let's make a small function in our TaskBook that returns the path separator character:

char pathSeparator() {
#if defined(_WIN32)
    return '\\';
#else
    return '/';
#endif
}

And in main:

#include <iostream>

char pathSeparator();

int main() {
    std::cout << pathSeparator() << '\n'; // on Windows: \   on Linux/macOS: /
}

Features: one config.hpp instead of “archaeology”

Conditional compilation is useful not only for platforms, but also for features: “debug logging”, “experimental mode”, “extra checks”. In large projects this may be “building a client version without this subsystem” or “enabling support for a specific backend”.

A strong practice is to keep feature flags in one place, for example in config.hpp, and include that file where needed. This helps avoid scattering #define FEATURE_X across ten files and then playing archaeologist.

Suppose we have a feature APP_ENABLE_COLOR_OUTPUT that enables colored output (and if disabled — plain):

// config.hpp
#pragma once
#define APP_ENABLE_COLOR_OUTPUT 0

And usage:

#include <iostream>
#include "config.hpp"

int main() {
#if APP_ENABLE_COLOR_OUTPUT
    std::cout << "\x1b[32mOK\x1b[0m\n"; // OK (in green, if the terminal supports it)
#else
    std::cout << "OK\n";               // OK
#endif
}

Note that code with ANSI escape sequences may be inappropriate in some environments. Conditional compilation allows you to exclude that text from the final code entirely.

7. The main trap: the unselected branch can be broken

The most unpleasant trap of conditional compilation is psychological. We are used to thinking that if the program compiles, all branches of if are at least syntactically correct (because the compiler saw them). With #if that's not true: if the flag is off, the branch can be written in any nonsense, the compiler won't see it and won't complain.

I'll show an intentionally bad example:

#include <iostream>

int main() {
#if 0
    std::cout << doesNotExist(123) << '\n'; // this function is not declared
#endif
    std::cout << "Hello\n"; // Hello
}

While #if 0 is set, everything is “fine”. As soon as you change to #if 1, you'll suddenly get a compilation error. Therefore conditional compilation is not a “way to hide code”, but a configuration tool that requires discipline.

8. Everyday tricks: #if 0 and commenting #endif

There are two very practical tricks in conditional compilation: temporarily “turning off” big blocks and keeping readability of long conditions.

#if 0 as a “super-comment”

Sometimes a programmer needs to quickly disable a large chunk of code for an experiment. You could comment the block, but comments are not always convenient to close (especially if there are comments inside). Then use #if 0 ... #endif, which works like a “preprocessor comment”: it cuts the text entirely.

It looks like this:

#include <iostream>

int main() {
#if 0
    std::cout << "Temporary disabled block\n";
    std::cout << "More lines...\n";
#endif
    std::cout << "Main path\n"; // Main path
}

Don't abuse this. If such a block lives for weeks, it becomes a “code graveyard”, and graveyards are better kept in Git, not in main.cpp.

Label your #endif if the block is longer than a few lines

When #if/#ifdef appear, it's easy to lose context: where you opened, where you closed, which branch belongs to what. Therefore it's very helpful to get into the habit of labeling #endif with a comment, especially if the block is long or nested.

For example:

#if defined(_WIN32)
    // Windows-specific code
#else
    // POSIX-like code
#endif // defined(_WIN32)

This is not a language rule, but a survival rule, especially if you open the file six months later.

9. Practical mini-example: TaskBook logger with #ifdef

Now let's make a piece of code that looks “like part of a real app”, not a set of disconnected examples. In TaskBook we'll need a simple logger logDebug, which simply does not exist in release mode.

Make logger.hpp:

// logger.hpp
#pragma once

void logDebug(const char* msg);

logger.cpp:

// logger.cpp
#include <iostream>
#include "config.hpp"
#include "logger.hpp"

void logDebug(const char* msg) {
#ifdef APP_DEBUG
    std::cout << "[debug] " << msg << '\n';
#else
    (void)msg; // to avoid warnings about unused parameter
#endif
}

And main.cpp:

#include <iostream>
#include "logger.hpp"

int main() {
    logDebug("program started"); // will appear only if APP_DEBUG is defined
    std::cout << "TaskBook\n";    // TaskBook
}

This is a good compromise for a tutorial level: we don't discuss complex architecture, but we already see how #ifdef helps make “two modes” without rewriting the program.

10. Common mistakes with conditional compilation

Mistake #1: confusing #if and if and trying to check ordinary variables.
A very common situation: a student sees #if and writes something like #if x > 0, where x is a C++ variable. The preprocessor doesn't know about variables; it runs before the compiler. Only numbers, macros and expressions from them live in #if. If you need to check a value at runtime — use a regular if.

Mistake #2: assuming the “turned off” branch is still checked by the compiler.
While the condition is false, the preprocessor cuts out the text and the compiler doesn't see it. Because of this it's easy to accumulate “dead” code that hasn't been compiled in a long time. If a branch is important, enable it periodically and check it, otherwise one day it will be enabled “in production” and that will be fun (not).

Mistake #3: scattering flags across the project instead of one place.
When #define FEATURE_X lives in one .cpp, and #ifdef FEATURE_X is in another, you get a configuration that's impossible to understand “from the head”. It's much better to keep flags in one config.hpp and include it where needed. This is not perfect architecture forever, but for a tutorial project and first real programs it's a sane step.

Mistake #4: making too large #if-blocks and losing readability.
Conditional compilation should enclose small differences: a couple of lines, a small piece of implementation, an include. When you wrap half a file in #if, the code becomes two parallel realities that are hard to maintain. At some point it's easier to make two small functions with the same interface and select the implementation with #if only in one place.

Mistake #5: forgetting directive pairing and breaking nesting.
An incorrect #endif or a accidentally missing #endif can cause errors in completely unexpected places, because the resulting text becomes different from what you "saw". If the compiler complains strangely and you recently touched #if/#ifdef, first check that all blocks are closed and nesting matches reality.

1
Task
C++ SELF, level 28, lesson 2
Locked
Application start
Application start
1
Task
C++ SELF, level 28, lesson 2
Locked
Log verbosity
Log verbosity
1
Task
C++ SELF, level 28, lesson 2
Locked
Two features
Two features
1
Task
C++ SELF, level 28, lesson 2
Locked
Release logger
Release logger
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION