December 13, 2016

Avoiding the Implicit

TL;DR: You have a mixed Objective-C/Swift project? Avoid runtime crashes by adding nullability annotations to your Objective-C headers. Integrate this cool script to find missing annotations. Also activate Treat Warnings as Errors.
We love the dynamic freedom of Objective-C, and we also love the static safety of Swift. We know that Swift is the future, but we cannot migrate everything at once, so we live in a mixed world, Objective-C and Swift tangled together via The Bridge.
The Bridge is a wondrous thing, but danger lurks in the dark. In this article, I want to help you sharpen your tools to defend yourself from a special kind of beast: Implicitly Unwrapped Optionals (IUOs) that crawl over from Objective-C land into Swift.


Don't get bitten by Implicitly Unwrapped Optionals Source: https://commons.m.wikimedia.org/wiki/File:Loup_garou.jpg
Don't get bitten by Implicitly Unwrapped Optionals
Source: https://commons.m.wikimedia.org/wiki/File:Loup_garou.jpg

Why Bother?

If you're lazy like me, you want the compiler to do as much work as possible. That's why turning on as many compiler warnings as you can and activating Treat Warnings as Errors is a good thing. A problem found at compile time is one problem less at run time. Using forced unwrapping (!) or IUOs tells the compiler "Shut up and take my code. I know better than you, and I don't care for safety here" – which might work out fine, until it doesn't. With this in mind, I try to avoid using ! as much as possible (well, except for non-equality).1
Here's the blind spot: return values from Objective-C code that is not nullability annotated.
Can you spot the issue in this Swift 3 code?2
// TheAbyss.h
+ (NSString *)greetingMessageFromTheOtherworld;

// TheAbyss.m
+ (NSString *)greetingMessageFromTheOtherworld {
    return nil;
}

// LonesomeWanderer.swift
TheAbyss.greetingMessageFromTheOtherworld().isEmpty
// compiles fine, but crashes at runtime with a lovely message:
//   "fatal error: unexpectedly found nil while unwrapping an Optional value"
In order to see the invisible IUOs from Objective-C, one has to look at the Generated Interface of the corresponding Objective-C header. Ours looks like this:
open class func greetingMessageFromTheOtherworld() -> String!
Hah, gotcha!
So, we need to do two things to protect ourselves from this trap:
  1. Fortify all headers imported via the bridging header by adding nullability annotations.
  2. Make it impossible to add headers without nullability annotations in the future.

Being Honest

How can we drill our companion the compiler to be more cautions about nil values passed from a given Objective-C method? The answer is via nullability annotations. Mark all of the pointers in a header with nullable/_Nullable/nonnull/_Nonnull, or even combine them with NS_ASSUME_NONNULL_BEGIN/END.
The good thing is, once you have added a single nullability annotation to a header, all the pointers missing one will complain with a warning:
// TheAbyss.h
+ (nullable NSString *)greetingMessageFromTheOtherworld;
+ (void)tasteTheDarknessWithDarknessLevel:(DarknessLevel *)darknessLevel 
                                  success:(void (^)(DarkFeelingResponse *))success 
                                  failure:(void (^)(NSError *error))failure;
// several compiler warnings:
//   "Pointer is missing a nullability type specifier
//   (_Nonnull, _Nullable, or _Null_unspecified)
This gets even better when you enable GCC_TREAT_WARNINGS_AS_ERRORS in your build settings.3
Think hard about the nullability of your methods. Analyze them thoroughly before coming to the conclusion that something never can become nil. If you are unsure about how to mark up your Objective-C headers, you can generally stay on the safe side if you:
  • Mark return types as nullable – then the Swift side has to unwrap them safely.
  • Mark parameters as nonnull and check them for nil on the Objective-C side nonetheless – the Swift side will be forced never to pass nil, and even if you pass nil by accident when calling from Objective-C, nothing bad happens.
  • Examine block types closely.
    • Blocks themselves might have return types and parameters.
    • To answer the question whether the block pointer can become nil, try to find out who creates the block.
    • To answer the question whether the block return value can become nil, inspect the code that is executed in the block.
    • To answer the question whether the block parameters can become nil, inspect the code that calls the block.
  • Stay away from NS_ASSUME_NONNULL_BEGIN/END, as you might accidentally mark some pointer as nonnull, even though nil could be passed – this is even less safe than having no nullability at all, so be warned.
    • Also, when you later add a new method to a code block already enclosed by NS_ASSUME_NONNULL_BEGIN/END, it is automatically annotated with nonnull, which might bite you.
Adding the missing nullability to the above example might look like this:
+ (nullable NSString *)greetingMessageFromTheOtherworld;
+ (void)tasteTheDarknessWithDarknessLevel:(nonnull DarknessLevel *)darknessLevel 
                                  success:(nullable void (^)(DarkFeelingResponse * _Nonnull))success 
                                  failure:(nullable void (^)(NSError * _Nonnull error))failure;
