How to use Swift macros to add new members to a struct and its init function?

167 Views Asked by At

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?

1

There are 1 best solutions below

1
On BEST ANSWER

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 @CountedInitialiserto 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 initialiser

This 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.