Method override with a generic signature with requirements not imposed by the base method

In the Xcode 11.4 Release Notes under the Swift section it has this:

  • A method override can no longer have a generic signature with requirements not imposed by the base method. For example, the below code produces an error. (23626260) (FB5382462)
protocol P {}
      
class Base {
    func foo<T>(arg: T) {}
}
      
class Derived: Base {
    // generates an error because of the added requirement
    override func foo<T: P>(arg: T) {}
}

I'm confused why this change was made. It has completely broken a core part of our app which is based on this framework: https://github.com/theappbusiness/TABResourceLoader

For making network requests we have the following concepts:

  1. Model : Strongly typed object in your codebase, may or may not be mapped 121 to the server model
  2. Resource : Defines through protocol conformance where and how to fetch a Model . For example a resource could define the URL of where a JSON file is and how to parse into strongly types model.
  3. Service : A type that knows how to retrieve a specific kind of Resource

So we have a class NetworkDataResourceService that can fetch Resources that conform to NetworkResourceType and DataResourceType:

open class NetworkDataResourceService {

    open func fetch<Resource: NetworkResourceType & DataResourceType>(resource: Resource,  completion: @escaping (NetworkResponse<Resource.Model>) -> Void) -> Cancellable? { ... }
}

We then have a subclass that restricts the Resource further to having to conform to ServiceableResource:

open class ResourceService: NetworkDataResourceService {
    open override func fetch<Resource: DataResourceType & NetworkResourceType & ServiceableResource>(resource: Resource, completion: @escaping (NetworkResponse<Resource.Model>) -> Void) -> Cancellable? { super.fetch(...) }
}

There are then 3 more subclasses in the same fashion, further restricting what resources can be fetched. This means that it is a compile time error to fetch a Resource from a service that doesn't support fetching it.

I can't understand why the change in Swift 5.2 was made. It seems to violate/break the Liskov Substitution Principle as ResourceType & NetworkResourceType & ServiceableResource is effectively a subtype of ResourceType & NetworkResourceType. The super class doesn't need to know that the Resource is also a ServiceableResource when calling super.fetch.

Is there any more information on why this change was made? (And if possible any workarounds) I've tried searching for the reference numbers in the Release notes but can't find anything.

The reason I made this change is because previously the compiler was allowing unsound code to compile, which later led to runtime crashes. Yes, sometimes the code would work, but that doesn't mean the code was safe.

Here's an example (based on the code you posted):

public protocol P {}
public protocol Q {}
public protocol R {
  func r()
}

open class A {
  open func foo<T: P & Q>(arg: T) {}
}

open class B: A {
  open override func foo<T: P & Q & R>(arg: T) {
    arg.r() // oops... this will actually crash in <= 5.1
  }
}

class C: P, Q {}

let a: A = B()
a.foo(arg: C())

You can see what the problem is - C does not conform to R (just P and Q) but we managed to pass it to B.foo (via A). So, calling R's requirement (the function r()) on it is illegal inside B.foo and thus leads to a crash.

And if possible any workarounds

You probably need to change your code.

I've tried searching for the reference numbers in the Release notes but can't find anything.

The main ticket is SR-4206 and the PR is https://github.com/apple/swift/pull/24484

2 Likes

This violates Liskov substitutability. I can’t believe it was ever allowed.

4 Likes

Looking at the above sample, what is Derived.foo supposed to do when a non-P argument is passed to it via a Base reference? It could either choke or pass code down to the Base implementation. Even if you think the second option should be the answer, it currently isn’t and would be non-obvious. Would you allow multiple partial overrides of foo? What would happen if a type matches more than one; handle like we do when something similar happens with extensions, where we have to make yet another overload specialized for when all conditions are active?

None of this can be done now because a derived class can override a base member at most once. As a workaround, check for P conformance within the override and have an else-block for other types.

2 Likes

If your protocol does not have an associated type, you could replace the compile-time requirement with a runtime check:

override func foo<T: P1 & P2>(arg: T) {
    guard let arg = arg as! P1 & P2 & P3 else {
        fatalError("Type \(arg.Type) must conform to P3")
    }
    /* ... */
}

If the protocol has an associated type, that won't work unfortunately.

ResourceType & NetworkResourceType & ServiceableResource is in fact a subtype of ResourceType & NetworkResourceType, but because the resource: Resource is in negative position in the method (that is, it's an input to the method) the variance rules for subtyping impose that a subtype of NetworkDataResourceService must accept either the same Resource or a supertype of the latter.

Your abstraction violates the LSP, and it's unsound. If you give me a NetworkDataResourceService, I expect to pass to fetch any NetworkResourceType & DataResourceType, but if you passed me an instance of ResourceService (masked behind the NetworkDataResourceService abstraction), the latter would expect to be passed to fetch something that's also a ServiceableResource. I'm actually surprised that Swift used to allow this, and thankfully it was corrected.

As a general rule, in subtypes you can restrict only "out" parameters, that is, stuff that's returned from methods; but for "in" parameters, you can only generalize them.

2 Likes

Yeah. Basically, function arguments are contravariant and function result is covariant. Generics are mostly invariant IIRC with exceptions (arrays can be covariant for example).

Arrays are covariant, which is incorrect for the same reasons, but being value types, and thanks I guess to some runtime juggling, they actually work, and allow for something like the following:

protocol A {}
protocol B: A {}
protocol C: A {}

extension String: B {}

var listOfB: [B] = ["howdy"]

var listOfA = listOfB as [A]

listOfA is [B] /// == `true`
listOfA as! [B] /// ok

extension Int: C {}

listOfA.append(42)

listOfA is [B] /// == `false`
listOfA as! [B] /// crash

Of course this is not going to be a problem in the 99,99% of times, but if one weirdly thinks to downcast a previously upcasted array, they could be in for a treat :smiley:

1 Like