SE-0299 Extending Static Member Lookup in Generic Contexts

Hello Swift community,

The review of SE-0299, Extending Static Member Lookup in Generic Contexts begins now and runs through February 1, 2021.

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to the review manager. When emailing the review manager directly, please keep the proposal link at the top of the message.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at

https://github.com/apple/swift-evolution/blob/master/process.md

Thank you,

Joe Groff
Review Manager

22 Likes

A downside that I think should be explicitly called out is that using this idiom means these static members end up on all implementors of the protocol: that is, SwitchToggleStyle.checkbox becomes legal. A possible workaround would be to lift the restriction on fully-constrained protocol extensions where Self == SwitchToggleStyle. It’d still be a choice to add that constraint, though, and like StaticMembers it wouldn’t be immediately obvious.

I think I ultimately don’t think this is a good idea; its main benefit is brevity with a side of discoverability and that doesn’t seem a good enough reason to encourage an idiom that muddies the API of adopters.

11 Likes

I would like this, because I wouldn't abuse it, but as Jordan says, it breaks the Liskov substitution principle, so it shouldn't be done. :disappointed:

(Now, allow for the final keyword to be used, to prevent that, and I think you might have a solution.)

However, we've always needed to be able to refer to protocols and generic types as non-derivable namespaces, and erased types. Not having that feature has resulted in the AnyX mess of a pattern.

Looks to me like the linked "would require significant changes to the implementation of protocols" thread mostly is what needs to be done. It just needs to apply to generic types as well as protocols.

This is a great addition to the language! I always thought it was a feature that was missing and using SwiftUI the problem became even more evident. The styles discoverability is a nightmare and this is very sad because the idea of using protocols is really good. With this problem solved, protocol-oriented programming becomes an even better idea.

1 Like

In some ways, as I mentioned in the pitch thread, this mostly feels like a bug fix to me. From my perspective as a user, there's not a great reason why the compiler shouldn't be able to find the switch member from ToggleStyle on S in the .toggleStyle(.switch) call. If it were possible to have the compiler perform member lookup on a yet-unbound generic parameter (based on the available constraints), then this feature would naturally fall out from that:

So, while I'm sympathetic to @jrose's concern about the motivation for this proposal, to me this feels like something that should "just work" given our existing rules for implicit member expressions.

This is also the reason that I oppose the restriction from the proposal:

  • Require that the result type of a member declaration conforms to the declaring protocol.

As far as I can tell, there isn't any reason that we need this restriction, and we don't have such a restriction for existing implicit member chains. IMO, with the following setup,

protocol P {}
extension Int: P {}
extension P {
  static var string: String { "" }
}

extension String {
  static var int: Int { 0 }
}

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

it should be perfectly valid to write both of the following:

takesP(.string.int)
takesInt(.string.int)

The type checking for the takesP expression would conceptually proceed as:

  1. Look at the contextual type for .string.int (it's T: P).
  2. Look up the .string member on T (it must be the T.string member guaranteed by the P conformance, with type String).
  3. Look up the .int member on String (it has type Int).
  4. The result of the member chain must be equal to the base, so the base type is Int, which conforms to P, and we are done.

With the conforms-to restriction for the first member of the chain in a generic context, this feels like too much of a special case to me, so I am -0.5 on the proposal in its current form. I would be a +1 if the conforms-to restriction were lifted.

3 Likes

Would you feel better about it if it was limited to extensions that constrain Self to a concrete type?

I think this principle is already then case when it comes to protocols and conforming types unless data is accessed through an existential type which represents a conforming type.

I think we have went through this extensively in the pitch thread but just to re-hash - it's impossible to access members on a protocol metatype, so in order to find a valid witness and satisfy unresolved member requirements, we need this requirement. Reference is re-written as if the member was referenced on a conforming type, also base and result are required to be equal in unresolved member chains and the rule serves to enforce that requirement.

Without that requirement we'd end up having String.string.int which results in two issues - String doesn't have a member string and String is not equal Int (as required by a member chain), so we'd have somehow retroactively "fix" the base to be Int which, IHMO is less straight-forward than requiring that base conform to P even though some of the expressions aren't going to be supported (which might not even be that useful in practice).

5 Likes

I checked. We don't actually have official guidelines on this? What should the API naming rule be?

To me, an enumeration case, and a get-only type property that returns an instance of that type, represent the same idea.

e.g. I would expect CheckboxToggleStyle.default to be an instance of the "default checkbox toggle style", whatever that means. And SwitchToggleStyle.default means "a default switch toggle style instance".

DefaultToggleStyle.default… I don't know. I can't see that being a useful name. But it certainly shouldn't be the same as the other two, and under this proposal, it would be. We could start the convention of always overriding/specializing these sort of properties names, but I don't think they're useful in the concrete types.


Combine started the convention of grouping types under a pluralized name of a protocol. I don't like it (though given what tools we have, I think it's reasonable); it's not as good as what this proposal is going for. But at least it doesn't produce lies. Could be a temporary solution.

