深入理解Swift Macro

avatar
@比心

关于宏的概念和使用我们并不陌生,说到宏大家第一时间想到的就是C语言的宏。例如有下面定义

#define PI 3.14159
#if !defined(MIN)
    #define __NSMIN_IMPL__(A,B,L) ({ __typeof__(A) __NSX_PASTE__(__a,L) = (A); __typeof__(B) __NSX_PASTE__(__b,L) = (B); (__NSX_PASTE__(__a,L) < __NSX_PASTE__(__b,L)) ? __NSX_PASTE__(__a,L) : __NSX_PASTE__(__b,L); })
    #define MIN(A,B) __NSMIN_IMPL__(A,B,__COUNTER__)
#endif

#if !defined(MAX)
    #define __NSMAX_IMPL__(A,B,L) ({ __typeof__(A) __NSX_PASTE__(__a,L) = (A); __typeof__(B) __NSX_PASTE__(__b,L) = (B); (__NSX_PASTE__(__a,L) < __NSX_PASTE__(__b,L)) ? __NSX_PASTE__(__b,L) : __NSX_PASTE__(__a,L); })
    #define MAX(A,B) __NSMAX_IMPL__(A,B,__COUNTER__)
#endif

使用起来也比较简单:

double area = PI * radius * radius; 
int larger = MAX(a, b);
int smaller = MIM(a, b);

从定义和使用方式上我们可以有如下理解:C语言宏是一种预处理能力,用于在代码编译之前对文本进行处理,将一组代码片段按照规则替换为另外一组代码片段后再进行后续编译的过程,从而实现代码重用、简化代码量的目的。

Swift宏介绍

Swift5.9 正式引入的 Swift 宏,较原有C语言宏更加强大,它在原有宏支持基础上新增了:类型检查,获取宏展开后的上下文,支持错误抛出和诊断等能力。

为什么要引入Swift宏?

按照Apple在WWDC所述:为了消除重复的代码,让简化这类乏味工作变得更加容易。从而引入了 Swift 宏,由于宏的定义和实现是放在单独的 Swift Package 中,使其也同时具备了可共享的特点。 在此之前,Swift 已经内置了很多支持在编译期自动展开的功能。如我们常用的 Property Wrapper, Result builder 等。

图片

在以前,如果上述的功能不能满足我们的需求,那我们的做法就是扩展 Swif 语言自身(得益于 Swift 是开源的)。但是引入 Swift 宏后,我们就可以按照个人需求来对 Swift 语言进行扩充。

Swift 宏特点

Apple 对 Swift 宏的设计,提出了四个原则:

  •  特定的使用场景 针对不同的使用场景,Swift Macro提供了两种类型的宏:
    独立宏(Freestanding macro): 用于创建一个表达式(expression)或者定义(declaration),以 # 开头
return #unwrap(icon, message: "should be in the app bundle")

    绑定宏(Attached macro):用于附加在另外的一个声明上,以 @ 开头

@AddCompletionHandler func sendRequest() async throws -> Response

如果没有看到# 和 @ 开头的定义,那么它一定不是宏 -- WWDC

  • 代码完整、类型检查和合法性校验 无论是传入宏的参数还是宏展开后的代码,都必须是完整的,并且也会经过类型检查。宏可以自动验证输入的合法性,如参数数量、参数类型等。对不合理的使用,可以抛出诊断信息,又称为Diagnostic,可以支持开发者自定义,还支持FixIt的能力

    图片

  •  以可预测的方式嵌入 宏在提供灵活性和易用性的同时,提供了安全的代码转换能力。

    1. 宏的展开是以可预测的、增量的方式添加到程序中。宏只能向程序中增加代码,不能删除或者更改已有的代码。下图中,无论宏的内容是什么,都不会影响到start( )和finish( )方法的调用。

    图片

    2. 宏的展开过程是安全的。编译器插件是一个独立的进程,在这个进程中,Swift限制了宏与外界交换信息的能力,如文件读取、网络请求等。

  •  宏不是魔法 Xcode 15.0支持了宏展开(Expand Macro)的功能。 在使用宏的地方,右键点击就能看到预览按钮

    图片

    帮助开发者了解到正在使用的宏只是代码的展开而不是魔法,并且做到了对展开后的代码进行断点调试。

