之前了解的宏
写过C++和OC的同学可能比较了解宏,一般应用场景是定义重复逻辑,最后在编译时进行文本替换。
#ifdef CCORE_IOS
// ...
#endif
#define HasFlag(flag, type) (((flag) == 0) || ((flag) & ((uint64_t)1 << (type))))
if (HasFlag(flag, value)) {
// ...
}
但是Swift在此之前并没有宏的概念,Swift中常见条件编译#if-#else-#end也不属于宏,只是预处理指令。想要实现类似C++和OC宏类似的能力只能通过定义全局变量和全局函数的方法实现。
什么是Swift Macros
Swift Marcos是WWDC23推出的新特性,在Swift5.9(Xcode15)中实现的能力,没有系统版本限制。Swift Marcos除了支持上述能力外还能通过修改语法树的元编程方式实现更多有意思的能力(减少体力活)。
下面是官方给出的一个Swift宏使用使用例子,我们希望同时得到表达式的结果以及表达式本身的字符串的元组,在此之前的Swift只能通过手动的方式一个个去写表达式的字符串,而通过#stringify这个宏很轻松的实现这种能力,通过宏展开可以看到自动帮我们完成字符串的填写,当然这个宏是自己实现的并非标准库内置的。
定义Swift Macro
开始可能会比较懵,但写过一遍后明白规则就很简单了,还是官方例子#stringify,定义宏有点像定义函数都有名称、入参和返回值,后面紧跟宏实现所在的模块和类名。
实现Swift Macro
实现Swift宏只需要实现相关协议函数即可,这个协议方法入参和返回值类型都有一个Syntax单词,是因为宏的能力是基于语法树去实现的,所谓的宏展开本质也是返回一个新的语法树去生成必要的代码。
再看控制台打印入参的内容,发现调用宏时的a+b会被拆分成语法树中的每一个Syntax,然后我们可以通过属性的方式获取这些信息。
最后返回值看似返回的是字符串,其实只是返回值的类型支持了字符串字面量创建的方式,而字符串内容会再次被拆分成各个Syntax,我们可以定义成变量po一下返回值的语法树结构,所以宏展开就能得到我们所期望的元组,看起来跟写脚本一样。
Swift** Macro编译过程**
When Swift sees you call a macro in your code, like the "stringify" macro from the Xcode macro package template, it extracts that use from the code and sends it to a special compiler plug-in that contains the implementation for that macro. The plug-in runs as a separate process in a secure sandbox, and it contains custom Swift code written by the macro's author. It processes the macro use and returns an "expansion," a new fragment of code created by the macro. The Swift compiler then adds that expansion to your program and compiles your code and the expansion together. So when you run the program, it works just as though you wrote the expansion yourself instead of calling the macro.
上面引用WWDC23原文,宏编译过程主要经历以下几个步骤:
-
将使用到宏的代码发送给包含该宏实现的特殊的编译器插件。
-
插件在沙盒安全的独立进程中执行宏的实现逻辑。
-
宏实现逻辑将返回一段新的代码块(宏展开)。
-
Swift编译器再将代码块添加回源码中一起参与编译。
(这里独立进程可以说明无法访问宏使用方的App沙盒)
再看语法树结构
现在我们了解宏的本质就是写一段新的语法树添加会源码中,那么了解语法树结构是整个宏实现过程的核心。我们再以一个struct Person类型了解下语法树结构,这里补充说明下@DictionaryStorage也是宏,用法后面再展开说明。
-
Person是个struct类型,因此语法树顶层是以Struct开头的StructDeclSyntax,当然也有对应其他类型的DeclSyntax,例如ClassDeclSyntax、EnumDeclSyntax、FuncDeclSyntax等。所以当我们编写宏时只需要关注宏作用在什么类型上,就转成对应的DeclSyntax即可。
-
Struct的内容会被拆分成特定的Syntax,然后可以通过属性的方式进行访问
(当然这里只举例了小部分的属性,实际开发时我们自行看下源码和调试时po下结构就知道有什么内容。)
Swift Macro种类
-
freestanding:独立宏,能够像函数调用的方式直接使用,不依赖上下文
-
attached:附加宏,不能直接使用,必须附加到其他声明代码上,给被标记的声明代码添加能力,注意附加宏展开后都是添加新的代码块,而不会修改原有的代码块。
Swift Macro指定命名
我们可以看到在定义宏时不管是freestanding宏还是attached宏都有一个names的参数,因为在宏展开后得到的新代码块会包含各种属性和函数,属性名和函数名可能会跟原本的代码中有命名冲突,可以通过names参数来指定命名。
@freestanding(declaration, names: arbitrary)
macro gyb(String, [Any]) = #externalMacro(module: "MyMacros", type: "GYBMacro")
@attached(peer, names: overloaded)
public macro AddCompletionHandler() =
#externalMacro(module: "MacroExamplesPlugin", type: "AddCompletionHandlerMacro")
-
overloaded:拥有相同的名字,可以用在函数重载的场景上
-
prefixed:宏添加相同名字声明,但添加指定的前缀
-
suffixed:宏添加相同名字声明,但添加指定的后缀
-
named:宏添加指定名字的声明,可以用在实现某些协议函数,因为我们比较明确宏展开后会有哪些名字
-
arbitrary:宏添加的名称无法用上述规则描述,宏展开后的名字都由宏实现本身决定,也是我们最常用的
可能的应用场景
现在我们把Swift宏的元编程能力基本都了解了,业务上以往一些有侵入性逻辑、维护成本大、迭代时容易遗漏的场景可以考虑通过宏的方案去解决:
-
自动实现对业务函数做无侵入打点、权限校验、阶段性数据缓存
-
自动实现线程安全数据结构
-
自动实现public struct的init方法
-
自动实现纯数据类型自定义序列化和反序列化
-
...
实践一个Swift Macro
目标:尝试实现一个PerformanceTrack宏,可以无侵入打印函数执行的耗时,对调用方无感知,调用原本的函数
// 原函数加上宏
@PerformanceTrack
func add(_ x: Int, _ y: Int) -> Int {
return x + y
}
print(add(1, 2))
// macro expands 期望宏展开后得到下面函数,调用方无感知
func add(_ x: Int, _ y: Int) -> Int {
let start = DispatchTime.now()
defer {
let end = DispatchTime.now()
let nanoseconds = end.uptimeNanoseconds - start.uptimeNanoseconds
let milliseconds = Double(nanoseconds) / 1_000_000
print("\(#function) 执行时间为 \(milliseconds) 毫秒")
}
return x + y
}
print(add(1, 2))
创建Swift Macro工程
Xcode新建Package工程选择Swift Macro,demo工程名比较随意就写了swift-macro,新建完成后得到上图工程文件
-
Sources
-
Tests
-
Dependencies
确定宏类型和宏展开
我们期望在main.swift通过上述方式添加宏来给add函数添加打印函数执行耗时的能力,首先比较明确宏是作用在函数上的,因此是个attached宏,宏展开的函数跟原函数在同一层级且没有属性没有协议实现,因此是个attached(peer)宏,命名上我们没有要求可以直接是arbitrary,最后我们打算把宏实现的类名定为PerformanceTrackMacro,因此在swift_macro.swift有宏的定义如下:
另外还有个问题是attached宏只会新增代码,对原来的函数不会有任何修改,相当于宏展开后将会有两个函数,而我们希望调用方还是无感知的调用原来的函数,宏展开的新函数不能与原函数有相同的函数签名否则会重复定义。
现在看来宏展开的新函数签名就不能与原函数相同,但是又要调用原函数,这种情况换在OC上是通过runtime method swizzling的方案去实现AOP,Swift虽然没有函数交换的能力,但是有函数替换的能力,通过给新函数加上@_dynamicReplacement标记和给原函数加上dynamic修饰符,就可以替换对应函数的实现。
在此之前Swift单独依靠@_dynamicReplacement标记是无法做到AOP的,因为替换的新函数并没有原来函数的实现逻辑,通过宏的元编程能力可以拿到原函数的语法树,相当于编译期拿到函数实现再进行函数替换。
宏实现、单测与调试
我们在swift_macroMacro.swift文件中实现宏,由于宏类型是**@attached**(peer, names: arbitrary),因此我们的宏也遵守PeerMacro协议,当然peer是attached的一个子类型,因此PeerMacro协议本身也遵守AttachedMacro协议,而最后AttachedMacro协议遵守Macro协议。接着我们需要实现PeerMacro协议的expansion函数,函数的返回值当然是个语法树,这里先返回空数组代表什么宏展开后代码都不生成。
新建工程时swift_macroMacro.swift文件中会自动生成一个swift_macroPlugin结构体,并且该结构体遵守CompilerPlugin协议,返回的providingMacros就是参与编译的宏类型,将我们的宏类型注册到编译插件中参与Swift Macro编译,这样我们就能main.swift中使用我们的宏,宏的展开就能执行我们的expansion函数,只不过目前展开没有任何新代码生成。
接下来开始编写语法树,即使我们在前面的内容了解过语法树的结构,我们还是会比较懵不知道怎么去写,这时候我们可以通过调试po的方式看看输入参数中原始函数的语法树结构。想要调试expansion函数,直接执行main.swift是无法断点的,只能通过单元测试的方式才能触发断点,幸运的是我们新建工程时就帮我们生成好单元测试的文件swift_macroTests.swift,我们参考模板中#stringify的单测来编写我们的宏的单测,我们先把PerformanceTrack先注册到testMacros中,再写我们的单测函数,这里调用的assertMacroExpansion第一个参数是展开前的代码,第二个参数是宏展开后的代码,第三个参数是用来查找宏,这里我只想看看语法树,所以第二个参数先不管。
这时我们就能断点到宏的expansion函数,前面介绍过宏作用的什么类型上,入参就是对应的类型DeclSyntax,这里宏作用在函数上,因此可以转成FunctionDeclSyntax类型,接着po下得到语法树就能看到每个Syntax。
po后我们知道原函数的语法树结构,现在也需要知道宏展开后我们所期望得到的函数的语法树结构,这样我们才知道应该怎么从原函数语法树结构转换得到新函数语法树结构,用同样的方法把单测的入参函数换成我们期望的函数。
通过断点po后我们看到宏展开函数的语法树结构,下图是两函数语法树的结构对比,可以明确我们需要做的事情:
-
attributes需要把PerformanceTrack改成_dynamicReplacement
-
modifiers需要把dynamic设置为nil
-
identifier需要把add改成add_PerformanceTrack
-
body中的statements需要添加两个代码块
动手写代码之前我们再了解一个东西,因为语法树每个属性都是Syntax类型,都遵守SyntaxProtocol协议,该协议有个便捷的with方法通过keypath的方式给属性赋值,同时返回一个新对象,为我们编写语法树结构时提供便捷的链式调用。
下面就是完整的实现过程,可以先看最下面的newFunc是通过funcDecl修改而来的,修改的过程通过with函数对属性进行设置,设置的属性就是上面提到要做的事情,最后把newFunc返回出去得到新函数。而所有的属性和值都可以按照po出来的语法树结构进行读取和赋值。另外我们可以注意到我们创建的各种Syntax都是通过字符串字面量的方式构建,因为代码本身就是字符串,对字符串内容进行拆分得到Syntax,所以语法树跟字符串是可以互相转换,所以我们只需要硬编码字符串插入变量就可以完成我们大部分的需求,掌握规则后就很简单了。
最后我们通过单测看输入输出结果是否符合预期,单测通过后我们在main.swift试运行下宏,还是调用原来的add函数,控制台打印出函数耗时,我们的宏就完成了。
错误抛出和Fix It
一般编写的宏都是为了实现特定功能,而且作用对象是很明确的,因此当宏被作用在不合符要求的地方时我们需要通过编译器报错提示错误原因,以及尽可能提供fix方案快速修正,可以使用Diagnostic类型来提供以下能力:
-
node:提示报错节点
-
message:提示error信息,需要实现DiagnosticMessage协议
-
fixIt:提供快速修正方案,需要实现FixItMessage协议
例如PerformanceTrack宏作用在非函数类型或者函数缺少dynamic修饰,我们先定义错误类型,message属性是DiagnosticMessage协议和FixItMessage协议需要实现的属性,用于编译器提示错误。
当宏作用在非函数类型时,宏expansion函数中无法转换成FunctionDeclSyntax类型,这里我们直接抛出错误,也不需要任何fix方案。
当宏作用的函数缺少dynamic修饰时,可以从语法树中检测到modifiers中没有包含dynamic。
接下来我们需要提供错误信息,以及fix方案,方案很简单,往原本函数的语法树modifiers属性添加dynamic就好了。而FixIt中的change是替换的是整个函数,是因为考虑到modifiers可选属性可能为nil则无法替换(nil的时候找不到要被替换的地方,也可能有办法做到但暂时没深入看)。
函数缺少dynamic修饰时,编译器就会提示错误信息,以及提供添加dynamic的fix方案(这里看其实是整个函数都被改了,因为我们上面替换的是函数node),点击fix按钮就会自动添加上dynamic修饰。
总结
Swift宏是一个很不错的元编程工具,iOS开发者可以通过熟悉的Swift语法实现各种工具和模板代码,不需要额外学习和使用其他元编程工具,毕竟对已有项目存量代码做修改用其他工具成本会比较大,同时我们在实现宏的过程中可以了解到Swift语法树的一些内容,但是需要注意宏是在编译时展开,留意宏带来编译耗时以及包体积的负向影响。