Swift delight: availability checking— Jordan Rose (@UINT_MIN) October 29, 2021
This is all about going further than plain binary compatibility, so let’s talk about what that means.
Part of the Swift Regrets series.
Swift’s availability model is all about going further than plain binary compatibility, so let’s talk about what that means first. At sort of the simplest end of binary compatibility, we have something like glibc. All else being equal, you can compile a program against glibc 2.30 and run it on a system with glibc 2.31. This uses dynamic linking, where glibc isn’t embedded in the program, just referenced. Dynamic linking has a lot of pros and cons and I might talk about that more some day, but for now I’ll just say it lets you (a) update the library without rebuilding its clients, and/or (b) share one copy of a library across multiple clients. So, good for OS-provided libraries.
But glibc’s compatibility guarantee is one-way. If you build against 2.31, it might run against 2.30, or it might not. By contrast, Android’s API levels provide a stronger guarantee: if you compile against Level 24 but only use Level 22 APIs, you’ll run on Level 22 OSs. You can use Level 24 APIs, even, as long as you do a run-time check first. If you don’t, though, you’ll get a JVM exception. (I’m a little fuzzy about how this works on Android, and I don’t think it works for NDK APIs, only Java. But I’m trying to build a story here.)
Apple’s APIs used to be like Android’s, except without the protection of the JVM. You could compile against the iOS 6 SDK and run on iOS 5, but you’d get an Objective-C exception or a straight-up crash if you used a too-new API on an old OS.
And most of the time Apple didn’t even recommend checking the OS version. The preferred idiom was seeing if a class responded to a method selector before calling it, or seeing if a weakly-linked C symbol was
NULL. This didn’t work if there was a private API by the same name, though. But all the APIs were annotated with their availability, i.e. the OS you needed to be running to use them. Why couldn’t the compiler check that?
This is where Devin Coughlin stepped in. Others figured it was a solvable problem, but he came up with the solution. In his design, not only could the compiler check whether an API was too new, it did so without any special analysis. The rules for safely invoking a new API are just based on the syntactic structure of the program.
You can use any API supported by your minimum deployment target, say, iOS 7.
Any API itself limited to, say, iOS 9, can call other APIs from iOS 9 or earlier.
Otherwise, to use a new API, you have to check what OS you’re on in a way the compiler understands.
The syntax for that last bit is
if #available(iOS 9, *), or
guard #available for guarding the rest of the block. The compiler treats that as permission to use APIs from iOS 9, and it compiles it into a real run-time check of the OS version. And with that, a class of errors from trying to deploy to earlier OSs while supporting features in newer ones was gone. It was so successful it got added to Objective-C as well (spelled
@available). (Someone pointed out that it was probably academic-paper-worthy, but alas.)
Aside: there was a lot of discussion about that
*. This construct only checks that if you’re on iOS you’re on iOS 9. This was a lesson learned from tvOS and watchOS forking off iOS: if you checked for iOS specifically, and it failed because you were on tvOS, your app would fail to compile for no good reason if the API was actually available on tvOS. On the other hand, if the API’s actually not available, the fallback you have for older iOS versions might not be the best choice on tvOS.
Bonus fun fact: the model would work for third-party binary dependencies as well. It just gets a lot more complicated in the compiler for a lot less win. But maybe someday.