Introducing Swift Macro

Johnny
Dcard Tech Blog
Published in
13 min readOct 24, 2023

Swift 5.9 推出了一個功能叫做「Macro」,本篇文章將簡單介紹:

  1. 什麼是 Macro?
  2. Macro 可以為我們帶來什麼好處?
  3. Macro 如何運作?
  4. 解讀 Macro 程式碼

並附上 Dcard 已經開源的 Macro 當作範例,一步一步帶大家走進 Macro 的世界

什麼是 Macro?

Macro 是 Swift 5.9 推出的新功能,根據蘋果官方的文件

Swift macros help you avoid writing repetitive code in Swift by generating that part of your source code at compile time.

簡單的來說,Macro 可以

幫你產生程式碼,省下做重複工作的時間

Macro 可以為我們帶來什麼好處?

你可能有寫過類似 code 的經驗,通常都是在打 API,要傳入變數時會發生

let type: String = "foo"
let offset: Int = 30
let parameters = ["type": type, "offset": offset]
// Send request to API..

不知道你覺得這樣的 code 有什麼問題呢?
嚴格上來說沒有問題,只是有許多重複的 code:

["type": type, "offset": offset]

我們暫時抽離 Swift,你知道這段 Javascript 代表什麼意思嗎?

const type = "foo"
const offset = 30
const parameters = { type, offset }

這段 code 代表

定義一個 Dictionary,Key 的名稱跟變數名稱一樣,Value 則是變數本身的值

讓我們來看看實際執行的結果:

Execution result of javascript code

我們可以省下自己定義 key 名稱的時間,讓 Javascript 來幫我們做這件事
而 Swift 沒有類似 Javascript 的特性,Swift 並不支援像 Javascript 這樣定義一個 Dictionary。你必須將 Key & Value 一個一個寫出來

Introducing Macro!

如果我們真的想要像 Javascript 一樣定義一個 Dictionary 怎麼辦?
以往是不可能的,但我們現在有 Macro,可以輕易做到這件事
我們開源了一個 Swift Macro 來完成這件事:DictionaryLiteralShorthandMacro

示範

import DictionaryLiteralShorthand

let offset = 30
let type = "foo"
let dictionary: [AnyHashable: Any] = #dictionaryLiteralShorthand(offset, type)

這段 code 在被 Macro 處理過後,等同於這段程式碼:

import DictionaryLiteralShorthand

let offset = 30
let type = "foo"
let dictionary: [AnyHashable: Any] = ["offset": offset, "type": type]

可以讓我們達成跟 Javascript 一樣的事情,不用再辛苦手寫每個 Key、Value 的名稱了!

發生了什麼事?

Macro 的運作原理,是將你的原始碼送到 Macro「處理」過後,把 Macro 「展開」來,插入到原本的程式碼裡面

How macro works, photo taken from: https://docs.swift.org/swift-book/documentation/the-swift-programming-language/macros/

以上面的例子來說,本來是這樣:

let dictionary: [AnyHashable: Any] = #dictionaryLiteralShorthand(offset, type)

送到 Macro 處理後,實際上會變成這樣:

let dictionary: [AnyHashable: Any] = ["offset": offset, "type": type]

也就是說

最終送到 Compiler 編譯的程式碼,實際上是第二段被「展開」後的程式,不是第一段!

解讀 DictionaryLiteralShorthand 原始碼

本段將簡單介紹我們的 Macro 是如何自動幫你產生 Dictionary 程式碼

PS. 我們就不從頭開始教學怎麼建立一個 Macro,有興趣的人可以參考下方 References,Apple 有提供非常詳盡的教學!

1. 定義 Macro

DictionaryLiteralShorthand.swift 檔案裡定義了 Macro

@freestanding(expression)
public macro dictionaryLiteralShorthand(_ value: Any...) -> [AnyHashable: Any] = #externalMacro(module: "DictionaryLiteralShorthandMacros", type: "DictionaryLiteralShorthandMacro")
  • dictionaryLiteralShorthand 是 Macro 的名稱,跟平常定義 function 名稱時一樣,使用的人會呼叫 #dictionaryLiteralShorthand 來使用 Macro
  • _ value: Any 則代表可以傳入 Macro 的型別為 Any。因為 Dictionary 的 Value 可以是 Any,所以 Macro 本身當然也接受 Any 當作傳入的值
  • 三個點 Variadic Parameters,接在 Any 後面,代表允許傳入 0 或無限個 Any 變數(每個變數用逗號分隔)
  • 我們最終的目的是希望幫開發者產生出一段定義 Dictionary 的程式碼,所以 Macro 的回傳值當然就是 [AnyHashable: Any] 囉!

2. 額外知識 — Abstract Syntax Tree

在往下講解前,我想要先簡單帶過 Abstract Syntax Tree,又稱作 — AST

AST 是一種用來表示程式原始碼的資料結構

舉例來說,常用的 String literal 在 Swift AST 裡面是 StringLiteralExprSyntax

AST 的用途很多,很常見的例子就是 linting,可以用來檢查程式碼有沒有符合我們定下的規範,SwiftLint 就是用 AST 來實作 linting 的

