Fixing two holes in declaration availability checking

While working on adding checks for uses of unavailable protocol conformances (Availability checking for protocol conformances), I found and fixed two problems in the existing declaration availability checking code that might come up in your Swift code.

In both cases, the existing code violated the availability model, meaning it could crash at runtime if the declarations in question were not available on the running deployment target. If you encounter either of these failures in your Swift code, you should fix them since they indicate a potential problem with the code. Unlike conformance availability checking, I did not stage this in as a warning that upgrades to an error with a compiler flag. If this causes difficulties for your projects, please let me know.

The first change concerns availability of protocols refining other protocols. For a while now, we have allowed a type to conform to a protocol that is less available than the type itself. For example,

@available(macOS 100, *)
protocol NewProtocol {
  func doStuff()
}

struct OldType : NewProtocol { // OK!
  func doStuff() {}
}

This is valid; even though OldType is available in contexts where NewProtocol is not, the protocol conformance is "separate" from the type itself, so as long as you did not use the protocol or the conformance anywhere, it is totally fine to use OldType by itself anywhere.

However, the bug was that this relaxed rule was also applied to protocols refining other protocols:

@available(macOS 100, *)
protocol NewProtocol {
  func doStuff()
}

protocol OldProtocol : NewProtocol {} // was OK, even though it shouldn't be!

The reason being is that since any type that conforms to OldProtocol must also conform to NewProtocol, it wasn't actually possible to define such a type in a useful way. At runtime, attempting to use the conformance to OldProtocol would also instantiate the conformance to NewProtocol, which might not exist on the deployment target.

With the changes I landed on the main branch, this is now an error.

The second change concerns the implicit call to super.init() that is emitted in a subclass designated initializer that omits an explicit super.init() call. Recall that when you override an initializer, if you do not explicitly call super.init() anywhere inside the initializer's body, and the superclass defines a no-argument super.init() call, the compiler would insert an implicit call for you:

class Base {
  init() { ... }
}

class Derived {
  var foo: String

  init() {
    // implicit super.init() call inserted here
    foo = "hi"
  }
}

The bug was that super.init() might be conditionally unavailable, and yet we did not check for this, meaning the compiler accepted this code even though it is invalid:

class Base {
  // "New" initializer
  @available(macOS 100, *)
  init() { ... }

  // "Old" initializer
  init(x: Int) { ... }
}

class Derived {
  var foo: String

  init() {
    // implicit super.init() call inserted here
    foo = "hi"
  }
}

Similar to the first example, the above code is invalid because we're going to call the conditionally unavailable super.init() at runtime, without any kind of guarantee that it is actually valid to do so on the deployment target. As with an explicit super.init() call, the correct fix here is add an @available annotation on the subclass initializer (or the entire subclass, or some other type if its a nested type, etc).

Once again, let me know if this causes any issues for your projects.

10 Likes