今年可以說是 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 的靈感!
 
 
       
        