SE-0299 Extending Static Member Lookup in Generic Contexts

I'm with @jrose that the static extensions propagating to unrelated types is not pretty.

But since protocols can be, and are, used similarly to the cases of an enum to offer alternatives for the programmer to choose from, it would be nice if the way to pick from those options would work the same way at the call site as it does for enums. And having autocompletion for the "cases" of different protocol conformances would be a big win in developer approachability I think.

I don't think we need to (or should) have existentials for solving the problem of this proposal which is to have toggleStyle(_:) work as a generic function:

extension View {
    public func toggleStyle<S: ToggleStyle>(_ style: S) -> some View
}

I wonder if we could instead borrow the some syntax for marking the extensions that the implicit member lookup would be (only) extended with? Like all of this:

extension some ToggleStyle {
    public static let `default` = DefaultToggleStyle()
    public static var `switch`: SwitchToggleStyle { SwitchToggleStyle() }
    public static func checkbox(_ checkMarkStyle: CheckMarkStyle = .tick) -> CheckboxToggleStyle { ... }
}

Toggle("Wi-Fi", isOn: $isWiFiEnabled)
    .toggleStyle(.checkbox(.power))

But I'm not very happy about adding syntax for one-off needs like this, and I can't think what else extension some T { ... } would be good for. Or maybe developer approachability indeed makes it a need big enough!

My initial thoughts were 'finally', but after further consideration and reading the discussion I have to agree with the others that allowing static members where they make zero sense on the concrete type is going to lead to undesirable side effects.

I do like this solution and I sympathise with the un-obvious nature of it. But I think as long as the error message is clear and descriptive enough as to why this restriction is in place, it's a possible avenue to a solution.

I'm wondering if we could go further with this idea and make it implicit based on the static member type? So building on the SwiftUI example:

extension ToggleStyle {
    public static var `default`: DefaultToggleStyle { get }
    public static var `switch`: SwitchToggleStyle { get }
    public static var checkbox: CheckboxToggleStyle { get }

    public static var description: String { get }
}

If the type (e.g SwitchToggleStyle) conforms to ToggleStyle then a restriction is placed upon its access to only be accessed from the top level protocol and that specific concrete type. Placing this restriction on the access would prevent cross type access in a generic context. This also wouldn't effect other static members used for other legitimate reasons which type isn't restricted by the containing protocol.

This would still work:

ToggleStyle.switch
SwitchToggleStyle.switch
ToggleStyle.description
SwitchToggleStyle.description
CheckboxToggleStyle.description
// etc.

But an error would be thrown when a user tried to do this:

SwitchToggleStyle.checkbox
CheckboxToggleStyle.switch
DefaultToggleStyle.switch
// etc.

I'm not entirely sure on the possibility of doing this, but since we have the concrete type and protocol information available as part of the member it seems like a valid solution and handles some of the concerns expressed so far.

3 Likes

Something like this is really needed. It is one of the issues I run into most often when designing API.

In addition to this, I think the ideal solution would be to allow static members which exist ONLY on the metatype:

protocol extension ToggleStyle {
    public static let `default` = DefaultToggleStyle()
    public static var `switch`:SwitchToggleStyle() {SwitchToggleStyle()}
    public static func checkbox(_ checkMarkStyle: CheckMarkStyle = .tick) -> CheckmarkToggleStyle
} 

Right now, I end up having to do the following a lot:

protocol AProtocol {
    func foo()
}

struct A:AProtocol {
    var definition:AProtocol

    init(_ definition:AProtocol) {
        self.definition = definition
    }

    func foo() {
        definition.foo()
    }

   static let myConcreteA = A(MyConcreteA())
   static let anotherA = A(AnotherA())
   ///etc...
}
1 Like

I somewhat dread the complexity of the solution by @jrose to the namespace pollution problem. Spelled in full it looks like this:

extension ToggleStyle where Self = DefaultToggleStyle {
   public static let `default` = DefaultToggleStyle()
}
extension ToggleStyle where Self == SwitchToggleStyle {
   public static let `switch` = SwitchToggleStyle()
}

This API is complicated to decipher.


What happens if a static member refers to Self? For instance:

