Skip to content

Instantly share code, notes, and snippets.

@manicaesar
Created September 24, 2021 19:10
Show Gist options
  • Save manicaesar/984ea25673b5dc412ce3fb05ea4deaa2 to your computer and use it in GitHub Desktop.
Save manicaesar/984ea25673b5dc412ce3fb05ea4deaa2 to your computer and use it in GitHub Desktop.
Swift property overloading
struct StructWithTwoObjects {
let object: String
/*
var object: A { // Stupid, but causes compilation error - OK!
return self
}
*/
}
// ---------------------
protocol SomeProtocol {}
protocol ObjectProviding {
associatedtype ObjectType: SomeProtocol
var object: ObjectType { get }
}
extension ObjectProviding where
Self: SomeProtocol, // It is not needed, can be commented out
Self.ObjectType == Self {
var object: ObjectType {
return self
}
}
struct ObjectContainer: SomeProtocol, ObjectProviding {
let object: String
init(object: String) {
self.object = object
}
}
struct ObjectPrinter<T: ObjectProviding> {
let objectContainer: T
init(objectContainer: T) {
self.objectContainer = objectContainer
}
func printTypeOfContainedObject() {
return print(type(of: objectContainer.object))
}
}
let objectContainer = ObjectContainer(object: "string")
let printer = ObjectPrinter<ObjectContainer>(objectContainer: objectContainer)
print(type(of: objectContainer.object)) // Prints: String
printer.printTypeOfContainedObject() // Prints: ObjectContainer
@Losiowaty
Copy link

Losiowaty commented Sep 25, 2021

I think I kinda know what and how is happening 😅
The explanation is super lengthy, mainly because I didn't know how to explain it in a shorter way so it would still be organised and readable 🙈

For reference - SIL docs

It seems it has to do with static dispatch and how this is done based on the variable type (or what type information we have).
To try to understand everything let's start from this super simple and straightforward sample :

protocol Provider { var value: Int { get } }
extension Provider { var value: Int { 0 } }

struct Struct: Provider {}

let s = Struct()
let sV = s.value // Int == 0

let p: Provider = Struct()
let pV = p.value // Int == 0

And the important/interesting parts of SIL are :

// #1
// Provider.value.getter
sil hidden [ossa] @$s4main8ProviderPAAE5valueSivg : $@convention(method) <Self where Self : Provider> (@in_guaranteed Self) -> Int { ... }

// #2
// protocol witness for Provider.value.getter in conformance Struct
sil private [transparent] [thunk] [ossa] @$s4main6StructVAA8ProviderA2aDP5valueSivgTW : $@convention(witness_method: Provider) (@in_guaranteed Struct) -> Int {
// %0                                             // user: %2
bb0(%0 : $*Struct):
  // function_ref Provider.value.getter
  %1 = function_ref @$s4main8ProviderPAAE5valueSivg : $@convention(method) <τ_0_0 where τ_0_0 : Provider> (@in_guaranteed τ_0_0) -> Int // user: %2
  %2 = apply %1<Struct>(%0) : $@convention(method) <τ_0_0 where τ_0_0 : Provider> (@in_guaranteed τ_0_0) -> Int // user: %3
  return %2 : $Int                                // id: %3
} // end sil function '$s4main6StructVAA8ProviderA2aDP5valueSivgTW'

// #3
sil_witness_table hidden Struct: Provider module main {
  method #Provider.value!getter: <Self where Self : Provider> (Self) -> () -> Int : @$s4main6StructVAA8ProviderA2aDP5valueSivgTW	// protocol witness for Provider.value.getter in conformance Struct
}

What is important to notice is that the Struct itself at this point has no notion of it having a value property - it was purely inherited from the protocol and its default implementation - it is "injected" into the struct via the witness table #3 as something (a method) with a signature (Self) -> () -> Int.

Now the call sites for value look a little bit different depending on what we know about the type :

