WWDC Quick Look 💓 By SwiftGGTeam
Expand on Swift macros

Expand on Swift macros

观看原视频

Highlight

Swift 宏通过角色(Role)系统约束宏的能力边界,提供独立宏(#)和附属宏(@)两大类共七种角色,让开发者在编译期安全地生成代码,同时保持输入类型检查、展开可预测、结果可调试。

核心内容

Swift 已经有很多自动生成代码的特性:Codable 自动合成 CodingKeys 和编解码方法,Result Builder 把声明式语法展开成命令式代码。但这些特性都硬编码在编译器里,想要新增类似能力必须修改编译器源码。

宏把这个能力开放给了开发者。你可以创建自己的”语言特性”,以 Swift Package 的形式分发,不需要 fork 编译器。

00:50

四大设计原则

Swift 宏的设计遵循四个核心目标:

显式标识。独立宏以 # 开头,附属宏以 @ 开头。没有看到这两个符号,就可以确定没有宏参与。这让代码阅读者有明确的预期。

类型安全。传入宏的参数必须是完整的、类型正确的表达式。#unwrap(1 + ) 会直接报语法错误,@AddCompletionHandler(parameterName: 42) 会报类型不匹配。宏的输入和输出都经过完整的类型检查。

可预测展开。宏只能添加代码,不能删除或修改已有代码。你不需要担心某个宏会偷偷删掉你的函数调用。

透明可调试。Xcode 支持右键查看宏展开结果、在展开代码中设断点、单步调试。即使宏来自闭源库,这些工具照样工作。

03:16

独立宏

独立宏用 # 调用,分两种角色:

表达式宏@freestanding(expression))展开成一个表达式。#stringify(a + b) 展开成 (a + b, "a + b"),可以在任何需要表达式的地方使用。

声明宏@freestanding(declaration, names: arbitrary))展开成一个或多个声明。比如 #makeArrayND(n: 5) 可以展开成 Array5D 结构体的完整定义。

04:51

附属宏

附属宏用 @ 标注在声明上,分五种角色:

peer@attached(peer, names: overloaded))在目标声明旁边添加新的声明。@AddCompletionHandler 可以为 async 函数自动生成一个带 completion handler 的重载版本。

@AddCompletionHandler(parameterName: "onCompletion")
func fetchAvatar(_ username: String) async -> Image? {
    // ...
}

// 宏展开后自动生成:
func fetchAvatar(
    _ username: String,
    onCompletion: @escaping (Image?) -> Void
) {
    Task.detached {
        onCompletion(await fetchAvatar(username))
    }
}

accessor@attached(accessor))为存储属性添加 getter/setter。@DictionaryStorage 可以把属性的读写自动代理到字典中。

memberAttribute@attached(memberAttribute))为类型的成员添加属性。@DictionaryStorage 标注在结构体上时,会自动为每个属性添加 @DictionaryStorage

member@attached(member, names: named(dictionary), named(init(dictionary:))))为类型添加新成员。@DictionaryStorage 会自动生成 dictionary 属性和 init(dictionary:) 构造器。

conformance@attached(conformance))为类型添加协议遵循。@DictionaryStorage 会自动让结构体遵循 DictionaryRepresentable 协议。

一个宏可以同时拥有多个角色。@DictionaryStorage 就同时实现了 accessor、memberAttribute、member 和 conformance 四种角色,把原本需要写几十行的样板代码压缩成一行标注。

11:23

实现机制

宏的实现写在独立的编译器插件中。声明用 #externalMacro 关联到实现:

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

实现需要导入 SwiftSyntax、SwiftSyntaxMacros 和 SwiftSyntaxBuilder:

import SwiftSyntax
import SwiftSyntaxMacros
import SwiftSyntaxBuilder

struct DictionaryStorageMacro: MemberMacro {
    static func expansion(
        of attribute: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        return [
           "init(dictionary: [String: Any]) { self.dictionary = dictionary }",
           "var dictionary: [String: Any]"
        ]
    }
}

18:01

错误报告

宏可以通过 context.diagnose() 向编译器报告错误:

struct DictionaryStorageMacro: MemberMacro {
    static func expansion(
        of attribute: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        guard declaration.is(StructDeclSyntax.self) else {
            let structError = Diagnostic(
                node: attribute,
                message: MyLibDiagnostic.notAStruct
            )
            context.diagnose(structError)
            return []
        }
        return [
            "init(dictionary: [String: Any]) { self.dictionary = dictionary }",
            "var dictionary: [String: Any]"
        ]
    }
}

enum MyLibDiagnostic: String, DiagnosticMessage {
    case notAStruct

    var severity: DiagnosticSeverity { return .error }

    var message: String {
        switch self {
        case .notAStruct:
            return "'@DictionaryStorage' can only be applied to a 'struct'"
        }
    }

    var diagnosticID: MessageID {
        MessageID(domain: "MyLibMacros", id: rawValue)
    }
}

这样当开发者把 @DictionaryStorage 用在枚举上时,Xcode 会直接显示错误提示。

25:17

命名声明

宏展开中生成的新声明需要在宏的 names: 参数中预告。这帮助编译器在宏展开前就了解程序结构:

  • named(xxx) — 生成名为 xxx 的声明
  • overloaded — 生成与目标同名的重载声明
  • arbitrary — 生成任意名称的声明(需要显式声明)
@attached(conformance)
@attached(member, names: named(dictionary), named(init(dictionary:)))
@attached(memberAttribute)
@attached(accessor)
macro DictionaryStorage(key: String? = nil)

@attached(peer, names: overloaded)
macro AddCompletionHandler(parameterName: String = "completionHandler")

@freestanding(declaration, names: arbitrary)
macro makeArrayND(n: Int)

