Search code examples
swiftswift-macro

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


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?


Solution

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