extension ToggleStyle {
    static func printMe() { print(Self.self) } 
}

ToggleStyle.printMe() // what does this do?

Edit: the answer is that this particular static member will not be made available on ToggleStyle because it does not have a return type conforming to ToggleStyle (which would be used as Self). I somewhat missed this implication when reading the proposal. I'm not sure I like the availability to be dependent on the return type.


There's been some talk before about the concept of a "factory initializer" for classes and protocols. The idea is the initializer would be called on the base but is allowed to return an instance of a subclass. I think the pattern we have here is similar: adding static members on the base to make initialization convenient, without polluting types conforming to the protocol.

It could be written like this:

extension ToggleStyle {
   public factory let `default` = DefaultToggleStyle()
   public factory let `switch` = SwitchToggleStyle()
}

and the factory keyword would confer them a different behavior from static members. The behavior should be to make them available only on ToggleStyle and not on concrete types conforming to the protocol. This would make ToggleStyle.default valid while DefaultToggleStyle.default would be invalid.

(If we later introduce factory initializers, they could share that same behavior of only be available on the base.)

5 Likes

SwiftUI is a leading motivation here but it has already shipped with the suboptimal initialiser based API. So, I don't really see a need to rush a compromised solution rather than 'do it properly'. And what @jrose highlighted is a pretty big deal IMO.

Obviously, SwiftUI isn't the only beneficiary — but everyone else has been making do to date also. Echoing other comments in this thread, there prolly needs to be new syntax to specify 'only make this static member available on the protocol type itself and not anything that conforms to it'.

When we've discussed this on the forum before, I always found the idea of extension Protocol.Type { ... } to be a logical spelling of this. But I'm not personally wedded to it.

14 Likes

Regarding requiring extensions like this to constrain Self to a specific type, we believe that that issue is orthogonal to this proposal. Such extensions are already allowed in the language so it’s unclear how this behavior would be prohibited without being rather unintuitive. It seems strange that adding the constraint on Self would be required for leading dot syntax to function considering that the constraint and leading dot syntax are completely disconnected from one another conceptually.

1 Like

We agree on the ultimate goal to do it properly, but it sounds like we disagree on the tradeoffs of what is being proposed here.

Here is our thinking, beginning with the technical considerations:

  • It is already possible to declare static properties and methods on protocols today, which is not inherently bad and should not be restricted (and cannot, for source compatibility reasons).
  • The specific change proposed here is narrowly-scoped, only extending member lookup in certain generic contexts. This narrow scope was chosen, based on community feedback, to maintain maximum flexibility to explore better solutions in the future.

We have not seen much feedback yet arguing that this change, considered on its own, should be rejected because it would be harmful. On the contrary, several have noted that this feels like a natural extension of our existing lookup rules.

Most of the feedback is specifically about whether it is wise for a framework like SwiftUI to take advantage of this new change to solve problems like the style example. Our considerations related to that:

  • There is agreement in this thread that this is a problem worth solving, and we have received enough real-world feedback to consider it a serious problem.
  • There is also agreement on how the solution should look at the call site. It's important to note that the debate is solely centered around the declaration site, which is important, but to a lesser degree.
  • The declaration site can change in the future, without impacting source compatibility or introducing warnings. Frameworks like SwiftUI have several tools available to handle this, including deprecation & replacement, emitting symbols into clients so they can be revised later, etc. In other words, we're not designing ourselves into a corner.

Finally, as noted earlier, we still plan to pursue a "proper" solution in the future. However, based on responses to this proposal, there also does not seem to be complete agreement on what that solution could be, so it will require more investigation. Some of those solutions, like protocol metatype extensions, also require much deeper thought into core aspects of the design of the language.

So putting that all together, our conclusions are:

  • We can fix a serious problem today, with minimal language impact and with support for the ideal call-site syntax.
  • We can maintain flexibility to implement a proper solution in the future.
  • Frameworks like SwiftUI can migrate to future solutions in a source-compatible way, with minimal-to-zero long-term impact on its API.

We believe this is a beneficial, viable path forward.

6 Likes

