Conformance Builders
Introduction
Swift currently provides two micro features: function builders and property wrappers. I propose that we introduce conformance builders for synthesizing protocol conformances on structs. Unlike other proposals, this proposal focuses solely on structs and doesn't try to address static reflection.
Motivation
Implementation Complexity of Compiler Synthesis
The compiler synthesizes implementations for standard-library protocols, such as Hashable
and Codable
. However, this introduces significant implementation complexity for a very narrow feature, that is not offered to libraries. This makes altering the synthesis behavior difficult for non-compiler developers.
Synthesis for Library Authors
Synthesis is also useful for libraries, which often resort to less performant approaches, such as reflection, for auto-synthesis-like behavior on their protocols.
Furthermore, auto-synthesis from reflection restricts type safety and results in less informative diagnostics, only available at runtime. When, for example, Hashable
synthesis is unavailable, the user is notified at compile-time and doesn't need to run their program to verify its validity.
Lack of Synthesis for Regular Users
Libraries that utilize reflection usually rely on custom reflection code â not the built-in Mirror
API â to minimize the performance penalties of reflection and to access more powerful APIs. This is not an option for small-scale libraries or regular users who need auto-synthesis, leading to boilerplate.
Boilerplate, of course, is prone to errors, pollutes the call-site, hinders code maintainability, and can, accidentally, lead to less performant implementations.
Proposed Design
I propose that we introduce conformance builders, as a well-defined, static, and type-safe feature for declaring explicit and implicit synthesis for protocol conformances.
Declaring a conformance builder requires a type marked @conformanceBuilder(of:)
with the protocol targeted for synthesis specified in the attribute. This type must provide an init(root:properties)
initializer, which is used for passing an instance of the synthesized type and key paths to its properties. Additionally, the conformance builder type can satisfy slightly altered requirements of the target protocol for which it offers synthesized implementations.
As for the application of conformance builders, a custom attribute is offered, which can be used to either declare them as implicit or explicit. For implicit application, the conformance builder custom attribute is applied to the protocol to be auto-synthesized. For explicit synthesis, the custom attribute is applied to the conformance declaration of a struct.
Implicit conformance builders can be overridden by explicit ones, and all synthesized requirements, provided by conformance builders, can be overridden by the conforming type itself.
Here's an example using SwiftUI's View
and DynamicProperty
protocols.
@conformanceBuilder(of: View)
struct ViewConformanceBuilder<Root> { ... }
@ViewConformanceBuilder
protocol View {
// Some hidden requirements...
static var _dynamicProperties(
instance: Self
) -> [KeyPath<Self, DynamicProperty>]
associatedtype Body: View
@ViewBuilder var body: Body { get }
}
struct ContentView: View {
var body: some View {
Text("Hello world!")
}
}
Detailed Design
Semantics
Conformance-Builder Type
The @conformanceBuilder(of:)
attribute only accepts protocols in its parameter and can be applied to both structs and enums.
Furthermore, a conformance builder is required to:
- satisfy at least one "adjusted" requirement of the protocol it builds conformances to (as described below);
- provide one or more initializers of the form
init(root:properties:)
, with the access level of the conformance builder, and a variadicproperties
parameter (or multiple parameters afterproperties
, or a combination of the two) where all accept values are of typeAnyKeyPath
(and any of its subclasses).
Adjusted Requirement
Adjusted requirements are all the non-type requirements taken from the protocol the conformance-builder targets, where Self
is substituted by the branch builder type, whose generic parameters are inferred as described below.
Implicit Conformance-Builder Requirements
Conformance builders must be unambiguous. This means, that their generic parameters must either be fixed â via a same-type constraint or by being specified in the custom attribute â or used in their significant initializers. Additionally, synthesis of protocol composition is not currently allowed.
Rationale: Implicitly synthesized protocols provide no means for the user to disambiguate by specifying the conformance builder's generic parameter, through its custom attribute; hence, it is required to be unambiguous.
Application
Applying two or more same-protocol conformance builders a declaration is prohibited. Furthermore, the conformance-builder custom attribute can only be applied to protocols, to indicate implicit synthesis, and to struct conformance declarations, for explicit synthesis.
Type Inference
Type inference may be used to determine: the generic parameters of the significant initializers; or the generic parameters of the conformance-builder type.
To determine the generic parameters of the initializer, the compiler will start by taking the conformance-builder type with any attributes specified on the custom attribute; then, it will call the initializer of this type. The initializer will be passed self
in its root
parameter and the stored property key paths for properties
and the following parameters. After that, the compiler will use the regular type-inference procedure for function calls to determine the generic parameters of the initializer.
To determine the concrete conformance-builder type, the compiler will follow the above process with a pseudo Self
instance, and use the inferred and concrete conformance-builder type for requirements with Self
in a covariant position, and for static requirements.
Synthesizing Witnesses
In conformance synthesis, the compiler will only synthesize the protocol requirements that the conformance builder has satisfied in their adjusted form, and omit any requirements witnessed by the conforming-struct. In particular, the synthesized members will, first, be transformed to a recursive form, and then replaced so as to transform any Self
or self
references and parameters.
Example:
@conformanceBuilder(of: Equatable)
struct VectorEquatableBuilder<Root, E1: Equatable, E2: Equatable> { ... }
struct Vector: @VectorEquatableBuilder Equatable {
let x, y: Double
}
// âââââââââââââââââââ Transforms to âŦī¸ âââââââââââââââââââ \\
struct Vector: Equatable {
let x, y: Double
static func == (lhs: Self, rhs: Self) {
lhs == rhs // The parameters 'lhs' and 'rhs' are of type 'Self'.
}
}
// âââââââââââââââââââ Transforms to âŦī¸ âââââââââââââââââââ \\
struct Vector: Equatable {
let x, y: Double
static func == (lhs: Self, rhs: Self) {
VectorEquatableBuilder(root: lhs, properties: \Self.x, \Self.y) ==
VectorEquatableBuilder(root: rhs, properties: \Self.x, \Self.y)
}
}
Diagnostics
Too Many or Too Little Properties
Some conformance builders may require a minimum or a maximum number of parameters. This is communicated to the compiler by the significant initializers, that â through overloads or variadic parameters â offer a certain number of available properties for their generic constraints. The compiler will understand and diagnose that with an appropriate error:
@conformanceBuilder(of: Equatable)
struct VectorEquatableBuilder<Root, E1: Equatable, E2: Equatable> { ... }
struct Vector3D: @VectorEquatableBuilder Equatable {
// â 'VectorEquatableBuilder' can't synthesize
// 'Equatable' conformance for struct with 3 properties.
let x, y, z: Double
}
Non-satisfied Constraints of Property Key Paths
There are, also, cases wherein the compiler can't find an overload for the generic constraints imposed on the conformance builder. In these cases, the compiler will inform the user about these constraints through notes, and emit an error at the conformance declaration:
struct MyNumber {
...
}
struct Vector: @VectorEquatableBuilder Equatable {
// â 'VectorEquatableBuilder' can't synthesize
// 'Equatable' conformance.
let x: MyNumber // âšī¸ 'MyNumber' does not conform to 'Equatable'
let y: MyNumber // âšī¸ 'MyNumber' does not conform to 'Equatable'
}
Alternatives Considered
Ban Synthesis for Structs containing Class or Unsafe Types
Structs whose properties may cause problems for the synthesized implementations can opt-out by offering their own implementations. Furthermore, some users may utilize immutable classes that do not require special implementations. As for the case of unsafe code, users utilizing it should be aware of its effects, and cautious when using synthesized implementations; nevertheless, unsafe code is often wrapped in safe container types to avoid such problems.
Future Directions
Options in Conformance Builder Attributes
Currently, conformance builders can only alter the synthesis behavior of a given conformance builder through their generic parameters; this approach is quite limited. To address this, we could ease the restriction that mandates that the initializer receive just one property, and use any parameters before root
as option parameters â similar to arguments on property-wrapper custom attributes.
@conformanceBuilder(of: Identifiable)
struct TargetedID<Root, ID> {
let id: KeyPath<Root, ID>, root: Root // No properties are allowed here.
var id: ID { root[keyPath: id] }
}
struct User: @TargetedID(id: \.userID) Identifiable {
let name: String
let userID: UUID
}
Stateful Conformance Builders
Protocols are not allowed to declare stored properties. However, with stateful conformance builders, we could alleviate some boilerplate associated with specifying stored properties by hand:
@conformanceBuilder(of: Identifiable, attributes: stateful)
struct UUIDIdentifiable<Root: Identifiable> where Root.ID == UUID {
let root: Root, let id = UUID()
}
struct User: @UUIDIdentifiable Identifiable {
let name: String
}
// âââââââââââââââââââ Transforms to âŦī¸ âââââââââââââââââââ \\
struct User: Identifiable {
let name: String
// The conformance builder is stored.
let __Identifiable_proxy: UUIDIdentifiable<Self> = UUIDIdentifiable(root: self)
var id: UUID {
__Identifiable_proxy.id
}
}
Mutable Root
The proposed solution doesn't offer a way to satisfy mutating
requirements, as root
can't automatically mutate self
, due to conformance builders' value semantics. This can be quite limiting and certain situations, and could be addressed by introducing mutable conformance builders:
@conformanceBuilder(of: IteratorProtocol, attributes: mutable)
struct ForwardedIterator<
Root: IteratorProtocol,
Iterator: IteratorProtocol
> where Iterator.Element == Root.Element {
var root: Root
let properties: WritableKeyPath<Root, Iterator>
mutating func next() -> Root.Element? {
root[keyPath: properties].next()
}
}
struct IteratorWrapper<Iterator: IteratorProtocol>: @ForwardedIterator IteratorProtocol {
var _iterator: Iterator
}
// âââââââââââââââââââ Transforms to âŦī¸ âââââââââââââââââââ \\
struct IteratorWrapper<Iterator: IteratorProtocol>: @ForwardedIterator IteratorProtocol {
var _iterator: Iterator
mutating func next() -> Root.Element? {
let proxy = ForwardedIterator(root: self, properties: \Self._iterator)
defer { self = proxy.root }
return proxy.next()
}
}
Conformance Builders for Enumerations
Enums, like structs, can conform to protocols, and mainstream protocols, such as Codable
, synthesize enum conformances. This could be expanded to conformance builder, by first introducing a standard-library union type, such as Either
:
enum Either<First, Second> {
case first(First), second(Second)
}
With an Either
type, we can allow conformance builders to declare an init(root:cases:)
initializer to indicate support for enum conformance-building:
extension Either: Encodable where
First: Encodable, Second: Encodable { ... }
@conformanceBuilder(of: Encodable)
struct EncodableBuilder<Root> {
private let encodable: Encodable
init<A: Encodable>(root: Root, cases case: A) {
encodable = case
}
init<A: Encodable, B: Encodable>(root: Root, cases: Either<A, B>) {
encodable = cases
}
func encode(to encoder: Encoder) throws {
encodable.encode(to: encoder)
}
}
enum Input: @EncodableBuilder Encodable {
case number(Double), text(String)
}
// âââââââââââââââââââ Transforms to âŦī¸ âââââââââââââââââââ \\
enum Input: Encodable {
case number(Double), text(String)
func encode(to encoder: Encoder) throws {
let either: Either<Double, String>
switch either {
case let .number(value): either = .first(value)
case let .text(value): either = .second(value)
}
return EncodableBuilder(root: self, cases: either).encode(to: encoder)
}
}
Some design concerns will, first, need to be resolved. Firstly, it is unclear whether struct- and enum-builders should be mandated to be declared as different types. Secondly, it is unclear whether Either
is the best solution for our problems, which could perhaps be better solved by variadic generics. Nonetheless, enum conformance-building seems like a straightforward extension to this feature.
Your thoughts
I am really interested to hear what you think of this proposal! All feedback is welcome.