在程式開發中,對抽象化的認知,多半停留在減少程式的複雜度,隱藏實作細節,讓開發者可以專注在更重要的事務上。

然而,不應只是一味尋找能減少複雜度與隱藏細節的抽象,更重要的是,尋找具有組合性的抽象。

抽象的惡名之源

談到抽象,在函數式領域的人,似乎不怎麼愛用甚至拒用這個名詞,抽象有幾個意義,抽取共同事物的特徵、找出可共用的行為模式、增加組合性、隱藏細節、得到簡單的概念、降低複雜度至開發者可以掌握的程度,這原本都是好事。不過,若只是為了隱藏細節、得到簡單的概念、降低複雜度至人們可以掌握的程度,而使用抽象化時,那麼,隨著抽象化的程度越高,就會有越多細節被隱藏,到最後就像面對一座冰山,我們只看得到浮出水面的抽象化,水面下卻隱藏了龐大的危險細節。

單純只為了隱藏細節、得到簡單概念、降低複雜度,而進行抽象化,開發者一開始乍看獲得益處,然而當這些危險的細節積累到過於龐大,這些細節會反過來危及開發者運用抽象化的初衷,這就是Joel Spolsky在〈抽象滲漏法則〉中談到的:「所有重大的抽象機制在某種程式上都是有漏洞的」,結果就是,開發者遭到抽象化反撲,細節被暴露出來、概念不再簡單,複雜度遠比不運用抽象化還要糟糕。

以物件導向為典範的抽象化,似乎很容易遭逢這類問題,或許是因為開發者總是試圖將現實生活中的事件,以物件為單位來抽象化,因而經常優先考慮的就是去除現實事物的行為細節,以含糊、曖昧的名詞或動詞進行描述,到最後,這種抽象化產生的問題與物件導向畫上了等號,無怪乎最後函數式(或其他)領域的人,都不愛用「抽象」這字眼。

抽象化不是無中生有

正如〈抽象滲漏法則〉中談到的:「抽象滲漏法則會造成問題的原因之一,是因為它說明了抽象機制並不真能照原構想簡化我們的生活」,想要將生活中的事物,直接簡化並映射至程式的虛擬世界之中,其實就是在無中生有,因為程式中這類物件一開始並不存在,從事這種映射行為,根本就是憑空想像這些生活事物,在程式世界中會具有這類共同特徵。

函數式的世界中也有抽象化,只是這個抽象化不是從生活事物映射至物件的角度開始,而是從計算的角度,直接以演算流程解決問題,然後發覺演算流程中有可共用的行為模式或特徵,為了能在不同演算中共用這些行為模式或特徵,因而將之抽取出來成為可共用的函式(而並非一開始就是物件)。

進一步地,當運用被抽取出來的可共用函式中發覺到,某些資料型態經常用到幾個被抽取出來的共用函式,這些資料型態與共用函式的關係,可進一步抽取出來,像是Haskell中就可抽取為一個Typeclass,這樣的過程,都是有根據而非憑空創造。

這也就是為什麼後來在物件導向的世界中,也開始注意避免過度設計而造成的不當抽象化,而鼓勵以重構的方式來逐步構築程式,讓抽象化成為一個有憑據的過程,儘管重構之後的程式,以物件導向的觀點來看,不見得是純粹的物件導向,而可能是其他的典範風格也沒關係,這或許也是後來以物件導向為主要典範的語言中,函數式典範得以進入的原因之一。

fold的進一步抽象化

舉現今開發者應該多少開始有接觸的map、filter、fold(reduce)為例,這原本就是從許多重複List的處理中,抽取出來的模式,在我先前專欄〈fold的抽象訓練〉中,也談到fold其實是map、filter的再抽象化,然而到此為止了嗎?

實際上,可以fold的資料結構並不只有List,像是察覺化想加總一棵Tree的節點值時,也有著類似fold的行為模式,因此在Haskell中就抽取為Foldable這個Typeclass,因此,並不是一開始就有Foldable,是從List可以fold,而Tree也有fold行為發掘出來的。

