Reputation: 1249
I'm curious, is there a way to use Swift macros to add new members and, at the same time, somehow augment the initializer to include initialization of the new members?
For instance, if I had a macro called Counted
that adds a count
member to a struct, it would be relatively easy to use an attached member macro to transform this:
@Counted
struct Item {
var name: String
init(name: String) {
self.name = name
}
}
to:
struct Item {
var name: String
var count: Int = 0 // <- "= 0" is what allows old init to work
init(name: String) {
self.name = name
}
}
But it doesn't allow me to set the count member during initialisation. If I want to be able to create an item like this:
let myItem = Item(name: "Johnny Appleseed", count: 5)
I can't because there isn't an initializer that takes both name and count.
The WWDC'23 Video Expand on Swift macros lays out the design philosophy used for Swift Macros, including a rule that changes must be incorporated in predictable, additive, ways. That actually leaves the door open for macros to be able to add code to the end of existing initializers, but I don't see any attached macro roles that suggest they'd be able to do this.
Is there a macro role that will add to the existing initializer, and if not, how would I structure things so a macro could create the initialiser I want?
Upvotes: 2
Views: 1017
Reputation: 270708
If you just want an extra initialiser, a MemberMacro
can generate that:
enum CountedMacro: MemberMacro {
static func expansion(of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext) throws -> [DeclSyntax] {
let initialisers = declaration.memberBlock.members.compactMap { member in
member.decl.as(InitializerDeclSyntax.self)
}
guard initialisers.count > 0 else {
context.diagnose(...)
return []
}
let newInitialisers = initialisers.map(generateNewInitialiser).map(DeclSyntax.init)
return ["var count = 0"] + newInitialisers
}
static func generateNewInitialiser(from initialiser: InitializerDeclSyntax) -> InitializerDeclSyntax {
var newInitialiser = initialiser
// add parameter
let newParameterList = FunctionParameterListSyntax {
newInitialiser.signature.parameterClause.parameters
"count: Int"
}
newInitialiser.signature.parameterClause.parameters = newParameterList
// add statement initialising count
newInitialiser.body?.statements.append("self.count = count")
return newInitialiser
}
}
If you want to add a new count
parameter to an existing initialiser, macros cannot do that currently. One workaround is to require the user of the macro to mark the initialisers as private. The macro can then generate new public
initialisers for each of the private initialisers. This effectively "hides" the existing initialisers.
// in generateNewInitialiser...
let modifiersToBeRemoved: [TokenSyntax] = ["public", "private", "internal", "fileprivate", "required"]
newInitialiser.modifiers = newInitialiser.modifiers.filter { m in modifiersToBeRemoved.contains { m.name == $0 } }
newInitialiser.modifiers.insert(DeclModifierSyntax(name: "public"), at: newInitialiser.modifiers.startIndex)
An alternative, more flexible design is to split this up into three macros:
@Counted
is a member macro which adds the var count = 0
, as well a member attribute macro that adds @CountedInitialiser
to all initialisers without a @CountedIgnored
macro attached.@CountedInitialiser
is a peer macro that generates a public initialiser using generateNewInitialiser
.@CountedIgnored
is a peer macro that does nothing. It is only used to tell @Counted
to ignore an initialiserThis allows the user of the macro to choose exactly which initialisers they want to augment. e.g.
@Counted
struct Foo {
let name: String
@CountedIgnored
init() { name = "Default" }
// @CountedInitialiser will be added to this when @Counted expands
private init(name: String) { self.name = name }
}
A similar technique can be seen in @Observable
(@ObservationTracked
/@ObservationIgnored
) from Observation and @Model
(@_PersistedProperty
/@Transient
) from Swift Data.
Upvotes: 1