WWDC Quick Look 💓 By SwiftGGTeam
Write Swift macros

Write Swift macros

Watch original video

Highlight

Swift 5.9 introduces the macro (Macro) mechanism. Developers can write compiler plug-ins to generate repeated code at compile time, while maintaining type safety, supporting IDE expansion and viewing, unit testing, and reporting custom errors to the compiler.

Core Content

Writing duplicate code is something every developer hates. For example, if you want to maintain a set of arithmetic exercises, each question must record the calculation results and the corresponding calculation string at the same time:

let calculations = [
    (2 + 3, "2 + 3"),
    (10 * 5, "10 * 5"),
]

This writing method has three problems: duplication, redundancy, and error-prone. The result and the calculation string are completely aligned manually. If you change one side and forget to change the other, bugs will be buried.

The solution given in Swift 5.9 is a macro.#stringify(a + b)This way of writing will automatically expand into(a + b, "a + b"), the result and calculation formula are always consistent.

Macros work completely differently from the C preprocessor. C macros do text replacement before type checking, Swift macros do it after type checking. This means that the macro parameters must pass type checking before the compiler will call macro expansion. You pass oneStringgive requestIntFor macros, the compiler will directly report an error and will not enter the macro expansion stage.

01:15

Independent expression macro

#stringifyBelongs to the freestanding expression macro (freestanding expression macro), use#At the beginning, it can be used anywhere an expression is required.

The declaration of a macro looks like a function, but uses@freestanding(expression)mark:

@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(
    module: "WWDCMacros",
    type: "StringifyMacro"
)

#externalMacroAssociate macro declarations with implementations. The implementation is written in a separate compiler plug-in and runs as an independent process.

05:55

Additional member macros

In addition to independent expression macros, there are also attached macros (attached macros), using@At the beginning, it is used to strengthen the statement.

The speaker gave the example of a skiing scene. There is oneSlopeThe enumeration represents all the snow trails, and there is also aEasySlopeEnumeration representing a subset of slopes suitable for beginners.EasySlopeTwo conversion methods need to be written by hand: fromSlopeinitializedinit?and transfer backSlopeofslopeproperty.

enum EasySlope {
    case beginnersParadise
    case practiceRun

    init?(_ slope: Slope) {
        switch slope {
        case .beginnersParadise: self = .beginnersParadise
        case .practiceRun: self = .practiceRun
        default: return nil
        }
    }

    var slope: Slope {
        switch self {
        case .beginnersParadise: return .beginnersParadise
        case .practiceRun: return .practiceRun
        }
    }
}

Each time a subset enumeration is added, the boilerplate code needs to be written again. use@SlopeSubsetMacros can be automatically generatedinit?method:

@SlopeSubset
enum EasySlope {
    case beginnersParadise
    case practiceRun
}

12:05

Error reporting for macros

Macros can check whether their usage scenarios are correct. for example@SlopeSubsetCan only be used for enumerations. If developers misuse it on structures, the macro can throw compilation errors:

enum SlopeSubsetError: CustomStringConvertible, Error {
    case onlyApplicableToEnum

    var description: String {
        switch self {
        case .onlyApplicableToEnum:
            return "@SlopeSubset can only be applied to an enum"
        }
    }
}

Check the declared type in the macro implementation, if it does not match thethrow

guard let enumDecl = declaration.as(EnumDeclSyntax.self) else {
    throw SlopeSubsetError.onlyApplicableToEnum
}

In this way, when developers use macros in the wrong location, Xcode will directly display friendly error prompts.

28:00

Detailed Content

Create macro project

Xcode 15 provides Swift Macro templates. File > New > Package, select the Swift Macro template to create a complete project structure including macro definition, macro implementation and testing.

Template generated#stringifyThe macro shows the complete workflow:

let a = 17
let b = 25
let (result, code) = #stringify(a + b)
print("The value \(result) was produced by the code \"\(code)\"")

Right-click the macro call and select Expand Macro to see the expanded code:

(a + b, "a + b")

05:55

Implementation principle of macro

When the compiler encounters a macro call, it sends the source code of the entire macro expression to the compiler plug-in. The plug-in uses SwiftSyntax to parse the source code into a syntax tree, the macro implementation performs transformations on the syntax tree, and then serializes the result back to the source code and returns it to the compiler.

StringifyMacroThe implementation demonstrates the basic pattern:

public struct StringifyMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) -> ExprSyntax {
        guard let argument = node.argumentList.first?.expression else {
            fatalError("compiler bug: the macro does not have any arguments")
        }

        return "(\(argument), \(literal: argument.description))"
    }
}

Key points:

  • RealizeExpressionMacroprotocol -expansion(of:in:)Receive syntax tree nodes and context
  • Extract the first expression from the argument list
  • return newExprSyntax, use string interpolation to construct the expansion result -\(literal: ...)Convert expression description into string literal

07:10

Implementation of member macro

SlopeSubsetMacroaccomplishMemberMacroprotocol, generated for the enumerationinit?method:

public struct SlopeSubsetMacro: MemberMacro {
    public static func expansion(
        of attribute: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        guard let enumDecl = declaration.as(EnumDeclSyntax.self) else {
            throw SlopeSubsetError.onlyApplicableToEnum
        }

        let members = enumDecl.memberBlock.members
        let caseDecls = members.compactMap { $0.decl.as(EnumCaseDeclSyntax.self) }
        let elements = caseDecls.flatMap { $0.elements }

        let initializer = try InitializerDeclSyntax("init?(_ slope: Slope)") {
            try SwitchExprSyntax("switch slope") {
                for element in elements {
                    SwitchCaseSyntax("""
                    case .\(element.identifier):
                        self = .\(element.identifier)
                    """)
                }
                SwitchCaseSyntax("default: return nil")
            }
        }

        return [DeclSyntax(initializer)]
    }
}

Key points:

  • usedeclaration.as(EnumDeclSyntax.self)Convert declaration to enumeration declaration -memberBlock.membersGet member list -compactMapFilter out enum case statements -flatMap { $0.elements }Extract all case elements
  • useInitializerDeclSyntaxandSwitchExprSyntaxConstructing a syntax tree from SwiftSyntax Builder
  • Traverse the case elements to generate the corresponding switch branch
  • return[DeclSyntax]array

19:25

Register macro plug-in

All macro implementations need to be registered at the compiler plug-in entry:

@main
struct WWDCPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        StringifyMacro.self,
        SlopeSubsetMacro.self
    ]
}

16:23

Test macro

SwiftSyntaxMacrosTestSupport providesassertMacroExpansionTo test the macro expansion results:

final class WWDCTests: XCTestCase {
    func testMacro() {
        assertMacroExpansion(
            """
            #stringify(a + b)
            """,
            expandedSource: """
            (a + b, "a + b")
            """,
            macros: testMacros
        )
    }
}

let testMacros: [String: Macro.Type] = [
    "stringify": StringifyMacro.self
]

Test member macro:

func testSlopeSubset() {
    assertMacroExpansion(
        """
        @SlopeSubset
        enum EasySlope {
            case beginnersParadise
            case practiceRun
        }
        """,
        expandedSource: """
        enum EasySlope {
            case beginnersParadise
            case practiceRun
            init?(_ slope: Slope) {
                switch slope {
                case .beginnersParadise:
                    self = .beginnersParadise
                case .practiceRun:
                    self = .practiceRun
                default:
                    return nil
                }
            }
        }
        """,
        macros: testMacros
    )
}

You can also test error scenarios:

func testSlopeSubsetOnStruct() throws {
    assertMacroExpansion(
        """
        @SlopeSubset
        struct Skier {
        }
        """,
        expandedSource: """
        struct Skier {
        }
        """,
        diagnostics: [
            DiagnosticSpec(message: "@SlopeSubset can only be applied to an enum", line: 1, column: 1)
        ],
        macros: testMacros
    )
}

09:12

Generalized macro

SlopeSubsetcan be generalized toEnumSubset, accepts a generic parameter specifying the parent enumeration:

@attached(member, names: named(init))
public macro EnumSubset<Superset>() = #externalMacro(
    module: "WWDCMacros",
    type: "SlopeSubsetMacro"
)

Extract generic parameters in macro implementation:

guard let supersetType = attribute
    .attributeName.as(SimpleTypeIdentifierSyntax.self)?
    .genericArgumentClause?
    .arguments.first?
    .argumentType else {
    return []
}

so@EnumSubset<Slope>It can be used for any enumeration subset.

31:03

Core Takeaways

Make a safe forced unpacking macro

There is always someone in the team who writes!Forced unpacking makes it difficult to locate problems. can make one#unwrapMacro, expanded to include file and line numberpreconditionFailure

let image = #unwrap(downloadedImage, message: "was already checked")

Entrance:@freestanding(expression) + ExpressionMacroprotocol. refer to#stringifyTo realize, usecontext.location(of:)Get the source code location.

Automatically generate alternative implementations of Codable

Some models require custom CodingKeys, but most fields are just simple key mappings. You can write one@CustomCodableMacro, automatically generatedinit(from:)andencode(to:), while allowing attribute tags to be used to override the behavior of specific fields.

Entrance:@attached(member) + MemberMacroprotocol. Traverse the attribute declarations and generate corresponding decode/encode code for each attribute.

Generate transformation code for state machine

There are often state machines in games or workflows, and each state needs to define a target state that it can transition to. You can write one@StateMachineMacro, automatically generates state transition methods and validity checks based on state declarations.

Entrance:@attached(member, names: arbitrary) + MemberMacroprotocol. Read all cases and generate for each statecanTransition(to:)andtransition(to:)method.

Generate multi-device snapshots for SwiftUI preview

You can write one#previewDevicesDeclare a macro and expand it into multiple.previewDeviceCombinations of modifiers to see the effect on different devices at once.

Entrance:@freestanding(declaration)+ Generate multiplePreviewProviderstatement.

Comments

GitHub Issues · utterances