SE-0299 Extending Static Member Lookup in Generic Contexts

I'm now thinking that maybe the proposal will be good, with one change, and a different convention.


The change:

We've gone over why that's problematic. Can it go elsewhere? I think that having a Swift-private type* (maybe even the same one for every use case) would be fine. A programmer will never need to use it.

Whether this works is dependent on how relaxed the rules are going to be, from the current "what comes after the leading dot must match what would come before the dot, exactly".


The convention:

Above, there are instances of using where Self == clauses to minimize incorrect member inclusion. What if we instead put the static members into an extension for types that don't actually exist (in a usable location)? The result would be complete elimination of incorrect member inclusion.

It seems to me that the code below still follows the laid-out rule…?

/// A protocol that is `enum`-like, 
/// in that the types that implement it are groupable options.
public protocol ProtocolWithCases { }

public extension TabViewStyle where Self: ProtocolWithCases {
  static var `default`: DefaultTabViewStyle { .init() }

  static func page(indexDisplayMode: PageTabViewStyle.IndexDisplayMode) -> PageTabViewStyle {
    .init(indexDisplayMode: indexDisplayMode)
  }

  #if os(watchOS)
  static var carousel: CarouselTabViewStyle { .init() }
  #endif
}

I think it's better to have something like a ProtocolWithCases that we all use for the same purpose, but any restriction to imaginary (except to the compiler) types will do.


[*] A possible implementation, where synthesized extensions get grouped similarly to source:

private enum _ProtocolWithCases<Case>: ProtocolWithCases { }

// Extensions would get synthesized by the compiler every time a 
// new static member is added to `ProtocolWithCases`.
extension _ProtocolWithCases: TabViewStyle where Case: TabViewStyle {
  static func _makeView<SelectionValue: Hashable>(
    value _: _GraphValue<_TabViewValue<Self, SelectionValue>>,
    inputs _: _ViewInputs
  ) -> _ViewOutputs {
    fatalError()
  }

  static func _makeViewList<SelectionValue: Hashable>(
    value _: _GraphValue<_TabViewValue<Self, SelectionValue>>,
    inputs _: _ViewListInputs
  ) -> _ViewListOutputs {
    fatalError()
  }
}

You would have to elaborate on how call site for this idea is going to look like since the idea is to hide _ProtocolWithCases (if I understand correctly), so I'm not sure how it's going to be used?...

No change to the proposed usage. How leading dot syntax actually works is an implementation detail that can be safely hidden.

Then I don't really understand what this is going to get us besides more auto-generated code which wouldn't even be visible to the users...

The proposal, without modifications, requires a convention of usable nonsense, in order to achieve fluent call site syntax.

DefaultToggleStyle.default
SwitchToggleStyle.switch
CardButtonStyle.card
PageTabViewStyle.page(indexDisplayMode:)

As far as I know, there's no way for the nonsense-naming to be avoided. But we can put it into a type that can't be used in source.

How about we just exclude results like that code completion since these members are supposed to be accessed via a particular syntax. In combination with Self == <Type> declaration there is going to be no visible effect for the API users unless they actually write out DefaultToggleStyle.default explicitly.

Sounds secret enough to be practically usable. Somebody will probably run into a naming collision, but I bet that would have been a confusing name anyway.


All this said, I do think that being able to discover these types outside of the context of generic methods would be helpful. Their initializers represent the same idea as enumeration cases (without exhaustibility).

Chris's idea here is good, but it, along with the original StaticMember, represents a larger scope than what we're specifically dealing with, which is an alternate way of accessing initializers.

You can almost represent what's needed, right now, with typealiases:

public enum TabViewStyleCase {
  public typealias `default` = DefaultTabViewStyle

  #if !os(macOS)
  public typealias page = PageTabViewStyle
  #endif
}

public extension TabViewStyle {
  typealias Case = TabViewStyleCase
}
.tabViewStyle(TabViewStyle.Case.default())
.tabViewStyle(TabViewStyle.Case.page())
.tabViewStyle(TabViewStyle.Case.page(indexDisplayMode: .always))

Just add compiler magic to make it so we can drop

  1. the type name, when using some specific protocol-nested typealias (Case, here)
  2. the parentheses when not using parameters

And ideally, you'd then do that code completion hiding so that concrete types don't appear to have access to the nested typealias.

If we have to choose one, then I think I would prefer having it on the meta type.

The factory let and factory func syntax was compelling. It doesn't have to be the word factory, but I like the idea of decorating the function (as opposed to the extension)

(To be clear, I would also happily accept the extension MyType.Type if that is what will get us this feature. As I said above, I run into the need for this near-constantly)

