Menu

Kompilacja warunkowa w C: #ifdef, #ifndef, #if i -D

Kompiluj różny kod dla różnych buildów za pomocą #ifdef, #ifndef, #if, #elif i #else: przełączniki debugowania, gałęzie dla platform, definiowanie makr z wiersza poleceń przez -D i wyłączanie kodu przez #if 0.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

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.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