35:44

详细内容

#unwrap 宏的实现

#unwrap 是一个实用的表达式宏,把危险的 ! 强制解包替换成带详细错误信息的 preconditionFailure

let image = #unwrap(downloadedImage, message: "was already checked")

// 展开后:
{ [downloadedImage] in
    guard let downloadedImage else {
        preconditionFailure(
            "Unexpectedly found nil: 'downloadedImage' was already checked",
            file: "main/ImageLoader.swift",
            line: 42
        )
    }
    return downloadedImage
}()

实现时需要处理几个细节:

  1. 捕获变量名可能与外部作用域冲突,需要用 context.makeUniqueName() 生成唯一名称
  2. 错误信息中要包含原始表达式的描述,用 originalWrapped.description
  3. 文件和行号通过 context.location(of:) 获取
static func makeGuardStmt(
    wrapped: TokenSyntax,
    originalWrapped: ExprSyntax,
    message: ExprSyntax,
    in context: some MacroExpansionContext
) -> StmtSyntax {
    let messagePrefix = "Unexpectedly found nil: '\(originalWrapped.description)' "
    let originalLoc = context.location(of: originalWrapped)!

    return """
        guard let \(wrapped) else {
            preconditionFailure(
                \(literal: messagePrefix) + \(message),
                file: \(originalLoc.file),
                line: \(originalLoc.line)
            )
        }
    """
}

关键点:

  • \(literal: messagePrefix) 把字符串作为字面量插入,避免转义问题
  • context.location(of:) 返回原始表达式的源码位置
  • context.makeUniqueName() 生成不会与外部变量冲突的唯一标识符

30:17

@DictionaryStorage 的完整演化

@DictionaryStorage 展示了如何组合多个角色来逐步简化代码。

原始代码:

struct Person: DictionaryRepresentable {
    init(dictionary: [String: Any]) { self.dictionary = dictionary }
    var dictionary: [String: Any]

    var name: String {
        get { dictionary["name"]! as! String }
        set { dictionary["name"] = newValue }
    }
    var height: Measurement<UnitLength> {
        get { dictionary["height"]! as! Measurement<UnitLength> }
        set { dictionary["height"] = newValue }
    }
    var birthDate: Date? {
        get { dictionary["birth_date"] as! Date? }
        set { dictionary["birth_date"] = newValue as Any? }
    }
}

第一步:用 accessor 角色为每个属性添加 getter/setter:

struct Person: DictionaryRepresentable {
    init(dictionary: [String: Any]) { self.dictionary = dictionary }
    var dictionary: [String: Any]

    @DictionaryStorage var name: String
    @DictionaryStorage var height: Measurement<UnitLength>
    @DictionaryStorage(key: "birth_date") var birthDate: Date?
}

第二步:加上 memberAttribute 角色,让结构体上的 @DictionaryStorage 自动为每个属性添加 @DictionaryStorage

@DictionaryStorage
struct Person: DictionaryRepresentable {
    init(dictionary: [String: Any]) { self.dictionary = dictionary }
    var dictionary: [String: Any]

    var name: String
    var height: Measurement<UnitLength>
    @DictionaryStorage(key: "birth_date") var birthDate: Date?
}

第三步:加上 member 角色,自动生成 init(dictionary:)dictionary 属性:

@DictionaryStorage
struct Person {
    var name: String
    var height: Measurement<UnitLength>
    @DictionaryStorage(key: "birth_date") var birthDate: Date?
}

第四步:加上 conformance 角色,自动遵循 DictionaryRepresentable

@DictionaryStorage
struct Person {
    var name: String
    var height: Measurement<UnitLength>
    @DictionaryStorage(key: "birth_date") var birthDate: Date?
}

原本 20 多行的样板代码,最终压缩成 5 行。

17:28

测试宏

SwiftSyntaxMacrosTestSupport 提供了 assertMacroExpansion

import MyLibMacros
import XCTest
import SwiftSyntaxMacrosTestSupport

final class MyLibTests: XCTestCase {
    func testMacro() {
        assertMacroExpansion(
            """
            @DictionaryStorage var name: String
            """,
            expandedSource: """
            var name: String {
                get { dictionary["name"]! as! String }
                set { dictionary["name"] = newValue }
            }
            """,
            macros: ["DictionaryStorage": DictionaryStorageMacro.self]
        )
    }
}

38:28

核心启发

自动为异步函数生成回调版本

@AddCompletionHandler 的思路可以扩展。很多项目需要同时维护 async 版本和回调版本。写一个宏自动从 async 函数生成 completion handler 版本,可以消除大量重复代码。

入口:@attached(peer, names: overloaded)。读取函数的参数和返回类型,生成一个调用原函数并在 Task 中 await 结果的新函数。

自动生成 Equatable/Hashable 的自定义实现

Swift 自动合成的 Equatable 有时不够用。可以写一个 @CustomEquatable 宏,根据属性标记决定哪些字段参与比较、如何比较。

入口:@attached(member) + MemberMacro。遍历属性,为标记了 @CompareBy 的属性生成对应的比较逻辑。

为 SwiftUI 生成预览代码

写一个 @PreviewVariants 宏,标注在 View 上,自动生成多个预览变体(不同尺寸、不同颜色模式、不同动态字体)。

入口:@attached(member)。读取 View 类型,生成多个 PreviewProvider 实现。

自动生成 Builder 模式代码

很多类需要 Builder 模式来构造复杂对象。写一个 @Builder 宏,为类自动生成对应的 Builder 类和链式调用方法。

入口:@attached(peer)。读取类的所有属性,生成一个同名的 Builder 类,每个属性对应一个 withXxx() 方法和一个 build() 方法。

关联 Session

评论

GitHub Issues · utterances