[summary] Protocol extension method dispatch - was [Static Dispatch Pitfalls]


(L Mihalkovic) #1

protocol A {
  func f()
  func x()
}

extension A {
  func x() {print("a-x")}
}

class B: A { // already strange. B depends on A extension. did not implement all required methods from A
  func f() {}
}

In order to understand the various perspectives on what constitutes expected versus strange, it might be useful to have a sense of which programing language the viewpoint would be expressed from.

For eg in this case, coming from objc it might indeed be surprising that one of the methods of Protocol A does not have to be implemented (this was well explained in last year's wwdc, or was it even 2 years ago). Coming from a java viewpoint however, this would present no surprise, except for having to write the default implementation in an extension rather than directly in the protocol itself. Scala, c#... and more? again different kinds of surprises, but overall the pleasant feeling that swift is actually a modern language.

I took the liberty to rewrite the examples with different variable name to avoid mixing expectations with behavior:

// ——————————————
protocol P {
  func x()
}

extension P {
  func x() {
    helper()
    print("ext-x")
  }
  func helper() {
    print("ext-helper")
  }
}

class A:P {
  func x() { // Note that ‘override’ is not required, even though in effect
    print("a-x”) // the local x() implementation is an override of the default
  } // supplied by the protocol extension. However at the same time,
} // by virtue of being defined inside an extension of the protocol
      // it is reasonable to consider that the default implementation
      // is NOT intrinsically a part of the protocol, defining an
      // implementation of x() inside a conforming class is NOT
      // considered overriding the definition existing in the extension

class B:P { // B is made to reliant on the extension for its A conformance
  func helper() {
    print("b-helper")
  }
}

class C:B {
  func x() { // Note that ‘override’ is not required because B does not provide
    print ("c-x”) // its own implementation of x() (wasn’t there a proposal from
  } // E.Sadun regarding ‘override’ at this location?!)
  override func helper() { // Here ‘override’ is mandated by the presence of a similar
    print("c-helper”) // helper inside B
  }
}

// ——————————————
// invocation via the object type

var x1 = A()
x1.x() // a-x no surprise
x1.helper() // ext-helper no surprise

var x2 = B()
x2.x() // ext-helper + ext-x !!! no surprise even if 'b-helper + ext-x’ might seem more ‘intuitive'
x2.helper() // b-helper no surprise

var x3 = C()
x3.x() // c-x no surprise
x3.helper() // c-helper no surprise

The direct invocation case is mostly without surprises, and in all cases, logically explainable. The only contentious point might be why the definition of helper() present in B is not used when helper() is invoked from the default method implementation supplied in the protocol extension.

// ——————————————
// invocation via the protocol type

var v1:P = A()
v1.x() // a-x no surprise (type has precedence over default when directly equivalent)
v1.helper() // ext-helper no surprise

var v2:P = B()
v2.x() // ext-helper + ext-x coherent with x2.yyy() calls
v2.helper() // ext-helper entirely coherent, even if possibly surprising

var v3:P = C()
v3.x() // ext-helper + ext-x !!! again this is surprising on the surface, but it stems from the lack
v3.helper() // ext-helper of direct link to P. So when it comes to dealing with C as a
          reference to a P, there is no alternative but to refer to B to
          find out what to do

So we have identified some cases where depending on which programming language we might come from, there might be a mismatch between expectations and current Swift behavior, leading to possible bugs and or frustrations. Considering that nothing says that one line of intuition is more right than any other or even than the existing behavior, it may still be useful to manage expectations differently than they are today.

If the desire is to align the current code with the one line of expectations/intuition mentioned above, then it seems that the alternatives are the following:

1) Allow ‘override’ at the point of definition of x() inside C() (despite the absence of a x() definition inside B). The same could be said of the definition of helper() inside B.

One issue with this scenario is that technically speaking, the definition of helper() inside B or x() inside C are NOT overrides, because the methods they define are NOT a part of the protocol. This stems directly from the fact that default protocol methods in extensions are an extension of the internal resolution mechanism that is NOT a part of the formal definition of the protocol they supplement (see #5 for a solution that would make them FORMALLY a part of the protocol itself). IMO this semantic gap should eliminate this solution entirely

2) Support the following calling convention

straw_man_dynamic_dispatch v2.x() // ext-helper + ext-x (NOTE: does leave an expectation mismatch regarding 'b-helper’)
straw_man_dynamic_dispatch v2.helper() // b-helper

straw_man_dynamic_dispatch v3.x() // c-x
straw_man_dynamic_dispatch v3.helper() // c-helper

In this scenario, the user of P would express the desire to include any object type level redefinitions take precedence over any possible default behavior she might have provided in a protocol extension. Note that it does leave a possible expectations mismatch regarding the call to helper() from within the context of a dynamically resolved parent call. This could also be resolved by deciding that once-dynamic, always dynamic which would create more cognitive overload by having to trace every call-tree...

3) Extend 2) to all call sites of x() by making the annotation on the method inside protocol extension

extension P {
  straw_man_dynamic_dispatch func x() {
    helper()
    print(“ext-x”)
  }
  fun helper() {
    print(“ext-helper”)
  }
}

v1.x() // a-x
v1.helper() // ext-helper

v2.x() // ext-helper + ext-x might surprise some, but once again logical as helper() is NOT straw_man_dynamic_dispatch
v2.helper() // ext-helper

v3.x() // c-x
v3.helper() // ext-helper again complete logical as helper() in P is NOT straw_man_dynamic_dispatch and C has no formal relationship to P

4) change the default behavior for dispatching calls to default methods in protocol extensions, and provide an annotation that indicates to opposite behavior per call-site and/or for all call-sites

extension P {
  straw_man_static_dispatch func x() {
    print(“ext-x”) STATICALLY dispatched
  }
  fun helper() {
    print(“ext-helper”) dynamic dispatch
  }
}

v1.x() // a-x
v1.helper() // ext-helper

v2.x() // b-helper + b-x
v2.helper() // b-helper

v3.x() // ext-x
v3.helper() // c-helper

5) leave things the way there are today, and support dynamically dispatched protocol defaults via a new default methods mechanism on protocol directly

protocol P {
  straw_man_default_attribute func x() {
    print(“proto-x”)
  }
}

v1.x() // a-x
v1.helper() // ext-helper

v2.x() // proto-x
v2.helper() // ext-helper

v3.x() // c-x - note that ‘override’ would then be REQUIRED inside the implementation of C().
v3.helper() // ext-helper again local due to he absence of direct relationship between C and P (it is all via B-ness)

Regardless of the path chosen, there seems to be room today from more information from the compiler.

@michael

Can we agree that two methods with the same name sometimes have the same contract and sometimes not? And that this is not a programmer error? And that it would be good to distinguish between these two cases?

yes on all accounts.

NOTES:
a reasonable candidate for the straw_man_dynamic_dispatch attribute may very well be the existing dynamic
a reasonable candidate for the straw_man_default_attribute attribute might be default
a reasonable candidate for the straw_man_static_dispatch attribute might be: nondynamic