而 Macro 做的事正是:

先將原始碼轉換為 AST ,再根據不同 macro 的實作,產生一個新的 AST,給 Macro 轉換成另一段原始碼

3. Macro 的核心

DictionaryLiteralShorthandMacro.swift 裡則是最核心的部分,裡面定義了 Macro 該如何「展開」成另一段程式碼!你可以先打開程式碼,再聽我一段一段解說

public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) throws -> ExprSyntax {

這段 function 是由 ExpressionMacro protocol 所定義,原始碼會被轉換成 AST 後,透過這個 function 傳進來,處理後再回傳一個新的 AST

guard !node.argumentList.isEmpty else {
let diagnose: Diagnostic = Diagnostic(
node: node._syntaxNode,
message: DictionaryLiteralShorthandMacroError.emptyInput
)
context.diagnose(diagnose)
throw DiagnosticsError(diagnostics: [diagnose])
}

這邊則是要檢查是否有變數傳入 Macro,若沒有,則讓編譯器報錯

因為 Variadic Parameters 允許傳入 0 個變數,因此 #dictionaryLiteralShorthand() 這種寫法是可以成功編譯的

雖然可以成功編譯,但傳入 0 個變數實際上沒有意義,因此我想要藉由讓編譯器報錯來提醒開發者不要寫這樣的程式碼

guard element.expression.is(IdentifierExprSyntax.self) else {
let diagnose: Diagnostic = Diagnostic(
node: element._syntaxNode,
message: DictionaryLiteralShorthandMacroError.acceptsOnlyIdentifierExpressionSyntax
)
context.diagnose(diagnose)
throw DiagnosticsError(diagnostics: [diagnose])
}

檢查傳入 Macro 的值是否都是 IdentifierExprSyntax,若不是則由 Compiler 報錯提醒使用者。下面這段程式碼的 foo 即為 IdentifierExprSyntax

let foo: String = "Hello World"
// ^ This is an IdentifierExprSyntax

但是為什麼我們要這樣做?來看看下面這段 code:

let dict: [String: Any] = #dictionaryLiteralShorthand("Hello", 999)

你覺得這段 code 可以達成我們的目標嗎?

定義一個 Dictionary,Key 的名稱跟變數名稱一樣,Value 則是變數本身的值

答案當然是不行,因為 "Hello"999String/Integer literal,它們沒有一個「名稱」可以放到 Dictionary 裡面當作 Key

IdentifierExprSyntax 這個 AST 才有一個「名稱」可以放到 Dictionary 當作 Key,因此這段檢查是非常重要的

let idSyntax = element.expression.cast(IdentifierExprSyntax.self)
let identifierText: String = idSyntanx.identifier.text // Get the actual variable name from IdentifierExprSyntax

guard !usedExpressions.contains(identifierText) else {
let diagnose: Diagnostic = Diagnostic(
node: element._syntaxNode,
message: DictionaryLiteralShorthandMacroError.duplicateIdentifiers
)
context.diagnose(diagnose)
throw DiagnosticsError(diagnostics: [diagnose])
}

本段用意為檢查傳入 Macro 的值是否重複,例如:

let a = "Foo"
let b = "Bar"
let d = #dictionaryLiteralShorthand(a, a, a, b)

不管傳入幾個 a,Dictionary 始終只會有一個 a 的 Key-Value pair,傳入多個重複的變數並沒有意義,因此若重複則讓 Compiler 報錯

let trailingComma: TokenSyntax? = index == node.argumentList.count - 1 ? nil : .commaToken()
elements.append(
DictionaryElementSyntax(
keyExpression: StringLiteralExprSyntax(content: identifierText),
valueExpression: idSyntanx,
trailingComma: trailingComma
)
)

usedExpressions.insert(identifierText)

重頭戲來了!我們要開始產生 Dictionary 的程式碼

DictionaryElementSyntax 代表的是 Dictionary 內的 element,下圖可以幫助你理解它

Demonstration of DictionaryElementSyntax
  • keyExpression 是 Dictionary 中的 Key,我們給賦予它:跟變數名稱一樣的 String literal
  • valueExpression 是 Dictionary 中的 Value,我們賦予它:目前在 for-loop 中存取到的 IdentifierExprSyntax
  • trailingComma 則是決定要不要有「逗號」來分隔不同 element。最後一個 element 不需要逗號,因此最後一個傳入 nil,其他都傳入 commaToken() 來分隔
let dictionaryLiteral = DictionaryExprSyntax(
content: .elements(DictionaryElementListSyntax(elements))
)

return ExprSyntax(dictionaryLiteral)

DictionaryExprSyntax 代表的則是整個 Dictionary 的 AST

最後將所有的 DictionaryElementSyntax 傳入 DictionaryExprSyntax,再 return 給 function,就大功告成了!

備註

想入門寫 Macro 的人,我非常推薦 Swift AST Explorer 這個線上工具,你可以用它來看程式碼對應的 AST 是什麼,再去查文件,對提升開發效率非常有幫助!

A photo of Swift AST Explorer

References

Apple 官方提供了許多 Macro 相關的資源,非常推薦想入門自己寫一個 Macro 的人看看!

--

--