Static member lookup on protocol metatypes
- Proposal: SE-NNNN
- Authors: Pavel Yaskevich, Sam Lazarus, Matt Ricketson
- Review Manager: TBD
- Status: Pitch
- Implementation: https://github.com/apple/swift/pull/34523, toolchain available.
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.