图片

Swift 宏开发

#stringify, 第一个Swift macro

在开发自定义的宏之前,我们先看一下Apple提供的模版宏:#stringify 做好以下准备:1. macOS Ventura 13.3以上系统 2. Xcode 15以上 3.默认你会Swift基本语法 前面提过,为了方便分享Swift宏是通过Package的方式创建(SPM不了解的可以参考文章:www.swift.org/package-man…

创建Package

打开Xcode 15, File-> New ->Package

图片

在新窗口中,选择Swift Macro

图片

输入Package的名字,这里你可以选择直接将Package通过Local Packge的方式添加到工程中去,也可以单独创建Package,然后在已有的工程中单独添加。

图片

创建完成后,就可以看到下面的文件结构:

图片

上面的文件都是创建后自动生成的:

  •  [Macro name].swift 文件是声明你自定义宏签名的地方(外部调用的时候所指定的宏名称就是在这里定义的,并且在这里关联到具体宏的实现)。
  •  main.swift 文件可以在这里对定义好的宏进行测试
  •  [Macro name]Macro.swift 文件是具体宏功能实现的地方
  •  [Macro name]Tests.swift 文件是对自定义宏进行单元测试的地方

代码分析

下面通过对 #stringfy 的分析,来了解下Swift宏的使用和实现。

宏定义

打开MyMacro.swift文件,发现这里是对宏的声明。使用 macro 关键字定义了宏对外的名字。

图片

这里 @freestanding(expression) 修饰符表明这个宏是一个独立表达式宏。 第二行我们看起来和函数的定义像,不过这里是通过 macro 关键字来定义了一个名为 stringify 的宏,它接受一个泛型参数,返回一个元组,包含了一个T类型的实例和一个String类型字符串。另外我们也可以看得出来#externalMacro 也是独立表达式宏。这里通过 module 指定对应宏实现所属的模块,type 指定了对应宏的具体实现。

宏实现

MyMacroMacros 就是宏的具体实现。我们看下,#stringify 是如何实现其功能的。

图片

这里的结构体 StringfyMacro 就是我们之前所对应的 type 名称。它遵循 ExpressionMacro协议。

图片

这里通过左边 MacroProtocols 下面的文件命名我们可以推测:如果我要定义一个独立表达式宏,则需要遵循 ExpressionMacro 协议, 如果我要定义一个独立声明宏, 则需要遵循 DeclarationMacro 协议。 如果我要定义其他的绑定宏,则要实现AccessorMacro、AttachedMacro等。 这里我们针对 expansion 协议方法进一步分析, 它有两个入参和一个返回值:

  •  node: 代表宏的语法树结构体,可以通过node获取语法树的Tokens,进而获取参数、宏名称等信息

  •  context: 代表当前宏所在的上下文。里面记录了当前宏所在文件,宏被展开后的具体代码位置信息等等,同时也是向调用者展示错误和诊断信息的一个媒介

  •  ExprSyntax: 宏展开后的语法树。在本例中,返回了一个字符串。 这里,我们不由会有一个疑问,整个expansion方法需要返回一个ExprSyntax,而我们却返回一个字符串,这样不是类型不匹配了吗? 那我们改一下代码,编译后发现报错类型不匹配

    图片

所以,实际上在这里,系统是通过字符串字面量创建语法树,当我们编译宏的时候,Swift Parser 会自动地将这段字面量代码转换为 SyntaxTree,也就是 ExprSyntax。有了这一特性,我们就可以在不需要深入了解语法树的基础上通过字面量完成宏的实现。 例如 对于下面的表达式:

let node: ExprSyntax = "let sum = a + b"

会被解析为:

图片

