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? 和转回 Slope 的 slope 属性。
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 元素- 用
InitializerDeclSyntax和SwitchExprSyntax从 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
- Expand on Swift macros — 深入理解宏的设计原则和更多角色类型
- Generalize APIs with parameter packs — Swift 泛型编程的高级特性
- Beyond the basics of structured concurrency — Swift 结构化并发的高级模式
评论
GitHub Issues · utterances