欢迎来到 Swift 宏的世界。我们将探索 Swift 5.9 版本中引入的一项革命性特性——宏(Macro),它为我们提供了一种在编译时处理源代码的能力。宏可以帮助我们自动化代码生成,减少模板代码,并使得代码更加简洁和易于理解。我们将从宏的基本概念开始,逐步深入了解关联宏(attached macro)和独立宏(freestanding macro)的区别与应用。接着,我们会学习如何通过 SwiftSyntax 库来解析和操作 Swift 代码的抽象语法树(AST),这是实现宏功能的基础。
在本篇详细的指南中,我们将深入探索Swift宏和SwiftSyntax库,逐步掌握Swift宏的精髓。以下是我们将要遵循的学习路线:
宏的基础知识:我们将从宏的定义入手,探讨它们在编译时如何转换源代码,以及如何利用宏减少重复代码的编写;
宏的分类:我们将区分并理解关联宏(attached macro)和独立宏(freestanding macro)的不同用途和特性;
宏的实现细节:通过具体的代码示例,我们将学习如何实现一个宏,包括宏的声明与实现的分离,以及如何遵循相应的协议;
深入SwiftSyntax:我们将深入了解SwiftSyntax库,探讨它如何提供操作Swift源代码的高级API,并学习如何使用这些工具来解析和生成代码。
01
宏Macro是什么
宏在编译时会将你的源代码进行转换,这样你就可以避免手动编写重复的代码。在编译过程中,Swift会在通常的代码构建之前将代码中的宏展开。扩展宏总是一种增量操作:宏会添加新的代码,但不会删除或修改现有的代码。
宏的输入和宏展开后的输出都会被检查,以确保它们是语法上正确的 Swift 代码。同样,您传递给宏的值以及由宏生成的代码中的值也会被检查,以确保它们具有正确的类型。此外,如果宏的实现在展开宏时遇到错误,编译器会将其视为编译错误。这些保证使得更易于理解使用了宏的代码,并且使得更易于识别诸如错误地使用宏或存在 bug 的宏实现等问题。
苹果按照宏不同的使用场景,将宏分成两个大类:关联宏(attached macro)和独立宏(freestanding macro) 。
了解了宏的基本概念后,我们进一步探讨宏的分类,特别是关联宏(attached macro)的特点和应用。
1.1关联宏(attached macro)
必须和另一个已有的类型或者是声明关联使用,以「@」号开头;每个宏都有一个或多个角色,这些角色在宏声明的开始部分以属性的形式定义。每个角色需要遵循不同的协议,实现对应函数,在函数内部实现宏展开的内容。
角色 | 协议 | 描述 |
---|---|---|
@attached(peer) |
PeerMacro | 为关联的声明添加一段新的声明 |
@attached(accessor) |
AccessorMacro | 为关联的声明添加存取代码(get、set 等) |
@attached(memberAttribute) |
MemberAttributeMacro | 为关联的类型或扩展添加新特性 |
@attached(member) |
MemberMacro | 为关联的类型或扩展添加新的声明 |
@attached(conformance) |
ExtensionMacro | 为关联的类型或扩展添加新的协议遵循 |
举例:你可以使用OptionSet
协议来表示bitset类型,其中每个比特代表一个集合的成员。在自定义类型中采用这个协议可以让你对这些类型执行集合相关的操作,如成员关系测试、并集和交集。
考虑下面的代码,它没有使用宏:
struct SundaeToppings: OptionSet {
let rawValue: Int
static let nuts = SundaeToppings(rawValue: 1 << 0)
static let cherry = SundaeToppings(rawValue: 1 << 1)
static let fudge = SundaeToppings(rawValue: 1 << 2)
}
在这段代码中, SundaeToppings
选项集中的每个选项都包含对初始化函数的调用,这很冗余且需要手动操作。在添加新选项时很容易出错,比如在行尾输入错误的数字。
这里有一个使用宏的代码版本:
@OptionSet<Int>
struct SundaeToppings {
private enum Options: Int {
case nuts
case cherry
case fudge
}
}
本版本的 SundaeToppings
调用了 @OptionSet
宏。该宏读取私有枚举中的case列表,为每个选项生成一组常量,并添加对 OptionSet
协议的遵从。
以下是扩展后的 @OptionSet
宏的示例:
struct SundaeToppings {
private enum Options: Int {
case nuts
case cherry
case fudge
}
typealias RawValue = Int
var rawValue: RawValue
init() { self.rawValue = 0 }
init(rawValue: RawValue) { self.rawValue = rawValue }
static let nuts: Self = Self(rawValue: 1 << Options.nuts.rawValue)
static let cherry: Self = Self(rawValue: 1 << Options.cherry.rawValue)
static let fudge: Self = Self(rawValue: 1 << Options.fudge.rawValue)
}
extension SundaeToppings: OptionSet { }
除了关联宏,Swift 还提供了独立宏(freestanding macro),它们在用法上有所不同。
1.2独立宏(freestanding macro)
独立宏在使用上无需和任何类型关联,以「#」号开头。独立宏可以声明一个新的类型,或者作为一段代码(表达式)的替换。
角色 | 协议 | 描述 |
---|---|---|
@freestanding(expression) |
ExpressionMacro | 创建一个有返回值的表达式 |
@freestanding(declaration) |
DeclarationMacro | 创建一个或多个声明 |
func myFunction() {
print("Currently running \(#function)")
#warning("Something's wrong")
}
在第一行代码中, #function
调用了 Swift 标准库中的 function()
宏。当您编译此代码时,Swift 会调用该宏的实现,将 #function
替换为当前函数的名称。当您运行此代码并调用 myFunction()
时,它会打印 "Currently running myFunction()"。在第二行代码中, #warning
调用了 Swift 标准库中的 warning(_:)
宏以生成自定义编译时警告。
独立的宏可以产生一个值,比如 #function
就会这样做,也可以在编译时执行一个操作,比如 #warning
就会这样做。
1.3命名规则
在命名规则上:
关联宏使用大写驼峰式命名法,类似于结构和类的名称;
独立宏的名称使用小写驼峰式命名法,类似于变量和函数的名称。
02
宏展开
当编写使用宏的 Swift 代码时,编译器会调用宏的实现来展开它们。
具体来说,Swift 以如下方式扩展宏:
编译器会读取代码,并创建一个内存中的语法表示;
编译器将内存中的部分表示发送给宏实现程序,该程序会展开宏;
编译器将宏调用替换为其展开形式;
编译器继续进行编译,使用扩展后的源代码。
let magicNumber = #fourCharacterCode("ABCD")
#fourCharacterCode
宏接受一个长度为 4 个字符的字符串,并返回一个无符号 32 位整数,该整数对应于字符串中字符的 ASCII 值之和。某些文件格式使用这样的整数来标识数据,因为它们格式紧凑且在调试器中仍有可读性。
要扩展上面代码中的宏,编译器会读取 Swift 文件并创建一个称为抽象语法树(AST)的内存中表示形式。AST 使代码结构显式化,这使得编写与该结构交互的代码变得更加容易——比如编译器或宏实现。以下是简化了一些额外细节的上述代码的 AST 表示形式:

上图显示了该代码在内存中的结构表示方式。AST中的每个元素都对应于源代码的一部分。“Constant declaration”AST元素下面有两个子元素,代表常量声明的两个部分:其名称和值。“Macro call”元素下面有子元素,代表宏的名称和传递给宏的参数列表。
在构建AST的过程中,编译器会检查源代码是否是合法的Swift代码。例如, #fourCharacterCode
需要一个单个参数,该参数必须是一个字符串。如果你尝试传递一个整数参数,或者忘记了字符串字面量的结尾的引号( "
),那么在编译过程中的这个阶段就会出现错误。
编译器会找到代码中你调用宏的位置,并加载实现这些宏的外部二进制文件。对于每个宏调用,编译器会将部分AST传递给该宏的实现。以下是部分AST的表示形式:

使用#fourCharacterCode
宏时,其实现代码在展开宏时会将该部分AST作为输入。宏的实现代码仅操作其接收到的输入部分AST,这意味着无论前后的代码是什么,宏始终以相同的方式展开。这种限制有助于简化宏展开的可理解性,并有助于加快代码构建速度,因为Swift可以避免展开未发生变化的宏。
Swift通过限制实现宏的代码来帮助宏作者避免意外读取其他输入:
传递给宏实现的AST仅包含表示宏的AST元素,而不包含该宏前后的任何代码;
宏实现在沙箱环境中运行,从而防止它访问文件系统或网络。
除了这些保护措施外,宏的作者还应确保不会读取或修改除宏输入以外的任何内容。例如,宏的展开不应依赖于当前的日期和时间。
执行 #fourCharacterCode
会生成一个包含扩展代码的新AST。以下是该代码返回给编译器的内容:

当编译器接收到这个扩展时,它会用包含宏调用的AST元素替换包含宏扩展的AST元素。在宏扩展之后,编译器再次检查以确保程序仍然是语法上正确的Swift代码,并且所有类型都是正确的。这会产生一个可以像往常一样编译的最终AST:
