WWDC Quick Look 💓 By SwiftGGTeam
Write Swift macros

Write Swift macros

观看原视频

Highlight

Swift 5.9 引入宏(Macro)机制,开发者可以通过编写编译器插件在编译期生成重复代码,同时保持类型安全、支持 IDE 展开查看、可单元测试,并且能向编译器报告自定义错误。

核心内容

写重复代码是每个开发者都讨厌的事。比如你要维护一组算术练习题,每道题都要同时记录计算结果和对应的算式字符串:

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

这种写法有三个问题:重复、冗余、容易出错。结果和算式字符串完全靠手动对齐,改了一边忘改另一边就会埋 bug。

Swift 5.9 给出的解法是宏。#stringify(a + b) 这样的写法会在编译期自动展开成 (a + b, "a + b"),结果和算式永远保持一致。

宏的工作方式跟 C 预处理器完全不同。C 宏在类型检查之前做文本替换,Swift 宏则在类型检查之后执行。这意味着宏参数必须先通过类型检查,编译器才会调用宏展开。你传一个 String 给要求 Int 的宏,编译器会直接报错,不会进入宏展开阶段。

01:15

独立表达式宏

#stringify 属于独立表达式宏(freestanding expression macro),用 # 开头,可以在任何需要表达式的地方使用。

宏的声明看起来像函数,但使用 @freestanding(expression) 标记:

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

#externalMacro 把宏的声明和实现关联起来。实现写在单独的编译器插件里,作为独立进程运行。

05:55

附加成员宏

除了独立表达式宏,还有附加宏(attached macro),用 @ 开头,用来增强声明。

演讲者举了一个滑雪场景的例子。有一个 Slope 枚举表示所有雪道,还有一个 EasySlope 枚举表示适合初学者的雪道子集。EasySlope 需要手写两个转换方法:从 Slope 初始化的 init? 和转回 Slopeslope 属性。

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
        }
    }
}

每增加一个子集枚举,这些样板代码就要重复写一遍。用 @SlopeSubset 宏可以自动生成 init? 方法:

@SlopeSubset
enum EasySlope {
    case beginnersParadise
    case practiceRun
}

12:05

宏的错误报告

宏可以检查自己的使用场景是否正确。比如 @SlopeSubset 只能用于枚举,如果开发者误用在结构体上,宏可以抛出编译错误:

enum SlopeSubsetError: CustomStringConvertible, Error {
    case onlyApplicableToEnum

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

在宏实现中检查声明类型,不符合就 throw

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

这样开发者在错误的位置使用宏时,Xcode 会直接显示友好的错误提示。

28:00

详细内容

创建宏项目

Xcode 15 提供了 Swift Macro 模板。File > New > Package,选择 Swift Macro 模板即可创建一个包含宏定义、宏实现和测试的完整项目结构。

模板生成的 #stringify 宏展示了完整的工作流程:

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

右键点击宏调用,选择 Expand Macro,可以看到展开后的代码:

(a + b, "a + b")

05:55

宏的实现原理

编译器遇到宏调用时,会把整个宏表达式的源代码发送给编译器插件。插件用 SwiftSyntax 把源代码解析成语法树,宏实现在语法树上做变换,再把结果序列化回源代码返回给编译器。

StringifyMacro 的实现展示了基本模式:

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))"
    }
}

关键点:

  • 实现 ExpressionMacro 协议
  • expansion(of:in:) 接收语法树节点和上下文
  • 从参数列表中提取第一个表达式
  • 返回新的 ExprSyntax,用字符串插值构造展开结果
  • \(literal: ...) 把表达式描述转成字符串字面量

07:10

成员宏的实现

SlopeSubsetMacro 实现 MemberMacro 协议,为枚举生成 init? 方法:

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)]
    }
}

关键点:

  • declaration.as(EnumDeclSyntax.self) 把声明转成枚举声明
  • memberBlock.members 获取成员列表
  • compactMap 过滤出枚举 case 声明
  • flatMap { $0.elements } 提取所有 case 元素
  • InitializerDeclSyntaxSwitchExprSyntax 从 SwiftSyntax Builder 构造语法树
  • 遍历 case 元素生成对应的 switch branch
  • 返回 [DeclSyntax] 数组

19:25

注册宏插件

所有宏实现需要在编译器插件入口注册:

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

16:23

测试宏

SwiftSyntaxMacrosTestSupport 提供了 assertMacroExpansion 来测试宏展开结果:

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
]

测试成员宏:

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
    )
}

也可以测试错误场景:

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

泛化宏

SlopeSubset 可以泛化为 EnumSubset,接受一个泛型参数指定父枚举:

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

在宏实现中提取泛型参数:

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

这样 @EnumSubset<Slope> 就可以用于任何枚举子集。

31:03

核心启发

做一个安全的强制解包宏

团队里总有人写 ! 强制解包,出问题很难定位。可以做一个 #unwrap 宏,展开成带文件和行号的 preconditionFailure

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

入口:@freestanding(expression) + ExpressionMacro 协议。参考 #stringify 的实现,用 context.location(of:) 获取源码位置。

自动生成 Codable 的备选实现

有些模型需要自定义 CodingKeys,但大部分字段只是简单的 key 映射。可以写一个 @CustomCodable 宏,自动生成 init(from:)encode(to:),同时允许用属性标记覆盖特定字段的行为。

入口:@attached(member) + MemberMacro 协议。遍历属性声明,为每个属性生成对应的 decode/encode 代码。

为状态机生成转换代码

游戏或工作流中常有状态机,每个状态需要定义可转换到的目标状态。可以写一个 @StateMachine 宏,根据状态声明自动生成状态转换方法和有效性检查。

入口:@attached(member, names: arbitrary) + MemberMacro 协议。读取所有 case,为每个状态生成 canTransition(to:)transition(to:) 方法。

为 SwiftUI 预览生成多设备快照

可以写一个 #previewDevices 声明宏,展开成多个 .previewDevice 修饰符的组合,一次看到不同设备上的效果。

入口:@freestanding(declaration) + 生成多个 PreviewProvider 声明。

关联 Session

评论

GitHub Issues · utterances