Do we need something like '#if available'?

There are a couple ways to address that:

Rather than #if available, I think adding a version parameter to the existing #if os macro would be better.

There are often times where if #available is not enough and having a macro for the same purpose would be superior.

For example if you have when building for macOS:

struct Foo {
    var bar: Int
    
    @available(iOS 15.0, *)
    var iosProperty: String // this won’t compile

    #if os(macOS)
    var macosPropery: String // this will compile
    #endif
}

Being able to check os version number inside the above macro would be a great addition.

Something like:

#if os(iOS, >= 15.0)
// your code here
#endif
1 Like

In packages, I think one can do this:

func flag() -> [SwiftSettings]
{
  if #available(
){
      return [.define(„MY_FLAG“)]
   }
   return []
}

This should work for OS checks. Swift version checks unfortunately seem to be determined by the swift-tools-version specified at the beginning of the package.

Now you can conditionally compile one of two overloads of the method:

#if MY_FLAG && Swift

func foo(){
//do new shiny stuff
}

#else

func foo(){
//do it The old way
}

#endif

A bit bolierplate-y, but it might work today

Edit:
Oh, damn, the only tests I did used #available(macOS ...). When I change the OS version of another OS, the code still compiles even though the new code shouldn't work there... So apparently, it checks the OS of wherever the package is compiled :( So no, THIS DOES NOT WORK

Background: I have a similar problem regarding main actor isolation in RedCat, which I want to enable only for the upcoming OS versions. Users of the framework are supposed to be able to use the old code whenever they have to support older OS versions, and they should automatically get static main actor checks for the relevant methods when they migrate to newer OS versions. They shouldn't have to care about moving to other types with basically the same functionality. But I'm not sure how to set my package up so that it works.

I don't know if it's right to blame this on the Swift compiler. The root of the problem is that you are trying to make sure a project is functional with multiple Xcode versions at the same time, which doesn't make much sense in my view. Wouldn't the appropriate way to go be to maintain a separate "xcode13" branch instead and leave the main code alone? I feel that wanting to bundle it all together breaks the purpose of continuous deployment.

The feature is useful, but I don't know if we should be using the compiler to solve external problems (in this case, project organization).

Some people need to make sure a project is functional with multiple Xcode versions, and among them are library developers.

Apple accepts App Store submissions from the two last versions of Xcode (currently Xcode 13 and Xcode 12.5.1). It's nicer for the users of a library if that library supports both versions.

On top of that, as time goes by, libraries that support a known set of Xcode versions at the time of their latest release will be compiled with newer Xcode releases as well. Again it's nicer for the users if the lib builds fine. And it's nicer for the library maintainers if a new Xcode release does not create mandatory maintenance.

Finally, dropping support for an Xcode version is a breaking change, in the context of semantic versioning (let's not forget to be pragmatic, though).

What is true for Xcode is also true for OS versions and Swift versions: in the end, yes: there is a strong need for code that supports multiple OS versions, multiple Swift versions, and multiple Xcode versions.

5 Likes

Doesn't ABI/module stability invalidate most of the issues you mentioned? The only true problem I see here is adding support to a new Xcode version, which of course today would be accompanied by a new release and not work with previous Xcode versions. In which scenario would it be necessary to ship both the new and old code of a library under the same tag?

When a library can afford ABI stability and library evolution, I guess you're right.

Unfortunately, designing a library for ABI stability and library evolution, in the context of semantic versioning, is excruciatingly difficult. I do not know many libraries that do this seriously.

The standard library is one obvious example. I have stopped counting the improvements or changes that were, unfortunately, rejected due to ABI stability constraints. And I have stopped counting the great improvements and changes that were possible despite ABI stability, because the designers of the stdlib have put a huge amount of thinking into their designs.

This really requires strong skills.

Some libraries out there support the BUILD_LIBRARY_FOR_DISTRIBUTION flag, so users can cache their own builds and still profit from compiler optimizations. But this is different from shipping a binary and promising future ones can be used as a drop-in replacement.

To be short: ABI stability and library evolution are too difficult to achieve. I wouldn't lightly suggest aiming at it as a replacement for supporting multiple Xcode/Swift/etc versions.

1 Like

I mean ABI stability from the Swift language side. You mentioned that library developers need to make sure a project is functional with multiple Xcode versions, but nowadays that would only be an issue when the library decides to use a feature from a Xcode version (the issue of the thread). Otherwise people could use any Xcode version above the minimum you're supporting.

Sure, when a new Xcode or Swift version ships a new feature, some libraries authors will want to enable new apis, whenever relevant. If it is possible to do so in an API-compatible way, with #if swift(>=...) and/or #if compiler(>=...), some libraries will chose to do so. Supporting this use case is important. Making is even easier is an interesting topic.

This doesn't really have much to do with Xcode or ABI stability.

It's about telling the compiler that there are future versions of an SDK library which it can't see, so it shouldn't try to compile that code. Essentially like #if swift(>= 5.6), except it decouples compiler versions from what ships in its SDK.

1 Like

I do think it's interesting, I just wanted to point out that this is not a problem of the Swift language itself, but more of how one should organize the existence of multiple working versions of a project. While this can be fixed by adding a new flag to the compiler, I'm more of the opinion that a problem should be fixed at its root rather than where it's perceived.

Considering that Xcode releases are typically in beta for a period of multiple months, maintaining a separate branch for a new Xcode release can be difficult. It either requires dealing with a long-running diverging branch, or requires frequent merges from the mainline branch into the "beta SDK" branch. These frequent merges can often become more noisy than the actual changes that exist on that branch.

Some teams may be fine with having a long-running branch, but some (including mine) prefer to get the beta SDK changes merged into the main branch, just guarded by a compile-time check. This prevents the beta SDK branch from diverging from ongoing development.

Precisely. We added #if swift() and #if compiler() to deal with the case where the same code base may be compiled with different versions of the compiler. I'm looking for something that does the same thing for different versions of the SDK, because there's no reason (especially outside the Apple ecosystem) that the compiler version should be tied to an SDK version.

We have #if canImport() for a similar reason; the same codebase might want to use one set of APIs if a certain SDK is available, and a different set of APIs if another SDK is available. The need here is similar, except that instead of only distinguishing between the availability of a particular SDK, we want to distinguish between the availability of a particular version of an SDK.

So maybe we want something more like #if canImport(macOS 12) or #if canImport(AppKit, version: 12). Both of those feel a little weird, because you don't "import" macOS, and we generally don't think of AppKit as having a number. But this does feel like it lives in the same space as canImport.

4 Likes

In my current understanding, this can be achieved with #if compiler() and/or #if canImport().

#if compiler() because SDK availability is tied to Xcode versions, which are tied to compiler versions as well. In other words, from the compiler version you derive the Xcode version, from which you derive the SDK availability.

#if compiler(>=5.5)
  // All SDKs shipped with Xcode 13+ are available.
  // Availability checks are still necessary, though:
  if #available(iOS 15, *) {
    // Can use iOS 15 api
  } else {
    // Can't use iOS 15 api
  }
#else
  // Can't use iOS 15 api
#endif

Am I wrong?

Unfortunately, that isn't sufficient:

  1. You can download newer toolchains and run them on older versions of macOS with older SDKs. For example, you can download a Swift 5.6 toolchain from swift.org and run it on Catalina (which IIRC supports a maximum of Xcode 12, and associated SDK).

  2. Take the recent SDK beta cycle. Xcode 13 betas shipped with the macOS Monterey beta SDK, so at that time, swift(>=5.5) was usable as a proxy for "has the Monterey beta SDK", meaning it had library interfaces including declarations for SwiftUI 3, etc.

    However, Xcode 13 GM (and swift 5.5) have now shipped, but without the Monterey GM SDK (because Monterey itself is still in beta). Any code you wrote during the beta, assuming that "swift(>=5.5) == an SDK with declarations for SwiftUI 3" will now fail to compile.

The root of the problem is that we don't have a way to express SDK versions for conditional compilation. So far we've been relying on the coincidence that compiler versions happened to align with new SDKs for Apple's platforms.

11 Likes

Thanks for making it crystal clear

1 Like

I agree that something like #if sdk(...) would be very welcome, but I don't think it's strictly necessary. I have found a workaround using SWIFT_ACTIVE_COMPILATION_CONDITIONS. The trick basically boils down to using Xcode's setting conditional assignment and variable substitution features. We can create a compilation condition that is only valid when compiling with a specific version of the SDK by substituting the SDK_VERSION_MAJOR definition into our active compilation condition and only assigning it when on a macOS SDK.

For example, if you need an active compilation condition that is only valid when building with the Monterey SDKs, you could define it as such:

// Define our macOS 12.0 SDK compilation conditions
MY_SWIFT_ACTIVE_COMPILATION_CONDITIONS_FOR_SDK_120000[sdk=macos*] = MAC_OS_VERSION_12_0_SDK_AVAILABLE

// Conditionally include our compilation conditions based on the SDK
SWIFT_ACTIVE_COMPILATION_CONDITIONS= $(inherited) $(MY_SWIFT_ACTIVE_COMPILATION_CONDITIONS_FOR_SDK_$(SDK_VERSION_MAJOR))

Then you can access your active compilation condition directly in Swift code:

// If MAC_OS_VERSION_12_0_SDK_AVAILABLE is an active compilation condition, we compiled against the macOS 12.0 SDK
#if MAC_OS_VERSION_12_0_SDK_AVAILABLE
// Do something that requires the Monterey-SDK to compile
#else
// Handle this scenario in the pre-Monterey way
#endif

I wrote up a post on my website that goes into some more details about how it all works a while back, linking it in case the extra context helps.

2 Likes

I need to ignore some code when compiling in iOS 13, otherwise a closure retain cycle bug by system appearred. So #if available is really nececcary for this case.