[Proposal] Static member lookup on protocol metatypes

Static member lookup on protocol metatypes

Introduction

Using static member declarations to provide semantic names for commonly used values is an important tool in API design, reducing type repetition and improving call-site legibility. Currently, when a parameter is generic, there is no effective way to take advantage of these facilities. This proposal aims to relax restrictions on accessing static members via protocol metatypes to afford the same call-site legibility to generic APIs.

Motivation

Background

Today, Swift supports static member lookup on concrete types. For example, SwiftUI extends types like Font and Color with pre-defined, commonly-used values as static properties:

extension Font {
  public static let headline: Font
  public static let subheadline: Font
  public static let body: Font
  ...
}

extension Color {
  public static let red: Color
  public static let green: Color
  public static let blue: Color
  ...
}

SwiftUI offers view modifiers that accept instances of Font and Color, including the static values offered above:

VStack {
  Text(item.title)
    .font(Font.headline)
    .foregroundColor(Color.primary)
  Text(item.subtitle)
    .font(Font.subheadline)
    .foregroundColor(Color.secondary)
}

In the example above, using the fully-qualified accessors for the font and color properties, which include the Font and Color type names, is redundant when considering the code in context: we know from the font and foregroundColor modifier names that we’re expecting fonts and colors, respectively, so the type names just add unnecessary repetition.

Fortunately, Swift’s static member lookup on concrete types is clever enough that it can infer the base type from context, allowing for enum-like “leading dot syntax” (including a good autocomplete experience). This improves legibility, but without loss of clarity:

VStack {
  Text(item.title)
    .font(.headline)
    .foregroundColor(.primary)
  Text(item.subtitle)
    .font(.subheadline)
    .foregroundColor(.secondary)
}

The Problem

Swift static member lookup is not currently supported for members of protocols in generic functions. For example, SwiftUI defines a toggleStyle view modifier with the following declaration:

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

which accepts instances of the ToggleStyle protocol, e.g.

public protocol ToggleStyle {
  associatedtype Body: View
  func makeBody(configuration: Configuration) -> some View
}

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

Today, SwiftUI apps must write the full name of the concrete conformers to ToggleStyle when using the toggleStyle modifier:

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

However, this approach has a few downsides:

  • Repetitive: Only the “Switch” component of the style name is important, since we already know that the modifier expects a type of ToggleStyle.
  • Poor discoverability: There is no autocomplete support to expose the available ToggleStyle types to choose from, so you have to know them in advance.

These downsides are impossible to avoid when the parameter in question is generic. This actively discourages generalizing functions, which goes against what is considered to be good Swift design. API designers should not have to chose between good design, and easy to read code.

Ideally, we could extend these same syntactic improvements to support static member lookup on generic types with known protocol conformances, like so:

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

Existing Workarounds

When SwiftUI was still in beta, it included a workaround for this language limitation in the form of the StaticMember type. In a prior pitch, Matthew Johnson called out how this is a general-purpose problem, demanding a general-purpose solution, instead of framework-specific solutions like StaticMember:

protocol ColorStyle {
  typealias Member = StaticMember<Self>
}

struct StaticMember<Base> {
  var base: Base
  init(_ base: Base)
}

extension StaticMember where Base: ColorStyle {
  static var red: RedStyle.Member { .init(.init()) }
  static var blue: BlueStyle.Member { .init(.init()) }
}

extension View {
  func colorStyle<S : ColorStyle>(_ style: S.Member) -> some View {
    ...
  }
}

MyView().colorStyle(.red)
MyView().colorStyle(.blue)

In this example, the type StaticMember serves no purpose outside of achieving a more ideal syntax elsewhere. Its inclusion is hard to comprehend for anyone looking at the public facing API, as the type itself is decoupled from its actual purpose.

SwiftUI removed StaticMember before exiting beta for exactly that reason: developers were commonly confused by the existence, declaration complexity, and usage of StaticMember within the framework. This is a strong example that this is fundamentally a language design problem which should not be worked around, but solved.

Proposed solution

In order to improve call site ergonomics of the language, and make leading dot syntax behave consistently for all possible base types, we propose partially lifting the current limitation placed on referencing of static members from protocol metatypes. Specifically, by allowing static members declared on protocols, or extensions of such, to be referenced if their result type conforms to the declaring protocol.

We propose partially lifting this restriction as an incremental step forward which doesn’t require addressing significant changes to the implementation of protocols. It would also allow for the possibility of lifting the requirement completely in the future without obsoleting any language changes made to work around the problem in the interim.

Detailed design

