Kompilacja warunkowa pozwala kompilatorowi włączać lub pomijać fragmenty kodu źródłowego w zależności od warunków znanych na etapie budowania. Według The Swift Programming Language (2026), dyrektywa #if jest przetwarzana na etapie analizy AST przed generowaniem kodu maszynowego. Kompilacja warunkowa daje programistom możliwość utrzymywania jednolitej bazy kodu dla wielu platform i konfiguracji bez powielania.
Najważniejsze
Kompilacja warunkowa — to mechanizm, w którym kompilator analizuje dyrektywy kompilacji warunkowej i włącza do wynikowego pliku binarnego tylko te bloki kodu, dla których warunki są spełnione. Pozwala to na posiadanie jednolitej bazy kodu, która dostosowuje się do różnych platform docelowych i konfiguracji.
Koncepcja pochodzi z C/C++ z dyrektywami preprocesora #ifdef, #ifndef, #endif. W nowoczesnych językach (Swift, Rust, Go) mechanizm działa na poziomie kompilatora bez osobnego preprocesora, co zwiększa bezpieczeństwo: bloki warunkowe muszą być składniowo poprawne, nawet jeśli nie są kompilowane.
Według danych Apple WWDC Session „Embrace Swift” (2025), około 40% projektów Swift używa kompilacji warunkowej do obsługi iOS i macOS w jednym targetcie. Dla projektów z UIKit i SwiftUI kod UI jest często rozdzielony dyrektywami #if os(iOS) i #if os(macOS), co pozwala na ponowne wykorzystanie logiki biznesowej.
Główna zaleta — bezpieczeństwo na etapie kompilacji. Kod dla nieodpowiedniej platformy nie tylko nie jest wykonywany, ale nie jest kompilowany. Oznacza to, że błędy w kodzie specyficznym dla iOS nie ujawnią się podczas budowania dla macOS i odwrotnie. Sprawdzanie w czasie wykonywania nie daje takiej gwarancji.
Swift udostępnia cztery kluczowe dyrektywy: #if, #elseif, #else, #endif. W przeciwieństwie do preprocesora C, Swift wymaga poprawności składniowej kodu we wszystkich gałęziach — kompilator parsuje cały kod, ale generuje kod maszynowy tylko dla aktywnych gałęzi.
Swift obsługuje wbudowane funkcje sprawdzania: os(iOS), os(macOS), os(tvOS), os(watchOS), os(Linux), os(Windows). Te funkcje sprawdzają platformę docelową, dla której budowana jest aplikacja. Kombinacja z && i || pozwala tworzyć złożone warunki.
// Wspólny kod dla iOS, macOS i tvOS
import Foundation
class PlatformService {
func getSystemVersion() -> String {
#if os(iOS) || os(tvOS)
return UIDevice.current.systemVersion
#elseif os(macOS)
let vers = ProcessInfo.processInfo.operatingSystemVersion
return "\(vers.majorVersion).\(vers.minorVersion)"
#else
return "unknown"
#endif
}
}
Swift obsługuje sprawdzanie wersji kompilatora: #if swift(>=5.9). Jest to przydatne dla bibliotek i frameworków, które obsługują wiele wersji Swifta. Nowe możliwości języka (na przykład makra w Swift 5.9) mogą być chronione takim sprawdzeniem.
// Wsteczna zgodność
#if swift(>=5.9)
@MainActor
struct ModernView: View {
var body: some View {
Text("Nowoczesny SwiftUI")
}
}
#else
struct ModernView: View {
var body: some View {
Text("Starszy SwiftUI")
}
}
#endif
Funkcja canImport(ModuleName) sprawdza, czy określony moduł jest dostępny w bieżącym środowisku budowania. Jest to najbardziej elastyczny mechanizm: nie jest powiązany z konkretną platformą. Na przykład kod używający CoreHaptics będzie kompilowany tylko na urządzeniach, gdzie ten framework jest dostępny.
#if canImport(CoreHaptics)
import CoreHaptics
class HapticManager {
private var engine: CHHapticEngine?
func playTapFeedback() {
guard let engine else { return }
// Implementacja odpowiedzi dotykowej
}
}
#endif
Kotlin jako język nie ma dyrektyw preprocesora. Zamiast tego ekosystem Android oferuje trzy alternatywy: pola BuildConfig (sprawdzanie w czasie wykonywania), sourceSets (zamiana całych plików) i expect/actual (w Kotlin Multiplatform).
Gradle sourceSets pozwalają na posiadanie różnych implementacji klas dla różnych wariantów lub typów budowania. W katalogu src/debug/ znajduje się implementacja dla debug, w src/release/ — dla release. Podczas budowania Gradle wybiera odpowiedni sourceSet i kompiluje tylko jego pliki.
// src/debug/kotlin/com/example/Logger.kt
object Logger {
fun log(tag: String, message: String) {
Log.d(tag, message)
}
}
// src/release/kotlin/com/example/Logger.kt
object Logger {
fun log(tag: String, message: String) {
// No-op w trybie release
}
}
KMP udostępnia mechanizm expect (deklaracja w kodzie wspólnym) i actual (implementacja dla konkretnej platformy). Jest to mechanizm na etapie kompilacji: dla iOS kompilowana jest implementacja actual z iOS sourceSet, dla Android — z Android sourceSet. Niepotrzebne implementacje nie są kompilowane.
// commonMain — deklaracja expect
expect fun getPlatformName(): String
// androidMain — actual dla Androida
actual fun getPlatformName(): String =
"Android \${Build.VERSION.SDK_INT}"
// iosMain — actual dla iOS
actual fun getPlatformName(): String =
UIDevice.current.systemName() + " " + UIDevice.current.systemVersion
Podczas tworzenia natywnych bibliotek przez Android NDK używany jest klasyczny preprocesor C/C++ z dyrektywami #ifdef, #ifndef, #define. W przeciwieństwie do Swifta, preprocesor C działa na poziomie tekstowym — kod w nieaktywnych gałęziach może być składniowo niepoprawny.
NDK definiuje makra dla każdej platformy: __ANDROID__ (Android), __APPLE__ (iOS/macOS), __linux__ (Linux). Dla architektur: __arm__, __aarch64__, __x86_64__. Te makra są ustawiane automatycznie przez kompilator podczas budowania dla platformy docelowej.
// Kod natywny dla Androida i iOS
#include <cstdint>
#ifdef __ANDROID__
int32_t getJniEnv(JNIEnv* env) {
return env->GetVersion();
}
#elif defined(__APPLE__)
#include <TargetConditionals.h>
int32_t getOsVersion() {
#if TARGET_OS_IOS
return "iOS";
#elif TARGET_OS_OSX
return "macOS";
#endif
}
#endif
Podczas pracy z NDK ważne jest, aby pamiętać, że preprocesor C/C++ to zamiana tekstowa. Jeśli w nieaktywnej gałęzi znajduje się błąd składniowy, kompilator go nie zauważy, ale jeśli z powodu nieprawidłowego #define zepsuje się aktywna gałąź — błąd się ujawni. Zaleca się minimalizowanie łańcuchów #define i używanie stałych constexpr.
Dla Rust, który również jest używany w tworzeniu aplikacji mobilnych przez UniFFI i Mozilla Application Services, istnieje własny mechanizm — feature flags w Cargo.toml. Flagi typu #[cfg(target_os = "android")] w Rust działają podobnie jak dyrektywy Swifta: sprawdzanie odbywa się na poziomie kompilatora, a nie preprocesora. To czyni Rust atrakcyjnym wyborem dla natywnych bibliotek, które muszą być kompilowane pod Android i iOS z jednej bazy kodu.
Kompilacja warunkowa jest skuteczna w ściśle określonych scenariuszach. Przy nieprawidłowym użyciu tworzy kod o nieprzyjemnym zapachu, który jest trudny do testowania i utrzymania. Rozważmy poprawne scenariusze i typowe błędy.
Pierwszy scenariusz — abstrakcja platformowa: jednolity fasad, wewnątrz którego kompilacja warunkowa wybiera implementację platformową. Drugi — debugowanie i profilowanie: narzędzia programistyczne, które nie powinny trafiać do wydania. Trzeci — wsteczna zgodność: obsługa starych wersji systemu, dopóki nie zostanie zaktualizowana minimalna wersja.
| Scenariusz | Język | Warunek |
|---|---|---|
| Abstrakcja platformowa | Swift | #if os(iOS) |
| Debugowanie | Swift/ObjC | #if DEBUG |
| Wsteczna zgodność | Swift | #if swift(>=5.7) |
| Biblioteka natywna | C/C++ | #ifdef __ANDROID__ |
| Testowanie A/B | Java/Kotlin | BuildConfig.FLAVOR |
Najbardziej niebezpieczny antywzorzec — rozprzestrzenianie dyrektyw po całym kodzie. Jeśli co drugi plik zawiera #if, to sygnał, że architektura wymaga refaktoryzacji. Prawidłowym rozwiązaniem jest wydzielenie kodu platformowego za protokoły/interfejsy i użycie Dependency Injection.
Często zadawane pytania
Kompilacja warunkowa działa na etapie kompilacji: nieaktywny kod nie trafia do pliku binarnego. Sprawdzanie w czasie wykonywania (if / switch) jest kompilowane zawsze, warunek sprawdzany jest podczas wykonania. Pierwsze jest bezpieczniejsze i wydajniejsze, drugie — bardziej elastyczne (można zmienić bez przebudowywania).
Tak, Swift dopuszcza dyrektywy #if wewnątrz funkcji, pętli, a nawet wewnątrz wyrażeń. To jedna z możliwości, której brakowało we wczesnych wersjach Swifta. Na przykład: let x = #if DEBUG 1 #else 0 #endif — poprawny kod.
Twórcy Kotlina świadomie zrezygnowali z preprocesora, uważając go za źródło kruchego kodu. Zamiast tego proponują expect/actual (bezpieczeństwo na etapie kompilacji) i Gradle sourceSets (izolacja na poziomie plików). Oba podejścia są bardziej niezawodne niż zamiana tekstowa.
Budować aplikację z różnymi kombinacjami flag w CI. Dla Swifta: skonfigurować osobne schematy Xcode z różnymi Active Compilation Conditions. Dla Androida: skonfigurować osobne Build Variants i uruchamiać testy dla każdego. Automatyzacja jest obowiązkowa.
W Swift warunek #if to dyrektywa kompilatora. Jeśli sam warunek jest składniowo niepoprawny (na przykład literówka w nazwie os()), kompilator zgłosi błąd kompilacji. W C/C++ preprocesor po prostu nie znajdzie makra i warunek stanie się fałszywy.
Podsumowanie
Opracujemy aplikację mobilną pod klucz
IT Sectr tworzy aplikacje na iOS i Androida dla startupów i firm od 2017 roku. Doradzimy Ci i zaproponujemy najlepsze rozwiązanie.
Przeczytaj również