[Pitch] SDK-conditional code

TL;DR: I'd like Swift to introduce better ways to conditionalize code for differing SDKs, to make supporting multiple versions of the iOS SDK in one codebase, easier.

The problem

As iOS developers, between June and September each year, we begin to prepare ourselves for the release of the next major iOS version. Apple releases a beta Xcode containing new SDK for the new OS, and usually containing a new Swift language version.

Until that beta Xcode hits general release, we have to keep our code compiling with the old GM Xcode and the previous OS's SDK, as well as allow it to compile with the new beta Xcode, and begin to develop new features using the next OS's SDK.

Example: SDK-Conditional Code

Swift's approach for handling API lifecycle is to use the #available() condition, so for most of the year, we are able to write code like

    if #available(iOS 14, *) {
        // do something new
    } else {
        // fall back to the old ways
    }

However, during the beta period, the "something new" often involves calling APIs that are unknown to the released Xcode, so we have to write this code like:

    let doItTheOldWay = {
        // a closure!
        // fall back to the old ways
    }
#if swift >= 5.5
    if #available(iOS 15, *) {
        // do something new
    } else {
        doItTheOldWay()
    }
#else
    doItTheOldWay()
#endif

This is

  • hard to read and understand
  • annoying to refactor once the beta period is over
  • pretends that the swift version and the SDK version are one and the same, when in reality there's no particular reason that two SDKs shouldn't ship with the same Swift version, and it's perfectly possible to use a newer Swift version with an older SDK.

Example: SwiftUI Modifiers

The problem gets worse where function call chains are involved, such as with Combine or SwiftUI. Although Swift 5.5 allows #if to interrupt a call chain, there's no way to add a #available check into a call chain:

    MyView()
        .modifier1()
#if swift >= 5.5
        // this does not work in practice, because
        // most modifiers added in the iOS 15 SDK
        // aren't available to deploy to iOS 14 &
        // below.
        .modifierTheNewWay()
#else
        .modifierTheOldWay()
#endif

So we end up having to create a custom modifier that works with both a #if and an if #available, as above.

It's also impossible to create a generic modifier to abstract this. For example, syntactically it might be nice to be able to write something like:

    MyView()
        .modifier1()
        .if_iOS15 {
#if swift >= 5.5
            $0.modifierTheNewWay()
#endif
        } otherwise: {
            $0.modifierTheOldWay()
        }

However, there's no way to treat a closure literal as having a known #available scope.

Example: Non-ABI Evolution

Another problem arises when an API evolves in a way that affects how it's called, but doesn't affect its "availability"; perhaps because it's inlined. A real example from SwiftUI:

in iOS 15, the constructor of Slider has this signature:

public init<V>(
    value: Binding<V>,
    in bounds: ClosedRange<V>,
    step: V.Stride = 1,
    @ViewBuilder label: () -> Label,
    @ViewBuilder minimumValueLabel: () -> ValueLabel,
    @ViewBuilder maximumValueLabel: () -> ValueLabel,
    onEditingChanged: @escaping (Bool) -> Void = { _ in }
) where
    V : BinaryFloatingPoint,
    V.Stride : BinaryFloatingPoint

in previous OS versions, it had a different signature. This version still exists, but is deprecated:

@available(iOS, deprecated: 100000.0, renamed: "Slider(value:in:step:label:minimumValueLabel:maximumValueLabel:onEditingChanged:)")
public init<V>(
    value: Binding<V>,
    in bounds: ClosedRange<V>,
    step: V.Stride = 1,
    onEditingChanged: @escaping (Bool) -> Void = { _ in },
    minimumValueLabel: ValueLabel, maximumValueLabel: ValueLabel,
    @ViewBuilder label: () -> Label
) where
    V : BinaryFloatingPoint,
    V.Stride : BinaryFloatingPoint

(The difference is only in the order of the arguments; onEditingChanged has moved to the end of the list).

Since this API isn't marked with appropriate availability and deprecation information, we're forced to either accept the deprecation warning when compiling with Xcode 13, or to conditionalize our code:

#if swift(>=5.5)
                Slider(
                    value: $hSpacing,
                    in: spacing.min...spacing.max,
                    step: spacing.step,
                    label: { Text("Horizontal spacing") },
                    minimumValueLabel: { Text(String(format: "%.1f", spacing.min)) },
                    maximumValueLabel: { Text(String(format: "%.1f", spacing.max)) },
                    onEditingChanged: updatedHSpacing
                )
#else
                Slider(
                    value: $hSpacing,
                    in: spacing.min...spacing.max,
                    step: spacing.step,
                    onEditingChanged: updatedHSpacing,
                    minimumValueLabel: Text(String(format: "%.1f", spacing.min)),
                    maximumValueLabel: Text(String(format: "%.1f", spacing.max)),
                    label: { Text("Horizontal spacing") }
                )
#endif

Proposed Solution

I think we can evolve in a few ways to address these three examples:

Allow #if sdk(iOS >= 15, *)

(or some similar compile-time check) so that we're not forced to presume that the swift language version and the SDK version are intertwined.

Allow #if to split the keyword from a braced block

For example, now we could write

#if sdk(iOS >= 15, *)
    if #available(iOS 15, *) {
        // do something new
    } else
#else
    do
#endif
    {
        // do it the old way
    }

It's not beautiful, but it's certainly easier to understand than what we have now, and easier to refactor when we can remove the #if.

Allow deprecation to work based on SDK, as an alternative to deployment target:

@available(iOS, deprecated: sdk(15), renamed: "Slider(value:in:step:label:minimumValueLabel:maximumValueLabel:onEditingChanged:)")
public init<V>(
    //...

This doesn't help us with the code we have to write, but at least the compiler can give us a sensible error message.

Allow closure literals to be marked to parse with particular availability

This could work a bit like result builders, in that it would only affect the parsing of a closure literal passed to an argument marked with an availability attribute:

extension View {

#if sdk(iOS >= 15, *)
    func if_iOS15<V: View, W: View>(
        _ iOS15: @available(iOS 15, *) (Self) -> V,
        otherwise: (Self) -> W,
    ) -> _ConditionalContent<V, W> {
        if #available(iOS 15, *) {
            iOS15()
        } else {
            otherwise()
        }
    }
#else
    func if_iOS15<W: View>(
        _ iOS15: (Self) -> Never,
        otherwise: (Self) -> W,
    ) -> W {
        otherwise()
    }
#endif

}

This almost allows the snippet from earlier to compile:

    MyView()
        .modifier1()
        .if_iOS15 {
#if swift >= 5.5
            $0.modifierTheNewWay()
#else
            fatalError()
#endif
        } otherwise: {
            $0.modifierTheOldWay()
        }

Conclusion

Swift's currently good at allowing code to be conditional based on the language version, and based on the deployment OS, but not good at allowing code to be conditional based on the SDK version. This problem is a recurrent one for developers on Apple platforms, and could be mitigated with some language evolution.

I'd be keen to hear from other people with similar problems, or ideas for better solutions, 'cos a lot of the stuff I'm proposing here is still pretty clunky!

4 Likes

This previous thread has some different opinions on a similar subject:

Seems like if we were to do this we should go all-in and extend it to all packages, not just the sdk.

3 Likes

FWIW, it looks like there's been some recent (this year) work done in this area in the compiler, behind underscore-prefixed features:

You can write #if canImport(ModuleName, _version: 1.2.3) where the version is compared against a value baked into the .swiftmodule file using the -user-module-version flag when it was compiled (example).

There's also support for checking the version of Clang modules using _underlyingVersion instead of _version, but it looks like that checks the version number in the .tbd file, which isn't as obvious/user-friendly as the SDK version number (example).

So that work seems like it would be an interesting jumping-off point for discussion, as well.

7 Likes