Swift宏系统是Swift 5.9引入的一组编译期代码生成能力,它的核心思路是在编译过程中展开宏,将展开后的代码参与类型检查与代码生成。宏本身运行在独立的编译插件进程中,通过SwiftSyntax解析和操作源码树。与Objective-C时代的预处理宏不同,Swift宏是类型安全的,宏实现能拿到上下文信息,例如类型的成员列表、泛型实参等。使用@attached和@freestanding这两类宏,可以分别覆盖附着式与独立式代码生成场景。

我们先区分这两个角色。@freestanding宏由编译器在调用点展开,例如生成一个表达式或者一个声明;@attached宏则附着在某个类型或成员上,由编译器在类型处理阶段展开。理解这个区别后再动手创建工程,会更容易梳理宏声明、宏实现和宏测试三部分之间的关系。
创建Swift宏工程与基础环境
宏必须放在独立的Swift Package中。可以在终端执行以下命令快速生成模板:
swift package init --type macro
该命令会生成一个包含宏声明、宏实现和测试目录的Package。打开Package.swift可以看到,宏模块被声明为一个宏目标(macro target),并且关联了SwiftSyntax相关依赖。宏目标最终会编译成被编译器加载的插件。
宏声明使用@freestanding或@attached属性标注。比如声明一个字符串转字面量的表达式宏:
@freestanding(expression) public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "MyMacros", type: "StringifyMacro")
这里#externalMacro指定了宏的实现类型。注意泛型标记<T>在源码中就是尖括号,但在文章HTML里必须写成<T>。宏实现类型需要符合对应的协议,表达式宏对应ExpressionMacro协议。
实现一个表达式宏通常依赖SwiftSyntax遍历节点。下方代码展示了最基本的字符串化宏实现:
import SwiftSyntax
import SwiftSyntaxMacros
public struct StringifyMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) throws -> ExprSyntax {
guard let argument = node.arguments.first?.expression else {
throw MacroError.message("需要一个参数")
}
return "(\(argument), \(literal: argument.description))"
}
}
enum MacroError: Error {
case message(String)
}
这段代码从宏调用节点中取出第一个参数,拼接成元组返回。由于宏展开结果会再次参与类型检查,这里返回的源码字符串必须语法合法。\(literal:)是SwiftSyntax提供的字符串字面量插值,它会正确转义内容,避免产生非法字符串。
模块中的宏实现类还需要通过插件入口暴露给编译器,通常使用@main和CompilerPlugin组合声明。完整插件代码如下:
import SwiftCompilerPlugin
import SwiftSyntaxMacros
@main
struct MyMacrosPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
StringifyMacro.self
]
}
@freestanding宏的调用场景与展开细节
@freestanding宏以独立形态出现,可以生成表达式或声明。最典型的表达式宏使用方式是在调用点加上井号前缀。例如,对于前面声明的stringify宏,可以这样调用:
let result = #stringify(5 + 3) print(result.0) // 8 print(result.1) // "5 + 3"
展开后编译器实际看到的代码是(5 + 3, "5 + 3")。宏展开发生在类型检查之前,因此参数表达式5 + 3还未被求值,宏拿到的是源代码而不是运行时值。这是编译期宏与函数的重要区别。
@freestanding声明宏则用于生成一个或多个声明。比如生成一个常量数组的宏,可以这样声明:
@freestanding(declaration, names: named(makeArray)) public macro makeArray() = #externalMacro(module: "MyMacros", type: "MakeArrayMacro")
声明宏实现时返回DeclSyntax列表。下面是一个简单实现,生成一个返回字符串数组的静态属性:
import SwiftSyntax
import SwiftSyntaxMacros
public struct MakeArrayMacro: DeclarationMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
let decl: DeclSyntax = "static let items: [String] = [\"apple\", \"banana\", \"cherry\"]"
return [decl]
}
}
虽然这个例子的生成结果固定,但宏可以根据调用参数动态生成不同的声明内容。比如通过参数控制数组元素数量,或者根据当前模块的可用条件生成不同代码。实际项目中,@freestanding声明宏常用于生成测试用例入口、资源访问器或专用DSL。
需要留意的是,@freestanding宏展开时可见的上下文不能访问局部变量或函数参数,只能使用宏参数、类型信息和模块可见的符号。如果宏生成的代码引用了未在展开作用域可见的符号,编译会报错。因此声明宏通常要求通过names:参数声明可能引入的符号名,让编译器提前知道潜在冲突。
@attached宏如何附着到类型与成员
@attached宏附着在类型、扩展、属性或方法等声明上,典型用途包括自动生成协议遵循代码、成员变量和辅助方法。以自动实现CaseIterable协议为例,可以声明一个附加宏:
@attached(member, names: named(allCases)) @attached(conformance) public macro AutoCaseIterable() = #externalMacro(module: "MyMacros", type: "AutoCaseIterableMacro")
这里出现了两个@attached特性:@attached(member)表示会给类型添加成员,@attached(conformance)表示会给类型添加协议遵循。宏实现需要同时遵循MemberMacro和ConformanceMacro协议。
下面是一个针对枚举的简化实现。它会读取枚举的所有case,生成allCases静态数组:
import SwiftSyntax
import SwiftSyntaxMacros
public struct AutoCaseIterableMacro: MemberMacro, ConformanceMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard let enumDecl = declaration.as(EnumDeclSyntax.self) else {
throw MacroError.message("只能用于枚举")
}
let cases = enumDecl.memberBlock.members.compactMap { member -> String? in
guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else { return nil }
return caseDecl.elements.map { element in
if let rawValue = element.rawValue?.value {
return "\(element.name.text): \(rawValue.description)"
}
return element.name.text
}.joined(separator: ", ")
}
let joined = cases.joined(separator: ", ")
let allCasesDecl: DeclSyntax = "static let allCases: [Self] = [\(raw: joined)]"
return [allCasesDecl]
}
public static func expansion(
of node: AttributeSyntax,
providingConformancesOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [(TypeSyntax, GenericWhereClauseSyntax?)] {
return [("CaseIterable", nil)]
}
}
上面的代码从枚举声明中提取case名称,并生成一个allCases数组。这里的字符串插值使用了\(raw:),目的是把拼接好的代码原样插入,避免额外加引号。使用该宏时,只需要在枚举定义前标注@AutoCaseIterable:
@AutoCaseIterable
enum Direction {
case north, south, east, west
}
编译后枚举会自动获得CaseIterable协议遵循,并且实现allCases静态属性。宏展开是在编译期完成的,所以开发者无法在源码中看到这些生成代码,但可以通过Xcode的Expand Macro功能查看展开结果。右键点击宏使用位置,选择Expand Macro即可弹出展开面板。
@attached宏还可以附着到属性、方法或扩展上,生成配套的存储属性或辅助方法。例如一个自动生成didSet回调的宏,可以读取属性名称和类型,生成对应的通知发布代码。这类宏能显著减少Combine或者响应式编程中的样板代码,但也要注意生成代码的可读性和调试成本。
宏的测试、调试与常见问题
Swift宏自带测试支持。宏工程模板会生成一个测试文件,通过assertMacroExpansion函数直接验证宏展开结果。例如测试stringify宏:
import XCTest
import SwiftSyntaxMacrosTestSupport
import MyMacros
final class MyMacrosTests: XCTestCase {
func testStringify() {
assertMacroExpansion(
#"""
#stringify(5 + 3)
"""#,
expandedSource: #"""
(5 + 3, "5 + 3")
"""#,
macros: ["stringify": StringifyMacro.self]
)
}
}
测试宏时不需要真实编译整个模块,宏测试框架会模拟编译器上下文,将宏展开结果与预期字符串进行比较。这种测试速度很快,也便于覆盖各种边界情况。如果展开结果不匹配,框架会输出详细的差异信息,帮助定位问题。
调试宏实现时,可以在宏实现方法中加入print语句,但这些输出会出现在编译日志中,不会显示在普通运行窗口。更直观的方式是利用SwiftSyntax的语法树打印功能,在宏实现中将传入的节点描述打印出来。例如:
print(node.debugDescription)
这样可以看到宏调用点的完整语法树结构,方便确定如何提取参数。另一个常见问题是命名冲突。如果@attached成员宏生成的成员与类型现有成员同名,编译器会报重复定义。因此在声明宏时,尽量通过names:参数声明所有可能引入的符号名,让编译器提前检查。
宏的展开会增加编译时间。虽然单个宏开销很小,但如果一个大型模块里有数百处宏调用,编译速度可能明显下降。这与宏实现中遍历语法树的复杂度有关。建议宏实现尽量只遍历必要的节点,避免对整个源文件进行全量扫描。Swift编译器的增量编译可以缓存未变化的宏展开结果,但插件本身仍会在每次编译时加载执行。
选择@attached还是@freestanding
判断应该使用哪类宏,核心看代码生成的目标位置。如果希望生成独立表达式或独立声明,并且调用点明确,使用@freestanding;如果希望给现有类型、属性或方法添加额外成员或协议遵循,使用@attached。两者也可以组合使用,例如一个@attached成员宏内部可以调用@freestanding表达式宏生成初始化表达式。
总体来说,Swift宏把部分运行时工作转移到编译期,减少了手写重复代码,同时保留了类型安全。但宏不是万能钥匙,过度生成代码会让项目变得难以理解。建议先从简单的字符串化或成员生成宏开始,逐步扩展到更复杂的场景。掌握@attached与@freestanding的声明和实现流程后,你可以将团队的样板代码沉淀为宏库,提升整体开发效率。

