程式設計語言的抽象機制包含了兩個最基本的方面:一是語言關注的基本元素/語義;另一個是從基本元素/語義到複合元素/語義的構造規則。在C、C++、Java、C#、Python等通用語言中,語言的基本元素/語義往往離問題域較遠,通過API庫的形式進行層層抽象是降低問題難度最常用的方法。比如,在C語言中最常見的方式是提供函數庫來封裝複雜邏輯,方便外部調用。
不過普通的API設計方法存在一種天然的陷阱,那就是不管怎樣封裝,大過程雖然比小過程抽象層次更高,但本質上還是過程,受到過程語義的制約。也就是說,通過基本元素/語義構造更進階抽象元素/語義的時候,語言的構造規則很大程度上限制了抽象的維度,我們很難跳出這個維度去,甚至可能根本意識不到這個限制。而SQL、HTML、CSS、make等DSL(領特定領域語言)的抽象維度是為特定領域量身定做的,從這些抽象角度看問題往往最為簡單,所以DSL在解決其特定領域的問題時比通用程式設計語言更加方便。通常,SQL等非通用語言被稱為外部DSL(External
DSL);在通用語言中,我們其實也可以在一定程度上突破語言構造規則的抽象維度限制,定義內部DSL(Internal DSL)。
本文將介紹一種被稱為流暢介面(Fluent Interface)的內部DSL設計方法。Wikipedia上Fluent Interface的定義是:
A fluent interface (as first coined by Eric Evans and Martin Fowler) is an implementation of an object oriented API that aims to provide for more readable code. A fluent interface is normally implemented by using method chaining to relay the instruction
context of a subsequent call (but a fluent interface entails more than just method chaining).
下面將分4個部分來逐步說明流暢介面在構造內部DSL中的典型應用。
1. 基本語義抽象
如果要輸出0..4這5個數,我們一般會首先想到類似這樣的代碼:
//Javafor (int i = 0; i < 5; ++i) { system.out.println(i);}
而Ruby雖然也支援類似的for迴圈,但最簡單的是下面這樣的實現:
//Ruby5.times { |i| puts i }
Ruby中一切皆對象,5是Fixnum類的執行個體,times是Fixnum的一個方法,它接受一個block參數。相比for迴圈實現,Ruby的times方式更簡潔,可讀性更強,但熟悉OOP的朋友可能會有疑問,times是否應該作為整型類的方法呢?在OOP中,方法調用通常代表了向對象發送訊息,改變或查詢對象的狀態,times方法顯然不是對整型對象狀態的查詢和修改。如果你是Ruby的設計者,你會把times方法放入Fixnum類嗎?如果答案是否定的,那麼Ruby的這種設計本質上代表了什麼呢?實際上,這裡的times雖然只是一個普通的類方法,但它的目的卻與普通意義上的類方法不同,它的語義實際上類似於for迴圈這樣的語言基本語義,可以被視為一種自訂的基本語義。times的語義從一定程度上跳出了類方法的框框,向問題域邁進了一步!
另一個例子來自Eric Evans的用兩個時間點構造一個時間段對象,普通設計:
//JavaTimePoint fiveOClock, sixOClock;TimeInterval meetingTime = new TimeInterval(fiveOClock, sixOClock);
Evans的設計是這樣的:
//JavaTimeInterval meetingTime = fiveOClock.until(sixOClock);
按傳統OO設計,until方法本不應出現在TimePoint類中,這裡TimePoint類的until方法同樣代表了一種自訂的基本語義,使得表達時間域的問題更加自然。
雖然上面的兩個簡單例子和普通設計相比看不出太大的優勢,但它卻為我們理解流暢介面打下了基礎。重要的是應該體會到它們從一定程度上跳出了語言基本抽象機制的束縛,我們不應該再用類職責劃分、迪米特法則(Law of Demeter)等OO設計原則來看待它們。
2. 管道抽象
在Shell中,我們可以通過管道將一系列的小命令組合在一起實現複雜的功能。管道中流動的是單一類型的文字資料流,計算過程就是從輸入資料流到輸出資料流的變換過程,每個命令是對文字資料流的一次變換作用,通過管道將作用疊加起來。在Shell中,很多時候我們只需要一句話就能完成log統計這樣的中小規模問題。和其他抽象機制相比,管道的優美在於無嵌套。比如下面這段C程式,由於嵌套層次較深,不容易一下子理解清楚:
//Cmin(max(min(max(a, b), c), d), e)
而用管道來表達同樣的功能則清晰得多:
//Bashmax a b | min c | max d | min e
我們很容易理解這段程式表達的意思是:先求a, b的最大值;再把結果和c取最小值;再把結果和d求最大值;再把結果和e求最小值。
jQuery的鏈式調用設計也具有管道的風格,方法鏈上流動的是同一類型的jQuery對象,每一步方法調用是對對象的一次作用,整個方法鏈將各個方法的作用疊加起來。
//jQuery$('li').filter(':even').css('background-color', 'red');
3. 階層抽象
除了管道這種線性結構外,流暢介面還可用於構造階層抽象。比如,用Javascript動態建立建立下面的HTML片段:
<div id="product_123" class="product"> <img src="preview_123.jpg"/> <ul> <li>Name: iPad2 32G</li> <li>Price: 3600</li> </ul></div>
若採用Javascript的DOM API,其可讀性和可維護性都不夠好:
//Javascriptvar div = document.createElement('div');div.setAttribute('id', 'product_123');div.setAttribute('class', 'product');var img = document.createElement('img');img.setAttribute('src', 'preview_123.jpg');div.appendChild(img);var ul = document.createElement('ul');var li1 = document.createElement('li');var txt1 = document.createTextNode("Name: iPad2 32G");li1.appendChild(txt1);…div.appendChild(ul);
而下面流暢介面API則要有表現力得多:
//Javascriptvar obj =$.div({id:'product_123', class:'product'}) .img({src:'preview_123.jpg'}) .ul() .li().text(Name: 'iPad2 32G')._li() .li().text(Price: 3600)._li() ._ul()._div();
和Javascript的標準DOM API相比,上面的API設計不再局限於孤立地看待某一個方法,而是考慮了它們在解決問題時的組合使用,所以代碼特別貼近問題的本質。這樣的代碼是自解釋的(self-explanatory)在可讀性方面要明顯勝於DOM API,這相當於定義了一種類似於HTML的內部DSL,它擁有自己的語義和文法。需要特別注意的是,上面的階層抽象和管道抽象有著本質的不同,管道抽象的方法鏈上通常是同一對象的連續傳遞,而層次抽象中方法鏈上的對象卻在隨著層次的變化而變化。此外,我們還可以把商務規則也表達在流暢介面中,比如上面的例子中,body()不能包含在div()返回的對象中,div().body()將拋出“body方法不存在”異常。
4. 非同步抽象
流暢介面不僅可以構造複雜的層次抽象,還可以用於構造非同步抽象。在基於回調機制的非同步模式中,多個非同步呼叫的同步和嵌套問題是使用非同步痛點所在。有時一個稍複雜的調用和同步關係會導致代碼充滿了複雜的同步檢查和層層回調,難以理解和維護。這個問題從本質上講和上面HTML的例子一樣,是由於多數通用語言並未把非同步作為基本元素/語義,許多非同步實現模式是向語言的妥協。針對這個問題,我用Javascript編寫了一個基於流暢介面的非同步DSL,範例程式碼如下:
//Javascript$.begin() .async(newTask('task1'), 'task1') .async(newTask('task2'), 'task2') .async(newTask('task3'), 'task3').when() .each_done(function(name, result) { console.log(name + ': ' + result);}) .all_done(function(){ console.log('good, all completed'); }) .timeout( function(){ console.log('timeout!!'); $.begin() .async(newTask('task4'), 'task4') .when() .each_done(function(name, result) { console.log(name + ': ' + result); }) .end(); } , 3000).end(); 上面的代碼只是一句Javascript語句,但從另一個角度看它卻像一段描述非同步呼叫的DSL程式。它通過流暢介面定義了begin when end的文法結構,begin後面跟的是啟動非同步呼叫的代碼;when後面是非同步結果處理,可以選擇each_done, all_done, timeout中的一種或多種。而begin when end結構本身是可以嵌套的,比如上面的代碼在timeout處理分支中就包含了另一個begin when end結構。通過這個DSL,我們可以比基於回調的方式更好地表達非同步呼叫的同步和嵌套關係。
上面介紹了用流暢介面構造的4種典型抽象,除此之外還有很多其他的抽象和應用場合,比如:不少單元測試架構就通過流暢介面定義了單元測試的DSL。雖然上面的例子以Javascript等動態語言居多,但其實流暢介面所依賴的文法基礎並不苛刻,即使在Java這樣的靜態語言中,同樣可以輕鬆地使用。流暢介面不同於傳統的API設計,理解和使用流暢介面關鍵是要突破語言抽象機制帶來的定勢思維,根據問題域選取適當的抽象維度,利用語言的基本文法構造領域特定的語義和文法。
參考
Wikipedia: Fluent Interface
Martin Fowler: Fluent Interface
jQuery is DSL
An Approach to Internal Domain-Specific Languages in Java