The type-checker is able to infer any protocol conformance requirements placed on a particular argument from the call site of a generic function. In our previous example, the toggleStyle function requires its argument conform to ToggleStyle. Based on that information, the type-checker should be able to resolve a base type for a leading dot syntax argument as a type which conforms to the ToggleStyle protocol. It can’t simply use the type ToggleStyle because only types conforming to a protocol can provide a witness method to reference. To discover such a type and produce a well-formed reference there are two options:

  • Do a global lookup for any type which conforms to the given protocol and use it as a base;
  • Require that the result type of a member declaration conforms to the declaring protocol.

The second option is a much better choice that avoids having to do a global lookup and conformance checking and is consistent with semantics of leading dot syntax, namely, the requirement that result and base types of the chain have to be equivalent. This leads to a new rule: if the result type of a static member conforms to the declaring protocol, it should be possible to reference such a member on a protocol metatype by implicitly replacing the protocol with a conforming type.

Note: “result type” is used loosely here - the type-checker is allowed to look through function types and optionals to infer the actual type to use as the base of the chain. This make it possible to form partial applications and optional chains with static members.

This approach works well for references with and without an explicit base, let’s consider an example:

protocol Style {
}

struct BorderedStyle : Style {
}

extension Style {
  static var bordered: BorderedStyle { BorderedStyle() }
}

let style = Style.bordered

func applyStyle<S: Style>(_: S) {
  // ...
}

applyStyle(.bordered)

In both cases the reference to the member .bordered is re-written to be Bordered.bordered in the type-checked AST. For references with an explicit base, such a transformation is straight-forward because the protocol type is known in advance, but for unresolved member chains it’s more complicated because both the base and result type must be inferred from context.

For cases like applyStyle(.bordered), the type-checker would attempt to infer protocol conformance requirements from context, e.g. the call site of a generic function (in this case there is only one such requirement - the protocol Style), and propagate them to the type variable representing the implicit base type of the chain. If there is no other contextual information available, e.g. the result type couldn’t be inferred to some concrete type, the type-checker would attempt to bind base to the type of the inferred protocol requirement.

Member lookup filtering is adjusted to find static members on protocol metatype base but the Self part of the reference type is replaced with result type of the discovered member (looking through function types and IUOs) and additional conformance requirements are placed on it (the new Self type) to make sure that the new base does conform to the expected protocol.

Source compatibility

This is a purely additive change and does not have any effect on source compatibility.

Effect on ABI stability

This change is frontend only and would not impact ABI.

Effect on API resilience

This is not an API-level change and would not impact resilience.

Alternatives considered

There have been multiple discussions on this topic on the Swift forums. The most recent one being a post from Matthew Johnson, which suggests adding a special syntax to the language to enable static member references on protocol metatypes. We have investigated these approaches and came up with a simpler design that doesn’t require any syntax changes and enables all of the use-cases that currently require workarounds. It’s also important to re-iterate that this is an incremental improvement over the status quo, so it shouldn’t impede any future changes aiming to change protocol metatype use.

38 Likes

I like it +1.

3 Likes

This example of code appears to unwittingly introduce the .bordered static member to any type conforming to Style, doesn't it?

Like so:

struct RoundedStyle : Style {}

extension Style {
  static var rounded : RoundedStyle { RoundedStyle() }
}

// bordered, but not at all rounded:
let style2 = RoundedStyle.bordered
// vice versa:
let style3 = BorderedStyle.rounded

So while it'd all work fine without ambiguity in the implicit member expression case under your proposal, I think it's less pretty that we'll be introducing potentially many unrelated static members to other conforming types of the same protocol.

Could there be a way to also mark the protocol extensions so they only limit to the intended type of Self, something like:

extension Style where Self == BorderedStyle {
  static var bordered: BorderedStyle { BorderedStyle() }
}

extension Style where Self == RoundedStyle {
  static var rounded : RoundedStyle { RoundedStyle() }
}

Edit: I realise following this convention even allows for using the Self shorthand for the var declarations:

extension Style where Self == BorderedStyle {
  var bordered : Self { Self() }
}
9 Likes

Yes, this is supported in the proposal, since Self is replaced with result type of rounded it's possible to form a correct reference here. @slazarus I think it would make sense to call that out in the proposal explicitly.

3 Likes

+1 A great addition to protocol-oriented programming

I’d love to see this happen :heart:

It is so annoying when we have to invent silly secondary namespaces like Things for Thing to get a nice place for such constants...

I have no insight though it this trips up something weird in the typesystem... does not seem like it could, but I don’t know :slight_smile:

5 Likes

I feel like I run into this limitation about once a month, and I love the simplicity of the proposed fix.

On a minor point of clarity, would this be expected to work with opaque types? (The corresponding construction with subclasses does.)

public protocol Foo {}

public extension Foo {
    static var frobbed() -> some Foo {
        return FrobbedFoo(self)
    }
}

internal struct FrobbedFoo {
    ...
}

Obviously this wouldn’t work with @pyrtsa’s variation; I’m not immediately sure which of the two I’d prefer to use in practice.

Does this imply that the following would be invalid?

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

extension P {
  static var r: R { R() }
}

extension R {
  var s: S { S() }
}

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

test(.r.s)

Also, in the following:

protocol P {}
struct S1: P {}
struct S2: P {}

extension P {
  static var s1: S1 { S1() }
}

extension S1 {
  var s2: S2 { S2() }
}

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

test(.s1.s2)

would the implicit base be inferred as S1, or S2?

Does this work with chains including associated types? e.g.:

protocol P {
  associatedtype T: P
}

struct S: P {
  typealias T = S
}

extension P {
  static var s: S { S() }
}

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

test(.T.s)

It's great to see this idea picked up and implemented, thank you! I have a few comments and questions.

protocol metatype extensions

One thing that is not addressed in your proposal is that protocol extensions add members to all conforming types (but not the metatype which is not directly usable). This fact was one of the primary motivations behind the syntax I suggested: it motivated by semantic concerns.

Extending your example:

protocol Style {}
struct BorderedStyle : Style {}
struct OtherStyle : Style {}

extension Style {
  static var bordered: BorderedStyle { BorderedStyle() }
}

// all members declared in the protocol extension are accessible on all conforming types
let bordered = OtherStyle.bordered

I think this is extremely undesirable. It may not be a big deal in some use cases (maybe SwiftUI is one) but it would be in others. This is why I think we need a way to distinguish protocol metatype extensions from protocol extensions (that affect all conforming types).

In addition to supporting dot shorthand without cluttering the namespace of all conforming types, protocol metatype extensions would support other helper declarations related to the protocol including nested types. This has been commonly requested and it isn't at all clear how it would work using the approach in this proposal (even as a future enhancement).

I know you have a preference to keep the proposal as simple as possible and I don't disagree with that. But I do think a design that makes these dot-shorthand-supporting factories available on all conforming types is problematic. Given source compatibility requirements, it doesn't seem like something that could be retracted once accepted either. So I think we should tread very carefully here.

You mention having investigated the approach I pitched. Can you elaborate on what you found in doing so and why you rejected it?

multiple constraints

This proposal does not address the behavior in a generic context that includes constraints requiring a constrained conformance or more than one conformance.

For example, maybe a context requires conformance to a protocol with an associated type and also requires that type to conform to Equatable. In this context, it seems straightforward that static member lookup would happen on the metatype of the required protocol, while only the members returning conformances meeting all constraints of the generic context (i.e. with the associated type conforming to Equatable) would be available.

In another example, maybe the context is actually T: Style & Equatable. How does lookup work in a context like this where two conformances are required of the type parameter? Does lookup consider static members of both protocol metatypes while only considering static members whose return type meets all constraints of the context?

7 Likes

This approach is clever but I don't think users should have to write this constraint out. It would be easy to forget and still introduces an unnecessary BorderedStyle.bordered symbol. Further, it doesn't allow for a future direction of using protocols as namespaces for nested helper types.

I think a clear distinction between extensions of conforming types and extensions of the protocol metatype is warranted and leads to a much more clear programming model. As an example, which of these more clearly declares the intent?

extension Style where Self == BorderedStyle {
  var bordered : Self { Self() }
}

or

meta extension Style {
  var bordered : BorderedStyle { BorderedStyle() }
}
3 Likes

I'm a bit unclear on how the "protocol metatype extension" solution is meant to address the issue of generic parameters. E.g., with:

meta extension Style {
  var bordered : BorderedStyle { BorderedStyle() }
}

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

it seems like test(.bordered) should fail to compile without further adjustments to the current model for implicit member syntax. At the call site, the expression .bordered is expected to have type T where T conforms to Style, but bordered is not a member of any such T (since Style does not itself conform to Style). The fully-spelled-out version would be test(Style.bordered), but that has the base as Style and the result/contextual type as BorderedStyle, which is not compatible with the current rules around implicit member chains (which are, roughly, that the base type is equal to the contextual type, and the type of the last member is convertible to the contextual type).

1 Like

