Do we need something like '#if available'?

During the beta period of Xcode 12, we wanted to start implementing features that relied on the new SDK, but still keep our code compiling with the previous SDK. I've found myself wanting to do a compile-time check on the availability of certain SDKs.

For example, I would want to write code like this:

func doStuff() {
  if #available(macOS 11.0, *) {
    // Use macOS 11.0-only code
    WidgetCenter.shared.reloadAllTimelines()
  }
}

However, the above code wouldn't compile at all with Xcode 11, because it didn't know about WidgetCenter at all.

I worked around it by wrapping the whole thing in an #if compiler(>=5.3) check. This meant that when compiling with Xcode 11, that code would be skipped entirely, but on Xcode 12 it would be present. We could start working on new features by using Xcode 12, but if we compiled with Xcode 11 they would be transparently excluded from the final build product.

Unfortunately, this was conflating the Swift compiler version with the platform SDK version. The final release of Xcode 12 came out, and it does not include the macOS 11.0 SDK, so now all my code is broken again.

What I really wanted was not #if compiler(>=5.3), but rather #if available(macOS 11.0). I recognize that this could cause confusion between these two:

if #available(macOS 11.0, *) // run-time check
#if available(macOS 11.0, *) // compile-time check

So I'm happy to consider other spellings of the compile-time check. But it seems like it might be nice to have some way to check the SDK at compile-time.

11 Likes

Wouldn’t marking WidgetCenter with @available be the solution here?

Nope, for the same reason that you can't compile this code in any current version of Xcode:

func doSomething() {
  if #available(macOS 15, *) {
    // error: Cannot find 'Unicorn' in scope
    Unicorn().doStuff()
  }
}

Note that WidgetCenter is part of the macOS 11 SDK, and is already marked as @available in that SDK. But in the stable Xcode 12, the macOS 11 SDK isn't present, so it doesn't even know what WidgetCenter is.

2 Likes

In this specific case, since you're checking for the existence of a new framework, you can do this:

#if canImport(WidgetKit)
  import WidgetKit
#endif

func doStuff() {
  #if canImport(WidgetKit)
    if #available(macOS 11.0, *) {
      // Use macOS 11.0-only code
      WidgetCenter.shared.reloadAllTimelines()
    }
  #endif
}

But this only works because you're using an entirely new framework that didn't exist before. What I don't think is possible today is using a new API in an existing framework while still supporting compiling against the old SDK as well, without introducing your own compilation condition. There's some older discussion about this:

2 Likes

Ah, yep, this does look like the same idea.

1 Like

Would’t it make sense to enhance the availability checking system to also include framework version checks, instead of just operating system checks?

@available(myFramework 2.0, *)
struct OnlyWorksInVersionTwo {
    // ...
}
1 Like

The previous thread proposed merging this into the if #available() check; if you passed an SDK that wasn't known, the compiler would just ignore the block entirely. That seems like a more dramatic approach, but is also in practice exactly the right thing to do in every case I can think of; if I had #if available(macOS 11), it would always be used like this:

#if available(macOS 11.0, *)
if #available(macOS 11.0, *) {
  WidgetCenter.shared.reloadAllTimelines()
}
#endif

Given that #if available would always need to be used with if #available, maybe just merging them together is the right approach. Specifically, if the compiler finds an if #available block with an SDK it does not recognize, it would emit an empty block for that sdk.

Would that be an acceptable approach?

That's a really subtle way to have code ignored just because you have the wrong Xcode active on the command line. :-/ Maybe it can be more explicit? if #availableIgnoringErrors(macOS 11.0, *) or something?

Availability could also apply to APIs in third-party frameworks that were built with a newer version of Xcode, but which an older version of Xcode can consume perfectly fine. (This obviously isn't a well-supported case because module interfaces don't particularly attempt compiler backwards-compatibility, but it's something we probably shouldn't break outright.)

What if it emitted a warning? Would that be enough to point out that you may have the wrong version selected? (I guess it depends on the "ambient warning level" of your codebase.)

