Articles, podcasts and news about Swift development, by John Sundell.

Using compiler directives in Swift

Published on 16 Aug 2020

Even though Swift has a very strong compile-time focus when it comes to how it verifies and type-checks the code that we write, it’s still primarily used to implement runtime logic.

However, sometimes we might want to perform certain checks and run other kinds of custom logic when our code is being compiled, and although Swift doesn’t (yet) include a fully-featured macro or preprocessing system, it does ship with a few built-in compiler directives and conditions that enable us to influence the compilation process in various ways.

This week, let’s take a look at a few of those compiler directives and what sort of situations that each of them might be particularly useful in.

Flags and environment checks

Perhaps the most commonly used Swift compiler directive is the #if command, which enables us to conditionally include or exclude certain code blocks when our program is being compiled.

For example, we might use that command to check if our app is currently being compiled with its debug build configuration, by checking if the default DEBUG flag is enabled. Here we’re doing just that to conditionally print a given expression only within debug builds:

func log(_ expression: @autoclosure () -> Any) {
    #if DEBUG
    print(expression())
    #endif
}

The @autoclosure attribute is used above to automatically turn any expression that was passed into our log function into a closure, in order to avoid evaluating it within release builds. To learn more about that attribute, check out “Using @autoclosure when designing Swift APIs”.

While the DEBUG flag provides an incredibly useful way to completely remove any code that we don’t want to include in our app’s shipping binary, sometimes we might want to use slightly more granular rules when deciding what code to include or remove.

That’s when custom compilation conditions can come in handy, which let us define our own, completely custom flags, which can then be enabled or disabled for different targets or build configurations. For example, let’s say that we’re currently working on a new SwiftUI-based profile view for an app, and while we’re not quite ready to ship that new implementation to the App Store, we do want to include it in internal builds of our app (such as TestFlight builds).

To make that happen, let’s define a dedicated SWIFTUI_PROFILE compiler condition, which we’ll only enable whenever we want to use our new SwiftUI-based profile view, and for all other builds, we’ll fall back to our previous UIKit-based implementation — like this:

func makeProfileViewController() -> UIViewController {
    #if SWIFTUI_PROFILE
    return UIHostingController(rootView: ProfileView())
    #else
    return ProfileViewController()
    #endif
}

To toggle the above flag on or off, we can then either use the Active Compilation Conditions build setting in Xcode, or if we’re building a Swift Package Manager-based project, then we can enable it by passing -Xswiftc SWIFTUI_PROFILE to command line tools like swift build.

To learn more about using feature flags, both compile-time and runtime ones, check out “Feature flags in Swift”.

Besides using various kinds of flags, another type of compilation condition that can be really useful is targetEnvironment — which lets us conditionally include a piece of code only when our app is being compiled for a given environment, such as macCatalyst, or the simulator. Here we’re using that feature to include a DebugViewController within an app’s tab bar when it’s being built for the iOS simulator:

func setupTabBarController(_ controller: UITabBarController) {
    var viewControllers = [UIViewController]()

    #if targetEnvironment(simulator)
    viewControllers.append(DebugViewController())
    #endif

    controller.viewControllers = viewControllers
}

As the above code samples show, using compiler flags and the targetEnvironment condition is particularly useful when we want to strip out code that’s either debug-specific, or not quite ready to ship, while still enabling us to include that code within our main code base.

Handling platform variations within cross-platform code

Another type of situation in which compiler directives and conditional compilation can come in handy is whenever we’re working on a code base that supports multiple platforms — such as a cross-platform Swift package, or an app that runs on multiple Apple platforms.

As an example, let’s say that we’re working on a SwiftUI-based drawing app for iOS, macOS and tvOS, and that we’re looking to share as much code as possible between those three platforms. While many aspects of SwiftUIs overall API are identical across all of Apple’s platforms, which is a big advantage in situations like this, there are still platform-specific variations that we might need to handle.

For example, on iOS and macOS, SwiftUI supports adding a DragGesture to a given view, while that API is completely unavailable on tvOS — meaning that the following code won’t compile when building our app for that platform:

struct EditorView: View {
    ...

    var body: some View {
        CanvasView().gesture(DragGesture().onChanged { state in
            ...
        })
        ...
    }
}

To fix that problem, we could use the os compiler condition, which — just like the conditions we used earlier — enables us to only include a given piece of code when our app is being built for a specific platform. That, combined with the #if and #else directives, essentially lets us set up platform-specific branches within our cross-platform code — like this:

struct EditorView: View {
    ...

    var body: some View {
        #if os(tvOS)
        CanvasView()
        #else
        CanvasView().gesture(DragGesture().onChanged { state in
            ...
        })
        #endif
        ...
    }
}