// #4
// function_ref Provider.value.getter
  %16 = function_ref @$s4main8ProviderPAAE5valueSivg : $@convention(method) <τ_0_0 where τ_0_0 : Provider> (@in_guaranteed τ_0_0) -> Int // user: %17
  %17 = apply %16<Struct>(%14) : $@convention(method) <τ_0_0 where τ_0_0 : Provider> (@in_guaranteed τ_0_0) -> Int // user: %19
  
 // #5
   %30 = open_existential_addr immutable_access %22 : $*Provider to $*@opened("3375C4B4-1E2C-11EC-AC38-ACDE48001122") Provider // users: %34, %33, %32, %31
  %31 = alloc_stack $@opened("3375C4B4-1E2C-11EC-AC38-ACDE48001122") Provider // type-defs: %30; users: %37, %36, %34, %32
  copy_addr %30 to [initialization] %31 : $*@opened("3375C4B4-1E2C-11EC-AC38-ACDE48001122") Provider // id: %32
  %33 = witness_method $@opened("3375C4B4-1E2C-11EC-AC38-ACDE48001122") Provider, #Provider.value!getter : <Self where Self : Provider> (Self) -> () -> Int, %30 : $*@opened("3375C4B4-1E2C-11EC-AC38-ACDE48001122") Provider : $@convention(witness_method: Provider) <τ_0_0 where τ_0_0 : Provider> (@in_guaranteed τ_0_0) -> Int // type-defs: %30; user: %34
  %34 = apply %33<@opened("3375C4B4-1E2C-11EC-AC38-ACDE48001122") Provider>(%31) : $@convention(witness_method: Provider) <τ_0_0 where τ_0_0 : Provider> (@in_guaranteed τ_0_0) -> Int // type-defs: %30; user: %35 

In #4 we know the exact type of the object so we are able to directly find the exact implementation we want to call ( function_ref @$s4main8ProviderPAAE5valueSivg - the default implementation from the protocol).
In #5 we know that we have "something" of type Provider so we need to reach into the actual existential and in its witness table find a method that fits our signature - and the only candidate is the method that points to the implementation that triggers the default implementation.

Let's adjust our sample by simply providing a dedicated implementation of the protocol requirements:

struct Struct: Provider { var value: Int { 10 } }

This little source change has some interesting implications in SIL :
The struct itself now actually knows about the value :

// Struct.value.getter
sil hidden [ossa] @$s4main6StructV5valueSivg : $@convention(method) (Struct) -> Int { ... }

which caused the implementation of // protocol witness for Provider.value.getter in conformance Struct to change and now call the dedicated value getter provided in the struct :

// protocol witness for Provider.value.getter in conformance Struct
sil private [transparent] [thunk] [ossa] @$s4main6StructVAA8ProviderA2aDP5valueSivgTW : $@convention(witness_method: Provider) (@in_guaranteed Struct) -> Int {
// %0                                             // user: %1
bb0(%0 : $*Struct):
  %1 = load [trivial] %0 : $*Struct               // user: %3
  // function_ref Struct.value.getter
  %2 = function_ref @$s4main6StructV5valueSivg : $@convention(method) (Struct) -> Int // user: %3
  %3 = apply %2(%1) : $@convention(method) (Struct) -> Int // user: %4
  return %3 : $Int                                // id: %4
} // end sil function '$s4main6StructVAA8ProviderA2aDP5valueSivgTW'

sil_witness_table hidden Struct: Provider module main {
  method #Provider.value!getter: <Self where Self : Provider> (Self) -> () -> Int : @$s4main6StructVAA8ProviderA2aDP5valueSivgTW	// protocol witness for Provider.value.getter in conformance Struct
}

Notice how the sil_witness_table hidden Struct: Provider module main didn't change at all! It still points to the same method as before, just its implementation changed.
The change in call sites accessing the property changed in an expected way - the direct access now calls the proper implementation directly, and accessing it via Provider variable still needs to reach out into the existential.

Up to this point everything looks and works as expected 👍 So far so good 😆

Let's shake things up! 🌪️

struct Struct: Provider { var value: String { "10" } }

