Minimum deployment target conditional compilation

Often as developers, we want to use a new API (e.g. Identifiable) which is restricted to an OS newer than our minimum deployment target, or simply conditionally compile code based on our deployment target.

When the code we’re trying to use is in a framework that was introduced in that version, canImport works fine. But when that’s a part of the Swift Standard Library, you have to something sketchy like an #ifdef checking a flag, or make a dummy framework which requires a given OS version and then use canImport.

I propose we extend the conditional compilation syntax to allow for basically the same syntax as SPM:

#if platform(iOS(v13)) || platform(macOS(v10_15))

I understand the preferred approach is to use availability checking, but there are some use cases that does not cover. It also does not conditionally compile the code.

1 Like

How would this work in practice, unless you’re shipping separate binaries for separate OS versions?

If you import a framework that was introduced in a certain OS version, and you set your deployment target to an older OS, that framework will be weak-linked, and likewise any symbols from the standard library or other OS frameworks that you reference will be weak linked and guarded by runtime availability checks. Unless I misunderstand your concern, availability should cover your use cases as is. Like @Jon_Shier said, the normal distribution model for software on Apple OSes isn't really conducive to distributing different binaries for different OS versions, so offering availability as a compile-time conditional would be of limited usefulness for those platforms.

2 Likes

The specific use case I was trying to solve for was compile-time polyfills:

And yes, this would mean potentially a module might have to be recompiled for different minimum deployment targets, but not for different platforms. You would ship the version that is supported at your minimum deployment target.

What this allows is for library authors to support multiple minimum deployment targets using backported polyfills using the same APIs. If your minimum deployment target is iOS 13, you don't compile the polyfill and use the standard library version of Identifiable. If your minimum deployment target is 10, you use the polyfill implementation. Library authors would implement against the SwiftPolyfill Identifiable which is in a package with platform: .iOS(.v10). In that package it would have:

#if platform(iOS(v13))
public typealias Identifiable = Swift.Identifiable
#else 
public protocol Identifiable { ... }
#endif

That isn't going to work. The way I would approach "polyfills" would be to have the compiler support emitting copies of the definitions into binaries, that can be overridden by the definitions in the OS on newer platforms. We already do this in the standard library to make new APIs backward-deployable where possible, and we're investigating ways to enable it for other things like types and protocol conformances. For example, see Backwards-deployable Conformances.

5 Likes

Could you elaborate on why that would not work?

edit: Also maybe "platform" was the wrong term here - it should really be a minimum deployment target.

The main problem is that, on Apple platforms at least, shipping different binaries for different OS versions simply isn't a practical option. Even if we disregard Apple platforms, you would be introducing incompatibilities between your code that uses the local polyfilled implementation and other packages or modules that either use the OS's version of Identifiable or their own polyfill implementations, because your polyfill, other modules' polyfills, and the OS definition would now be different types. Furthermore, if you're shipping your code as a framework or package, you would never be able to migrate from the polyfill to the standard library implementation without breaking your clients. You would at best get something that looks like the new API that you could use locally in your own code, but you wouldn't be able to use it to talk to anyone else's code, which for things like types and protocols would severely limit their usefulness.

1 Like

Well you would ship exactly one binary to Apple. (At least, one per platform you're targeting - iOS and macOS would obviously be different binaries.) That binary would use either the iOS 13 / macOS 10.15 version of Identifiable, or the polyfill implementation, but not both. The main value would come out of having a shared Standard Library Polyfills package as I proposed in the post I linked above.

E.g. I use a library called RxDataSources which currently uses its own Identifiable protocol which they support back to iOS 9. They currently can't use the standard library Identifiable protocol without raising their deployment target. And if I wanted to offer my own Identifiable extensions, I either need to define my own identifiable protocol, or explicitly depend on them for theirs. If we both depended on the SwiftPolyfills package for its Identifiable, with a deployment target of iOS 13+ that is the standard library Identifiable, while on earlier OS versions it would be the polyfilled one.

As a consumer you would conform to SwiftPolyfills.Identifiable which is the same as Swift.Identifiable if your deployment target is iOS 13+

Ideally, what should happen is that the Swift module (or whatever OS module) acts as its own polyfill—Swift.Identifiable is the thing you use regardless of deployment target, and the runtime figures out whether that's a copy linked into your binary or the canonical version in the OS depending on what OS you run on (or the binary just uses the version in the OS directly if the minimum deployment target is new enough). Having multiple copies of the protocol that exist in different modules is not something we want to promote. It creates compatibility problems as soon as you need to interoperate with modules from more than one author, or if you want to conditionally use new OS APIs when running on new OSes.

6 Likes

The original suggestion is nice to have for the SDK upgrade periods. I miss an option to conditionally compile code depending on the base SDK version. Otherwise, you can't try any new APIs until all team members upgrade. Checking for Combine before using new UIKit stuff looks a bit hacky.

I wanted to ask the same thing.
In my case, it's a struct member whose type is only available on BigSur (SwiftUI's Style) and I want my library to support Catalina as well. So yes I want it to produce different binaries depending on the platform (it's a library) and I cannot use classic ifs as I need it to be a compile-time query.

I could maybe use other ways?:
Here is a simple repro of my example:

public struct ProtoView: NSWrapper{
  @Binding var name: String
   
   #if platform(macOS(v11))
   var style: NSOutlineView.Style
   #endif
}

Anything that makes use of style is then wrapped with @availble

1 Like

One problem occurs when a framework (say, CoreBluetooth) changes the return type of a function (say, -[CBCharacterstic service]) from one storage type to another (say, assign to weak).

Arguably, this is an API change that shouldn't happen. Maybe it's harmless if the compiler DTRT and inserts all the "weak" bits in when it sees the new iOS 15 declaration, but if you're mocking those objects and overriding everything, now it's super-hard to get the mock right with the return type in an environment where you're still working on iOS 14.5 stuff but want to get ahead of the iOS 15 world.