Using subtypes in the implementation of protocol properties and function return values

Given the following interface:

protocol Animal {
    var head : AnimalHead { get }
}

protocol AnimalHead {}

A Horse module might implement it as such:

struct Horse : Animal {
//    let head = HorseHead() // [1]
    let _head = HorseHead()
    var head: AnimalHead { _head } // [2]
}

struct HorseHead : AnimalHead {}

Unfortunately, it appears the language does not permit [1] and forces me to use a workaround [2] to implement the head property requirement.

Can anyone explain why this limitation exists? I would understand that it is impossible for the compiler to accept [1] if the property was {get set}, seeing as it's not possible to honour the guarantee of the protocol in that case, but seeing as the property is read-only, I do not see why this restriction exists and the example demonstrates that it is in fact trivial to implement correctly.

What's going on here? Should the compiler consider allowing [1] in the case of read-only properties?

The same is of course true for function return values. In the following code, Horse is theoretically fully compliant with the contract requirements of Animal, but the compiler refuses to permit it:

protocol Animal {
    func copy() -> Animal
}

struct Horse : Animal {
    func copy() -> Horse { // Compiler error
        Horse()
    }
}

On the other hand, the following is perfectly permissible:


struct Horse : Animal {
    func copyHorse() -> Horse {
        Horse()
    }
    func copy() -> Animal {
        copyHorse()
    }
}

There does not appear to be any legitimate reason for why the latter should be allowed but the former illegal.

That is – per my understanding, a protocol definition such as:

func copy() -> Animal

is a contract requiring that any implementation of copy yields an instance that supports at a bare minimum the contract requirements of the Animal type. It appears that the way these things have currently been implemented in the compiler is to require that the implementation of copy may only support the contract requirements of Animal and nothing more - and I can't fathom a reason as to why this should be enforced.

The issue is relatively simple: AnimalHead is not the same as HorseHead. To comply with Animal, you have to use the right type:

struct Horse : Animal {
  let head: AnimalHead = HorseHead()
}

This is SR-522, and it would be source-breaking to change. However, the change could be tied to a language mode, so somebody motivated could suggest it and drive it for Swift 6.

4 Likes

Thanks for the feedback!

Is there a rational for why the current behaviour is as-is? I'm also not entirely sure I can see where it's source-breaking.

1 Like

I thought this was a type inference/erasure issue?

For instance, Horse can’t satisfy a requirement for Animal unless it is explicitly used as one. If you use as Animal or return it in a function that returns Animal, it works.

Am I missing something here?

That's an excellent suggestion. Unfortunately it's not universally useful. It's in fact very sensible for Horse to define its own head as a HorseHead specifically, and allowing Horse to have any type of AnimalHead may not at all be the right thing to do. Furthermore, it's unclear as to why the Animal protocol should make this type equivalency requirement, rather than simply requiring that the return value conforms the protocol parameter's type.

As a quick example, Horse may want to be Hashable, but AnimalHead is not Hashable while HorseHead can be.

What you want is an associated type.

protocol Animal {
  associatedtype Head: AnimalHead
  var head: Head { get }
}

That requires a specific type that conforms to AnimalHead. When you instead use the protocol itself as a type, you are requiring the ability to have any implementation of it.

This certainly solves the issue, but the down-side is that it makes the protocol far heavier than it needs to be. Note that if I want to use an animal as an Animal, simply to use its head as an AnimalHead, I don't need to have any concrete information on the specific type of animal or animal head I'm working with. Your suggestion however forces the types to resolve to concrete types and the user of the protocol is now forced to either have full compile-time type information of the concrete types involved or it forces me to type erase the concrete types; all of this for simply being able to access an abstract animal head which depends on absolutely no concrete type information.

1 Like

I believe that is what @jrose was referring to earlier. The compiler could in theory cast HorseHead to AnimalHead implicitly and conform to the protocol that way. If you are always returning a HorseHead, that does meet the requirement of returning anything that conforms to AnimalHead.

1 Like

There are some examples in the bug. The current behavior comes from way back in Swift 1 when the compiler wasn’t good at synthesizing wrappers to convert between the concrete return type and the abstract one, i.e. it was more an implementation restriction than a design one. You can see this in method overrides for classes, where subtyping like this is permitted.

2 Likes

Very helpful insight. Being new to the evolution process, what are the steps that would be required to proceed on this front, or where can I learn about what I can do to contribute? Does Sam Ballantyne own the evolution on this?

Note that attempting to use an extension as a work-around yields code that compiles but (confusingly) the fork implementation does not replace the extension fork when the type is Animal:

protocol Animal {
    func fork() -> Animal
}
extension Animal {
    func fork() -> Animal {
        fatalError("https://bugs.swift.org/browse/SR-522")
    }
}
struct Horse : Animal {
    func fork() -> Horse {
        Horse()
    }
}


let horse = Horse()
let animal : Animal = horse

horse.fork() // fine.
animal.fork() // fatalError.

Very belated answer: the full process is documented here, https://github.com/apple/swift-evolution/blob/main/process.md, but the most important thing would be to start a discussion in the Evolution > Pitches section of this forum. It would probably help a lot to at least sketch out the proposal template (also linked from the process page) with what would need to change, some motivating examples, and things like that.

Terms of Service

Privacy Policy

Cookie Policy