Unexpected behavior with optional requirements and default implementations

here was a really subtle bug i encountered today:

protocol Nominal
{
    var name:String? { get }
}
extension Nominal
{
    var name:String? { nil }
}

struct Struct:Nominal
{
    var name:String
}

let x:any Nominal = Struct.init(name: "x")

print(x.name as Any)

remarkably, this prints nil, as the compiler prefers the default nil-returning implementation over the actual stored property.

the bug arose from a fairly innocuous change:

struct Struct:Nominal
{
-   var name:String?
+   var name:String
}

there should really be a warning when this happens?

6 Likes

This sort of near-miss bug for witnesses of protocol requirements with default implementations is really pernicious. Something narrowly tailored for the optional case seems potentially valuable, though we’d need a way to silence the diagnostic—backticks, I guess?

I’ve also seen this arise in cases where a defaulted requirement f is removed as a customization point (the default implementation remains), but a previous witness of f now becomes a shadowing declaration with different semantics. I would like if there were some way to say “this declaration witnesses some requirement” so that you’d get a warning/error in these sorts of scenarios. IMO dovetails nicely with formalizing @_implements as well.

2 Likes

I used to have the SR number for this memorized. :smiling_face_with_tear:

Default implementations definitely make this rougher…

1 Like

This is reasonable. We already have something similar for @objc optional requirements, where a near-miss can also cause silent confusion; we silence the warning by moving the near-miss witness to a different context (extension vs nominal type) than the conformance itself. Another case we might want to warn about is when a subclass attempts to "override" a protocol requirement witnessed by a default implementation; in this case there's no vtable slot in the superclass:

protocol P { func f() }
extension P { func f() {} }
class C: P {}
class D: C { func f() {} } // not what you expect
4 Likes

This does have the downside that you wouldn’t be able to opt into the warning on a stored property if there’s a general policy of “declare conformances and their witnesses as separate extensions” since the stored properties would then ~always be separated from the extension where the conformance is declared.

1 Like

This is especially annoying because inits are able to do this, and it seems natural that this should work for functions too.

I’m not going to defend return type overloading, but that’s why things are different here: inits do not allow overloading by failability but methods do. Of course, properties don’t allow overloading at all, so this isn’t a principled justification by any means; it’s just an explanation of why it happens to work for inits in the compiler implementation.

4 Likes

I don't suppose there's any way (in theory on paper) to get this to work despite the existance of return type overloading?

You can read the discussion in the Issue. It works for method overrides, so it could certainly work for protocol requirements; the trouble is it’s a source-breaking change, and a very subtle one at that. So at the very least it would have to be done in a new swift-version.

2 Likes

Well at least it's not ABI breaking, I'll just wait for Swift 7 then. :P

Is optionality important here? I'm getting the same behaviour in the following example:

protocol Nominal {
    var name: String { get }
}
extension Nominal {
    var name: String { "default implementation" }
}
struct Struct: Nominal {
    var name: Int
}
let x: Nominal = Struct(name: 0)
print(x.name) // "default implementation"
Ditto when using methods instead of vars
protocol Nominal {
    func name() -> String
}
extension Nominal {
    func name() -> String { "default implementation" }
}
struct Struct: Nominal {
    func name() -> Int { 42 }
}
let x: Nominal = Struct()
print(x.name()) // "default implementation"
1 Like

the difference with Int is that the type checker would likely prevent you from accidentally calling the wrong overload. whereas there is nothing preventing the wrong overload from being called here:

let name:String? = x.name

Int/String was just an example, here's another one:

class I {}
class S: I {}

protocol Nominal {
    var name: S { get }
}
extension Nominal {
    var name: S { S() }
}
struct Struct: Nominal {
    var name: I { I() }
}
let x: Nominal = Struct()
let name1: S = x.name
let name2: I = x.name
print(type(of: name1)) // S
print(type(of: name2)) // S
// compiler is happy, no warnings

The point of these examples to show it's not just about Optional.