Could you clarify why this is a language feature rather than, say, a SourceKit feature? Say something along the lines of:

  1. You type a leading dot at an argument position where the type is generic.
  2. If there are no protocol requirements, SourceKit does nothing.
  3. If there are protocol requirements, SourceKit looks up which types are declared to conform to protocol requirements and pass on the list to the editor for completion.
  4. When a completion is chosen, the editor deletes the previous dot and inserts the selected type.

I'm reading through the comments here and re-reading the proposal, and it seems like all of the discussion is focused on describing how we could make this work as a language feature. As I understand it, the primary benefit is in helping someone write code [speaking as someone who just started learning SwiftUI last week and hit this issue of "wait what do I put here???"], and the benefit for readability is not that big (IMO).

Implementing this as a SourceKit feature has three benefits:

  1. We don't need to change the language. If people still feel that this really impairs readability, then we can revisit the issue at a later stage.
  2. It avoids the weird edge cases that other people have pointed out, such as this one.
    func squared<T: FixedWidthInteger>(_ x: T) -> T? { ... }
    let squaredBitWidth = squared(.bitWidth) // picks T == Int
    
  3. Library maintainers don't have the additional burden of such similar static members for protocols they are vending.
6 Likes

If these members are defined on the metatype (ToggleStyle.Type) rather than the existential type (any ToggleStyle), then they shouldn't also still be static members, should they?

But besides that point, I do think the approach you mention is a superior one to the proposal's.

I would echo the general tenor of the feedback here that, while the problem is worth tackling, I don't think the proposed solution meets the challenge as it introduces some difficult-to-understand rules to an already difficult-to-understand feature (dot expressions). I honestly cannot imagine any Swift user besides the proposal's authors being able to explain correctly how dot expressions work in full if the proposed design is adopted. The rest of us will have to apply "approximations" of the true rules that sometimes don't align with reality.

11 Likes

@Varun_Gandhi I think the only way to accomplish what you are proposing would be implement a type-checker feature and expose it through SourceKit only requests. SourceKit itself can't possibly implement all of the checks required here - it would have have to transitively pass protocol requirements through chains of members to the base, handle overloaded calls, disambiguate etc. and methods would still have to be declared in a way we propose which is the main point of contention here.

2 Likes

Personally, I am not a fan of the whole idea based on the issue @jrose pointed out. At a conceptual level, I think, a protocol's members need to make sense for all conforming types. However, this proposal encourages code patterns which break that. Is that a serious issue? I've been trying to rack my brain, but I haven't been able to come up with compelling examples.

Moreover, if you work with classes instead of protocols, the following works today:

class C {
    static var d: D = D()
}
class D: C {}
func f<T: C>(_: T) {}
let _ = f(.d)

So, it's a bit puzzling that this doesn't work when you use value types + protocols instead of classes. :slight_smile: In that sense, I think this change makes the language more consistent [and arguably means that we should prefer this spelling in the proposal over the ones other people have suggested.]

Given that, on deeper introspection, my objections don't have compelling examples (apart from the .bitWidth one earlier in the thread, which is an unfortunate casualty), but are based more on an aesthetic judgement (I wouldn't write such code and I wouldn't want other people to write such code), I would like to withdraw my previous objections to the proposal. I think it's fine.

2 Likes

Thanks, @Varun_Gandhi! Also note that, as you mentioned, we have exactly the same issue with classes as with protocol declarations here - static var d would be propagated to all subclasses or C so if there is no problem with that, to enable leading-dot syntax, I don't know why it should be any different for protocols.

Just to clarify, this works today because C here carries a witness for d since it's a concrete class and : C is just a subtype relation, not a conformance.

Right, I get that, but they do share some similarities in syntax and behavior (subclassing doesn't have different syntax), so I think the same syntax working for protocols makes sense. :+1:

I understand, just wanted to clarify since you mentioned that it's puzzling :slight_smile:

Just to build on @Varun_Gandhi's point a bit further, I'll also note that the following compiles using classes, even though the equivalent construction using protocols wouldn't compile even under this proposal:

struct Other {
    var d = D()
}

class C {
    static var other = Other()
}
class D: C {}
func f<T: C>(_: T) {}
let _ = f(.other.d)

In this example C would be picked for T and base is going to be C.Type. Protocols would have to get multiple features, including self-conformance when associated types are involved, for this to work. Once language is expended to support that it would be possible for leading-dot syntax to be consistent for both conformance and subtyping.

2 Likes

I came here to ask about this. I don't quite see how this could work without something like that being autogenerated? I mean the upper protocol declaration just says that ToggleStyle needs to have a static var that returns a member of CheckboxToggleStyle, but wouldn't there also have to be an implementation?

Thanks for participating in the review, everyone! The core team has decided to return this proposal for revision to address the problem of type namespace pollution.

1 Like
Terms of Service

Privacy Policy

Cookie Policy