另外,我们发现在生成字符串的时候,返回值中用到了 literal 关键字,这里 literal的作用是为了帮助我们对传入的字符串变量的字符进行转义以确保编码正确。 当然,最后不要忘记在 MyMacroPlugin 的数组中注册StringifyMacro,不然会导致外面使用的时候引用不到。

图片

如果自定义宏报这种错提示,注意查看 MacroPlugin 中是不是忘记注册了。

宏背后的原理

下面深入了解一下,是怎么做到把 #stringify(a + b) 展开的。

宏展开

我们从宏的定义和宏注册部分看,

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

/// 宏注册
@main
struct MyMacroPlugin: CompilerPlugin {
    let providingMacros[Macro.Type] = [        StringifyMacro.self,    ]
}

图片

  1.  Swift compiler识别到宏,将其发送给包含宏实现的Compiler plugin
  2.  Compiler plugin 在独立进程和安全沙盒中调用宏定义实现
  3.  宏的实现完成对原始语法树的展开,并生成新的语法树
  4.  Compiler plugin 将新语法树序列化后插入到源码中,参与后续的编译

宏实现

在Compiler plugin中,我们通过 #externalMacro 来建立宏声明和实现之间的链接,它本质上也是一个宏。下面来具体看一下, StringfyMacro 是如何工作的。

图片

在 StringfyMacro 中,要根据不同的Role来实现不同的协议,Swift 提供了不同的Role来满足不同的使用场景。

图片

可以看到,上面的 @freestanding(expression) 就是一种Role。所有要实现的协议,都有共同的方法:expansion, 需要实现该方法,返回展开后宏的内容。 在Swift macro中,无论是宏的定义还是展开后的宏,都是通过特定的语法树结构来描述的,也就是 AST 。SwiftSyntax 提供了源码和语法树之间互转的能力。比如,对于 #stringify(2 + 3),SwiftSyntax 会把它解析为一个语法树。相反地,会把我们在 expansion 方法中构造的语法树,转换为源码

图片

通过这两步转换,可以顺利地把宏展开。

实战环节

下面针对表达式宏和绑定宏分别给到两个具体实战例子。 例子一:我们有这样一个诉求,在编译阶段校验一段字符串地址的合法性,合法的话返回该字符串的URL编码值,不合法的话就将错误抛出。 如果按照我们传统方法实现,大概方法如下:

let validUrl = {
  guard let temp = URL(string"https://www.baidu.com"else {
    // Throw compiler error
  }
  return temp
}( )

那么按照Swift macro 的目标,我们大概想做成这个样子

let validUrl = #URL("https://www.baidu.com")

具体实现 这里我们抛开前面创建Package的过程,直接到创建完Package以后。 首先我们要明确,我们需要实现的是一个表达式宏。所以在宏定义中,我们实现如下:

@freestanding(expression)
public macro URL(_ stringLiteral: String) -> URL = #externalMacro(module:"SwiftMacroExample"type"URLMacro")

这里我们映射了宏的具体实现为 URLMacro类。那么我们继续实现URLMacro

public struct URLMacroExpressionMacro {
    public static func expansion(
        of nodesome FreestandingMacroExpansionSyntax,
        in contextsome MacroExpansionContext
    ) throws -> ExprSyntax {
        print(node.argumentList.map { $0.expression })
        #wanring(TODO: 取出宏参数进行编码)
        return "URL(string: "https://www.baidu.com")!"
    }
}

这里我们先看下log内容

[StringLiteralExprSyntax
├─openingQuote: stringQuote
├─segments: StringLiteralSegmentListSyntax
│ ╰─[0]: StringSegmentSyntax
│   ╰─content: stringSegment("https://www.baidu.com")
╰─closingQuote: stringQuote]

这里我们需要将segments数组的第一个元素的content内容取出来,进行URL编码,所以代码继续编写如下:

public struct URLMacroExpressionMacro {
    public static func expansion(
        of nodesome FreestandingMacroExpansionSyntax,
        in contextsome MacroExpansionContext
    ) throws -> ExprSyntax {
        guard
            /// 1.提取宏定义输入的参数,当然我们只有一个,所以这里取first
            let argument = node.argumentList.first?.expression,
            /// 2. 确保参数中包含一段字符串字面量
            let segments = argument.as(StringLiteralExprSyntax.self)?.segments,
            segments.count == 1,
            /// 3. 提取实际的字符串字面量
            case .stringSegment(let literalSegment)? = segments.first
        else {
            throw URLMacroError.requiresStaticStringLiteral
        }

        /// 4. Validate whether the String literal matches a valid URL structure.
        guard let _ = URL(string: literalSegment.content.text) else {
            throw URLMacroError.malformedURL(urlString: "(argument)")
        }

        return "URL(string: (argument))!"
    }
}

这里我们考虑到入参错误,编码错误的情况,需要对错误进行抛出,所以自定义了Error

enum URLMacroErrorErrorCustomStringConvertible {
    case requiresStaticStringLiteral
    case malformedURL(urlStringString)

    var descriptionString {
        switch self {
        case .requiresStaticStringLiteral:
            return "字符串编码失败"
        case .malformedURL(let urlString):
            return "输入的字符串格式不合法: (urlString)"
        }
    }
}

所以如果我们写的url不合法,则报错如下

图片

最后别忘记了单元测试:

let testURLMacro: [String: Macro.Type] = [
    "URL": URLMacro.self,
]

final class MyMacroTests: XCTestCase {
    func testURLMacroExpanation() {
        assertMacroExpansion(
            """
            #URL("https://www.baidu.com")
            """,
            expandedSource:
            """
            URL(string: "https://www.baidu.com")!
            """,
            macros: testURLMacro
        )
    }
}

例子二:我们使用枚举类型,判断是否是特定的枚举值的时候,需要手动判断。

enum Animal {
  case Duck
  case Elephant
  case Monkey
}

let animal: Animal = .Duck

print ("animal is duck: (animal == .Duck)")

有了 Swift 宏后, 我们可以对每个枚举的类型自动生成判断方法,避免了每次都需要手动做比较的过程。 这里对现有的 case 的每个条件进行拓展, 所以使用到 @attached(memeber) 定义如下:

@attached(member, names: arbitrary)
public macro CaseDetection() = #externalMacro(
    module"MyMacroMacros",
    type"CaseDetectionMacro"
)

member 作为一种 role, 则需要实现 MemberMacro protocol

public struct CaseDetectionMacroMemberMacro {
  public static func expansion<
    DeclarationDeclGroupSyntaxContextMacroExpansionContext
  >(
    of nodeAttributeSyntax,
    providingMembersOf declarationDeclaration,
    in contextContext
  ) throws -> [DeclSyntax] {
    declaration.memberBlock.members
      .compactMap { $0.decl.as(EnumCaseDeclSyntax.self) }
      .map { $0.elements.first!.name }
      .map { ($0$0.initialUppercased) }
      .map { original, uppercased in
        """
        var is(raw: uppercased): Bool {
          if case .(raw: original) = self {
            return true
          }

          return false
        }
        """
      }
  }
}

/// TokenSyntax 拓展
extension TokenSyntax {
  fileprivate var initialUppercased: String {
    let name = self.text
    guard let initial = name.first else {
      return name
    }

    return "(initial.uppercased())(name.dropFirst())"
  }
}

最终实现结果如下:

图片

限定只能用于枚举类型,定义如下

图片

报错提示:

图片

总结

如我们所看到的:如果要使用宏,以后可能会花费比较多的时间去实现一个宏来完成简单任务。但是,一旦掌握了宏的用法,它们就会变得非常有用,而且可以为你节省大量的模板代码。当然宏的使用,也不是一概而论的,如果仅仅是生成一段固定格式代码可能我们通过脚本反而更快。作为开发者,我们需要做的就是灵活地根据自身需求去选取更适合的工具。不过相信随着Swift版本的迭代,会有更多高效、实用的宏被创造出来。

image.png