Unexpected type change when extending a protocol

Hello!

I'm experiencing a strange and unwanted behaviour with Protocols and Extensions that I'm hoping someone here can both explain why this is happening, and what I should do to prevent it.

Here's a boiled-down example:

protocol Fruit {
    var colour: String { get }
    var price: Double { get }
}

struct Apple: Fruit {
    var colour = "red"
    var price: String = "Expensive"
}

The compiler complains about the type mismatch on price, here. This is expected.


My code, howeever, actually had an extension on the analog to the Fruit protocol here, to provide a default [computed (which is necessary in an extension)] value. Like this:

protocol Fruit {
    var colour: String { get }
    var price: Double { get }
}

extension Fruit {
    var price: Double { 3.99 }
}

This also works fine. If I follow up with definition of Apple, I get:

struct Apple: Fruit {
    var colour = "red"
}

let apple = Apple()
apple.price  // 3.99 (double)

The price is correct, here: 3.99

I can however, overwrite (sort of, more on this below) the type by declaring it in the struct.

struct Apple: Fruit {
    var colour = "red"
    var price = "expensive"
}

let apple = Apple()
apple.price  // "expensive" (string)

This only works if I declare the property in the extension. If I do, I can change the type in the struct.

Even more suspicious, if I do the above, I can get two different price properties:

let apple = Apple()
let stringPrice: String = apple.price  // "expensive" (string)
let numPrice: Double = apple.price // 3.99 (double)

This gets especially messy when trying to call functions with the same name but different signatures:

func doStuff(_ arg: String) {
    print("String doStuff!")
}

func doStuff(_ arg: Double) {
    print("Double doStuff!")
}

doStuff(apple.price) // which one is called?

It's the String one that gets called in my testing, but I expected the definition in the protocol to enforce doStuff(Double).

(Note: I discovered this while refactoring what is price in this example from one type to another; it was a much bigger struct and the price declaration was missed; the compiler didn't complain so it was ambiguous to me until I realized that it was using the old type for price. The example is contrived, but this is a thing I actually experienced.)

So, here's my main question: is this expected behaviour?
Secondarily: what can I do to force the type in the protocol? (see the note about force-unwrapped-type below)

Here are the parts that seem strange to me:

  • the protocol doesn't actually enforce the type if the extension declares price
  • the extension isn't allowed to declare a different type
  • there can be two properties with the same name, but I can't declare two properties of the same name (and different types) in a normal struct definition
  • there's no obvious way to ensure the type in the protocol (properties can't be marked final for example)
  • if, I mark the type Double! in the extension (this was an accidental discovery), the compiler does complain about the type change in the struct; I don't think this is the correct way to do this, though, and it's probably just a side effect of forcing the unwrap. https://files.scoat.es/4Mlwoo3dNM.png
  • if I define a type in a protocol, and something else extends the protocol in this way, apple.price as! Double becomes a crash (the fact that I should not be force casting there aside)

Thanks for reading this far. I know it's a long post but I wanted to be as clear as possible about what seems like a really strange behaviour (and maybe even a compiler misbehaviour) to me.

S

In this case there are two solutions involving the two overloads of price. The type checker prefers an overload that is not from a protocol extension in a situation like this, which is why it picks the price inside the struct.

Yes, this is expected behavior given that the two declarations of price are independent overloads (they just share a name). Only one has the correct type (the one in the extension) so it’s the one we choose as the witness the protocol requirement.

We don’t allow overloading properties by type within the same scope (so eg a single struct definition) but using extensions it is possible to overload properties in this way.

Fundamentally this is no different than overloading a method and then using one of the overloads (which may be in a protocol extension) to witness a protocol requirement. There’s no way to prevent the scenario you described from occurring, but its not a type safety hole.

4 Likes

Thanks for the reply. I do really appreciate it.

I understand that the extension fulfills the protocol requirements. Now that you've pointed it out, it makes sense.

I think the disconnect for me here is that I explicitly declared what I expect price's type to be in the protocol and it ended up being something else (or really that there ended up being two prices, silently). The main benefit of Swift for me (after decades of celebrating dynamic languages) is that this kind of thing doesn't happen. Or at least I didn't expect it to happen.

If there's no way to prevent this, even a compiler warning about ambiguity on my redeclaration (with the "wrong" type) would have been very helpful to me figuring out what was going wrong with this.

This feature is a really good way to provide concrete overloads.

protocol Fruit {
  var price: Int8 { get }
}
extension Fruit {
  var price: Int16 { _price() }

  fileprivate func _price<Int: FixedWidthInteger & SignedInteger>() -> Int {
    .init()
  }
}

protocol FruitStripedGum: Fruit { }
extension FruitStripedGum {
  var price: Int32 { _price() }
}
func price(fruit: some FruitStripedGum) {
  _ = fruit.price // Int8
  _ = fruit.price as Int16
  _ = fruit.price as Int32
}

Make sure you understand the difference that occurs when there's no concrete type, or protocol requirement, however.

protocol Fruit { }
_ = fruit.price // Int32
_ = fruit.price as Int16
_ = (fruit as Fruit).price // Int16

You're right that we could produce a warning here, and in fact we do something similar with @objc protocols. An @objc protocol can declare an optional requirement, which means it doesn't have to be witnessed by the conforming type. Now if you write something like this, where f() below doesn't match the signature of the requirement, you end up in a similar situation where the conformance check succeeds but maybe not in the way you intended:

import Foundation

@objc protocol P {
  @objc optional func f(_: Int)
}

class C: NSObject, P {
  func f(_: String) {} // wrong type
}

We emit a warning in this case:

 7 | class C: NSObject, P {
 8 |   func f(_: String) {}
   |        |- warning: instance method 'f' nearly matches optional requirement 'f' of protocol 'P'
   |        |- note: candidate has non-matching type '(String) -> ()'
   |        |- note: move 'f' to an extension to silence this warning
   |        `- note: make 'f' private to silence this warning

A protocol requirement that has a default implementation in a protocol extension is the Swift analog of an @objc optional protocol requirement, and we could also produce a similar warning if we pick up the default implementation when another member in the conforming type itself has the same name but wrong type.

1 Like