Publishers | Subscribers | Subscriptions

public enum ToggleStyles {
  public typealias Default = DefaultToggleStyle
  public typealias Switch = SwitchToggleStyle
  public typealias Checkbox = CheckboxToggleStyle
}
.toggleStyle(ToggleStyles.Default())
1 Like

Yeah we don't need to go through the whole discussion again, I just wanted to raise my objection in the review thread since this element of the proposal has remained unchanged.

This sounds like it's conflating implementation concerns with the language model, IMO. I realize that the current implementation doesn't allow us to perform lookup without a concrete type (even if we have conformance constraints that could be used), but I'm not convinced that this limitation should be surfaced to the user via artificial restrictions on which members can be accessed, even if that means we have to do a bit of post-hoc rewriting of the member chain once we actually figure out what type at the tail is.

3 Likes

Both things I have mentioned are language limitations (in case of protocol metatypes, potentially, a temporary one). I think protocol conformance requirement makes the semantics clear but I am fine to agree to disagree :)

2 Likes

Sorry, that wasn't super clear of me—the implementation concern I was referring to was the fact that, from my understanding, the step #2 that I wrote above is not possible today:

because T is not something we can perform member lookup on before it is bound to a concrete type. The way I see it, the compiler and the user both know that if there's a valid solution to the system then a) T will eventually be bound to a concrete type, and b) that concrete type will have a member T.string of type Int. So all the information is already there to make arbitrary members of T accessible via implicit member expressions, it may just require some backtracking on the compiler's part to go back and fill in the concrete base type once we've resolved the entire chain.

Yeah, I've said my piece so I'll let this point lie for now. :slight_smile: In any case, it seems like a restriction that we could go back and lift later in a source-compatible way unless I'm missing something...

Great work on the part of the authors, excited to see this make it to review!

Two nuances - T is something the chain result supposed to be convertible to, so we can’t really bind chain result to T until it’s resolved, and what we are using as a base for a lookup is actually a metatype of some type.

I understand your point and I want to note that we wouldn’t need any of the aforementioned acrobatics if we could just access members on protocol metatypes...

2 Likes

Better, if not completely happy. It’s still a long spelling and still doesn’t quite match the intent. Generic adopters would also need the where clause on methods instead until we get parameterized extensions, making it just a little extra janky.

After reading @jrose's first comment, I wonder if something like this might be a better solution:

public protocol ToggleStyle { ... }

public struct DefaultToggleStyle: ToggleStyle { ... }
public struct SwitchToggleStyle: ToggleStyle { ... }
public struct CheckboxToggleStyle: ToggleStyle { ... }

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

extension any ToggleStyle {
    public static let `default` = DefaultToggleStyle()
    public static let `switch` = SwitchToggleStyle()
    public static let checkbox = CheckboxToggleStyle()
}

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

Perform lookup on an existential, for its static members declared in an extension.

However, it might be a long time before we can use existentials like this in Swift.

That’s the current behavior; the one we need to provide this feature without affecting adopters is performing lookup on the protocol itself rather than the existential.

1 Like

I'd love to see this fixed, I agree it is a problem we should address.

  • I agree with the proposal that we should not do a global lookup among all the conforming protocols, that will make simple syntax have significant compile time hit.
  • Outside of the SwiftUI use case as explained, it makes sense to enable this for consistency with the rest of the language. We already support static members on extensions of protocols and looking them up makes a lot of sense.
  • I agree with Jordan that these members getting inherited onto all of the implementations of the protocol would be a bad thing for the SwiftUI case in particular.

I wonder if there is a way to embrace the general idea here but extend it a bit to solve the SwiftUI issue. If we make the compiler look for 1) declared static members, and fall back to 2) looking through a typealias of a specific name for more static members, then we could be on to something. I'm not sure exactly what to name that typealias, but I'm suggesting that instead of:

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

That we might want to allow:

enum ToggleStyleDotLookupStuff {
  public static var `default`: DefaultToggleStyle { get }
  public static var `switch`: SwitchToggleStyle { get }
  public static var checkbox: CheckboxToggleStyle { get }
}

extension ToggleStyle {
  public typealias DotLookupStuff = ToggleStyleDotLookupStuff
}

Which could also be declared as:

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

This two phase resolution is similar to how dynamic member lookup works.

-Chris

3 Likes

I couldn't quite grasp the extend of this extension. Does the conforming type also obtain the static member? Can I do SwitchToggleStyle.switch?

Yes, if declared directly on the protocol without where Self == <conforming type> the static member would be reachable for all of the conforming types.

3 Likes

Can this be compiler generated? So whenever we declare this:

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

The compiler wil auto generate this:

enum Static_ToggleStyle: ToggleStyle {
    public static var `default` = DefaultToggleStyle()
    public static var `switch` = SwitchToggleStyle()
    public static var checkbox = CheckboxToggleStyle()
}

And the lookup will be restricted to this type.

Thanks for the feedback, everyone!

I'd like to reiterate that we fully agree that this is not the ideal solution for the SwiftUI example. This motivates the purposefully minimal approach being proposed here, in order to maximize our flexibility to pursue better solutions in the future.

In that spirit, I'd recommend separating concerns with the specific changes being proposed to the language (which would likely be permanent), apart from the wisdom of leveraging those changes to improve a framework like SwiftUI (which can be changed in the future, if necessary). We're interested in hearing both, but separating those will help with evaluating the feedback. Many responses have already done this — thank you!

Yes, this is a known caveat, and we agree that this should be explicitly called out in the proposal. We'll amend the text of the proposal to include that, thank you for drawing attention to it.

This is likely the approach that a framework like SwiftUI would take, to minimize the proliferation of these symbols.

We fully agree this is not ideal, but it's worth nothing that this is significantly more approachable than a StaticMember-like solution, both for API authors and for clients. API authors always have a greater burden to understand how to design a good API, but StaticMember was rejected specifically because of the confusion it caused for clients, making it a bad API.

Clients would have a significantly better experience with the proposed approach over both the status quo and a StaticMember-like solution.

I don't think the discoverability benefit should be minimized; I'd say the main benefit instead is discoverability with a side of reduced repetition.

The primary goal here is not to improve aesthetics (that's a bonus), but to solve a real issue impeding developers from fully utilizing our APIs. We've received a significant amount of real-world feedback over several years citing the subpar developer experience that comes from the lack of discoverability for these types. Further, these practical issues end up influencing API design, as authors try to avoid generic, protocol-oriented solutions to circumvent these issues, which we think is worse than the tradeoffs proposed here.

This is an interesting idea, which could be implemented additively on top of what is proposed here.

Our current feeling is that the most intuitive long-term solution would be to allow extensions on the protocol's metatype and include those in the lookup. What do you think?

If that is the right future direction, our concern with your suggestion is that it would be adding additional magic to the compiler that would ultimately become obsolete, and is a little less straightforward to an API author or client developer in terms of how it works. With the proposed solution, the location of the static member declaration has less-than-ideal implications, but it has the benefit of being where most developers intuitively expect these declarations to live.

4 Likes