With this change we reached a simplified version of your sample - just without the generics (we'll get to them later).
Before seeing your post on Twitter I'd have said with full conviction that at this moment Swift should start throwing errors - I'd expect either invalid redeclaration of value or Struct does not conform to protocol Provider - I guess either would make sense for me.
But as we already know, Swift is happy with it (well, I guess the compiler more than Swift per se).

Let's dive deep into SIL again 🤿
Just to see that actually not much has changed 😅
Which actually kinda explains the entire situation 😅

One of the most important part that didn't change is still the sil_witness_table hidden Struct: Provider module main:

sil_witness_table hidden Struct: Provider module main {
  method #Provider.value!getter: <Self where Self : Provider> (Self) -> () -> Int : @$s4main6StructVAA8ProviderA2aDP5valueSivgTW	// protocol witness for Provider.value.getter in conformance Struct
}

Now we enter a, more or less "educated", guessing territory, which most likely could be confirmed by looking at Swift/compiler source code.
I believe this is what happens :

  1. Struct is compiled and has the value: String property
  2. Provider conformance gets compiled and the witness table is generated
  3. It looks for a conforming declaration in Struct, doesn't find it.
  4. But it knows it has a default one as a fallback!
  5. It happily uses the default implementation and calls it in // protocol witness for Provider.value.getter in conformance Struct

The other important part that didn't change are the call sites :

  • for access on Struct we have direct static dispatch
  • for access on Provider we reach into the existential and find the method pointed to by its witness table - which in its implementation calls the default implementation!

Instinctively the entire process should error out somewhere between steps 3 and 4 described above.


Now there are two questions left.

  1. Why does it work like that? 🤔 🧐
    Dunno 😆 Apart from actually reading Swift/compiler source code (not saying I'm not gonna do it 😅 ) the best would be to ask on Swift forums ;)
    I've found those two threads (maybe the search term needs to be refined) :

They aren't precisely about what you are asking but there is some similar behaviours and interesting knowledge between the lines.

  1. What about this specific case with generics?
    The basics are the same. The only extra thing that happens is that the compiler, trying to be super extra helpful is also figuring out that since ObjectContainer does conform to SomeProtocol itself it can infer typealias ObjectType = ObjectContainer in that case and then happily use the default implementation you provided.

This inference can obviously bee seen in SIL :

struct ObjectContainer : SomeProtocol, ObjectProviding {
  @_hasStorage let object: String { get }
  init(object: String)
  typealias ObjectType = ObjectContainer
}

and seems to be the crux of the issue in this sample - as soon as you explicitly state that typealias ObjectType = String or remove the conformance of ObjectContainer to SomeProtocol we get expected failures.
Alternatively providing extension String: SomeProtocol {} pushes the compiler in the expected direction when it sees that the custom provided let object: String can fulfil the protocols requirements so its best to use it.


After this lengthy text (which I immensely enjoyed researching and writing) my conclusion is that this is the result and a mix of the compiler trying to be super helpful (by trying to figure out the associatedtype by itself in the generic case) and the fact that the default implementation "lives in a different place" - in terms of static dispatch - so it doesn't clash when you use the same names but otherwise don't fulfil the protocols requirements.

All of this kinda reminds me of this behaviour :

protocol FooProtocol {
  func foo()
}

extension FooProtocol {
  func foo() { print("default foo") }
  func bar() { print("default bar") }
}

struct Foo: FooProtocol {
  func foo() { print("custom foo") }
  func bar() { print("custom bar") }
}

let f1 = Foo()
f1.foo() // prints "custom foo"
f1.bar() // prints "custom bar"

let f2: FooProtocol = Foo()
f2.foo() // prints "custom foo"
f2.bar() // prints "default bar"

In this case both func bar() methods, while having the same signature and name don't clash because they both "live in different contexts".

@manicaesar
Copy link
Author

Many thanks for the thorough investigation.

It is interesting that the issue can be reproduced with the simple struct & protocol example you provided. I assumed the same as you - that it would not compile - and didn't even try to simplify the example further - thanks for proving me wrong! 😀

Yeah, it reminded me of the behaviour you mentioned at the end of your comment as well... but then I realised it is a little bit different, because property signatures (if there is such thing as property signature, not sure 😅) in terms of returned type are different, while foo and bar are the same in terms of returned type and parameter types.

I think the culprit here is that from SIL perspective, getters are equivalent to parameterless methods (more on that below). So if we modify the example to use parameterless functions instead of getters, then I guess you won't be surprised that the code below compiles - as method overloads are legal:

protocol Provider {
  func fvalue() -> Int
}

extension Provider {
  func fvalue() -> Int { 1 }
}

struct Struct: Provider {
  func fvalue() -> String { "11" }
}

let s = Struct()
let fint: Int = s.fvalue()
let fstr: String = s.fvalue()

Now, why I think that:

from SIL perspective, getters are equivalent to parameterless methods

?
Well, merging the two examples together into:

protocol Provider {
  var value: Int { get }
  func fvalue() -> Int
}

extension Provider {
  var value: Int { 0 }
  func fvalue() -> Int { 1 }
}

struct Struct: Provider {
  var value: String { "10" }
  func fvalue() -> String { "11" }
}

let s = Struct()
let int: Int = s.value
let str: String = s.value
let fint: Int = s.fvalue()
let fstr: String = s.fvalue()

and looking at the SIL generated, it basically almost the same for value and fvalue, e.g.:

// Provider.value.getter
sil hidden [ossa] @$s7SILTest8ProviderPAAE5valueSivg : $@convention(method) <Self where Self : Provider> (@in_guaranteed Self) -> Int {
// %0 "self"                                      // user: %1
bb0(%0 : $*Self):
  debug_value_addr %0 : $*Self, let, name "self", argno 1 // id: %1
  %2 = integer_literal $Builtin.IntLiteral, 0     // user: %5
  %3 = metatype $@thin Int.Type                   // user: %5
  // function_ref Int.init(_builtinIntegerLiteral:)
  %4 = function_ref @$sSi22_builtinIntegerLiteralSiBI_tcfC : $@convention(method) (Builtin.IntLiteral, @thin Int.Type) -> Int // user: %5
  %5 = apply %4(%2, %3) : $@convention(method) (Builtin.IntLiteral, @thin Int.Type) -> Int // user: %6
  return %5 : $Int                                // id: %6
} // end sil function '$s7SILTest8ProviderPAAE5valueSivg'

// Int.init(_builtinIntegerLiteral:)
sil [transparent] [serialized] @$sSi22_builtinIntegerLiteralSiBI_tcfC : $@convention(method) (Builtin.IntLiteral, @thin Int.Type) -> Int

// Provider.fvalue()
sil hidden [ossa] @$s7SILTest8ProviderPAAE6fvalueSiyF : $@convention(method) <Self where Self : Provider> (@in_guaranteed Self) -> Int {
// %0 "self"                                      // user: %1
bb0(%0 : $*Self):
  debug_value_addr %0 : $*Self, let, name "self", argno 1 // id: %1
  %2 = integer_literal $Builtin.IntLiteral, 1     // user: %5
  %3 = metatype $@thin Int.Type                   // user: %5
  // function_ref Int.init(_builtinIntegerLiteral:)
  %4 = function_ref @$sSi22_builtinIntegerLiteralSiBI_tcfC : $@convention(method) (Builtin.IntLiteral, @thin Int.Type) -> Int // user: %5
  %5 = apply %4(%2, %3) : $@convention(method) (Builtin.IntLiteral, @thin Int.Type) -> Int // user: %6
  return %5 : $Int                                // id: %6
} // end sil function '$s7SILTest8ProviderPAAE6fvalueSiyF'

// protocol witness for Provider.value.getter in conformance Struct
sil private [transparent] [thunk] [ossa] @$s7SILTest6StructVAA8ProviderA2aDP5valueSivgTW : $@convention(witness_method: Provider) (@in_guaranteed Struct) -> Int {
// %0                                             // user: %2
bb0(%0 : $*Struct):
  // function_ref Provider.value.getter
  %1 = function_ref @$s7SILTest8ProviderPAAE5valueSivg : $@convention(method) <τ_0_0 where τ_0_0 : Provider> (@in_guaranteed τ_0_0) -> Int // user: %2
  %2 = apply %1<Struct>(%0) : $@convention(method) <τ_0_0 where τ_0_0 : Provider> (@in_guaranteed τ_0_0) -> Int // user: %3
  return %2 : $Int                                // id: %3
} // end sil function '$s7SILTest6StructVAA8ProviderA2aDP5valueSivgTW'

// protocol witness for Provider.fvalue() in conformance Struct
sil private [transparent] [thunk] [ossa] @$s7SILTest6StructVAA8ProviderA2aDP6fvalueSiyFTW : $@convention(witness_method: Provider) (@in_guaranteed Struct) -> Int {
// %0                                             // user: %2
bb0(%0 : $*Struct):
  // function_ref Provider.fvalue()
  %1 = function_ref @$s7SILTest8ProviderPAAE6fvalueSiyF : $@convention(method) <τ_0_0 where τ_0_0 : Provider> (@in_guaranteed τ_0_0) -> Int // user: %2
  %2 = apply %1<Struct>(%0) : $@convention(method) <τ_0_0 where τ_0_0 : Provider> (@in_guaranteed τ_0_0) -> Int // user: %3
  return %2 : $Int                                // id: %3
} // end sil function '$s7SILTest6StructVAA8ProviderA2aDP6fvalueSiyFTW'

sil_witness_table hidden Struct: Provider module SILTest {
  method #Provider.value!getter: <Self where Self : Provider> (Self) -> () -> Int : @$s7SILTest6StructVAA8ProviderA2aDP5valueSivgTW	// protocol witness for Provider.value.getter in conformance Struct
  method #Provider.fvalue: <Self where Self : Provider> (Self) -> () -> Int : @$s7SILTest6StructVAA8ProviderA2aDP6fvalueSiyFTW	// protocol witness for Provider.fvalue() in conformance Struct
}

So yeah, I guess the best idea would be to ask on Swift forums whether this is actually expected behaviour (as I am not going to read compiler's code either 😝). Thanks again and I am glad you enjoyed diving into this topic 👍

@manicaesar
Copy link
Author

Ok, I have found this thread: https://forums.swift.org/t/concerned-about-this-protocol-extension-conformance-bug/9687

And I guess we can assume Ben Cohen's response is official explanation (not a bug, if we want to change it, it is a feature request 😀)
https://forums.swift.org/t/concerned-about-this-protocol-extension-conformance-bug/9687/5

Though looks like this never got enough traction to at least provide "near-miss warning" from the compiler.

@Losiowaty
Copy link

Losiowaty commented Sep 26, 2021

O wow! That's a super interesting thread, glad you found it ❤️

While it's (a little) reassuring that this is expected and well-defined behavior, I don't think I personally like the potential implications of all of this, especially when we move this discussion to consider cross-framework implications.

Since in your merged sample we see that we can force different value to be used by giving some hints to the compiler than this becomes possible :

// Assume this is in External Module
protocol Provider {
  var value: Int { get }
  func fvalue() -> Int
}

extension Provider {
  var value: Int { 0 }
}
// -------
// And now in Our Module
struct Struct: Provider {
  var value: String { "1" }

  // Provider conformance
  func fvalue() -> Int { return 1 }
}

func takeInt(_ i: Int) { print(i) }

takeInt(Struct().value)

This can be unexpected from the External Module consumer perspective - the compiler obviously told them that they need to implement func fvalue() to fulfil the requirements but in no other way hinted them that there are other requirements that have default values - so they aren't aware that they "silently" have an Int property and would (rightfully so!) expect this to fail, or maybe assume that there is a conversion happening under the hood (conversion from Int to String would be more natural, but still)

Not to mention the samples that Xiaodi Wu and Marc Palmer mentioned in their posts 🤯
It can easily lead to a whole lot of errors that may be confusing to track down.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment