Bye-bye Preprocessor Macros. Hello Active Compilation Conditions

12 Sep 2016 2 min read

In an Objective-C project, it is not uncommon to open Build Settings and see a long list of Preprocessor Macros. Preprocessor Macros are a feature inherited from C. Before the compiler compiles our code, a separate step - the preprocessor - runs through the source files and performs text substitution, replacing every occurrence of a macro with its defined value. So when we define API_DOMAIN as "https://platform.example.com" in our build settings, the compiler never sees API_DOMAIN - it just sees the raw string. The real power of Preprocessor Macros is that each build configuration can have its own list, so we can have a different value for API_DOMAIN depending on whether the project was built using the Debug or Release configuration.

So by using Preprocessor Macros we could write code like this:

@interface Environment : NSObject

// 1
@property (class, nonatomic, readonly) NSString *apiDomain;

@end

@implementation Environment

// 2
+ (NSString *)apiDomain {
    return @API_DOMAIN;
}

@end
  1. apiDomain is declared as a read-only class property, meaning it's accessed on the class itself rather than on an instance.
  2. The getter is manually implemented since class properties aren't auto-synthesised. It returns the API_DOMAIN preprocessor macro as an NSString literal.

The @ before API_DOMAIN in the getter converts the C string macro into an NSString literal.

Sadly, Swift will never know the joys of Preprocessor Macros 😫.

Preprocessor Macros don't fit the design philosophy of Swift - there's no type checking, no scoping, and no namespacing with Preprocessor Macros.

Photo of a control button with lots of switches

This post will cover how we can migrate our Preprocessor Macros to Active Compilation Conditions.

The Swift Way

Active Compilation Conditions are boolean switches resolved at compile time. The compiler evaluates #if / #elseif / #else blocks and only compiles the code in the matching branch - the other branches don't exist in the final binary at all. Like Preprocessor Macros, they can be set per configuration; unlike Preprocessor Macros, they can't carry a value - they either exist or they don't. Where Preprocessor Macros performed text substitution, injecting values directly into our code, Active Compilation Conditions simply control which code paths get compiled.

For new projects, Xcode automatically populates Active Compilation Conditions with DEBUG for the Debug configuration - other configurations don't get pre-populated. If you are migrating from an existing project, Xcode isn't as helpful - you will need to add them to all configurations.

To get the same functionality as we used to with Preprocessor Macros, we need to combine these flags with conditional logic to get the values we want:

// 1
enum Environment {
    static func apiDomain() -> String {

        // 2
        #if DEBUG
            return "https://development.platform.example.com"
        #elseif RELEASE
            return "https://platform.example.com"
        #else
            // 3
            fatalError("Missing API domain")
        #endif
    }
}
  1. Environment is declared as an enum with no cases, making it act as a pure namespace that can't be instantiated.
  2. These preprocessor directives filter out the branches that don't match the current active build configuration.
  3. If neither DEBUG nor RELEASE is set, the app crashes with a descriptive message. We want to fail fast here so that the missing branch is picked up during development.

It's not as clean a solution as Preprocessor Macros; however, it's safer, and it still prevents the need to manually edit source files to change environment settings.

Safer because the compiler checks every branch rather than silently substituting text.

To see the complete working example, visit the repository and clone the project.

What do you think? Let me know by getting in touch on Mastodon or Bluesky.