The good is the enemy of the perfect, and the perfect is the enemy of the good. For me, this change means we'll see a proliferation of the "imperfect" idiom and the drawbacks that entails, and it may take longer to get the actual desired feature (which is related to features like nesting types inside protocols). It's a trade-off for sure.

8 Likes

Yes, this is definitely a trade-off. Our reasoning is that reward of more expressive/concise call site out-weights having to deal with extensions for the API authors because that's usually M-N relationship where M is significantly smaller than N in the number of authors and users.

2 Likes

I think library writers sometime have a tendency to downplay the importance of clean declarations. As long as the call site looks clean it's good right? But when something goes wrong people will refer to the declaration, and if the declaration is complex it makes troubleshooting more complex.

With a where clause, to understand how .toggleStyle(.switch) works you have to understand what a conditional declaration with where does, and then you what Self is referring to within a protocol, and then have some imagination to figure out what's the point of having this where in the first place. That's a mouthful of concepts to teach in one go. (Technically you don't have to understand it all, but you have to understand it enough to know you can ignore it.)

I'll bite. I think it's slightly harmful, but I had to think about what I wrote in my last post before realizing that. Let me know if I got something wrong in my examples; I'm extrapolating from what I understood in the proposal.

I find it very surprising that some static properties can be called on the protocol and others can't. Consider this:

protocol P {
   static var number { get }
}
struct S: P {
   static let number = 1
}
extension P {
   static var a: Int { 1 }
   static var b: Int { Self.number }
   static var c: S { S() }
   static let d = S()
}

let x = P.a // error, can't infer Self
let y = P.b // error, can't infer Self
let z = P.c // ok, Self inferred to be return type S
let w = P.d // ok, Self inferred to be return type S

Whether the static member is available on the protocol depends on its return type. I don't think that's very sound. What's actually preventing them from being called is the impossibility to infer a Self.

It's a funny thing is that you can make the x and y line above compile by adding this:

extension Int: P {
   var number: Int { 2 }
}
let x = P.a // ok, can infer Self to Int now
let y = P.b // ok, can infer Self to Int now

In practice that means that in contexts where a protocol conformance is visible members will be callable directly on P and in contexts where the conformance is not visible it won't.

And by conforming Int to P, the behaviour of b (which uses Self) becomes a bit surprising:

P.b // returns 2; Self inferred to Int
Int.b // returns 2; Self inferred to Int
S.b // returns 1; Self inferred to S

Here Int becomes the default Self when called on P simply because it's the return type, but the returned value varies depending on Self and makes little sense when called on the protocol itself. Think this is a far fetched scenario?

FixedWidthInteger.bitWidth // Self inferred to Int

That's the only concrete example I could find, but there might be others.

What would be a better rule for calling things on a protocol is that they don't use Self at all. We're only introducing this strange inference of Self because there's no syntax to make the declaration not require a Self. But I think a @notUsingSelf attribute would be better for that than the proposed inference of based on the return type.

6 Likes

The proposal intentionally doesn't allow for references with explicit protocol metatype base, because the behavior would be confusing (as determined during discussion in the pitch). We have restricted the inference to leading-dot syntax which, in our opinion, would get the most benefit and remove the confusion that same call site syntax has different behavior for concrete vs. generic functions.

1 Like

Isn't this one more inconsistency then? Until now this syntax:

let x = Type.member

has been pretty much equivalent to this when the member returns a Type:

let x: Type = .member

But for some reason, for static members of a protocol type only the later will work.

You can change all my examples to use the second form and they still behave the same.

Except for the last one. That rule successfully scrubs the FixedWidthInteger.bitWidth example because of the protocol's Self-requirement. But what happens with this?

func squared<T: FixedWidthInteger>(_ x: T) -> T? { ... }
let squaredBitWidth = squared(.bitWidth)
5 Likes

Yes, (which I advocated) could be lifted, but people disagreed on that regard and we have decided to go with the most impactful situations only.

Edit: I realized that I didn't explain what it would take to lift this inconsistency within current rules - we'd have to require result type of a member to conform to a protocol and do an implicit transformation to replace protocol with its conforming type so P.bar becomes Foo.bar.

This is outlined in the pitch too. The reason for this behavior is rooted in the language itself. To call something you'd need to have a witness, protocol requirements are witnessed by conforming types. that's why let _: Foo = .member syntax works, since the base is actually Foo.member (because base has to be equal to result), and let _: Foo = P.member doesn't because P doesn't have a witness to call. Fixing that requires re-thinking protocols and would be a much larger feature, but having this proposal accepted wouldn't impede that work in any way. The only thing that would need to happen is lifting conformance requirement from a result type of a member which is a completely transparent change.

If the result of .bitWidth is a concrete type that conforms to FixedWidthInteger this should type-check. Edit: Note that member has to be static to be accessible through metatype, which bitWidth isn’t.

This proposal doesn't break that model. I think that the original pitch has possibly caused some confusion, since it did allow for expressions of the form P.member, but IMO that's not the best way to think about this feature.

Under this proposal, when we write:

.toggleStyle(.switch)

the argument type is SwitchToggleStyle, meaning that the explicit version of this (partial) expression is:

.toggleStyle(SwitchToggleStyle.switch)

There's nothing new conceptually here with regards to how implicit member syntax works, we're just improving the compiler's ability to infer the base type through generic constraints*. This is also why I object to the conforms-to requirement for the first member—I think it's much simpler to think about this feature just in terms of improving inference rules.

On that note, something else comes to mind: does the following work?

protocol P {}
extension P {
    static var someS: S { S() }
}

struct S: P {
    var r: R { R() }
}

struct R: P {}

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

takesP(.someS.r)

If so, what is the actual base here, S or R?

*: I suppose there's a bit of nuance since, presumably, if SwitchToggleStyle declared its own switch member that shadowed the declaration the explicit version would use the switch from SwitchToggleStyle and not the switch from ToggleStyle.

Everything works according to the rules of leading dot syntax so the result has to be equal to the base plus the conformance requirement, so switching base like that wouldn't be allowed.

1 Like

Hmm as far as I can tell, all of these are satisfied in the above example—am I missing something? My expectation would be that the base is R, result type of someS access is S which conforms to P, and result type of r access is R, which is equal to the base.

1 Like

The base is going to be S since that's the result of the member .someS, it's slightly different from leading dot syntax because it propagates types forward instead of backward because there is no contextual information besides conformance requirement.

Ah, I see—so in addition to the first member conforming to the protocol, the base must be equal to both the first member type and the final member type?

IMO, this proposal does not fulfill one of its stated goals, to "make leading dot syntax behave consistently for all possible base types."

I think the status quo of "leading dot syntax doesn't work in generic contexts" is a better resting place than "leading dot syntax works in generic contexts but only if the first member and the last member have the same type" until we can address this more holistically.

1 Like

I honestly don't know what is the argument here - the result type is effectively a hole in a generic context which supposed to conform a some protocol or protocol composition. It's a requirement of a leading dot syntax that base has to be equal to result type. In non-generic cases type is inferred from the context (e.g. parameter type of a current overload) and propagated to result and then down to the base and lookup is made from there; if result of the chain turns out to be different from contextually inferred one - expression doesn't type-check. The only difference here is that the base comes from the first member instead of from context because that's the only possible place where it be inferred from to make forward progress.

1 Like

But that's a limitation of the current implementation, not a logical limitation of the information available to the compiler. In the example above, when the compiler encounters:

takesP(.someS.r)

it knows the following:

  • takesP will accept as an argument some concrete type conforming to P.
  • Every concrete type conforming to P has a static member someS of type S.
  • Every instance of S has a member r of type R.

Today, the compiler is unable to make forward progress because it cannot leave the base type partially unresolved while it works on later members in the chain, but as the programmer I can reason about that expression perfectly well:

  1. There will eventually be some type at the implicit base, equal to whatever we get at the end of the chain.
  2. Let's skip over that for now and fill it in later if possible.
  3. Even though we don't know the exact type, we know that, due to P conformance, .someS will produce a value of type S.
  4. Now, .r will produce a value of type R.
  5. That's the end of the chain, so try to go back and fill in R at the base.
  6. R conforms to P, so everything works, and the expression type checks!

Is any logical step here invalid?