[Returned for revision] SE-0299: Extending Static Member Lookup in Generic Contexts

The review period for SE-0299: Extending Static Member Lookup in Generic Contexts has concluded. The core team has decided to return this proposal for revision. We agree that extending .leadingDot contextual member lookup to work in generic contexts fits well with the direction of Swift, and that extending it to find members from protocol extensions corresponding to constraints on the generic context is a reasonable incremental expansion of the existing model. However, the core team is concerned about the potential for namespace pollution when using this new feature as exemplified in the proposal, an issue the review discussion also raised concerns about, and would like to see the proposal revised to address this concern.

To reiterate, the proposed feature allows an API to associate constants for different concrete conforming types with a protocol. For example, given:


protocol P {}

struct A: P {}
struct B: P {}
struct C: P {}

extension P {
  static var a: A { return A() }
  static var b: B { return B() }
  static var c: C { return C() }
}

func useP<T: P>(_: T) {}

SE-299 would allow users to call into a generic API like useP with shorthand syntax for these static properties:

useP(.a)
useP(.b)
useP(.c)

This effect is desirable. However, because a protocol extension extends all types conforming to the protocol, these static constants end up polluting the namespace of unrelated types, allowing for other nonsensical combinations like:

useP(B.a)
useP(A.c)

This will noise up documentation and code completion for these concrete types, running against the proposal's stated goal of improving the ergonomics and discoverability of these sorts of APIs. The core team believes this is an important problem to solve before accepting this proposal.

In the review discussion, there were three broad approaches to addressing this problem:

  • Adding the ability for a protocol to specify a different type to perform contextual member lookup in
  • Adding the ability to extend a protocol existential type's metatype (P.Protocol) instead of extending all conforming types to the protocol, and expanding the scope of contextual member lookup to also include these existential types
  • Adding the ability for protocol extensions to apply same-type constraints to the Self type

Of these approaches, the core team favors the third. There are discoverability concerns using a different type to contain contextual member lookup members; SwiftUI had tried this with its StaticMember approach, but it proved to make constants in the StaticMember difficult for users to discover. If this were made an official language feature, tooling could conceivably be updated to recognize this new delegation pattern, but that tooling work would nonetheless need to be added to the scope of the proposal.

On the other hand, the core team acknowledges the idea of extending existential types independent of conforming types is an appealing one; it maintains discoverability of the API by associating constants directly with the protocol, without polluting the namespaces of unrelated conforming types. However, it would also mean that those constants are also not available even on the "correct" concrete type. Extending existential types would also generally be a larger expansion of the model; although the core team supports exploring extensions of structural types as an eventual direction for Swift, it's a feature that needs broader consideration.

Prior to review, both of these approaches had also been explored in the original pitch, which led to SE-299 leveraging existing protocol extensions as an incremental improvement that does not require large model changes to achieve the desired effects. The core team is supportive of this incremental approach, and we see the idea of applying same-type constraints to Self in a protocol extension as a promising way of addressing the biggest shortcoming of this proposal while maintaining its incremental nature. Although in other contexts, an extension P where Self == ConcreteType seems like a long-winded way of saying extension ConcreteType, in the case of generic contextual member lookup, it expresses what we want: name lookup should be able to find these members in a context where we only know a type conforms to the protocol, but it should only actually be available when the concrete type matches the type of the member. The core team would like to see this proposal revised to include Self same type constraints.

Thanks to everyone who participated in the review!

21 Likes

Sorry, as a point of clarification: A revised design where this feature requires same-type constraints would also define away the special-cased inference rules described below, yes?

Did the core team have comments/thoughts on the feedback received during review regarding differences in the rules surrounding type inference between this proposal and existing dot expressions, and was any part of the core team's decision influenced by considerations along those lines?

3 Likes

I'm a bit confused here—I thought this ability already existed? What's being suggested here that isn't exhibited by the following?

protocol P {}

struct S: P {}
struct R: P {}

extension P where Self == S { // OK
    static var s: S { S() }
}

print(S.s) // OK
print(R.s) // Error

This is consistent with the way contextual lookup works when type variables are bound in other positions. For example:

protocol P {}

struct Foo: P {}
struct Bar: P {}

struct Q<T: P> {
  static var foo: Q<Foo> {
    print("foo via P")
    return Q<Foo>()
  }
}

extension Q where T == Foo {
  static var foo: Q<Bar> {
    print("foo via Foo")
    return Q<Bar>()
  }
}

extension Q where T == Bar {
  var bar: Q<Bar> { Q<Bar>() }
  var baz: Q<Foo> { Q<Foo>() }
}

func test<T: P>(_: Q<T>) {}

test(.foo.bar.baz)

also prints foo via Foo. Encouraging and/or requiring static members found in a top-level generic context to use Self same type constraints would nonetheless reduce the potential for this behavior.

The core team would like the proposal to acknowledge the issue of concrete type namespace pollution, and include this in its examples as the encouraged way of avoiding that problem. We should also consider whether such a constraint ought to be required to find members in generic context.

4 Likes

In this case, isn't this because generic parameter inference is specifically guided by availability of down-chain members? I.e., we pick Q<Foo>::foo over Q<T: P>::foo only because the first one yields a solution and the second one doesn't. The presence (or not) of Q<T: P>::foo doesn't influence the type checking of the final expression beyond introducing another overload:

struct Q<T> {} // remove P conformance constraint and `foo` member from struct body
... // Everything else remains the same
func test<T>(_: Q<T>) {} // remove P conformance constraint

test(.foo.bar.baz) // foo via Foo

This strikes me as an important difference between the two cases. The proposal would introduce situations where removing or changing a declaration could break expressions which do not reference the declaration at all. AFAICT, there are not examples of that behavior today.

Though, as you note, requiring same type constraints on Self (and also, requiring the member itself to have type Self) would make this issue moot.

Got it, thank you for clarifying!