排序處理模式


iThome 網站首載:排序處理模式

當面對的問題是要解決底層細節時,可採用面對底層平台或機器的低階思維來解決;當解決方案出現重複性,抽取重複的部份以進行重用,這就開始隱藏了某些底層細節,也就是一定程度脫離了低階層次;程式碼或結構等的重複是較易觀察的,然而語意或商務領域上的重複行為則不易察覺,可從改良可讀性開始,進而察覺每次處理時的重複意圖,從中建立更高階的處理語意。

ComparableComparator

現代語言搭配的程式庫多半提供了排序演算法的實作,如何實作排序演算法是低階細節,實際上演算法執行流程可以共用,排序過程取得兩元素進行順序比較時,兩元素前後順序的資訊則必須由使用者提供。以Java為例,可定義物件與生俱來的順序(Natural ordering),也就是定義類別時實作Comparable介面,如果不打算使用或無法定義物件與生俱來的順序(例如不打算或無法修改原始碼),那麼也可以建立比較器,也就是另行實作Comparator介面,並在需要排序的場合使用指定的比較器。

當有A、B兩個元素進行順序比較時,會有三個可能性,A順序在B之前、A與B順序相同、A順序在B之後,通常使用者必須實作函式或方法來告知這三者之一,為了使傳回值能讓排序演算進行判斷,多數程式庫要求使用者必須傳回整數型態的-1、0或1。以Java為例,無論是ComparablecompareTo,或者是Comparatorcompare,傳回值必須是int型態的-1、0或1,然而許多開發者經常搞錯這三個整數實際對應的意義。

想進行高層次語意封裝的第一步,就是改進可讀性,若將A當作左值而B當作右值,在想要相容於現有程式庫的情況下,可定義int變數LEFT_IS_GREATERRIGHT_IS_GREATER分別代表1與-1,這就可以讓比較後傳回值的意圖明顯許多。有些訴求高階語意的語言,像是Haskell,就定義有Ordering型態,其有LTEQGT三個值,在需要比較的場合,可明確地表示出程式碼的意涵與結果。

抽取可重用的排序模式

有些語言本身就具有高階概念,純函數式語言以宣告式(Declarative)風格來撰寫程式,只不過談到函數式總讓習慣命令式(Imperative)的開發者望之卻步,其實像是SQL也是宣告式(Declarative)語言。就排序這方面來說,如果SELECT的結果打算先依姓來排、再依名來排,如果姓名相同再依郵遞區號來排,只要使用ORDER BY子句來指定就可以了。如果姓、名、郵遞區號是某物件的屬性,無論是實作ComparableComparator,那麼compareTocompare中的程式碼基本上並不容易閱讀。

實際上觀察程式碼中每個屬性的比較處理,會發現有個模式,亦即都是在兩個屬性不相等時直接return傳回結果,相等時才進行接下來其他屬性的比較,透過設計將這流程封裝,就可建立類似宣告式的風格。例如,Guava的ComparisonChain,就可以撰寫出以下的宣告式風格:

ComparisonChain.start()
    .compare(lastName, other.lastName)
    .compare(firstName, other.firstName)
    .compare(zipCode, other.zipCode).result();

ComparisonChain
的概念,可以用來實作ComparableComparatorcompareTocompare,而對於Comparator,可觀察出幾個常見的實作品,像是依物件與生俱來的順序或反序實作、依物件字串描述比較、基於某運算式結果來排序等。仔細想想,字串與生俱有辭彙順序(Lexicographic ordering),而反序則只需將原比較器要比較的兩值對調即可,也就是說條件複雜一些的比較器,可以基於某個既有的比較器組裝得來。在我先前專欄〈辨識物件的可裝飾行為〉中談過,一旦這類行為辨識出來,可以裝飾器(Decorator)模式加以抽取行為,如此就可以動態對行為進行功能擴充。

建立高階處理語意

單純只是抽取出可裝飾行為,以裝飾器模式加以實作,基本上只是觀察出重複的程式碼或結構,某些程度上已脫離了低階層次的處理,然而語法及組裝方式上還是機器的思維模式。例如若想根據某物件屬性排序,若屬性為null優先排在前頭,若屬性非null就依物件與生俱來的順序反向排序,此需求單純使用裝飾模式實現的話,使用上會產生如new OnResultOf(Person p -> p.field == null ? null : p.field, new NullsFirst(new Inverse(new Natural())))的結果。

首先,new本身就是建立物件的概念,這是從JVM角度看待的概念,摻雜了建構物件概念的程式碼,加上刮號語法的干擾,使得這樣的程式碼並不是人類易於理解的語法。談到建構語意,在我先前的專欄〈運用工廠、回呼與鞣製自訂語意〉中談過,鞣製是可用的實作方向之一。仔細想想,OnResultOfNullsFirstInverseNatural若都實作了Comparator,如果裝飾的職責由物件本身負責,也就是定義為物件的方法之一,那麼每次方法產生的物件也是Comparator,就可以用來實現鞣製概念,創造方法操作鏈來建立語義。

Guava的Ordering就是如此實作,Ordering實作了Comparator,本身定義了naturalreversenullsFirstonResultOf等方法,每個方法的傳回實例都是Ordering型態,裝飾行為實現在各個方法之中。例如reverse的實作就是傳回new ReverseOrdering<S>(this),因此,就可以使用Ordering.natural().reverse().nullsFirst().onResultOf(Person p -> p.field == null : p.field)操作鏈,建構出想要的Ordering實例。

使用Guava的Ordering建構複雜的比較器時與採用純裝飾器時思路相同,從一個基本的比較器開始,逐步裝飾以得到最後想要的比較器。只不過以純裝飾器模式實作時,語法上基本裝飾器會位於右邊,逐一在左邊裝飾上其他裝飾器,也就是想得知裝飾器建立過程時,必須由右往左閱讀。使用Guava的Ordering時,則是由左往右組裝。有些程式庫也是這樣的建構概念,像是Joda-Time的DateTime,就程式碼而言,從左邊一個基本的DateTime開始,往右逐步產生想要的DateTime物件。Guava的Wiki對Ordering的說明中指出,Ordering的操作鏈閱讀時應由右往左讀,才能瞭解排序的結果,這反而令人不易瞭解語意;人類閱讀方式基本上就是由左而右,Ordering的操作鏈由左而右的閱讀方式,目的就是讓人類以自然的閱讀方式,瞭解Ordering實例如何組裝而成。

從意圖中察覺重複模式

Guava的Ordering以鞣製概念,製造出方法操作鏈來創造語意,留下的問題是,Ordering上該有哪些方法?若不能辨識出最通用的方法再定義在Ordering上,那最後Ordering將負有過多的職責。Ordering上定義了最通用的如naturalreversenullsFirst等,當然,通用的定義每個人認知不同,為了提供更多建構比較器的彈性,Ordering提供了fromcompound等方法,讓使用者可以有更多組裝過程的可能性。

思考如何解決問題時,可將問題以通用語言定義,而不是受限於語法或低階的模型,這在我的專欄〈List處理模式〉中談過,當時舉List處理時經常出現的模式為例,並從處理流程中抽取出重複模式,發現面對資料管理問題時,幾乎都可以運用mapfilterfold的概念。這篇文章中以排序時實作的ComparableComparator,其實也是類似的道理,有時應當從意圖中察覺重複模式,而不只是從程式語法中察覺重複模式,當我們一再寫出new OnResultOf(Person p -> p.field == null ? null : p.field, new NullsFirst(new Inverse(new Natural())))的語法時,就應思考我們的意圖就是從基本元件開始,逐步組裝出我們想要的功能。