不只這樣,一個可以fold的資料結構之間,它們的元素也有著共同行為,元素要有個能處理它的函式,這個函式要具有兩個參數,且傳回型態與參數型態相同,存在一個恒等值(Identity),具有結合律(Associativity),一個資料型態與其具有這些行為的函式結合起來,在Haskell中就定義為Monoid這個Typeclass。

如果沒有實際依據,開發者很難想像為何要定義Monoid這個Typeclass,一個資料型態與一個具有恒等值與結合律的對應函式,有什麼好定義為一個Typeclass?但為了定義出一個foldMap,以便對任何能fold的資料型態進行fold,Monoid就是必要的定義。

跳出Haskell的世界,難道就不會有定義出Monoid的需求嗎?實際上是有的,舉例而言,若想在Java 8中使用Stream的filter、map、reduce時,有想過元素要遵守哪些規範嗎?回顧一下我先前專欄〈資料平行的效能考量〉中談到的:「numbers.stream().reduce(5, (acc, x) -> x * acc)結果是正確的,但stream方法改呼叫parallelStream就會產生錯誤的結果」,這是為什麼?

因為5對*並不是一個恒等值,這時要改為5 * numbers.parallelStream().reduce(1, (acc, x) -> x * acc)才會是正確的,這是因為1對於*是個恒等值,而「整數存在恒等值1,*具有結合律」,這就是Haskell中定義的Monoid,如果要強制使用parallelStream進行平行處理時,要求開發者遵守這些規範,也許可以設計出個Monoid的interface來加以限制吧!

隱藏細節不代表不遵守定律

先前專欄〈探索Haskell的Monad〉中,我談過Functor、Applicative、Monad,它們也都不是無中生有,都是從既有演算流程中抽取出來,認識這些Typeclass,不是直接想看懂它們的定義,而是先設想有哪些情境才使得它們被抽取出來。

如此一來,就會知道在實現這些Typeclass,有哪些必須遵守的定律,這就是Functor Law、Applicative Law、Monad Law的由來,只有在遵守這些定律的情況下,才能在運用這些被隱藏的細節同時,又能透明地明白它們的行為,進一步地,就能隨意地組合這些行為,例如,如果(:)函式原本可以對List進行1:[2, 3]而得到[1, 2, 3]的話,List實現Applicative時,也應當能進行(:) <$> Just 1 <*> Just [2, 3]而得到Just [1, 2, 3],這就是透明度,而有了透明度,才能隨意組合。

那麼,像Functor Law、Applicative Law、Monad Law這些定義,非函數式的語言中就沒有嗎?不是的!Java的Object在API文件中就規範著,equals在實作時要遵守 Reflexive、Symmetric等定律,而hashCode也有其應遵守的規範;實際上,在物件導向設計中,子類別在重新定義父類別行為時,也有一些規範,像是要遵守父類方法的行為,不得有額外行為,父類別沒有拋出的例外,子類別就不應拋出之類的規範等。

這類規範都是開發者自己應當認識與遵守的,編譯器不會為你檢查這類事,因為,軟體開發者可以在不遵守上述這些規範的情況下,照樣實現出Functor、Applicative、Monad,或者是實作equals、hashCode,只不過行為變得不可預測,隱藏細節的同時,又會在某些時候令細節滲出,這麼一來,程式雖然抽象了,也失去了組合性,規模越大就越不容易擴展而僵化。

說穿了,並不是抽象不好,而是在抽象的同時,應當同時考量程式是否更具有組合性,而不單純只是為了隱藏細節、降低複雜度。

總而言之,不憑空抽象、不堅持以物件為單位、發掘抽象前與抽象後都應遵守的定律,可以讓程式變得透明且更具有組合性。若抽象能變得透明,那就能避免抽象滲漏的問題,因為開發者對細節早就一清二楚!

作者簡介


Advertisement

更多 iThome相關內容