V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
iOS 开发实用技术导航
NSHipster 中文版
http://nshipster.cn/
cocos2d 开源 2D 游戏引擎
http://www.cocos2d-iphone.org/
CocoaPods
http://cocoapods.org/
Google Analytics for Mobile 统计解决方案
http://code.google.com/mobile/analytics/
WWDC
https://developer.apple.com/wwdc/
Design Guides and Resources
https://developer.apple.com/design/
Transcripts of WWDC sessions
http://asciiwwdc.com
Cocoa with Love
http://cocoawithlove.com/
Cocoa Dev Central
http://cocoadevcentral.com/
NSHipster
http://nshipster.com/
Style Guides
Google Objective-C Style Guide
NYTimes Objective-C Style Guide
Useful Tools and Services
Charles Web Debugging Proxy
Smore
RayJiang9
V2EX  ›  iDev

开始写一个 Swift 宏吧

  •  
  •   RayJiang9 · 2023-06-15 11:44:09 +08:00 · 1819 次点击
    这是一个创建于 527 天前的主题,其中的信息可能已经有所发展或是发生改变。

    image-20230612150131818.png

    什么是宏

    Apple 在 Swift 5.9 里面加入了 Swift macros (宏),宏可以在编译的过程中帮我们生成一些需要重复编写的代码。WWDC 23 中有两个关于宏的 Session ,Expand on Swift macros 介绍了什么是宏和宏的几种类型,Write Swift macros 介绍了怎么去写一个宏。这两个 Session 介绍了每种宏可以做什么,但是缺少了详细的代码,我不知道具体要怎么去实现我想要的效果,在查阅了一些资料Swift 官方库的内部实现之后才知道每个宏的定义和用法。

    宏类型介绍

    宏主要分为两种类型:

    @freestanding 是一个独立的宏(与 # 语法一起使用),并且可以用作表达式。

    @attached 是一个附加宏(与 @ 语法一起使用),需要搭配 struct/class/enum/property/function 等类型使用,可以为其添加代码。

    每个类型的宏具体能干什么?

    @freestanding(expression)

    编写一段代码使其返回一个值。

    let url = #URL("https://www.baidu.com")
    // 宏内部会判断该字符串能否生成 URL ,如果无法生成会报错,将运行报错提前到了编译阶段。
    let url = #URL("https:// www.baidu.com") // 报错:UnableToCreateURL
    

    宏生成代码:

    let url = URL(string: "https://www.baidu.com")!
    
    @freestanding(declaration)

    宏可以写在任意地方,可以创建一段或多段代码。

    #guardValue(self)
    

    宏生成代码:

    guard let self = self else { return }
    
    @attached(peer)

    宏会在同个代码层级生成一段代码。

    @AddCompletionHandler()
    func fetchDetail(_ id: Int) async -> String? { }
    

    宏生成代码:

    // 宏会在同个代码层级生成代码
    func fetchDetail(_ id: Int, completionHandler: @escaping (String?) -> Void) {
      Task {
        completionHandler(await fetchDetail(id))
      }
    }
    

    该宏来自 Swift 官方库 声明 实现

    目前在 beta 1 中生成出来的代码无法直接被调用,不清楚是否是宏写的有问题,还是有 Bug 。我更倾向这是 Bug ,上面提到的 #guardValue 宏也无法调用到解包后的变量。如果是我用法的问题,麻烦在评论区告诉我。

    @attached(accessor)

    可以给变量生成 get 、set 、willSet 、didSet 等方法。

    class Foo {
        @PrintWhenAssigned
        var name: String = ""
    }
    
    let f = Foo()
    f.name = "Tom" // Logs: Tom
    f.name = "Bob" // Logs: Bob
    

    宏生成代码:

    class Foo {
        @PrintWhenAssigned
        var name: String = ""
        {
            didSet {
                print(name)
            }
        }
    }
    
    @attached(memberAttribute)

    可以给 struct/class/enum 等里面的属性、方法加上 attribute ,比如 @property 、宏 等。

    @TestMemberAttribute
    public class Foo {
        var name: String = ""
        func foo() { }
    }
    

    宏生成代码:

    @TestMemberAttribute
    public class Foo {
        @SomeMacro
        var name: String = ""
        @SomeMacro
        func foo() { }
    }
    
    @attached(member)

    可以给 struct/class/enum 添加属性、方法。

    @CaseDetection
    enum Animal {
        case cat(String)
    }
    

    宏生成代码:

    @CaseDetection
    enum Animal {
        case cat(String)
      
        var isCat: Bool {
            if case .cat = self { true }
            else { false }
        }
    }
    

    宏的实现代码在后面的案例中。

    @attached(conformance)

    可以给 struct/class 添加协议和约束。

    @TestConformance
    struct Foo { }
    

    宏生成代码:

    extension Foo : SomeProtocol where AAA: BBB {}
    

    怎么自己创建宏

    写宏的准备工作

    1.创建工程

    新建一个 Swift Macro Package ,Xcode -> File -> New -> Package ,选择 Swift Macro

    Swift Macro 需要依赖 apple/swift-syntax 第三方库,这是 Apple 的词法分析库,用于解析、检查、生成和转换 Swift 源代码。

    创建完成后,我们可以看到项目的结构是这样的:

    ├── Package.resolved
    ├── Package.swift
    ├── Sources
    │   ├── MyMacro
    │   │   └── MyMacro.swift // 宏声明文件
    │   ├── MyMacroClient
    │   │   └── main.swift // 可运行文件,可以在这里测试宏的实际效果
    │   └── MyMacroMacros
    │       └── MyMacroMacro.swift // 宏实现文件
    └── Tests
        └── MyMacroTests
            └── MyMacroTests.swift // 宏测试文件,用于编写、调试宏
    
    2.宏实现文件

    我们先打开 MyMacroMacro.swift 写一下上面提到的 @CaseDetection 宏。先让宏遵守 MemberMacro 协议,然后点击报错让 Xcode 生成协议方法,生成之后先返回一个空数据,并将断点打到 return [] 上面,不着急写宏。

    public struct CaseDetectionMacro { }
    
    extension CaseDetectionMacro: MemberMacro {
        
        public static func expansion<Declaration: DeclGroupSyntax, Context: MacroExpansionContext>(
            of node: AttributeSyntax,
            providingMembersOf declaration: Declaration,
            in context: Context
        ) throws -> [DeclSyntax] {
            return []
        }
    }
    

    然后我们需要在底部将宏加到 MyMacroPlugin 里面。

    @main
    struct MyMacroPlugin: CompilerPlugin {
        let providingMacros: [Macro.Type] = [
            StringifyMacro.self,
            CaseDetectionMacro.self,
        ]
    }
    
    3.宏声明文件

    打开 MyMacro.swift 文件声明一下宏:

    // 如果宏遵守了多个协议,需要在这里写上多个 @attched()
    @attached(member)
    public macro CaseDetection() = #externalMacro(module: "MyMacroMacros", type: "CaseDetectionMacro")
    
    4.宏测试文件

    打开 MyMacroTests.swift 文件写一个测试用例,目的是为了能断点打到宏里面。

    先在 testMacros 里面加上我们的宏:

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

    再写一个测试用例,这里 expandedSource 是宏预期生成出来的代码,我们可以先不写。

    func testCaseDetectionMacro() {
        assertMacroExpansion(
            """
            @CaseDetection
            enum Animal {
                case cat
            }
            """,
            expandedSource: """
            """,
            macros: testMacros
        )
    }
    

    运行测试用例,我们就会进入宏实现的断点里面了,这时候我们可以开始写宏了。

    开始写宏

    public static func expansion<Declaration: DeclGroupSyntax, Context: MacroExpansionContext>(
        of node: AttributeSyntax,
        providingMembersOf declaration: Declaration,
        in context: Context
    ) throws -> [DeclSyntax] {
        return []
    }
    
    node

    node 参数可以获取宏的声明部分,如果宏接收参数可以从 node 中取到,执行 po node

    AttributeSyntax
    ├─atSignToken: atSign
    ╰─attributeName: SimpleTypeIdentifierSyntax
      ╰─name: identifier("CaseDetection")
    

    如果我们想要获取宏的名称可以这样写:

    let macroName = node.attributeName.description // "CaseDetection"
    
    declaration

    declaration 参数可以获取类型里面的定义,执行 po declaration

    EnumDeclSyntax
    ├─attributes: AttributeListSyntax
    │ ╰─[0]: AttributeSyntax
    │   ├─atSignToken: atSign
    │   ╰─attributeName: SimpleTypeIdentifierSyntax
    │     ╰─name: identifier("CaseDetection")
    ├─enumKeyword: keyword(SwiftSyntax.Keyword.enum)
    ├─identifier: identifier("Animal")
    ╰─memberBlock: MemberDeclBlockSyntax
      ├─leftBrace: leftBrace
      ├─members: MemberDeclListSyntax
      │ ╰─[0]: MemberDeclListItemSyntax
      │   ╰─decl: EnumCaseDeclSyntax
      │     ├─caseKeyword: keyword(SwiftSyntax.Keyword.case)
      │     ╰─elements: EnumCaseElementListSyntax
      │       ╰─[0]: EnumCaseElementSyntax
      │         ╰─identifier: identifier("cat")
      ╰─rightBrace: rightBrace
    
    调试

    宏需要获取枚举的名称,我们现在断点里面获取到想要的数据,再去写代码。

    我们一步步去点开,会发现到 decl 就下不去了。

    po declaration.memberBlock.members.first!.decl
    

    因为 decl 是顶层的协议 DeclSyntax,我们需要使用 as() 将其转换为 EnumCaseDeclSyntax

    po declaration.memberBlock.members.first!.decl.as(EnumCaseDeclSyntax.self)
    

    在写宏的过程中,我们会经常遇到这个问题,发现类型对不上可以用 as() 进行类型转换,最终的调试代码:

    po declaration.memberBlock.members.first!.decl.as(EnumCaseDeclSyntax.self)?.elements.first!.identifier.description // "cat"
    
    宏实现代码

    根据这个调试代码,我们可以去写宏实现代码了。

    public struct CaseDetectionMacro { }
    
    extension CaseDetectionMacro: MemberMacro {
        
        public static func expansion<Declaration: DeclGroupSyntax, Context: MacroExpansionContext>(
            of node: AttributeSyntax,
            providingMembersOf declaration: Declaration,
            in context: Context
        ) throws -> [DeclSyntax] {
            var names: [String] = []
            for member in declaration.memberBlock.members { // 循环获取所有属性、方法
                let elements = member.decl.as(EnumCaseDeclSyntax.self)?.elements
                if let propertyName = elements?.first?.identifier.description {
                    names.append(propertyName) // 取出枚举名
                }
            }
            
            return names.map { // 拼接实现代码
                """
                var \("is" + capitalized($0)): Bool {
                    if case .\($0) = self { true }
                    else { false }
                }
                """
            }.map {
                DeclSyntax(stringLiteral: $0)
            }
        }
        
        /// 首字母大写
        private static func capitalized(_ str: String) -> String {
            var str = str
            let firstChar = String(str.prefix(1)).uppercased()
            str.replaceSubrange(...str.startIndex, with: firstChar)
            return str
        }
    }
    
    查看宏效果

    最后我们到 main.swift 里面写一个枚举测试一下宏。

    @CaseDetection
    enum Animal {
        case cat
    }
    

    写完我们可以右击 @CaseDetection 宏,点击 Expand Macro 查看宏生成的代码。

    报错处理

    Declaration name 'isCat' is not covered by macro 'CaseDetection'

    宏生成的代码非常完美,但是编辑报错了,这是因为宏生成出来的变量 /方法需要在宏声明部分定义好,回到 MyMacro.swift 宏声明文件修改一下声明代码:

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

    ⚠️注意:arbitrary 表示宏可以生成任意变量 /方法,在这个例子中,由于我们要生成的变量是动态变化的,所以只能写 arbitrary,如果你的宏生成的变量 /方法是固定的,建议在这里也固定写死,比如:

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

    我们再运行就发现编译通过了,最后的最后,记得去完善测试用例~

    总结

    宏非常强大,可以帮我们省去很多重复的代码,虽然写宏的过程会比较麻烦,但是写完之后就可以为你节省非常多的时间。另外每一个类型的宏都是 protocol,所以我们可以将多个宏组合在一起使用,比如 Swift Data 里面的 @Model。目前宏还在 beta 测试阶段,后续 Apple 也可能会对宏进行改进,我也会持续关注并更新哒。


    由于 V 站不支持折叠代码,示例代码比较长就删掉了,可以去掘金看代码。

    2 条回复    2023-06-15 22:13:56 +08:00
    maxmak
        1
    maxmak  
       2023-06-15 13:39:42 +08:00
    其实是知道,看你这个搞到都不知道怎么用了
    xingheng
        2
    xingheng  
       2023-06-15 22:13:56 +08:00 via iPhone
    感觉更像是 decoration ,或者高阶一点儿的 code snippet ,不像是 C 里的 macro
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2878 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 25ms · UTC 07:57 · PVG 15:57 · LAX 23:57 · JFK 02:57
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.