【WWDC23】一文看懂 Swift Macro

10,825 阅读29分钟

前言

今年的 WWDC 重头戏无疑是苹果首次发布的 Vision Pro 头显,以及配套的 visionOS,感谢苹果爸爸的努力,iOS开发终于又双叒叕 有人要辣!

除此之外,苹果也发布了一系列基建更新,其中包括 Xcode 15、Swift 5.9、iOS 17 等等,而 Swift 5.9 引入了一个重大更新——Swift 宏(Macro)。

关于宏的概念和使用在其他语言中并不陌生,如果写过 objective-c 的话,那最常见到的宏莫过于下面这一对

// 出自 FLEX 源码
#define weakify(var) __weak __typeof(var) __weak__##var = var;

#define strongify(var) \
_Pragma("clang diagnostic push") \
_Pragma("clang diagnostic ignored \"-Wshadow\"") \
__strong typeof(var) var = __weak__##var; \
_Pragma("clang diagnostic pop")

#endif

它们的用法也很简单

// 出自 FLEX 源码
weakify(self);
xxx = [NSNotificationCenter.defaultCenter
    addObserverForName:UIContentSizeCategoryDidChangeNotification
    object:nil queue:nil usingBlock:^(NSNotification *note) { 
                strongify(self)
                ···
    }
];

C 语言的宏,其本质是文本替换,说白了就是一堆支持一定插值能力的替换规则,并在编译过程中的预处理阶段将用到的宏按照规则替换成对应代码,然后参与后续的编译过程,这就导致这种宏存在以下问题

  1. 缺乏类型检查和编译校验,不能限制宏的使用范围,也感知不了宏展开后的源码是否满足语法语义要求

  2. 宏对源码的改动缺乏上下文信息,在使用到宏的地方,宏针对源码具体做了什么,很难被开发者轻松辨别

  3. 使用到宏的源码,如果想要了解宏展开后的代码,或者进一步针对这些代码进行断点调试,难度很大

Swift 宏的特点

而 Swift 宏一举解决了上述问题,并提出了宏设计的四个原则

  1. Dinstinctive use Sites

  2. Complete, type-checked, validated

  3. Inserted in predictable ways

  4. Macors are not magic

这些原则在今年 WWDC 的 Expand on Swift macros 有详细讲解,我这里就用直观的方式展示一下 Swift 宏的特点。

首先是 Swift 宏在 Xcode 里的展示,用到的 Swift 宏都会用 # 或者 @ 进行标识,就像下面这样,一目了然

image

其次,Swift 宏具备类型检查和语法树检查能力,能够限定宏的使用场景,还可以明确宏接受的参数类型,从这一点看,宏的实现和函数非常类似

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

而对于宏的不合理使用,可以抛出诊断信息,又称为 Diagnostic,可以支持宏开发者自定义,还支持 FixIt 的能力。

image

最后,Xcode 提供了预览宏展开的能力,直接对用到的宏右键就能看到预览按钮,预览速度也非常快,再也不用人肉分析宏展开的代码了。

image

Xcode 对于宏的支持非常强大,甚至到了能针对宏展开后的源码进行断点调试的地步,可以说是非常贴心了。

image

Swift 宏的安全性

Swift 宏在提供足够灵活性和易用性的同时,还提供了安全的代码转换能力,保证了宏对于源码有限的修改能力,这种安全代码转换主要体现在

  • Swift 宏只能新增源码,不能删除或者修改已有的源码,从而保证宏的代码插入是可预测的

例如下面这段代码,无论中间的 macro 是什么,都不会影响到 start 和 finish 方法的调用。

image

  • Swift 宏内部使用 SwiftSyntax 访问源码和组装新代码,确保了展开前后语法树的结构不会被破坏,也实现了类型检查和错误提示能力,这一点我们在后面的实战环节再展开讲述
// Swift 宏实现的核心方法之一
// DeclGroupSyntax 和 AttributeSyntax 都是 SwiftSyntax 提供的 Swift 语法树的模型类
static func expansion<
  Declaration: DeclGroupSyntax,
  Context: MacroExpansionContext
>(
  of node: AttributeSyntax,
  providingMembersOf declaration: Declaration,
  in context: Context
) throws -> [DeclSyntax]
  • Swift 宏的展开过程是安全的

这里先简单介绍下 Swift 宏的展开过程

image

  1. Swift 编译器从源码中提取宏的调用,转化为原始语法树,发送给包含宏实现的编译器插件(Compiler Plug-in)

  2. 编译器插件在 独立进程安全沙盒 中获取到原始语法树后,调用宏定义的实现

  3. 宏的实现完成对原始语法树的展开(expansion),并生成新的语法树

  4. 编译器插件将新的语法树序列化后插入到源码中,参与后续的编译过程

可以看到,编译器插件是一个独立进程,在这个进程里,Swift 限制了宏与外界交换信息的能力,例如文件读取、网络请求等等,如果调用相关系统 API,例如通过 FileManager 读取磁盘文件,将会直接返回错误。从而杜绝了我们在使用外部宏的过程中,无意中被外部宏非法攻击的风险(在宏插件中挖矿,想想还挺刺激🤔)。

"The file “xxx” couldn’t be opened because you don’t have permission to view it."

Swift 官方也明确建议我们不要在宏实现当中使用除了编译器提供的信息以外的其他任何信息,我们需要确保宏本身是一个纯函数(pure functions),只要编译器传入的语法树没有发生变化,我们的输出就不应该变化,这可以帮助编译器优化宏的展开,例如缓存展开后的代码等等。

而如果我们使用了额外的上下文信息,比如随机数(每次调用随机返回一段代码实现👻)、日期(根据单双号返回 true 和 false 👻),或者结合多个宏的上下文信息(通过宏来悄悄收集源码细节👻),这种宏的行为将是不可预知的。

开发 Swift 宏

环境准备

