Kompilacja warunkowa pozwala, żeby jeden plik źródłowy stał się kilkoma różnymi programami. Preprocesor decyduje, zanim uruchomi się kompilator, które bloki tekstu przetrwają, więc kod w niespełnionej gałęzi nie jest po prostu pomijany w czasie działania, tylko usuwany. Nigdy nie jest parsowany, więc nic nie wnosi do pliku wykonywalnego i może nawet zawierać konstrukcje, które bieżący kompilator by odrzucił.
To różnica w stosunku do zwykłego if. Oba wybierają między ścieżkami; tylko jeden wybiera, zanim program w ogóle powstanie.
#ifdef i #ifndef
Najprostsze pytanie brzmi: czy makro w ogóle jest zdefiniowane?
Usuń linię #define DEBUG, a każde wypisywanie debugowe zniknie z buildu: nie zostanie wyłączone, tylko zniknie. Uruchom program raz w obecnej postaci, potem usuń tę linię i uruchom go ponownie.
#ifndef to zaprzeczenie: zachowaj blok tylko wtedy, gdy makro nie jest zdefiniowane. Najczęściej używa się go w strażnikach dołączania, omówionych przy plikach nagłówkowych, a także do podania wartości domyślnej, którą wywołujący może nadpisać:
Każde #ifdef i #ifndef musi zostać zamknięte przez #endif. Opisanie zamknięcia w długich plikach, na przykład #endif /* DEBUG */, oszczędza później sporo czasu.
#if, #elif, #else
#if przyjmuje stałe wyrażenie całkowite i zachowuje blok, gdy jest ono niezerowe. To umożliwia porównywanie wersji i poziomów:
Zmień LOG_LEVEL na 0 albo 3 i uruchom ponownie, żeby zobaczyć, jak zmienia się kształt samego programu.
Wyrażenie oblicza preprocesor, co oznacza, że może ono używać tylko stałych całkowitych, operatorów arytmetycznych i porównania oraz makr, które się do nich rozwijają. Nie widzi sizeof, wartości enum, zmiennych ani niczego, co wymaga kompilatora. Niezdefiniowane makro wewnątrz #if daje 0 zamiast błędu, co jest wygodne, a czasem zaskakujące:
#if FEATURE_X /* FEATURE_X nigdzie nie jest zdefiniowane -> 0 -> blok odrzucony */
defined(NAME) zamienia pytanie z #ifdef w coś, czego można użyć wewnątrz #if, więc da się łączyć testy:
#if defined(DEBUG) && !defined(NDEBUG)
/* build debugowy z włączonymi asercjami */
#endif
#if defined(LINUX) || defined(BSD)
/* dowolny z tych dwóch systemów uniksopodobnych */
#endif
#if defined(X) i #ifdef X znaczą to samo; sięgaj po pierwsze, gdy potrzebujesz &&, || albo !.
Definiowanie makr z wiersza poleceń
Prawdziwa siła tych przełączników polega na tym, że kod źródłowy nie musi się w ogóle zmieniać. gcc -D definiuje makro dla całej kompilacji:
gcc -DDEBUG program.c -o program # DEBUG zdefiniowane jako 1
gcc -DLOG_LEVEL=3 program.c -o program # konkretna wartość
gcc -DDEBUG -DBUFFER_SIZE=512 a.c b.c -o app
Samo -DNAME jest równoważne #define NAME 1. -U NAME usuwa definicję, co ma znaczenie, gdy nagłówek definiuje coś, co chcesz wyłączyć.
Typowy układ wygląda więc tak: kod źródłowy zawiera wartości domyślne chronione przez #ifndef, a polecenie budowania wybiera konfigurację.
/* config.h */
#ifndef LOG_LEVEL
#define LOG_LEVEL 1 /* domyślnie cicho */
#endif
#ifndef MAX_CONNECTIONS
#define MAX_CONNECTIONS 64
#endif
# build deweloperski
gcc -DDEBUG -DLOG_LEVEL=3 -Wall -Wextra -g src/*.c -o app-dev
# build produkcyjny
gcc -DNDEBUG -O2 src/*.c -o app
NDEBUG jest ustandaryzowane: jego zdefiniowanie wyłącza każde assert() w programie, bo sam <assert.h> jest napisany z użyciem kompilacji warunkowej. To ten wzorzec w miniaturze: nagłówek, który kompiluje się do różnych rzeczy w zależności od tego, co zdefiniował build.
Przełączniki platformy
Kompilatory predefiniują makra identyfikujące system docelowy, więc jeden plik źródłowy może wywołać właściwe API na każdym z nich:
#if defined(_WIN32)
#include <windows.h>
#define CLEAR_SCREEN "cls"
#elif defined(__APPLE__)
#include <unistd.h>
#define CLEAR_SCREEN "clear"
#elif defined(__linux__)
#include <unistd.h>
#define CLEAR_SCREEN "clear"
#else
#error "Nieobslugiwana platforma"
#endif
Najczęstsze z nich: _WIN32 (zdefiniowane zarówno w 32-, jak i 64-bitowym Windows), _WIN64, __linux__, __APPLE__, __unix__, __ANDROID__. Tożsamość kompilatora ma własny zestaw (__GNUC__, __clang__, _MSC_VER), podobnie jak architektura (__x86_64__, __aarch64__).
Wersja do uruchomienia, która raportuje, dla czego została zbudowana:
#error warto znać samo w sobie: zatrzymuje kompilację z twoim komunikatem. Zakończenie łańcucha platform przez #else / #error "Nieobslugiwana platforma" zamienia cichy, błędny build w czytelny błąd na samej górze logu budowania.
Żeby wypisać każde makro, które predefiniuje twój kompilator:
gcc -dM -E - < /dev/null
To wyjście jest autorytatywną odpowiedzią na pytanie "co mogę sprawdzić na tej maszynie".
#if 0 do wyłączania kodu
Zakomentowanie bloku przez /* ... */ przestaje działać w chwili, gdy blok zawiera własny komentarz, bo komentarzy w C nie da się zagnieżdżać: pierwsze */ wewnątrz kończy zewnętrzny komentarz, a wszystko za nim staje się zabłąkanym kodem. #if 0 nie ma tego problemu:
#if 0
/* Cały ten fragment jest usuwany, razem z komentarzami. */
legacy_init();
int n = old_calculation(42); /* nawet ten komentarz nie przeszkadza */
report(n);
#endif
Zmień to na #if 1, żeby przywrócić kod. Edytory nadal podświetlają go jako C, a #if 0 można zagnieździć wewnątrz innego #if.
To narzędzie do debugowania, a nie magazyn. Kod siedzący w #if 0 nigdy nie jest kompilowany, więc po cichu się psuje: zanim ktoś przestawi przełącznik, nie będzie się już kompilował. Używaj go przy zawężaniu problemu, a potem usuń blok i pozwól, żeby zapamiętał go system kontroli wersji.
Gdzie kompilacja warunkowa zawodzi
Trzy rodzaje problemów odpowiadają za większość bólu.
Kod, który nigdy nie jest kompilowany, nigdy nie jest sprawdzany. Literówka w nieaktywnej gałęzi #ifdef jest niewidoczna, dopóki ktoś nie zbuduje tej konfiguracji: może po miesiącach, może w CI na platformie, której nie masz. Jeśli projekt ma istotne gałęzie dla platform, regularnie buduj je wszystkie.
Przeplatanie #ifdef z przepływem sterowania szybko robi się nieczytelne. O czymś takim trudno myśleć:
if (ready) {
#ifdef FAST_PATH
fast_send(buf);
} else {
#endif
slow_send(buf);
}
Nawiasy otwarte w jednej gałęzi i zamknięte w innej są dozwolone i okropne. Wybieraj warunki, które obejmują całe funkcje, i wybieraj między nimi:
#ifdef FAST_PATH
static void send_data(const char *buf) { fast_send(buf); }
#else
static void send_data(const char *buf) { slow_send(buf); }
#endif
Zwykły if w czasie działania jest często lepszy. Jeśli obie gałęzie skompilowałyby się wszędzie, zwykłe if (debug_enabled) sprawia, że obie ścieżki są sprawdzane pod kątem typów, testowalne i przełączalne bez ponownego budowania. Zostaw preprocesor do tego, co tylko on potrafi: kodu, który naprawdę nie może skompilować się na innej platformie.
Wzorzec, który łączy to wszystko (nagłówek z wartościami domyślnymi, -D dla każdego buildu i gałęzie platform otoczone strażnikami dołączania), znajdziesz w artykule o plikach nagłówkowych; o samym #define przeczytasz w artykule o makrach.
Najczęściej zadawane pytania
Co to jest kompilacja warunkowa w C?
To użycie dyrektyw preprocesora do zachowania albo odrzucenia bloków kodu źródłowego, zanim uruchomi się kompilator. Kod wewnątrz niespełnionego #ifdef jest całkowicie usuwany z tekstu: nigdy nie jest parsowany, nigdy kompilowany i nigdy nie trafia do pliku wykonywalnego. W ten sposób jeden plik źródłowy obsługuje kilka platform albo rodzajów buildów.
Jaka jest różnica między #ifdef a #if?
#ifdef NAME pyta tylko, czy makro istnieje, niezależnie od jego wartości. #if expression oblicza stałe wyrażenie całkowite, więc może porównywać wartości: #if VERSION >= 3. Używaj #ifdef dla flag, które po prostu są albo ich nie ma, a #if, gdy liczy się wartość. #if defined(NAME) łączy jedno z drugim i można go łączyć przez && i ||.
Jak zdefiniować makro z wiersza poleceń w gcc?
Użyj -D: gcc -DDEBUG program.c definiuje DEBUG tak, jakby plik zaczynał się od #define DEBUG 1, a -DMAX=50 nadaje mu konkretną wartość. W ten sposób systemy budowania włączają funkcje bez edytowania kodu źródłowego, a -U NAME usuwa definicję.
Dlaczego #if 0 zamiast zakomentowania kodu?
Bo komentarzy /* ... */ nie da się zagnieżdżać: pierwsze */ wewnątrz fragmentu przedwcześnie kończy komentarz, a reszta staje się zepsutym kodem. #if 0 ... #endif usuwa dowolnie dużo kodu z dowolną liczbą komentarzy, zachowuje podświetlanie składni i łatwo je odwrócić, zmieniając na #if 1.