Conditional Compilation, Part 1: Precise Feature Flags
Part 1 in a series on conditional compilation:
- Conditional Compilation, Part 1: Precise Feature Flags
- Conditional Compilation, Part 2: Including and Excluding Source Files
- Conditional Compilation, Part 3: App Extensions
- Conditional Compilation, Part 4: Deployment Targets
When developing an app or a library, it’s pretty common that at least once in the course of development, you’ll need to conditionalize compilation of your code. Maybe you’ll be accounting for a bug in the operating system where things that don’t work quite the same on your device as they do on the simulator. Or perhaps you’ll want to simply exclude code from your simulator builds because the simulator simply doesn’t have that functionality (like invoking the camera).
With Swift, we have one or two ways of doing this already, but I’ve developed my own style of doing it, which I like for its consistency in style and customizability.
The typical way
Let’s take, as an example, wanting to alter behavior depending on whether we’re building for device or simulator.
Normally, you’d use the arch()
conditional in your source file to do something like this:
func isFeatureAvailable() -> Bool {
#if arch(i386) || arch(x86_64)
// i386 or x86_64 = mac hardware
return false
#else
return true
#endif
}
There are a couple of stylistic flaws with this approach:
-
It’s not immediately clear what we’re checking
Written this way, it’s difficult to scan over this code and understand what this check is for. There are times when checking processor architecture has meaningful differences in your code, but those are pretty rare times. As such, it can be a little confusing about why an architecture check exists in an iOS app or library.
-
It’s semantically the wrong check
We really want to test for the simulator. Checking for whether we’re building for the
i386
orx86_64
processor architecture happens to coincide for with that check, but it’s not quite the same thing. -
The comment will likely end up rotting
To get around the not-immediately-clear semantics of the check, we’ve got a comment describing the logic. However, all good programmers know that comments rot, just like code rots. At some point, the logic is going to change, and the comment is not going to get updated. The comment then becomes actively harmful, because it misdirects future maintainers.
-
It’s not future-proof
Checking for the architecture of the built product is currently correct, but it’s not guaranteed to stay that way. We keep hearing rumors of ARM-based Macs, which means that at some point, we might be building for
arm64
, but still targeting the simulator. (Disclaimer: despite my prior employment of Apple, I have no idea if this is a real thing or not)If we do end up having ARM-based Macs, then this code is going to break because the assumptions we made about our environment are no longer valid.
Wouldn’t it be nice if we could have something that’s more expressive, doesn’t require commenting, and will work regardless of whatever architecture the simulator does (or doesn’t) have?
Enter Configuration Files
Configuration Files can help us solve this problem. If you’re not familiar with configuration (“xcconfig”) files, I highly recommend these articles on them:
- Configuration Settings File format reference
- Xcode Build Settings Reference
- Using Xcode Configuration to Manager Different Build Settings
- Generating Xcode Build Configuration Files
Now that you’ve read those and are an expert on xcconfig files (you are, right??), it’s time to dig in to conditional build settings.
Settings in configuration files can be configured to have different values based on certain conditions (namely, the name of the SDK being used and/or the architecture being built).
This, combined with a certain build setting, gives us everything we need to have nicer conditional compilation.
I recently came across this article by Andrew McKnight (@tworingsoft), in which he describes using the SWIFT_ACTIVE_COMPILATION_CONDITIONS
build setting to define preprocessor-like values in Swift, like GCC_PREPROCESSOR_DEFINITIONS
does in C-based languages.
When you create a new Swift target, this build setting has the following value in the debug configuration:
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG
At build-time, this gets turned in to a -DDEBUG
flag that’s passed as a compiler argument, which gets surfaced in your code like so:
#if DEBUG
// building for debug
#endif
With this build setting, we can achieve the expressivity and customizability we’re looking for.
The Result
The result is surprisingly straight-forward. For each “build” value we want to define, we’ll define a custom build setting in an .xcconfig
file. For example, here’s a build setting that only has a value if we’re building with a macOS SDK:
_DD_DESKTOP =
_DD_DESKTOP[sdk=mac*] = BUILDING_FOR_DESKTOP
Similarly, we can do iOS values, by setting the value to BUILDING_FOR_MOBILE
as the general setting, but then “erasing” it when building with a macOS SDK:
_DD_MOBILE = BUILDING_FOR_MOBILE
_DD_MOBILE[sdk=mac*] =
We can also recognize simulator SDKs like so:
_DD_SIMULATOR =
_DD_SIMULATOR[sdk=*simulator*] = BUILDING_FOR_SIMULATOR
With these values defined, we can then pass them in to the compiler to get exposed to our source code:
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) $(_DD_DESKTOP) $(_DD_MOBILE) $(_DD_SIMULATOR)
Now we have something that’s much clearer and future-proof:
func isFeatureAvailable() -> Bool {
#if BUILDING_FOR_SIMULATOR
return false
#else
return true
#endif
}
In the next post, we’ll look at another build setting that lets us accomplish the same thing in a different way.