Conditional Compilation in Swift, Part 2

In the previous post, we saw how the SWIFT_ACTIVE_COMPILATION_CONDITIONS build setting can inject values in to our .swift files that we can use to conditionalize code depending on our active SDK and/or architecture.

However, there are times when this approach isn’t entirely practical, and we want another option.

It’s a simple fact that the more control flow statements you have in your code, the more complex it is. There’s a rough measurement of “code complexity” called “Cyclomatic Complexity” that simply counts the number of times your code branches (using if, for, while, switch, etc statements). Broadly speaking, the more branches you have, the more complex your code is, and generally anything more complex than about “5” is considered too complex and a candidate for refactoring and simplification.

When it comes to build conditionals, we want to avoid using them too frequently in our code files, because they increase complexity. Having #if BUILDING_FOR_WHATEVER scattered all throughout a file makes it extremely difficult to reason about what’s actually going to happen at runtime.

The standard approach for solving this is to move the platform-specific code into a single unit (method, class, etc) and then conditionally compile in that structure. I’ve used this approach before, and it works, but I still find it lacking. I’ve still got a file that is mixed in its implementation. I can’t read through the file and automatically assume that the code I’m scrolling past is directly applicable to my situation.

The solution, then, is to move that code in to its own file, and then surround the contents of the entire file with the #if BUILDING_FOR_WHATEVER value. This is pretty good, but I find the repetitious act of making sure I have the conditional block tiresome and unnecessary. Wouldn’t it be great if I could have the compiler just exclude the file automatically for me?

Let’s return to our old friend, the configuration file.

Conditionally including and excluding files

Buried deep inside the Xcode Build Settings reference is a pair of obscure build settings: INCLUDED_SOURCE_FILE_NAMES and EXCLUDED_SOURCE_FILE_NAMES.

As their names suggest, these build settings allow you to specify additional files via your build configurations. When compiling, the exclusions happen first, and then the inclusions are applied, resulting in a final set of files passed to the compiler.

The values to these settings can be explicit paths to files, or they can be glob patterns (like *.swift). Since this is a build configuration file, substitutions work too: *.$(CURRENT_ARCH).c. And finally, since these are regular build settings, we can conditionalize the value based on the SDK.

In my “standard” build configuration file, I use these characteristics to create rules on including and excluding platform-specific code files:

MACOS_FILES = */macOS/* *~macos.*
IOS_FILES = */iOS/* *~ios.*
TVOS_FILES = */tvOS/* *~tvos.*
WATCHOS_FILES = */watchOS/* *~watchos.*

EXCLUDED_SOURCE_FILE_NAMES = $(MACOS_FILES) $(IOS_FILES) $(TVOS_FILES) $(WATCHOS_FILES)

INCLUDED_SOURCE_FILE_NAMES =
INCLUDED_SOURCE_FILE_NAMES[sdk=mac*] = $(MACOS_FILES)
INCLUDED_SOURCE_FILE_NAMES[sdk=iphone*] = $(IOS_FILES)
INCLUDED_SOURCE_FILE_NAMES[sdk=appletv*] = $(TVOS_FILES)
INCLUDED_SOURCE_FILE_NAMES[sdk=watch*] = $(WATCHOS_FILES)

For convenience, I define a few values for matching the platform files. There are two kinds of things being matched. First, I’ll find anything within a specifically named folder (for example: macOS/). Second, I’ll find any file that has a ~macos qualifier in the name. Long time iOS developers will recognize this format from when we had iPhone (~iphone) and iPad (~ipad) specific resource files.

Then for good measure, I tell the compiler to exclude every file it finds that matches these, and then to conditionally re-include the ones I care about.

You can see how using this approach could also lend itself towards creating simulator specific files (by conditionalizing the build setting based on sdk=*simulator*).

One of the neat benefits of these build settings is that it works with more than just code files. You can conditionally include and exclude resources as well. For example, if you’re building a cross-platform view controller, you might have a MyViewController~macos.xib file and a MyViewController~ios.xib file, and the compiler will get the right one for you.

In Action

Like a lot of developers, I’ve developed my own libraries of code that I use in pretty much all the apps I develop on the side. Since I mostly focus on macOS development, they skew pretty heavily towards Mac-specific functionality.

However, I also occasionally write the odd iOS app, and having these libraries build across platforms is a “must-have”. I use these file inclusion and exclusion settings to accomplish this.

For example, if I’m looking to make the IndexPath API be more consistent across platforms, then I might have an IndexPath~macos.swift file with this:

public extension IndexPath {

    public var row: Int {
        get { return self.item }
        set { self.item = newValue }
    }

    public init(row: Int, section: Int) {
        self.init(item: row, section: section)
    }

}

The macOS SDK doesn’t have UI elements that follow the same “row-section” pattern that UITableView does. However, if you’re writing code that runs on both, it can be useful to have this API exist on macOS, simply so you don’t have to conditionalize the IndexPath usage at the call-site. By using a ~macos file, we’re instructing the compiler to “only include this file if we’re building a Mac app”.

More often than not I need to define slightly different things depending on the platform. Maybe a framework has a different name on macOS than it does on iOS (like CoreServices vs MobileCoreServices); platform-specific Swift files make that easy.

Another example is when I need different values or algorithms on iOS than on macOS. For example, a while back I wrote about reading your own entitlements at runtime. If this is code that lives in a cross-platform world, then you have to account for differences in entitlements between platforms. One such difference is the key used for defining the push notifications environment (development vs production). On iOS, this value is encoded under the aps-environment key, but on macOS it’s under the com.apple.developer.aps-environment key. Similarly, there are several entitlements that exist on one platform but not the other.

You can imagine, then, having a base Entitlements.swift file that defines the common code and logic that is shared between platforms, and then an Entitlements~macos.swift file for the macOS-specific keys and values, and an Entitlements~ios.swift file for the iOS-specific things.

In this manner, you decrease the overall complexity of the individual code files, and can create search scopes targeting specific platforms. When you scan through your list of files, it’s easy to immediately glance at the name of the file and understand if it’s applicable to your current needs or not.

All with just a couple of pretty straight-forward build settings.


Related️️ Posts️

Conditional Compilation in Swift, Part 1
Swift Protocols Wishlist