讲完了 Swift 宏的特点和安全性,终于我们可以进入到实战环节了,工欲善其事必先利其器,首先我们需要做好以下准备

  • macOS Ventura 13.3 以上操作系统

  • Xcode 15 以上,本文使用的版本是15.0 beta (15A5160n)

  • Swift 入门级语法(掌握 Hello World 的 4 种写法🙂)

然后我们需要初始化一个 Swift 宏开发工程。

  1. 直接打开 Xcode 15,File -> New -> Package

image

  1. 选择 Swift Macro 模版,然后给我们的工程起一个名字,我这里就叫做 “SwiftMacroKit”

image

  1. 打开工程,大功告成

这是一个 SPM 管理的工程,如果打开 Package 文件,我们可以看到它依赖了前面提到的 SwiftSyntax 库

dependencies: [
        // Depend on the latest Swift 5.9 prerelease of SwiftSyntax
        .package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0-swift-5.9-DEVELOPMENT-SNAPSHOT-2023-04-25-b"),
    ],

除此之外,工程其实还依赖了两个库, SwiftSyntaxBuilder 和 SwiftSyntaxMacros ,这三个库的职责分别是

  • SwiftSyntax:提供 Swift 语法树支持

  • SwiftSyntaxMacros:提供实现宏所需要的协议和类型

  • SwiftSyntaxBuilder:为展开新代码提供便捷的语法树创建 API

整个工程的源码主要有 4 个部分,截图示意如下

image

它们分别是

  • SwiftMacroKit:包含 Swift 宏的定义,注意这里不提供实现,但是会将定义与实现连接起来

  • SwiftMacroKitClient:一个测试工程(所以称为 Client),可以在 main 函数里测试 SwiftMacroKit 定义的宏

  • SwiftMacroKitMacros:宏的核心实现部分,最终打包成 macro 产物提供给其他模块使用,例如在 SwiftMacroKit 中引用

  • SwiftMacroKitTests:Swift 宏的测试模块,苹果官方推荐我们采用 TDD 的方式开发我们的宏,后面会讲到

Swift 宏分类

在开发第一个宏之前,我们还需要了解 Swift 提供了哪些宏。按照不同宏在源码中扮演的角色(role),以及在源代码中可以扩展的不同位置,Swift 将宏分为了两大类

  • 独立宏(Freestanding):顾名思义,可以独立存在的宏,不依赖于已有的代码实现

  • 绑定宏(Attached):需要绑定到特定源码位置的宏,包括类型、枚举、方法、函数等等

独立宏

进一步地,独立宏可以分为

  • 表达式宏(Expression)

  • 声明宏(Declaration)

表达式与声明是编程语言中两个特定的概念,为了偷懒,我这里就直接把 chatgpt 的解释搬过来了。

在计算机编程中,表达式和声明是两个不同的概念。

表达式表示计算和返回一个值的特定操作或语句。表达式可以包括基本的算术操作(例如:+、-、*、/ 等),逻辑操作(例如:&&、||、!等),还可以包括函数调用、变量赋值、字面量等。表达式通常会产生一个结果值,并且可以把这个值传递给其他的表达式或语句进行操作。

声明用于将一个实体引入到程序中,例如变量、常量、函数、类、结构体、枚举等。声明提供了实体的定义,包括其名称、类型、作用域等信息,并且告诉编译器如何在程序中创建该实体。声明并不执行任何操作,并且不会产生结果值。而是定义程序中特定实体的属性和行为,以及该实体如何执行操作。

总体来说,表达式和声明在编程语言中具有不同的作用和功能。表达式用于执行操作并输出结果值,声明用于引入实体,并定义其属性和行为。这两个概念在程序设计中都很重要,因为它们允许开发人员定义程序的运行和行为,并执行特定的计算和操作。

简单而言,表达式和声明的区别在于,表达式有一个返回值结果,而声明会引入新的实体,例如属性、类型等等,可以看到下面分别举了表达式和声明的三个例子,注意到,表达式的返回值是可以作为参数传递给其他函数的,而声明没有返回值,所以不能作为参数传递

var x: Int // 声明一个名为 x 的变量,类型为整数
x = 10 // 表达式,将 x 的值设置为 10
print(var y: Int) // Error: Expected expression in list of expressions
print(x = 10) // 打印结果:()

不过了解这些其实也没什么用,后面举个实际例子就能看明白了,现在我们只需要记得,独立宏分为表达式宏和声明宏,它们可以在代码中展开为表达式和声明。

绑定宏

绑定宏也可以进一步分为 5 类,分别标识了绑定宏所绑定的对象

  • 对等宏(peer)

  • 访问器宏(accessor)

  • 成员宏(member)

  • 成员属性宏(memberAttribute)

  • 一致性宏(conformance)

这些绑定宏的命名和它们的用法息息相关,我们就留到实战环节去讲解,需要说明的一点是,这里的中文名称都是我自己机翻的,不代表官方用法,如果担心产生误导,建议以英文为准。

实战环节

实战环节,我会按照上面的宏分类,依次以一个业务场景和技术领域里存在的问题为线索,分别实现出对应类别的宏。

表达式宏(Expression Macro)

由于独立表达式宏是第一个讲解的宏,所以我们会讲述的细致一些。

宏定义

我们以官方提供的 demo 为例,打开 SwiftMacroKit,也就是定义宏的文件,我们就可以看到模版自动生成的 stringify 宏。

/// A macro that produces both a value and a string containing the
/// source code that generated the value. For example,
///
///     #stringify(x + y)
///
/// produces a tuple `(x + y, "x + y")`.
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "SwiftMacroKitMacros", type: "StringifyMacro")

可以看到,第一行的 @freestanding(expression) 修饰符表明了这个宏是一个独立表达式宏,本质上这里的 freestanding 扮演的就是宏的角色。