In our case, we know that the method implementation handles nil passed for success and failure blocks fine, so we can safely mark them as nullable. The block parameters of types DarkFeelingResponse and NSError are less straightforward; it depends on the piece of code that executes that block. In our case, we have analyzed the code and come to the conclusion that these parameters never can be nil, so we marked them as _Nonnull. Note that the lowercase variant nonnull cannot be used for parameters or return types of blocks. It will result in a compiler error.
Now that we are equipped with nullability annotations as a weapon, we just need to find all the headers in need of protection.

Secure the Perimeter

Just as Amy Dyer suggested in her great talk, you should make a habit of annotating your header files when you import them into the bridging header. The downside is that we are forgetful beings and want to concentrate on more delightful things than checking a header imported by a header imported by our bridging header. This is especially true when we add a new import of a non-annotated header to the first one and are unaware that it sneaked itself recursively into the bridging header.
How can we fight an invisible enemy? Right, make him visible!

Automated Defense System

After quite a journey4, I settled on writing a simple Ruby script that checks all relevant headers for nullability. You can download it here.
The script does not try to do anything fancy, it just recursively finds all the #import statements in the headers starting at the bridging header, and then checks each file to see if it contains one of the following strings: NS_ASSUME_NONNULL_BEGIN, nullable, nonnull, _Nullable, _Nonnull. This may sound too simple, but it is good enough for us, because:
  • It is very probable that none of the checked nullability strings appear in source files as part of comments or method signatures, except when the file already contains nullability annotations.
  • It is enough to check for one appearance of a nullability annotation, because the compiler will then mark all pointers missing one with a warning or error, as described in the previous section.
  • The script has no external dependencies.
  • We do not need to parse Objective-C code.
  • We can easily filter out headers that cannot be checked, such as system headers, Pods, or generated code.
  • It is super fast, and thus can be run with every build.
  • Header files missing nullability will be highlighted in the Xcode issue navigator.

Integrate via an Xcode Build Phase

In order to run the script with every build, just add a build phase that executes it. Be sure to insert the build phase before the Compile Sources build phase. That way, the build will be aborted early if nullability is missing, and not recompile the whole project first.
No more imports missing nullability annotations can be added to the bridging header in the future. Cheers!

Avoid Superfluous Work

The script might find a lot of headers missing nullability, even more than you really use from Swift. Recursion can ruin your day.
Remember that I said I'm lazy? Be lazy too. Most of the imports in headers are superfluous and can be replaced with @class or @protocol. That way, many recursive imports can be removed from the bridging header and they do not need to be annotated. The only really required imports are those of the superclass and other exceptions, like enums or protocols used for parameter or return types.

All Safe?

While we secured one lane of the bridge, the opposite one is still open for other monsters, like Objective-C code calling Swift methods with nonnull parameters, but that is an enterprise for another day.
I hope you profit from my telling, young traveller. Forge your own tools, build a shelter, develop strong habits. Be safe out there!

  1. I strongly recommend not using any IUOs in Swift, neither forced unwrapping nor forced downcasts. There is nearly always a safer way. IBOutlets may pose an exception now, but the delayed property behavior might make using IUOs obsolete soon in that case, too. Whether that improves the crash resilience of disconnected outlets is another story. As IUOs and forced unwrapping/downcasts can be detected more easily in Swift – either by searching for ! or using a linter – they are beyond the scope of this blog post.
  2. Matters became a lot better in Swift 3, due to SE-0054, as now IUOs cannot propagate further through one's program. The following code compiles in Swift 2.3, but does not in Swift 3, as the IUO becomes an Optional by assigning it to a variable and would need unwrapping:
    // LonesomeWanderer.swift
    let greeting = TheAbyss.greetingMessageFromTheOtherworld()
    greeting.isEmpty
    // thankfully does no longer compile in Swift 3 due to SE-0054
  3. For the higher goal of safety, I'd recommend activating SWIFT_TREAT_WARNINGS_AS_ERRORS and a load of other warnings, too, that are sadly deactivated by default. Rather than doing this directly via the build settings in the project file, a much more elegant way is to use xcconfig files. An excellent default configuration can be found at jspahrsummers/xcconfigs. You might want to turn off RUN_CLANG_STATIC_ANALYZER again though, as it increases build time a great deal.
  4. A brief history of failure: When I first realized that Objective-C code is imported using IUOs by default, I thought that was a bug. I even wrote a swift-evolution proposal, which unfortunately was declined by the core team. Then I created a Swift ticket for adding a compiler warning that did not get any attention. After that I, looked into SourceKitten and tried generating Swift headers from Objective-C headers without too much luck. Also, this was way too complicated. The next thing was to investigate OClint, as we had that integrated into our nightly reports already but I knew it was too slow for executing on every build, and it was an extra dependency when merely debugging code. Finally, I resigned and stopped looking into fancy solutions that need source parsing and instead regexed my way out of the problem. Long story short: pursue your ideas.

No comments:

Post a Comment