In order to make dot shorthand work in a generic context the existing rules must be extended one way or another. I think it's cleaner to extend the rules to consider static members of protocol metatypes directly in a generic context (where the return type of the static member conforms to the protocol and meets any other constraints imposed by the context).

TBH, I'm a bit surprised that the syntax that this proposal enables doesn't already "just work"... in my mind for the example in the OP (applyStyle(.bordered)), I would expect type checking to proceed something like:

  1. The contextual type for .bordered is S where S: Style, so S is substituted at the base.
  2. Lookup of bordered on S finds (via Style), the bordered: BorderedStyle member.
  3. Since the result of S.bordered has type BorderedStyle, S is determined to be equivalent to BorderedStyle, and the expression is type checked as applyStyle(BorderedStyle.bordered).

All of the above, to me, seems perfectly consistent with the existing rules—the compiler has enough context to uniquely determine the type of the .bordered reference. @xedin, do we really need to complexify the model as much as this proposal does to support the applyStyle case, or is there a simpler improvement we could make to the type checker?

This is the problematic step. S is a generic type. The compiler cannot find any members on S "via Style" without changes to the static member lookup rules. This thread is discussing what those changes should be.

In the design proposed here, I think many programmers will be confused as to why this works:

extension Style where Self == BorderedStyle {
  var bordered : Self { Self() }
}

and this doesn't:

extension BorderedStyle {
  var bordered : Self { Self() }
}

and I think there will also be confusion as to whether this should work or not:

extension Style where Self == BorderedStyle {
  var bordered : OtherStyle { OtherStyle() }
}
2 Likes

I'm totally fine with tweaking the lookup rules (obviously something has to change since the proposal's examples don't compile today), but tweaking the lookup rules doesn't necessitate tweaking the rules for implicit member syntax. Obviously, the compiler is capable of looking up members of S via Style just fine—you're free to use any instance members declared in Style, so why shouldn't you be able to use static members?

At the step you highlighted, the compiler knows that S is a concrete type conforming to Style, and the compiler knows that every such S has a member named bordered with type BorderedStyle, and the compiler knows that the result of .bordered should be the same as the type S itself.

The only wrinkle I can see is that because of @_implements, S.borderedStyle isn't guaranteed to be the same as BorderedStyle.borderedStyle... but it's not immediately obvious to me that that's a show-stopper.

Perhaps, but I think both of those could be addressed with some diagnostic effort, and the rationale for rejecting them is relatively explainable under the existing rules for implicit member syntax. The more complex we make the current rule:

When the contextual type T for an implicit member chain can be determined, substitute T at the base of the chain.

the more difficult it will be to explain any failures. I still can't quite figure out what the 'new' rule would look like under this proposal, but it seems to add a lot of complexity for marginal gain.

No it doesn't. That is only true when the member is declared in an unconstrained extension. In fact, that is not the case in the example we've been looking at:

extension Style where Self == BorderedStyle {
  var bordered : Self { Self() }
}

I don't think a warning for this code would be justified:

extension BorderedStyle {
  var bordered : Self { Self() }
}

How do you know that was declared on BorderedStyle by mistake instead of Style? It is perfectly valid code.

1 Like

I was specifically considering the example from the proposal where bordered is declared in an unconstrained extension. Understanding the situation for constrained extension is important as well, though.

To make things a little more concrete, there's already some similar behavior to what I'm talking about with generic parameter inference for implicit member expressions. So, I'm asking what the technical difference is between a setup like this:

struct S<T> {}

extension S where T == Int {
    static var sInt: S<Int> { S() }
}

extension S where T == String {
    static var sString: S<String> { S() }
}

func takesS<T>(s: S<T>) {}
takesS(s: .sInt) // OK

and this:

protocol P {}
extension Int: P {}
extension String: P {}

extension P where Self == Int {
    static var pInt: Int { 0 }
}

extension P where Self == String {
    static var pString: String { "" }
}

func takesP<T: P>(p: T) {}
takesP(p: .pInt) // Error!

I was imagining that the diagnostic would be at the point-of-use of .bordered, not at the declaration site.

I believe that diagnostic would require the same kind of exhaustive consideration of all conforming types that we definitely do not want to have to do.

IMO it would be enough to simply say something to the effect of "Style does not declare a member bordered", which is all information that is readily available at the use site. That would inform the user that the member they're trying to use must be somehow attached to Style, not just BorderedStyle.

Fair enough. In any case, I still don't think this falls out naturally from the existing rules. And I still think a distinction between extensions of conforming types and extensions of protocol metatypes is a better programming model.

1 Like