第二行,我们用到了 macro 关键字来声明一个名为 stringify 的宏,它接受一个泛型参数 T,和一个 T 类型的参数 value,同时返回一个元组,分别包含一个 T 类型的实例和一个 String 字符串。整个宏定义非常类似于一个函数,这样一定程度上帮助我们降低了学习成本。

func stringify<T>(_ value: T) -> (T, String)

接下来,在等号右侧,我们用到了 externalMacro 关键字,它类似一个独立表达式宏,因为它用到了 # 前缀符号。externalMacro 用于引用一个外部模块的宏实现,这里我们将 stringify 的实现绑定到 SwiftMacroKitMacros 模块的 StringifyMacro,待会我们会看到 StringifyMacro 是什么。

通常我们都会采用这样的方式,在我们自己的工程里引入外部宏,苹果官方也提到了我们可以引入闭源实现的宏,或者直接在当前模块实现宏,因为我还没有全部尝试,所以后续就都以这种方式引入宏。

用法

我们还没有提到 stringify 宏的用法,它主要将传入的参数,与其对应的字符串表达组装成一个元组,作为表达式的返回值返回,例如下面这段示例代码

let 大锤 = 80
let 小锤 = 40
let 一杯宫廷玉液酒 = 180
let 一副拐 = 220

let (result, code) = #stringify(大锤 + 小锤 + 一杯宫廷玉液酒 + 一副拐)

print("\(code) = \(result)")

它将打印如下内容 (对不起,玩个烂梗)

大锤 + 小锤 + 一杯宫廷玉液酒 + 一副拐 = 520

宏的实现

接下来,我们就来看看 stringify 宏是如何实现的,我们打开 SwiftMacroKitMacro.swift

import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

/// Implementation of the `stringify` macro, which takes an expression
/// of any type and produces a tuple containing the value of that expression
/// and the source code that produced the value. For example
///
///     #stringify(x + y)
///
///  will expand to
///
///     (x + y, "x + y")
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))"
    }
}

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

可以看到,模板代码甚至连注释都写好了,详细解释了 StringifyMacro 的作用。我们直接看下代码。

首先看到,在代码最底部,有一个 @main 修饰的结构体 SwiftMacroKitPlugin,实际上这就是 SwiftMacroKitMacro 的 main 函数,它实现了 CompilerPlugin 协议,并通过 providingMacros 对外暴露了 StringifyMacro 的类型,由于所有的宏协议的核心方法都是静态方法,我们在这里不需要初始化任何宏对象,直接返回类型即可,这也符合我们将宏视为纯函数的设计原则。

而如果这里我们不暴露对应的宏,那么外部就无法引用到,我们用到宏的地方就会报错

image

最后让我们看看 StringifyMacro 这个结构体,它实现了 ExpressionMacro 协议,表明它是一个表达式宏,同样的,后续我们会看到,前面提到的全部 7 大类宏都有对应的协议

  • 独立宏

  • 绑定宏

而所有这些宏协议,都会提供一个核心的 expansion 方法用于实现宏展开,这里我们看到,SwiftMacroKitPlugin 就实现了这样一个 expansion 方法

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

我们先看下方法签名里的入参

  • node:代表宏本身,这里也就是 stringify 这个宏,我们能够访问到 node 的宏名称、参数列表等等

  • context:宏上下文,它可以帮助我们抛出 Diagnostic 诊断信息,还可以定位源码文件名和行数等等上下文信息

在方法实现中,我们就通过 node 的 argumentList 属性,取到了第一个参数及其表达式,根据前面的例子,以及我们的断点结果,可以看到 argument 长这样

image

可以看到,这里的 argument 实际上就是一个 SyntaxTree,上面有很多节点,包含了源码中的所有语法信息,我们可以根据我们的需求去遍历获取想要的内容。

在这里,我们直接将 argument 和 argument 的 description 属性,也就是 argument 的字符串表示,通过最后一行的字面量代码,组合为元组返回。没错,这里我们像写代码一样,把元组作为”字符串”返回了,非常简单直接。

return "(\(argument), \(literal: argument.description))"

但我们会发现,整个 expansion 方法需要返回一个 ExprSyntax,也就是表达式语法实体,而我们却返回了一个”字符串”,这样不会类型不匹配吗?

实际上,这里并不是返回字符串,想要验证这一点,我们可以换成下面的写法

let result = "Hello world" // result is String
return result

编译后我们就会发现 Xcode 因为类型不匹配直接报错了。

image

实际上在这里,我们是在通过 字符串****字面量 创建语法树,当我们编译宏的时候,Swift Parser 也会自动将这段字面量代码转换为 SyntaxTree,也就是 ExpreSyntax,这一特性可以帮助我们在不用深入了解语法树的基础上,也能通过字面量完成宏的实现,毕竟复制粘贴谁还不会了🙃

我们同时会发现,返回值的第二个参数用到了 literal 关键字,literal 可以帮助我们对传入的字符串变量里的字符进行转义以确保编码正确,例如,如果我们想让元组第二个字符串参数始终返回一个双引号

假如我们直接用原始字符串的值

return "(\(argument), \(raw: "\""))"

那么我们最终会发现代码展开后变成了这样,双引号直接导致语法错误。

let (result, code) = (大锤 + 小锤 + 一杯宫廷玉液酒 + 一副拐, ")

但如果我们用 literal 关键字,就可以得到正确的展开结果

public static func expansion(...) {
                  ···
        return "(\(argument), \(literal: "\""))"
}

