SE-0299 Extending Static Member Lookup in Generic Contexts

Why isn't the knowledge that T conforms to P sufficient to know that someS will be of type S? Is there any situation where the type chosen for T will somehow result in the witness for someS not having type S? Sure, we cannot resolve the exact witness for someS until we have the fully-resolved type, but it seems to me like the exact witness is irrelevant for determining the type of the whole chain.

The proposal as-written is already introducing the back-propagation from member type to base type, so I don't really see why the back-propagation shouldn't apply to multi-member chains as well as single-member chains.

ETA: if it were possible to reference a generic function argument from within the context of the caller, I would think of takesP(.someS.r) as basically analogous to writing takesP(T.someS.r). This is a perfectly well-formed expression within the body of takesP, and the type resolves to R.

Because conforming type case override static method on a protocol, if there is a default implementation we'd have to consider that and the override so it's impossible to determine whether a particular member would return the type without actually checking.

I agree with Benjamin. The problem is that static requirements declared in a protocol are not static members on the protocol type, and we/I keep confusing them. It seems more clear to differentiate them and spell the later as:

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

This would allow dot member lookup to work to solve the SwiftUI problem, solves Jordan's top level concern about type pollution, would provide a solution to the let x = Type.member vs let x: Type = .member consistency issue (by making them both look in the metatype instead of static requirements) and would also nudge the whole existential / PAT / self conformance discussion in an interesting way.

-Chris

9 Likes

Are you talking about a situation where we additionally have something like:

extension S {
  static var someS: R { R() }
}

?

I was trying to propose semantics which sidestep this issue altogether: at the point where type inference has to use the P conformance to resolve someS (since we don't have the concrete type yet), someS would be fixed to refer to the witness for P.someS, so we couldn't later consider a different overload from whatever the concrete type ends up being.

This would be similar to how from within takesP, T.someS can only refer to the witness for P.someS, and won't use a shadowed someS member on T even if at runtime the generic argument for T has such overloads.

There is no witness for P.someS that's exactly the reason why conformance requirement on the member result is necessary.

I pulled this discussion into private with @xedin to reduce noise in the review thread, and I think I now have a much better grasp of the semantics here as well as my discomfort with them! Just going to drop a brief summary here in case anyone else faced the same confusion as myself. :slightly_smiling_face:

@xedin provided a useful example which I think is illustrative:

protocol P {
  static var foo: Foo {
    print("foo via P")
    return Foo()
  }
}

struct Foo : P {
  static var foo: Bar {
    print("foo via Foo")
    return Bar()
  }
}

struct Bar : P {
  var bar: Bar { Bar() }
  var baz: Foo { Foo() }
}

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

test(.foo.bar.baz)

The main gap in my understanding of the semantics here was not realizing that that the static member lookup on the protocol metatype is only used to determine the base type of the expression—after that is determined, there's a second-pass lookup of the static member on that base type, which may not end up referencing the same declaration.

So in the example above, the expression test(.foo.bar.baz), prints "foo via Foo", since .foo references Foo::foo and has type Bar. The P::foo declaration is only used for finding the Foo type to insert at the implicit base, and does not appear in the type-checked expression.

IMO, having the type inference be influenced by the type of a declaration that doesn't end up in the final expression is pretty unintuitive. So I'm still a -1 on these semantics.

ETA: and of course, @xedin, correct me if it seems like I've misstated anything here.

Thanks for writing up a summary of our discussion! I'm okay if the semantics are changed to use declaration reference found on protocol metatype (this is actually what implementation as-of-today does), but I'm not okay with picking base via backtracking from the result of a chain, besides being confusing I don't see a good way to implement that in the solver.

2 Likes

I think that for sure results in more explainable outcomes, personally! Though I'd love to hear others chime in as well—I'll bow out of this thread for a while now that I feel like I actually know what's going on. :sweat_smile:

Totally reasonable! I'll have to decide if I want my semantics badly enough to try to implement them myself. :grinning_face_with_smiling_eyes:

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
Terms of Service

Privacy Policy

Cookie Policy