However, while the above approach does work, it’s arguably a bit messy, and can easily lead to code that’s hard to both read and maintain — given that we’re mixing cross-platform and platform-specific code all within a single View implementation.

So instead, let’s see if we can isolate the above compilation condition a bit better. One way to do that would be to move it behind a dedicated abstraction, for example by implementing a custom modifier method for it, which could look something like this:

extension View {
    func addingDragGestureIfSupported(
        withHandler handler: @escaping (CGPoint) -> Void
    ) -> some View {
        #if os(tvOS)
        return self
        #else
        return gesture(DragGesture().onChanged { state in
            handler(state.location)
        })
        #endif
    }
}

Since the above method signature doesn’t rely on any platform-specific types, but rather just CGPoint (which is available on all of Apple’s platforms as part of CoreGraphics), we can now freely call it within our cross-platform EditorView, and it’ll simply have no effect when our app is running on tvOS:

struct EditorView: View {
    ...

    var body: some View {
        CanvasView().addingDragGestureIfSupported { location in
            ...
        }
        ...
    }
}

Another option which can be good to keep in mind when dealing with the above kind of situation is to instead create multiple platform-specific variants of the same type. In our case, that could mean defining two separate EditorView types, one for tvOS and one for the other platforms, which could then be placed in two separate files. We’d then include the tvOS-specific file only within our app’s tvOS target, and the other one in the rest of our targets.

However, sometimes it might be more appropriate to check for the existence of a given module, rather than which platform that our code is being compiled for. That can be done using the canImport condition, which is particularly useful when we wish to extend an otherwise cross-platform API with features that rely on a framework that’s not universally available — such as Combine in this case:

#if canImport(Combine)
import Combine

public extension GitHubSearchService {
    func publisherForRepisitories(
        matching query: String
    ) -> AnyPublisher<[Repository], Error> {
        ...
    }
}
#endif

The benefit of using canImport in situations like the above is that we don’t need to manually keep track of what platforms that a given framework is available on, and it also becomes crystal clear that the code within that block is defining a framework-specific API.

Emitting warnings and errors

The idea of intentionally producing warnings and errors within a code base might at first seem a bit strange, but can be an incredibly useful tool in many kinds of situations — for example if we want to remind ourselves of a shortcut that we just took, or if we want to add a bit of extra verification to our release builds.

For example, let’s say that we’re working on a deep linking system for a shopping app, and that in order to quickly get something up and running, we’ve made a few somewhat risky assumptions within the code used to extract a product ID from a given URL. So to give ourselves a very prominent reminder to go back and fix that code before submitting it, we could use the #warning directive, which will cause a warning to be emitted every time that our app is being compiled:

func extractProductID(from url: URL) -> Product.ID {
    #warning("Needs validation. Also uses a hard-coded index.")
    let components = url.pathComponents
    let rawID = components[2]
    return Product.ID(rawID)
}

Another option would be to use a classic TODO-style comment, perhaps in combination with a linter in order to turn such comments into warnings — but by using the above approach we’re guaranteed to always get a warning issued by the Swift compiler itself, which in turn decreases the chance that the above shortcut will be forgotten and that our code will accidentally be shipped as-is.

To further improve the prominence of the above type of warnings, we could also enable the Treat Warnings as Errors build setting for our app’s release configuration, which turns all warnings into actual build errors when we’re building our app for release — completely preventing us from shipping any unfinished code that’s been marked with a warning.

It’s also possible to tell the compiler to directly produce an error as well, by using the #error directive, for example in order to show exactly what data that needs to be manually filled in after generating a piece of boilerplate code using some form of template:

#error("Enter your public API key here")
let service = AmazingAPIClient(apiKey: "")
...

We could also conditionally emit an error in order to make sure that none of our debug-specific compiler flags were accidentally enabled for release builds — like this:

#if !DEBUG && ENABLE_INTERNAL_TOOLS
#error("Internal tools must be disabled in RELEASE builds.")
#endif

Adding the above kinds of checks within our source code might seem a bit excessive, but especially if we’re often tweaking what flags that are enabled within what builds, it could be a good idea to add a bit of extra protection to prevent debugging code from being shipped within App Store builds.

Conclusion

While Swift’s compiler directives might currently be quite limited compared to what some other languages offer, they still enable us to perform a fair amount of custom compile-time checks, and to create various kinds of conditional branches within our code.

However, while each of the directives that we took a look at in this article are useful within certain kinds of situations, it’s also important not to over-use them, as each time that we introduce a conditionally compiled code block, we’re essentially adding a new variant of our app that needs to be constantly tested and maintained.

Got questions, comments or feedback? You’re always welcome to contact me either via Twitter or email.

Thanks for reading! 🚀