Introduction
Swift macros offers a variety of different macro types for performing code expansions for various use cases. One macro type that's discussed in the Macros Vision document but not yet implemented is the witness
macro type, which is an attached macro that synthesizes requirements for members its attached to in protocol declarations.
While this attached macro type is certainly useful in its own right this document proposes a different, but related, macro type for declaring a protocol and its requirements where the invocation of the macro synthesizes all the requirements of the protocol.
Motivation
The kinds of macros that exist today make it simple to extend the Swift language in a highly customizable ways, which was previously only achievable through modification of the language/compiler itself. There's one feature, however, that's currently only available for select protocols that would be useful as its own macro type which we'll dub "Protocol Macros."
These macros are associated directly with a given protocol and invoked when that protocol is attached to a particular type. This macro then generates the requirements of the protocol it's associated with, fulfilling the protocol's requirements. Two standout examples of this kind of macro (which is currently implemented as a compiler/language feature, not via a macro like this) are the Encodable
and Decodable
protocols:
protocol Encodable {
func encode(to encoder: Encoder) throws
}
protocol Decodable {
init(from decoder: Decoder) throws
}
When either of these protocols are attached to a given type, the compiler synthesizes the protocol's requirements in the type its attached to. Not only that, but the compiler performs validation of the type to ensure that each of its stored members conform to the protocol which is required for being able to automatically synthesize the conformance. If the requirements can't be automatically synthesized, the requirements can be manually implemented on the type. Not only that but the protocol's requirements can be manually implemented even if the requirements could be synthesized by the compiler.
This kind of functionality can already be somewhat emulated using the 'conformance' field on the member
or extension
attached macro type. For example, we could create an attached member macro that conforms the attached type to a given protocol and synthesizes the requirements of the protocol:
// Declaration
protocol FooBar {
associatedtype Foo
associatedtype Bar
func foo() -> Foo
func bar() -> Bar
}
@attached(member, conformances: FooBar, named: named(foo()), named(bar()))
macro FooBarMacro() = #externalMacro(...)
...
// Implementation
struct EncodableMacro: ExtensionMacro {
static func expansion(
of node: AttributeSyntax,
attachedTo declaration: some DeclGroupSyntax,
providingExtensionsOf type: some TypeSyntaxProtocol,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [ExtensionDeclSyntax] {
return [
ExtensionDeclSyntax(
...
inheritanceClause:
InheritanceClauseSyntax(inheritedTypes:
InheritedTypeListSyntax(protocols.map {
InheritedTypeSyntax(type: $0)
})
),
...
)
]
}
}
Although this works fine, it does lack some of the functionality that makes these kinds of protocols what they are today. Specifically, if one directly conforms to a given protocol (e.g. FooBar
) then the protocol's requirements aren't automatically synthesized like they would have been had the macro been used intstead:
struct FooBarImplA: FooBar {
// error: Missing implementations for `foo()` and `bar()`
}
@FooBarMacro
struct FooBarImplB {
}
// Conformance to `FooBar` + requirements synthesized in an extension of `FooBarImplB`:
//
// extension FooBarImplB: FooBar {
// func foo() -> Self.Foo {
// ...
// }
// func bar() -> Self.Bar {
// ...
// }
// }
Furthermore, this workaround doesn't allow for the associated protocol to be composed into other protocols or types like the way that Encodable
and Decodable
are composed in the Codable
typealias:
typealias HashableFooBar = (FooBar & Hashable)
// OR: `protocol HashableFooBar: FooBar, Hashable { }`
struct FooBarImpl: HashableFooBar {
func hash(into hasher: inout Hasher) {
...
}
// error: Missing implementations for `foo()` and `bar()`
}
Because of these drawbacks we are proposing a new protocol attached macro type that mirrors the behavior of these kind of special protocols where the macro is invoked by conforming a type to the protocol.
Proposed solution
The proposed solution is to create a new attached macro type conformance
whose usage is restricted to protocol declarations:
@attached(conformance, ...)
protocol FooBar {
...
}
The implementation of the macro would then be invoked whenever the protocol is formally adopted on a conrete type, that is to say adopted by a class, struct, enum, or actor. When added to the inheritance clause of another protocol, the macro wouldn't be invoked. However, any conformance to the new protocol on a conrete would invoke the macro for that conrete type. For example:
@attached(conformance, ...)
protocol Foo {
associatedtype Foo
func foo() -> Foo
}
// `Foo`'s macro isn't invoked here
protocol FooBar: Foo {
associatedtype Bar
func bar() -> Bar
}
// `Foo`'s macro is invoked here and synthesizes the `foo()` requirement.
struct FooBarImpl: FooBar {
func bar() -> Self.Bar {
...
}
}
Detailed design
The new conformance
attached macro type would accept two different parameters in the attribute's usage:
additionalMembers
: If provided, this field will accept a list of named parameters that specify any additional declarations that the macro will create. All of the declarations in the associated protocol are implicitly included and therefore it's not necessary to include them in this field. For example, if theEncodable
protocol were declared as this kind of macro it would include an additional declaration for theCodingKeys
enum that it creates:
@attached(conformance, additionalMembers: named(CodingKeys), macro: #externalMacro(...))
protocol Encodable {
func encode(to encoder: Encoder) throws
}
macro
: This field is required to be include in the attribute and is provided with the implementation specification of the macro:
@attached(conformance, macro: #externalMacro(...))
protocol FooBar {
...
}
Next, a new macro protocol type would be created in the SwiftSyntax
library for specifying the implementation of the macro:
public protocol ProtocolConformanceMacro: AttachedMacro {
static func expansion(
of protocol: ProtocolDeclSyntax,
attachedTo declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax]
}
The parameters of the required expansion function are defined as follows:
-
protocol
: In lieu of providing anAttributeSyntax
like is done in all other attached macros, this function accepts the declaration of the protocol that this macro is being invoked. -
declaration
: Thedeclaration
field is provided with the concrete declaration type that the protocol is attached to. The AST this declaration would be equivalent to the declaration that would be provided to the invocation of amember
orextension
macro. That is to say that the full AST of the conforming type would be provided, not just the type of the declaration or a "skeleton" of the declaration. -
context
: The standard macro expansion context included in all other macro expansion functions.
Protocols declared in this manner effectively function as both a normal protocol and an attached macro. These protocols can be used identically to all other protocols and are semantically just protocols themselves, but with the addition of performing macro expansion on the types that conform to them.