// 宏展开后
let (result, code) = (大锤 + 小锤 + 一杯宫廷玉液酒 + 一副拐, #"""#)

当然知道这些其实也没什么用,只要记得带上 literal 大概率就不会有字符串转义问题就可以了,就算真的遇到了也会很容易发现问题所在。

宏的测试与调试

以上就是 stringify 的全部实现了,接下来我们应该继续讲解其他类型的宏,但为了方便理解,有必要在这里提一下如何测试和调试宏。

前面提到,我们是通过在 expansion 函数中断点的方式,来确定如何从 node 参数获取到我们想要的宏入参 ,而为了实现断点调试,就必须执行单元测试,在这里也就是 SwiftMacroKitTests.swift 文件。

let testMacros: [String: Macro.Type] = [
    "stringify": StringifyMacro.self,
]

final class SwiftMacroKitTests: XCTestCase {
    func testMacro() {
        assertMacroExpansion(
            """
            #stringify(a + b)
            """,
            expandedSource: """
            (a + b, "a + b")
            """,
            macros: testMacros
        )
    }
}

文件已经提供了两个单元测试方法,我们只看其中一个即可,这里用到了 assertMacroExpansion 方法,它可以根据传入的原始代码和预期的展开后代码,对宏进行测试,测试阶段,我们就可以在宏的 expansion 实现中加入断点调试了。assertMacroExpansion 还需要传入待测试的宏,这里传入的是文件最开头定义的 testMacros,如果我们加入了新的宏,也需要更新这里才能测试。

不过这里需要吐槽的是,像这种字符串代码的传入方式真的非常容易出错,也不是很直观,假如只是为了断点调试,还是比较麻烦的,希望以后能找到其他更好的调试方法。

声明宏(Declaration Macro)

其实独立表达式宏很多时候和一个函数定义很像,所以还是很容易上手的,接下来我们看独立宏的另一个类别,声明宏。

让我们考虑这样一个场景,我们有一些蛇形命名的字符串常量,希望将它们定义为常量,常量名是驼峰命名的,大概像这样

struct Constaints {
    
    public static var appIcon = "app_icon"
    
    public static var emptyImage = "empty_image"
    
    public static var errorTip = "error_tip"
}

每次新增一个常量,都需要我们重新定义变量名,会很麻烦,我们就可以用声明宏来解决这个问题。

首先让我们定义一个声明宏,可以看到我们接受一个 String 类型的参数。

@freestanding(declaration)
public macro Constant(_ value: String) = #externalMacro(module: "SwiftMacroKitMacros", type: "ConstantMacro")

接下来,我们去实现这个 ConstantMacro 类,别忘了在 SwiftMacroKitPlugin 中添加我们新创建的宏。

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

ConstantMacro 的实现非常简单,我们需要实现 DeclarationMacro 协议的 expansion 方法。

public struct ConstantMacro: DeclarationMacro {
    public static func expansion<Node, Context>(of node: Node, in context: Context) throws -> [DeclSyntax] where Node : FreestandingMacroExpansionSyntax, Context : MacroExpansionContext {
        guard
            let name = node.argumentList.first?
                .expression
                .as(StringLiteralExprSyntax.self)?
                .segments
                .first?
                .as(StringSegmentSyntax.self)?
                .content.text
        else {
            fatalError("compiler bug: invalid arguments")
        }
        
        let camelName = name.split(separator: "_")
            .map { String($0) }
            .enumerated()
            .map { $0.offset > 0 ? $0.element.capitalized : $0.element.lowercased() }
            .joined()
        
        return ["public static var \(raw: camelName) = \(literal: name)"]
    }
}

在方法实现里,我们首先通过 node.argumentList 获取到入参字符串 name。

如何确定访问路径呢,当然是通过断点调试,配合上控制台打印。由于我们想获取到 “error_tips” 这个字符串本身,所以我们需要将 expression 通过 as() 函数转换为字符串字面量表达式符号(StringLiteralExprSyntax),然后从 segments 中获取到第一段字符串,最终从 content 中取出字符串。

(关于 segments 的处理涉及到 SwiftSyntax 当中的字符串字面量的概念,为了简单处理,我们这里假设传入的字面量不包含段落和插值)

image

然后通过一系列高阶函数,我们将 name 转换为驼峰命名的 camelName。

在返回值这里,DeclarationMacro 和 ExpressionMacro 略有不同。因为表达式只能有一个返回值,所以 ExpressionMacro 只能返回一个 ExprSyntax,而 DeclarationMacro 允许我们创建多个声明,所以返回的是 DeclSyntax 数组。

在这里,我们就简单将 camelName 和 name 组合起来,形成一个静态属性返回出去,就完成了声明宏。

让我们看看实际使用的效果。

struct Constaints {
    
    #Constant("app_icon")
    #Constant("empty_image")
    #Constant("error_tip")
}

不出意外的话,Xcode 会抛出三个错误,大概意思是 name 不符合 Constant 宏。

image

出现这个错误的原因是因为声明宏要求我们展开宏以后所得到的 name 名称,无论是变量名、方法名还是类型名,都应该明确它的命名规则,以避免命名冲突。

例如下面这种代码,我们通过 Constant 所生成的 errorTip 变量将与 errorTip() 方法冲突。

struct Constaints {
    
    #Constant("error_tip")
    
    func errorTip() -> String {
        
    }
}

所以 Swift 宏规定了以下几种命名规则

  • overloaded:与绑定的命名完全一致

  • prefixed:添加具有相同基本名称的声明,但添加了指定的前缀

  • suffixed:与 prefixed 类似,使用后缀而不是前缀

  • named:宏添加具有特定的、固定的基本名称的声明

  • arbitrary:宏添加了一些其他名称的声明,这些名称无法使用任何这些规则来描述

其中前四种都是限定宏名称在特定范围的,官方文档提到使用这些规则能提升编译器对宏的处理性能,我们会在后面通过实际案例讲解。而这里,因为我们的变量名只能根据传入的静态字符串决定,所以我们选择最后一种规则 arbitrary。

@freestanding(declaration, names: arbitrary)
public macro Constant(_ value: String) = #externalMacro(module: "SwiftMacroKitMacros", type: "ConstantMacro")

要注意,表达式宏并不强制要求 names,因为表达式宏不太可能引入新的名称,而声明宏则是强制要求的,否则就会报错。

这里还有一个问题,我们对于 names 的限制是在声明宏的地方,但是实际上,真正决定名称规则的是宏的实现,这样相当于由宏的调用方反向限制宏的实现方,感觉设计上并不是很合理。按照 Swift 宏的设计文档 看,本意是希望根据 names 选择性展开宏,但从目前表现来看,并没有发现这种选择性的存在。

最后让我们看下宏展开后的预览效果,非常完美~

image

对等宏(Peer Macro)

看完了独立宏,我们接下来看看绑定宏,绑定宏的使用看起来就比较高级了,首先我们看看对等宏,这里 Peer 的意思是说,对等宏展开后的代码将与原绑定的元素处于同一层级,例如都在 toplevel,或者都在一个类、一个枚举里。

让我们思考一个实际问题,假设我们在一个模块内定义了一个基类 Merchant。

class Merchant: MerchantInterface {
    
    var name: String = ""

    func product(num: Int) {
        // ...
    }
}

模块内部分方法原本只接受基类作为参数,但随着业务发展,现在我们允许外部也传入和基类 Merchant 结构相同、但实现不同的示例,换句话说,我们希望抽象出一个协议 MerchantInterface,让外部可以传入自定义的模型参数。

public protocol MerchantInterface {
    var name: String  {
        get
    }

    func product(num: Int)
}

我们希望能基于 Merchant 的实现,自动地生成和更新对应的 MerchantInterface 协议类,避免重复枯燥的协议声明过程,这种场景就可以用对等宏来实现。

我们将根据 Merchant 的实现,取出它的属性和方法,组合成一个 MerchantInterface 协议,展开后它将附加在 Merchant 的后面,同时 Merchant 也可以实现这个协议,具体效果见下图。

image

我们需要实现一个 InterfaceGenMacro 的对等宏,宏的声明和注册我们就不赘述了,直接看下宏的核心实现吧。

public struct InterfaceGenMacro: PeerMacro {
    public static func expansion<Context, Declaration>(of node: AttributeSyntax, providingPeersOf declaration: Declaration, in context: Context) throws -> [DeclSyntax] where Context : MacroExpansionContext, Declaration : DeclSyntaxProtocol {
                ...
    }
}

InterfaceGenMacro 遵循了 PeerMacro 协议,所以要实现对应的 expansion 方法。我们可以将我们的诉求拆分为三个部分

  • 产生协议名

  • 产生协议中的变量声明

  • 产生协议中的方法声明

首先看下协议名,我们假定我们的 Merchant 只能是类,通过断点调试我们可以知道,expansion 方法的 declaration 参数会是一个 ClassDeclSyntax 类型的实例,所以我们先进行一次转换。

image

guard
    let classDecl = declaration.as(ClassDeclSyntax.self)
else {
    fatalError("compiler bug: invalid declaration")
}

稍后在这里我们可以抛出 Diagnostic,给出详细的错误信息。

接下来根据 ClassDeclSyntax 的结构,我们很容易知道如何获取类名,从而就可以确定协议的名称。

let className = classDecl.identifier.text // \(raw: className)Interface

然后,对 ClassDeclSyntax 进一步分析可以发现,它还有一个 members 的对象,其中包括两个 MemberDeclListItemSyntax 元素,从它们的 decl 对象名 VariableDeclSyntax 和 FunctionDeclSyntax 我们可以判断出一个是变量,一个是方法(从后面的字符串常量也可以分析出来)。

memberBlock: MemberDeclBlockSyntax
├─leftBrace: leftBrace
├─members: MemberDeclListSyntax
 ├─[0]: MemberDeclListItemSyntax
  ╰─decl: VariableDeclSyntax
    ├─bindingKeyword: keyword(SwiftSyntax.Keyword.var)
    ╰─bindings: PatternBindingListSyntax
      ╰─[0]: PatternBindingSyntax
        ├─pattern: IdentifierPatternSyntax
         ╰─identifier: identifier("name")
        ├─typeAnnotation: TypeAnnotationSyntax
         ├─colon: colon
         ╰─type: SimpleTypeIdentifierSyntax
           ╰─name: identifier("String")
        ╰─initializer: InitializerClauseSyntax
          ├─equal: equal
          ╰─value: StringLiteralExprSyntax
            ├─openQuote: stringQuote
            ├─segments: StringLiteralSegmentsSyntax
             ╰─[0]: StringSegmentSyntax
               ╰─content: stringSegment("")
            ╰─closeQuote: stringQuote
 ╰─[1]: MemberDeclListItemSyntax
   ╰─decl: FunctionDeclSyntax
     ├─funcKeyword: keyword(SwiftSyntax.Keyword.func)
     ├─identifier: identifier("product")
     ├─signature: FunctionSignatureSyntax
      ╰─input: ParameterClauseSyntax
        ├─leftParen: leftParen
        ├─parameterList: FunctionParameterListSyntax
         ╰─[0]: FunctionParameterSyntax
           ├─firstName: identifier("num")
           ├─colon: colon
           ╰─type: SimpleTypeIdentifierSyntax
             ╰─name: identifier("Int")
        ╰─rightParen: rightParen
     ╰─body: CodeBlockSyntax
       ├─leftBrace: leftBrace
       ├─statements: CodeBlockItemListSyntax
       ╰─rightBrace: rightBrace
╰─rightBrace: rightBrace

我们先处理变量名。

let variables = classDecl.memberBlock.members
    .compactMap({ member -> PatternBindingListSyntax? in
        member.decl.as(VariableDeclSyntax.self)?.bindings
    }).compactMap { bindings -> (String, String)? in
        guard
            // 获取变量名
            let variable = bindings.first?.pattern
                .as(IdentifierPatternSyntax.self)?
                .identifier.text,
            //获取类型
            let type = bindings.first?.typeAnnotation?.type.description
        else {
            return nil
        }
        return (variable, type)
    }
    .map({ "    var \($0.0): \($0.1) { get }" })
    .joined(separator: "\n")

可以看出,我们从 VariableDeclSyntax 中分别提取出变量名和类型,组成元组后,通过字符串插值,组合成变量声明,然后拼接成一个完整的字符串。

接下来是方法声明的处理。

let functions = classDecl.memberBlock.members
    .compactMap({ member -> FunctionDeclSyntax? in
        member.decl.as(FunctionDeclSyntax.self)
    }).compactMap({ funcDecl in
        var new = funcDecl
        new.body = nil // 去除方法体
        return new.description
    })
    .map({
        // 移除前后空格和换行符,并添加缩进
        "    \($0.trimmingCharacters(in: .whitespacesAndNewlines))"
    }).joined(separator: "\n")

为了生成协议中不带方法体的方法声明,这里我采用了一个 tricky 的方法,将 FunctionDeclSyntax 的 body 直接设置为空,最后通过裁剪拼接,生成方法声明。

最终,我们只需要将协议名、变量声明和方法声明组合起来,就完成了宏的实现,而展开效果就和前面展示的截图一模一样。

return ["""
        public protocol \(raw: className)Interface {
        \(raw: variables)
        
        \(raw: functions)
        }
        """
]

当然,为了便于讲解,这里的处理还是比较简单的,还有许多问题需要考虑,例如

  • 通过默认值进行类型推断的变量 var a = "ABC"

  • 带默认参数的方法 func(a: Int = 1024)

  • 区分变量的访问权限,只读 or 可读可写

这些都可以通过进一步完善 expansion 方法来实现。

抛出 Diagnostic

接下来,我们希望在开发者误用我们的宏的时候给出详细的提示,例如针对结构体或者枚举的时候。实际上我们现在也有错误处理,就是代码开头的 fatalError。

guard
    let classDecl = declaration.as(ClassDeclSyntax.self)
else {
    fatalError("compiler bug: invalid declaration")
}

想要抛出诊断信息,可以借助 expansion 的 context 参数,有两种方式。

  • 直接自定义 Error 并抛出
enum SwiftMacroKitError: CustomStringConvertible, Error {
    case unsupportType
    
    var description: String {
        switch self {
        case .unsupportType:
            return "不支持的类型"
        }
    }
}

{
    context.addDiagnostics(from: SwiftMacroKitError.unsupportType, node: node)
}

其中 node 参数需要给出 SyntaxTree 中的节点,错误信息也会附着在对应节点上,最终效果长这样

image

  • 通过自定义 DiagnosticMessage 实现
enum SwiftMacroKitDiagnostic: String, DiagnosticMessage {
    case unsupportType
    
          // 诊断信息类型,warning/error
    var severity: DiagnosticSeverity { return .error }
    
    var message: String {
        switch self {
        case .unsupportType:
            return "不支持的类型"
        }
    }
    
          // 诊断唯一标识
    var diagnosticID: MessageID {
        MessageID(domain: "SwiftMacroKitDiagnostic", id: rawValue)
    }
}

{
    context.diagnose(Diagnostic(node: node._syntaxNode, message: SwiftMacroKitDiagnostic.unsupportType))
}

相比于第一种方式,这种方式可以抛出更丰富的信息,例如诊断信息的锚点代码、高亮代码、FixIt 等等,但我没有深入尝试,这里就不细说了。

访问器宏(Accessor Macro)

访问器宏,顾名思义,可以拓展生成一个属性的访问器,包括 get、set、willSet、didSet 等等。在工程中,经常有一些变量需要实时持久化到本地,例如 UserDefault 当中,以前我们可以通过 PropertyWrapper 来实现,而现在,我们也可以通过访问器宏来实现。

让我们先看看最终效果,可以看到,我们提供了一个 UserDefault 宏,并直接用变量名作为 key,实现了 flag 变量和 tag 变量的持久化读写逻辑,当然,我们也可以给 UserDefault 加上参数,实现自定义 key 的设置。

image

细心的话会发现这里生成的 key 前面有一个空格,但宏展开的实现并没有加入空格,尝试了很多方法都去不掉,感觉是 Swift 宏的 bug,待我后续 oncall 完再贴结论吧, 先记个 TODO🙂

和之前一样,我们还是需要实现一个 UserDefaultMacro 类,它实现了 AccessorMacro 协议。

public struct UserDefaultMacro: AccessorMacro {
    public static func expansion<Context, Declaration>(of node: AttributeSyntax, providingAccessorsOf declaration: Declaration, in context: Context) throws -> [AccessorDeclSyntax] where Context : MacroExpansionContext, Declaration : DeclSyntaxProtocol {

    }
}

我们首先需要分析 declaration,找到变量名和对应的类型

    let binding = declaration.as(VariableDeclSyntax.self)?.bindings.first,
    let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text,
    let type = binding.typeAnnotation?.as(TypeAnnotationSyntax.self)?.type.as(OptionalTypeSyntax.self)?.wrappedType.description
else {
    fatalError("compiler bug: unknown error")
}

接下来就是实现 getter 和 setter

let getter = AccessorDeclSyntax.init(accessorKind: .keyword(.get), bodyBuilder: {...})
let setter = AccessorDeclSyntax.init(accessorKind: .keyword(.set), bodyBuilder: {...})

我们没有像之前直接用字符串字面量生成代码,而是调用了 AccessorDeclSyntax 的初始化方法来构建 Syntax,它接受一个访问器类别,这里我们用 keyword 分别指定了 get 和 set。

第二个参数是一个 resultBuilder,如果熟悉 SwiftUI 的话应该不会感到陌生,在 bodyBuilder 里我们可以执行多行表达式,最终都会合并为一个结果返回出去,例如下面这样的写法,可以在 setter 里生成三行代码

let setter = AccessorDeclSyntax.init(accessorKind: .keyword(.set), bodyBuilder: {
    DeclSyntax(stringLiteral: "print(\"before set \(name)\")")
    DeclSyntax(stringLiteral: """
    UserDefaults.standard.setValue(newValue, forKey: \"\(name)\")
    """)
    DeclSyntax(stringLiteral: "print(\"after set \(name)\")")
})

image

当然这里我们并不需要,我们直接通过字符串字面量就生成好了持久化代码。

let getter = AccessorDeclSyntax.init(accessorKind: .keyword(.get), bodyBuilder: {
    DeclSyntax(stringLiteral: """
    UserDefaults.standard.value(forKey: \"\(name)\") as? \(type)
    """)
})
let setter = AccessorDeclSyntax.init(accessorKind: .keyword(.set), bodyBuilder: {
    DeclSyntax(stringLiteral: """
    UserDefaults.standard.setValue(newValue, forKey: \"\(name)\")
    """)
})

以上就是访问器宏的一个简单使用。

成员属性宏(Member Attribute Macro)

我们的 UserDefaultMacro 已经非常好用了,但是如果属性多了,我们的代码可能会变成这样。

image

这样当然是不能忍的,有没有办法自动给所有属性加上 @UserDefault 宏呢?我们可以用成员属性宏来实现,成员属性宏可以给一个类型的成员加入新的属性,其中就包括加入宏,当然这里也体现出宏展开的一个”递归”的特点,也就是可以在一个宏的展开中引入新的宏,新的宏还会进一步展开,就像下面这样

image

第一层宏展开后

image

第二层宏展开后,得到了最终的代码。

image

代码实现也非常简单,我们甚至不需要新创建宏,直接拓展 UserDefaultMacro,让它实现 MemberAttributeMacro 协议即可。

extension UserDefaultMacro: MemberAttributeMacro {
    public static func expansion<Declaration, MemberDeclaration, Context>(of node: AttributeSyntax, attachedTo declaration: Declaration, providingAttributesFor member: MemberDeclaration, in context: Context) throws -> [AttributeSyntax] where Declaration : DeclGroupSyntax, MemberDeclaration : DeclSyntaxProtocol, Context : MacroExpansionContext {        
        return [.init(stringLiteral: "@UserDefault")]
    }
}

当然这里的实现比较简单,没有考虑很多边界 case,比如如果属性已经加入了 UserDefaultMacro,就不能重复添加了,至于为什么不考虑,具体原因见下图

image

成员宏(Member Macro)

成员宏与成员属性宏很像,但作用不同,成员宏可以为类型增加新成员,成员包括属性、方法、枚举等等。

众所周知,Swift 中的 struct 和 class 天生有一个不同点,就是在打印对象的时候,struct 可以打印出属性细节,而 class 只有一个类型,像下面这样

struct Product {
    var name: String = "Apple Vision Pro"
    var price: Int = 25888
}

class BusinessModel {
    var count: Int = 0
    var tag: String?
}

print(Product()) // Product(name: "Apple Vision Pro", price: 25888)
print(BusinessModel()) // SwiftMacroKitClient.BusinessModel

而 class 如果也想拥有这一特性,就需要开发者自己遵循 CustomStringConvertible 协议,然后实现一个 description 的字符串属性,这样就可以定制字符串的内容了。但一般在开发中,我们并不想高度定制化的 description,只要能像 struct 一样打印出所有属性即可,这一需求就可以用成员宏来实现。

我们定义一个 DescriptionMacro,让它继承自 MemberMacro。

public struct DescriptionMacro: MemberMacro {
    public static func expansion<Declaration, Context>(of node: AttributeSyntax, providingMembersOf declaration: Declaration, in context: Context) throws -> [DeclSyntax] where Declaration : DeclGroupSyntax, Context : MacroExpansionContext {
        guard let classDecl = declaration.as(ClassDeclSyntax.self) else {
            fatalError("compiler bug: unknown error")
        }        
    }
}

第一步我们要求被绑定的元素是 ClassDeclSyntax,这里可以按照前面所说,抛出 Diagnostic。

然后还需要校验类是否遵循了 CustomStringConvertible 协议,我们还是需要借助断点调试来确认节点和类型信息。

guard
    let inheritedTypeCollection = classDecl.inheritanceClause?.inheritedTypeCollection,
    inheritedTypeCollection.compactMap({
        $0.typeName.as(SimpleTypeIdentifierSyntax.self)
    }).contains(where: { typeSyntax in
        typeSyntax.name.text == "CustomStringConvertible"
    })
else {
    fatalError("compiler bug: need `CustomStringConvertible` conformance")
}

接下来我们取出类名和所有属性名,并开始组装代码

let className = classDecl.identifier.text
let variables = classDecl.memberBlock.members
    .compactMap({ member -> PatternBindingListSyntax? in
        member.decl.as(VariableDeclSyntax.self)?.bindings
    }).compactMap { bindings -> String? in
        bindings.first?.pattern
            .as(IdentifierPatternSyntax.self)?
            .identifier.text
    }
    .map({ "\($0): \\(\($0))" })
    .joined(separator: ", ")

最后,只需要按照 struct 的打印格式,产生出对应的 description 定义即可。

return [
    """
    var description: String {
        "\(raw: className)(\(raw: variables))"
    }
    """
]

让我们看看最终效果

image

不过成员宏由于可能引入新成员,进一步引入新的命名,所以也需要指定命名规则,我们先回忆一下前面提到的规则。

  • overloaded:与绑定的命名完全一致

  • prefixed:添加具有相同基本名称的声明,但添加了指定的前缀

  • suffixed:与 prefixed 类似,使用后缀而不是前缀

  • named:宏添加具有特定的、固定的基本名称的声明

  • arbitrary:宏添加了一些其他名称的声明,这些名称无法使用任何这些规则来描述

那么这里因为 DescriptionMacro 一定会引入的是 description,所以适用于 named 规则。

@attached(member, names: named(description))
public macro Description() = #externalMacro(module: "SwiftMacroKitMacros", type: "DescriptionMacro")

假如我们的宏会根据方法名或者类型名添加固定前缀或者后缀,就可以用 suffixed 和 prefixed,如果我们新增的名称与绑定的命名一致,就可以用 overloaded。

当然如果我们声明了对应的命名规则,却又不按规则办事,Xcode 也会直接报错。(只是这个模糊的错误信息可能会让你摸不着头脑)

@__swiftmacro_19SwiftMacroKitClient13BusinessModel11DescriptionfMm_.swift:1:5: Declaration name 'descriptions' is not covered by macro 'Description'

但是这些规则并不能完全解决命名冲突的问题,让我们看看下面这个 DescriptionMacro 的实现。

return [
    """
    var description: String {
        var content = "\(raw: className)"
        content += "(\(raw: variables))"
        return content
    }
    """
]

我们生成的代码里引入了 content 局部变量,用来暂存和拼接需要打印的内容,大部分情况下这并没有什么问题,但如果遇到下面这样的 BusinessModel 就会出问题了。

@Description
class BusinessModel {
    var count: Int = 0
    var tag: String?
    var content: Data? // 同名 content 属性
}

我们直接看下打印结果

print(BusinessModel()) // BusinessModel(count: 0, tag: nil, content: BusinessModel)

content 明明是空 Data,结果却打印出来了 BusinessModel,让我们看下宏展开后的代码

image

没错,因为局部变量优先级更高,我们生成的代码错误地将局部变量 content 带入到了字符串里。所以为了避免这种变量命名的冲突,Swift Macro 提供了一个方法用于生成独一无二的、与上下文变量名不会冲突的变量名,用法也很简单。

let contentVariable = context.makeUniqueName("content")
return [
    """
    var description: String {
        var \(raw: contentVariable) = "\(raw: className)"
        \(raw: contentVariable) += "(\(raw: variables))"
        return \(raw: contentVariable)
    }
    """
]

而宏展开后的代码就变成了这样,这么一长串变量名就很难冲突了。

var description: String {
    var $s19SwiftMacroKitClient13BusinessModel11DescriptionfMm_7contentfMu_ = "BusinessModel"
    $s19SwiftMacroKitClient13BusinessModel11DescriptionfMm_7contentfMu_ += "(count: \(count), tag: \(tag), content: \(content))"
    return $s19SwiftMacroKitClient13BusinessModel11DescriptionfMm_7contentfMu_
}

一致性宏(Conformance Macro)

DescriptionMacro 已经很优秀了,但是还是有一个小瑕疵,那就是它要求被绑定的类型要遵循 CustomStringConvertible 协议,为什么不能直接连协议都帮我们实现好呢?

一致性宏就可以做到这一点,它可以给类型新增协议,甚至协议的泛型约束都可以,也就是我们常见到的 “where xxx“ 语句,让我们看下如何实现。

我们仍然借助 DescriptionMacro 来实现,直接遵循 ConformanceMacro 协议即可。

extension DescriptionMacro: ConformanceMacro {
    public static func expansion<Declaration, Context>(of node: AttributeSyntax, providingConformancesOf declaration: Declaration, in context: Context) throws -> [(TypeSyntax, GenericWhereClauseSyntax?)] where Declaration : DeclGroupSyntax, Context : MacroExpansionContext {
        return [("CustomStringConvertible", nil)]
    }
}

可以看到,ConformanceMacro 的 expansion 函数需要返回一个元组数组,元组内第一个元素是协议声明,第二个就是泛型约束语句,这里我们不需要就直接返回 nil。

现在对于想要实现 CustomStringConvertible 协议的类,我们就只需要用一个 DescriptionMacro 来修饰即可。

@Description
class BusinessModel {
    var count: Int = 0
    var tag: String?
    var content: Data?
}

让我们看看展开效果,是不是很完美~

image

总结

以上就是我基于 WWDC2023 放出的 Swift Macro 相关信息总结的所有内容了,总结起来,Swift Macro 能做的事情有点类似元编程,也就是通过程序来生成程序,而在 Swift Macro 出现之前,我们也有类似的解决方案,比如 SourcerySwiftGen 等等。

现在 Swift Macro 可以部分地替代它们的能力,有了 Apple 生态的加持,Swift Macro 与现有工程的结合可能是它的一部分优势,比如我们上面所开发的 SwiftMacroKit,是可以通过 SPM 分发到其他工程的,甚至直接本地路径依赖就可以使用。

再比如 Xcode 原生支持宏展开预览、调试等等能力,以及对语法树的访问和生成,都是第三方工具有实现成本的地方。

但就目前而言,Swift Macro 也有一定局限性,以及不完善的地方,从我自身的体验简单总结了几点

  • 由于 Macro 的安全性设计,导致宏无法与被绑定源码外的信息交互,例如工程信息、磁盘数据甚至远端数据等等,导致适用范围不如第三方工具

  • Macro 的开发和调试成本相对较高,需要对 SwiftSyntax 有一定了解才能开发出功能完备的宏

  • Macro 还处于初期阶段,存在很多 bug 和问题(当然也可能单纯是我不会用),影响使用体验,包括但不限于

* 字面量生成的代码,对于缩进的处理比较简陋

* 宏展开后有时候会产生奇奇怪怪的空格

* Xcode 的宏展开预览 bug,有时候会遇到无法展开的问题,这一点很影响实际开发

* 独立声明宏生成带默认值的实例属性后,Xcode 会提示未初始化

从性能考量出发,Swift Macro 也不适合大规模使用,毕竟宏需要在每次编译运行时单独执行一定的代码逻辑,从本质上说这是一种用(编译)时间换(模版代码)空间、以及(开发者)开发时间的策略,假如我们在宏当中进行了非常复杂的逻辑,最终仅仅只是生成一段固定格式的代码,也许直接通过自动化脚本一次性生成好代码会是更优解。


相关资料