今年可以說是 Swift DSL 元年,建造者函數 (Builder functions) 與 SwiftUI 讓開發者們看到在 Swift 內設計內嵌 DSL 的各種可能性。雖然這樣說,但 Swift 一直以來都提供了許多 DSL 實作的功能,只是還沒有出現在官方框架而已。舉例來說,我們可以利用自訂運算子 (Operators) 與下標 (Subscripts) 等功能,來寫出簡單又明瞭的 DSL。
DSL:領域專用語言 (Domain-specific Language),指針對某個問題領域特化的語言。比如說 HTML 就是針對網頁結構的語言,JSON 是針對資料結構的語言等等。相較於通用目的語言 (GPL 或 General-purpose Language,如 Swift 本身),DSL 的語法會更簡潔,邏輯更簡單。
那麼,甚麼地方會用得到 DSL 呢?其實任何的問題領域 (Problem domain) 都有使用 DSL 的潛力,像處理排版問題的 Auto Layout 就是一個好例子。
問題:囉唆的 Auto Layout API
Auto Layout 本身其實就是一套宣告式的系統。透過定義一個個的約束條件,它會自動去處理所有的狀態變更事件。但是,它的 API 是延續自 Objective-C 的習慣,所以相當囉嗦。
// 創造 viewA 與 viewB 之間的約束條件。
let constraints = [
viewA.topAnchor.constraint(equalTo: viewB.topAnchor, constant: 8),
// ...
]
// 啟動約束條件。
NSLayoutConstraint.activate(constraints)
在這段程式碼裡,可以看到陳述句都是滿滿的文字。雖然已經沒有甚麼多餘的資訊,但過多的文字還是不好閱讀。如果能用更簡潔、好懂的方式取代這個 API 的話,之後維護的工程師想必會更感謝你。
理想的語法
在 NSLayoutConstraint
的官方文件裡,其實就提供了一個用來描述約束條件的語法:
item1.attribute1 = multiplier × item2.attribute2 + constant
這個語法是用數學的等式,來表達兩個排版錨 (Layout anchor) 之間的關係。它用簡單的加號、乘號與等號等運算子取代文字描述,這樣除了減少語句長度之外,更可以突顯整個句子的目的。原本可能要靠搜尋來找跟排版有關的程式碼,現在只要掃一眼就可以辨認出來了。更棒的是,它已經被寫在官方的文件裡面了,所以學習的成本也降低許多。
要注意的是,這裡的單等號並不是指派運算子,而是描述左右比較關係的比較運算子。因此,我們需要把 =
改成 ==
,像這樣:
item1.attribute1 == multiplier × item2.attribute2 + constant
由於整個句子不是命令句,而是描述句,所以我們的目標應該是用這個句子去建構約束條件,而不是去套用約束條件。
// 僅創造出一個 NSLayoutConstraint 而沒有啟動它。
let constraint = (item1.attribute1 == multiplier × item2.attribute2 + constant)
換句話說,整個 DSL 句子其實就是一種建構 NSLayoutConstraint
的方法。
那麼,要怎麼實作呢?
重載運算子
運算子的自訂是 Auto Layout DSL 的核心。而在跳到實作的部分之前,先來看看它的概念。
如果退一步來想的話,運算子本身其實就是為了 DSL 而存在的。比如說,1 + 2
其實是 add(1, 2)
的數學運算 DSL 寫法;"foo" + "bar"
則是 concatenate("foo", "bar")
的文字處理 DSL 寫法(註:非實際 Swift 原始碼)。我們所要做的,就只是再給 ==
、+
與 *
等運算子新的意義(新的函數)而已。
由於要設計的 DSL 語法當中,用到的都是既有的運算子,所以我們並不需另外去定義運算子,只要重載它們就可以了。
首先,我們要使 ==
這個運算子變成一個回傳 NSLayoutConstraint
的函數:
// 從 == 左邊與右邊的排版錨產生約束條件。
func == <T>(lhs: NSLayoutAnchor<T>, rhs: NSLayoutAnchor<T>) -> NSLayoutConstraint {
return lhs.constraint(equalTo: rhs)
}
重載 ==
之後,原本的:
viewA.topAnchor.constraint(equalTo: viewB.topAnchor) // 產生約束條件
就可以重寫成這樣了:
viewA.topAnchor == viewB.topAnchor // 產生約束條件
是不是很簡單呢?
但事情沒有這麼簡單就可以解決!除了 ==
之外,我們還需要實作倍數與常數(*
與 +
)。問題是,現在 ==
的參數是 NSLayoutAnchor
,而它並無法儲存倍數與常數的資訊。所以,我們必須要找其他方式來實作。
建造者模式 (Builder pattern)
建造者模式是在 OOP 的一開始,由四人幫在他們的書中所提出的 (Gang of Four,1994,《Design Patterns: Elements of Reusable Object-Oriented Software》)。它跟工廠方法或建構式一樣,都是建構物件的方式。
一般在創造物件的時候,物件所需的資訊可以透過建構式輸入;或者等物件建構好之後,直接將資訊指派給物件的屬性。但當這些資訊變得複雜的時候,這兩種方法就會顯得很局限。
建造者模式提供了第三種做法:我們並不直接建構物件,而是先把所需的資訊集中到所謂的建造者上,再由建造者去建構物件。而由於建造者本身就只是簡單的資料結構,所以操作的彈性比直接操作物件要大得多。
用程式碼來說的話,大概是這樣:
// 產生一個 view 的建造者。
var builder = ViewBuilder()
// 給建造者資訊。
builder.frame = rect
builder.backgroundColor = .white
// 由建造者建構 view。
let view = builder.build()
在
Foundation
裡的URLComponents
就是一個典型的創造者結構。我們把scheme
、host
、path
與queryItems
等資訊餵給它,再用它的url
屬性來從這些資訊創造出一個URL
實體來。
用我們的 Auto Layout DSL 來說,==
相當於 build()
,item1.attribute1
與 item2.attribute2
是建造者,而 *
與 +
則是用來更改建造者資訊的函數。所以,我們可以這樣寫:
// 用 AnchorType 來限制錨之間只有同種類才能互動。
struct ConstraintBuilder<AnchorType> {
var item: UIView
var attribute: NSLayoutConstraint.Attribute
var constant: CGFloat
var multiplier: CGFloat
}
// 由 == 兩邊的 ConstraintBuilder 建構出一個約束條件。
func == <T>(lhs: ConstraintBuilder<T>, rhs: ConstraintBuilder<T>) -> NSLayoutConstraint {
return NSLayoutConstraint(
item: lhs.item, attribute: lhs.attribute,
relatedBy: .equal,
toItem: rhs.item, attribute: rhs.attribute,
multiplier: rhs.multiplier / lhs.multiplier,
constant: rhs.constant - lhs.constant
)
}
接著,只要給 UIView
定義一些ConstraintBuilder
屬性:
extension UIView {
var top: ConstraintBuilder<NSLayoutYAxisAnchor> {
return ConstraintBuilder(
item: self,
attribute: .top,
constant: 0,
multiplier: 1
)
}
}
就可以重現剛剛的寫法:
viewA.top == viewB.top // 產生約束條件
而現在,我們也可以實作 *
與 +
這兩個函數了:
// 更動 * 右邊的 ConstraintBuilder。
func * <T>(lhs: CGFloat, rhs: ConstraintBuilder<T>) -> ConstraintBuilder<T> {
var builder = rhs
builder.multiplier *= lhs
return builder
}
// 更動 + 左邊的 ConstraintBuilder。
func + <T>(lhs: ConstraintBuilder<T>, rhs: CGFloat) -> ConstraintBuilder<T> {
var builder = lhs
builder.constant += rhs
return builder
}
如此一來,Auto Layout DSL 的語法已經完成了:
viewA.top == 2.0 * viewB.top + 20.0 // 產生一個 NSLayoutConstraint 實體。
是不是很神奇呢?
啟動約束條件
現在,我們已經可以把這些 Auto Layout DSL 語句全部放進一個陣列裡,並一次啟動它們了:
let constraints = [
viewA.top == viewB.top + 20,
viewA.leading == viewB.leading + 8,
viewB.bottom == viewA.bottom + 20,
viewB.trailing == viewA.trailing + 8
]
NSLayoutConstraint.activate(constraints)
或者直接把陣列寫在 NSLayoutConstraint.activate(_:)
的參數裡面,像這樣:
NSLayoutConstraint.activate([
viewA.top == viewB.top + 20,
viewA.leading == viewB.leading + 8,
viewB.bottom == viewA.bottom + 20,
viewB.trailing == viewA.trailing + 8
])
但方括號與括弧疊在一起,還是不夠美觀。有沒有辦法只寫一個括號呢?
可變數量參數 (Variadic parameters)
可變數量參數基本上就是一個陣列型別的參數,但當輸入引數 (Arguments) 的時候,我們用的不是陣列的寫法,而是把它當成不限數量、相同型別的參數來寫。
讓我們用可變數量參數,來定義一個新的 NSLayoutConstraint
靜態方法:
// 使用可變數量參數。
func activate(_ constraints: NSLayoutConstraint...) {
// 確保所有有參與的 view 都會使用自訂的約束條件。
constraints.forEach {
($0.firstItem as? UIView)?.translatesAutoresizingMaskIntoConstraints = false
($0.secondItem as? UIView)?.translatesAutoresizingMaskIntoConstraints = false
}
// 啟動所有約束條件
NSLayoutConstraint.activate(constraints)
}
如此一來,就可以寫成這樣:
activate(
viewA.top == viewB.top + 20,
viewA.leading == viewB.leading + 8,
viewB.bottom == viewA.bottom + 20,
viewB.trailing == viewA.trailing + 8
)
是不是更簡潔了呢?
不過,Apple 的工程師們仍有感於這種寫法的侷限(也覺得逗號有點多餘),所以提出了一個全新的功能——
建立者函數
建立者函數是一種特化的函數。它有回傳值,但使用者不用在函數裡回傳任何的值,因為它會自動去捕捉函數內所有沒有用到的值。邏輯上有點複雜,但用起來很簡單。比如說,如果有一個會搜集 NSLayoutConstraint
的建立者函數的話:
@ConstraintBuilder
func build() -> [NSLayoutConstraint] {
// 不必寫 return 等關鍵字,這些 NSLayoutConstraint 就會被捕捉起來,轉成一個 [NSLayoutConstraint]。
constraint1
constraint2
constraint3
}
build() // 產生 [constraint1, constraint2, constraint3]。
我們就可以用建造者函數,來改寫 activate(_:)
函數:
func activate(@ConstraintBuilder makeConstraints: () -> [NSLayoutConstraint]) {
// ...
let constraints = makeConstraints()
NSLayoutConstraint.activate(constraints)
}
接著就可以把 DSL 寫成這樣:
// 閉包裡每一行產生的 NSLayoutConstraint 都會被捕捉起來。
activate {
viewA.top == viewB.top + 20
viewA.leading == viewB.leading + 8
viewB.bottom == viewA.bottom + 20
viewB.trailing == viewA.trailing + 8
}
這就是我們的 Auto Layout DSL 終極型態了。
建造者函數的功能還在測試階段,所以最後的模樣仍可能會改變。
結論
設計自己的 Swift DSL 是一個很有趣的過程。許多看似魔術一般的語法,實作起來其實並不會很複雜,重點在於要用合適的設計模式與語言功能。在本文的例子裡,我們就是用了運算子的重載功能、與建造者模式來做 DSL。我們用運算子取代函數,並透過暫時的結構體來傳遞資訊,就可以用最簡單的方式,實現理想中的 Auto Layout 語法。希望你在看完這篇文章後,能有更多實作各種 DSL 的靈感!