How Swift API Availability Works Internally
We use API availability checks all the time to provide fallbacks for users running older iOS versions, but have you wondered how the Swift compiler handles this? In this article, we'll take a deep dive on how the #availability
condition works, how the Swift compiler is able to know if a specific symbol is available for usage and what the code you wrote looks like after being optimized.
I have recently authored an evolution proposal to add a new #unavailable
attribute to Swift, and while I haven't needed to do any meaningful work on Swift's availability system to implement it, this gave me the opportunity to learn a little more about things work deep down. Here's a deep dive on Swift's availability system!
Why are #available
checks necessary?
While the reason why API availability checks are needed might be obvious, let's take a practical look at it for educational purposes before seeing how it works deep down.
Every UIKit
or Foundation
code you use comes from the iOS SDKs in your machine. While users of new iOS versions can still use your app normally if you don't update your app to support the new version, you'll only be able to use the version's new features if you submit a version that links with the relevant SDK. Currently these SDKs are shipped within Xcode, so you can always expect newer iOS versions to accompany a new Xcode version as well. You can always find an Xcode's provided SDKs in the description of the version.
Xcode 12 includes Swift 5.3 and SDKs for iOS 14, iPadOS 14, tvOS 14, watchOS 7 and maccOS Catalina.
However, even though your app now links to the correct SDK and uses its features, you don't know if the users of your app have the latest iOS version installed! If you were allowed to ship your app without compatibility checks, an app using newer iOS features would crash when used in old versions because the SDK in that user's device will not have those symbols. Thus, unless you explicitly set your app's minimum deployment target to be the latest available iOS version, you must use Swift's #available
condition to provide a suitable fallback for older versions.
if #available(iOS 14.0, *) {
SomeiOS14NewType()
} else {
SomeOlderType()
}
Here, (iOS 14.0, *)
means "If this is an iOS device, return true
only if it contains the iOS 14 SDK. Always return true
if this is a different platform (*)."
You can only use platforms that are hardcoded into Swift (iOS, OSX, tvOS and watchOS), but you can use any version you wish. The Swift compiler funnily uses absurd version numbers to prevent things from running:
if #available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, *) {
expectTrue(isP(CFBitVector.makeImmutable(from: [10, 20])))
expectTrue(isP(CFMutableBitVector.makeMutable(from: [10, 20])))
}
You can also lock types to work only when used in a very distant future, although that might not be really useful unless you're trying to predict future features, I guess.
@available(iOS, introduced: 999)
final class HologramCreator {}
HologramCreator()
// 'HologramCreator' is only available in iOS 999 or newer
How Availability works in the Swift Compiler
In the compiler, the availability of symbols is treated during the type-checking phase.
If you haven't read my previous articles or need a refresher, the type-checking phase is when the compiler wants to determine if what you coded semantically correct. At this stage, the compiler has a basic Abstract Syntax Tree of your code that it knows to be correct in terms of structure, but it needs to know if what you're doing is actually possible. For example, does the type you're referencing actually contains the method you're calling? Are the return types correct?
Type Refinement Contexts for AST nodes
Naturally, checking if a symbol you're calling is available for usage falls into that phase. The compiler does it by building Type Refinement Contexts, which is a special structure that can hold any relevant additional information that a scope should have. Currently, this is only used for the very symbol availability we're interested in.
The process starts when the compiler wants to type-check a statement that contains an availability check. Let's take this one as an example:
if #available(iOS 14.0, *) {
} else {
}
At this phase, the purpose of the compiler is to search for any availability conditions and build a proper refinement context if needed. For every condition, the compiler will extract the currently-being-built platform from the condition and attempt to build a range of available version numbers. In this case, the range will simply be minimumTarget...iOS 14
, while the else branch will keep whatever its parent refinement context was unless your condition is checking for some lower than the current context (which would reduce it instead). The main refinement context allows you to freely use anything up to your app's minimum deployment target, which is why you don't need to bother with these checks when you have a sufficiently high deployment target.
if #available(iOS 14.0, *) {
// Symbol Availability: minimumTarget...iOS 14
} else {
// Symbol Availability: 0...minimumTarget (The default)
}
The fact that it works with ranges makes it possible for it to locate potentially useless checks. If a condition's range is completely contained by the current context, the compiler will ignore it and display a warning.
if #available(iOS 14.0, *), #available(iOS 13.0, *) {
// (iOS 13.0) Unnecessary check for 'iOS'; enclosing scope ensures guard will always be true
// Symbol Availability: minimumTarget...iOS 14
} else {
// Symbol Availability: 0...minimumTarget (The default)
}
When the refinement contexts for each scope are determined, the compiler will associate it with the current statement's AST node and use it for any future availability checks. The refinement contexts are built like trees (where a context has a pointer to its parent), but they are used as a stack. As the compiler traverses your code, these refinement contexts will get pushed and popped as needed.
if #available(iOS 9.0, *) {
// Symbol Availability: minimumTarget...iOS 9
if #available(iOS 13.0, *) {
// Symbol Availability: minimumTarget...iOS 13
} else {
// Symbol Availability: minimumTarget...iOS 9
}
} else {
// Symbol Availability: 0...minimumTarget (The default)
}
While the outer else scope will make no changes to availability, the inner else scope will keep the increased iOS 9 availability as that was the current refinement context at the time. Here's a visual example of how this works in practice:
// Refinement Context Stack: [MinimumTarget]
if #available(iOS 9.0, *) {
// Push: iOS 9 ([MinimumTarget, iOS 9])
if #available(iOS 13.0, *) {
// Push: iOS 13 ([MinimumTarget, iOS 9, iOS 13])
} else {
// Pop: iOS 13 ([MinimumTarget, iOS 9])
}
} else {
// Pop: iOS 9 ([MinimumTarget])
}
Everything shown here also applies for guard
statements, except in that case the positive availability changes are applied in what's left of the current scope.
guard #available(iOS 14, *) else {
// Symbol Availability: 0...minimumTarget (The default)
return
}
// Symbol Availability: minimumTarget...iOS 14
This process involving refinement contexts is precisely why you can't use availability conditions outside of statements like this:
let isAvailable: Bool = #available(iOS 13.0, *)
if isAvailable {
// ?????
}
// error: #available may only be used as condition of an 'if', 'guard' or 'while' statement
Even though doing something like this might make sense at first glace, what should be the symbol availability of that if
statement? Something like this would be incredibly hard to process as now every boolean you create must also have its own refinement context, and would lead to many situations where the compiler wouldn't be able to process something that a human being can visualize as being possible.
Determining if a symbol is available
With the type refinement contexts created, the compiler can check if something is available by matching its current availability status with the top context in the refinement stack. The availability of the declaration in question is determined by the presence of the @availability
attribute in its type. If there's none, the type will always be available.
Optional<AvailabilityContext> AnnotatedRange = annotatedAvailableRange(D, Ctx);
if (AnnotatedRange.hasValue()) {
return AnnotatedRange.getValue();
}
// Treat unannotated declarations as always available.
return AvailabilityContext::alwaysAvailable();
To check whether or not this specific declaration is available, the compiler retrieves the current refinement context of the declaration and checks if it's contained by the declaration's own availability range. This is where an app's minimum deployment target comes into play: If there's no refinement context (because no availability conditions have been seen yet), the compiler will build one that has it as the maximum available version. Finally, if this availability check returns false, the compiler will emit an error and suggest a fix-it that includes adding an availability condition.
// Code sligthly changed for readability purposes
bool TypeChecker::isDeclAvailable(const Decl *D,
const DeclContext *referenceDC) {
ASTContext &Context = referenceDC->getASTContext();
AvailabilityContext declAvailability{
AvailabilityInference::availableRange(D, Context)};
AvailabilityContext currentAvailability =
overApproximateAvailabilityAtLocation(referenceDC);
return currentAvailability.isContainedIn(declAvailability);
}
Additionally, if you're building something outside of Xcode, the minimum deployment target will be the current version of whatever it is that you're doing. For example, when running .swift
scripts, the "minimum deployment target" will be your macOS's version.
/// Returns the minimum platform version to which code will be deployed.
///
/// This is only implemented on certain OSs. If no target has been
/// configured, returns v0.0.0.
llvm::VersionTuple getMinPlatformVersion() const {
unsigned major = 0, minor = 0, revision = 0;
if (Target.isMacOSX()) {
Target.getMacOSXVersion(major, minor, revision);
} else if (Target.isiOS()) {
Target.getiOSVersion(major, minor, revision);
} else if (Target.isWatchOS()) {
Target.getOSVersion(major, minor, revision);
}
return llvm::VersionTuple(major, minor, revision);
}
Translating #available
to a boolean
Finally, after determining that our code is structurally and semantically correct, the compiler can wrap up by swapping the availability conditions with actual booleans. Currently, this works by replacing your statement with a call to _stdlib_isOSVersionAtLeast
that receives the version range calculated and stored in each refinement context and returns a boolean if the current device is running the desired version.
// Before
if #available(iOS 14.0, *) {
}
// After
if _stdlib_isOSVersionAtLeast(14, 0, 0)
}
As you would expect, _stdlib_isOSVersionAtLeast
works by determining the current OS version and checking if it matches the provided value. Here's how the compiler tries to determine the current iOS version:
static os_system_version_s getOSVersion() {
auto lookup =
(int(*)(struct os_system_version_s * _Nonnull))
dlsym(RTLD_DEFAULT, "os_system_version_get_current_version");
struct os_system_version_s vers = { 0, 0, 0 };
lookup(&vers);
return vers;
}
If you'd like to see this happening in practice, you can ask the compiler to emit the Swift Intermediate Language for a certain code like this:
swiftc -emit-sil myFile.swift
After running it, you can see all availability conditions being replaced by the lower-level OS version check.
// function_ref _stdlib_isOSVersionAtLeast(_:_:_:)
%5 = function_ref @$ss26_stdlib_isOSVersionAtLeastyBi1_Bw_BwBwtF
References and Good reads
The Swift Source CodeTypeCheckAvailability.cpp
SE-NNNN: Unavailability Condition