Still seems weird to me, but if the idea is eventually you'd stop compiling in the old environment then maybe. We've been saying "it's okay if the most correct code in compiler version 9 results in warnings being emitted in compiler version 8, as long as the new code really is more correct", but extending that to what SDK you're using is a new step. (SDKs can effectively have a minimum compiler version, but usually not a maximum, which is why downloadable toolchains work at all.)

Yeah, in my experience, it's generally the goal that we'll eventually stop compiling in the old environment at all; it comes up during the beta period where we're starting to use new APIs in the new SDK, but still need to be able to make releasable builds with the stable tools. Since we're using if #available, it's already expected that the code in that block wouldn't run at all. A warning to say "you've got code that will never run here because it specifies a future SDK version" seems in line with other "this code will never run" warnings.

4 Likes

A warning like this would quickly join deprecation warnings in the please-can-I-exclude-this-from--Werror bucket.

3 Likes

If we wanted this, we could spell it like this to reduce confusion:

#if os(macOS, >= 11.0)       // add "|| !os(macOS)" to duplicate the meaning of the "*"

But I also worry about conceptual confusion. Developers already struggle sometimes to understand the difference between #if and if #available(...); for instance, I spoke to someone the other day who was having trouble understanding why he couldn't do something like this:

if #available(iOS 12, *) {
  // Mac Catalyst will always use this branch since it's always on iOS 13 or later...
}
else {
  // ...so this branch is dead code on that platform! And yet this is not allowed:
  deprecatedFunctionThatIsUnavailableInMacCatalyst()
}

I ended up telling him to wrap the unavailable call in #if targetEnvironment(macCatalyst), but if #if could do SDK version checks, I could see him writing this instead:

#if os(iOS, >= 12)
  // Mac Catalyst will always use this branch...but so will iOS!
#else
  deprecatedFunctionThatIsUnavailableInMacCatalyst()
#endif

And possibly never realizing that he'd accidentally removed the fallback path from the binary.

How many people are we leading onto the right path only because they literally can't express a version check with #if?

2 Likes

Yeah, it's unfortunate that the spelling of the two is so visually similar. I feel like the use of '#' in #available is particularly misleading, since almost every other case # indicates some sort of compile-time processing (e.g. raw strings, conditional compilation, #file and friends). IME, this makes it pretty hard for the idea that "if #available performs a runtime check" to 'stick' in the minds of beginners.

Perhaps this would have been clearer if availability checks had been spelled something more like:

if currentDeviceIsRunningAtLeast(#os(iOS 12.0, *)) { ... }

Though, that probably wouldn't do much to help people from reaching for #if when they really want a run-time check, since they would still have to know they want a run-time check first in order to reach for if over #if...

2 Likes

#if only accepts comptile-time constants as a condition. Maybe we could allow the addition of a runtime expression that would transform it into a run time branch. When it becomes a run time branch both sides would get compiled. For instance:

#if targetEnvironment(macCatalyst) || evaluate(#available(iOS 12, *))
  print("a")
#else
  print("b")
#endif

At compile time the condition is true in the macCatalyst environment. Otherwise it becomes a "maybe" at compile-time, causing the #if/#else to morph into a run time if/else with the condition taken from the evaluate predicate.

The result you get when targetEnvironement(macCatalist) is true:

print("a")

Otherwise it becomes:

if #available(iOS 12, *) {
  print("a")
} else {
  print("b")
}

Even with this added run time component, #if is still about compile-time inclusion or exclusion of code. The difference is now we can choose to compile both sides.

1 Like

I also have a need for something like this. In my case, I want an #if (i.e. compile-time) condition which allows me to hide certain code if the SDK isn't new enough to even contain those declarations.

Specifically, I'm using this in a polyfill of String.init(unsafeUninitializedCapacity):

