广告位 · 页头(后台-模块管理-新建模块后替换此区域)

Swift宏开发如何入门?理解@attached与@freestanding的编译时代码生成

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

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的声明和实现流程后,你可以将团队的样板代码沉淀为宏库,提升整体开发效率。

广告位 · 文章底部(后台-模块管理替换此区域)