extension String {
  init(
    _unsafeUninitializedCapacity capacity: Int,
    initializingUTF8With initializer: (_ buffer: UnsafeMutableBufferPointer<UInt8>) throws -> Int
  ) rethrows {

    // Here: I want to replace "swift(>=5.3)" with a check which says:
    // "Ignore this whole block if your SDK doesn't have the decls for macOS 10.16"
    #if swift(>= 5.3)
    if #available(macOS 10.16, iOS 14.0, watchOS 7.0, tvOS 14.0, *) {
      self = try String(unsafeUninitializedCapacity: capacity, initializingUTF8With: initializer)
      return
    }
    #endif
    if capacity <= 32 {
      let newStr = try with32ByteStackBuffer { buffer -> String in
        let count = try initializer(buffer)
        return String(decoding: UnsafeBufferPointer(rebasing: buffer.prefix(count)), as: UTF8.self)
      }
      self = newStr
      return
    } else {
      let buffer = UnsafeMutableBufferPointer<UInt8>.allocate(capacity: capacity)
      defer { buffer.deallocate() }
      let count = try initializer(buffer)
      self = String(decoding: UnsafeBufferPointer(rebasing: buffer.prefix(count)), as: UTF8.self)
    }
  }
}

I could imagine this being written #if sdk(availability-string):

#if sdk(macOS 10.16, ..., *)
  if #available(macOS 10.16, *) {
    self = try String(...)
  }
#endif

It's a bit verbose, but combining both a compile-time SDK check and run-time OS version check is also a bit iffy:

// not sure about this. Is compile-time conditional, but doesn't look like it.
if #inSDKAndAvailable(macOS 10.16, ..., *) {
  self = String(...)
}
1 Like

I like sdk(...) but would make it #sdk(...) since it is resolved at compile time. This would make it useable both at compile-time and at run-time:

#if #sdk(macOS 10.15, ..., *)
   // This part is ignored by the compiler when using
   // SDK 10.14. No syntax checking, nothing. Just like
   // #if UNDEFINED
   //   dsfjkghedzukg
   // #endif
#endif
if #sdk(macOS 10.16, ..., *) {
   // This part gets compiled (and syntax checked) but:
   // - an compiler with SDK 10.15 will strip the code
   //   since the if condition never evaluates as true.
   // - an compiler with SDK >=10.16 will just remove
   //   the test because it always evaluates as true.
}
2 Likes

I've kind of changed my mind on this point: SDK conditionals are just a transitioning technology, so that developers can take advantage of features in beta SDKs while their code still compiles on the stable version, and once that beta SDK becomes stable, they will want to transition to if #available blocks anyway. So maybe it's good to include both, and the verbosity isn't something we need to worry about.

Long, awkward availability strings are a separate problem. I've seen 2 PRs with different approaches:

  • @lorentey created a PR for indirect availability, so you can reference the availability of some other declaration.
  • @xymus created a PR for defining availability strings as compile-time arguments.

They are different solutions, but there's a lot of overlap in what they enable. Ultimately, that could lead to writing something like:

@available(macOS 10.16, iOS 14.0, ..., *)
typealias Darwin_beta = Void

#if sdk(Darwin_beta)
if #available(Darwin_beta) {
  self = String(...)
}
#endif

In most cases you will use the latest SDK but I can think of cases where you are forced to support the use of old SDKs, e.g.:

  • If you provide the code to others (open source framework), you might have to support both, older and newer SDKs.
  • If Xcode drops support for a platform (has happened before, 32/64 bit transition) that you have to support.
1 Like

Another reason to have this, even though it is usually a temporary condition, is that sometimes you can't upgrade to the new Xcode right away but the new SDKs break your app in a way where you need to call new APIs to fix it. So people that happen to be using the new Xcode can work with the fixes while the old Xcode can still compiles the same code.

This is currently happening with Xcode 12 & 13 due to the new UITableView sectionHeaderTopPadding property. We can use __IPHONE_OS_VERSION_MAX_ALLOWED in places to avoid this in Objective-C but unfortunately we have one place in Swift that needs this also.