函式式非凡的抽象能力

張逸發表於2016-03-31

我在閱讀或編寫具有函式式風格的程式碼時,常常為函式式思想非凡的抽象能力所驚歎。作為一直以來持有OO信仰的程式設計師而言,對於“抽象”並不陌生。我甚至將物件導向思想的精髓定義為兩個單詞:職責(Responsibility)與抽象(Abstraction)。只要職責分配合理,設計就是良好的;若能再加上合理的抽象,程式會變得更精簡且可擴充套件。如果你熟悉GoF的設計模式,你幾乎可以從每個模式中讀出“抽象”的意義來。

然而,無論如何,物件導向思想構築的其實是一個名詞的世界,這在很大程度上侷限了它的世界觀,它只能以實體(Entity)為核心。雖然我們仍然可以針對實體提煉共同特徵,但這些特徵若為行為,卻無法單獨存在,這是物件導向思想的硬傷。

如果說物件導向思想是物質世界的哲學觀,則函式式思想展現的就是純粹的數學思維了。函式作為一等公民,它不代表任何物質(物件),而僅僅代表一種轉換行為。是的,任何一個函式都可以視為一種“轉換(transform)”。這是對行為的最高抽象,代表了型別(type)[注意,是型別(type),而不是類(class)]之間的某種動作。函式可以是極為原子的操作,也可以是多個原子函式的組合,或者在組合之上再封裝一層語義更清晰的函式表現。

理解了函式的轉換本質,我們就必須學會在具體行為中“洞見”這種轉換本質。這種“洞見”可以理解為解構分析,就好似我們在甄別化石的年代時,利用核分析技術去計算碳14同位素原子數量一般。我們解構出來的“原子”函式往往具有非凡的抽象能力。例如,我們針對集合的sum與product操作,可以解構出原子的fold函式。雖然從行為特徵看,sum為求和,product為求積,但從抽象層面看,都是從一個初始值開始,依次對集合元素進行運算。而運算本身,又是抽象的另一個轉換操作,從而引入了高階函式的概念。若要讓fold不止侷限於某一種具體型別,則可以引入函式式語言的型別系統。fold可以根據摺疊的方向分為foldRight與foldLeft。foldRight(或flodr)的函式定義如下:

//scala語言
def fold[A, B](l: MyList[A], z: B)(f: (A, B) => B):B = l match {
    case Nil => z
    case Cons(x, xs) => f(x, fold(xs, z)(f))
}
--haskell語言
foldr f zero (x:xs) = f x (foldr f zero xs)
foldr _ zero []     = zero

深入理解Scala》一書在講解Scala的Option時,給出了一個有趣的案例,其中揭示的抽象思想與fold有異曲同工之妙。這個案例講解了如何用多個可能未初始化的變數構造另一個變數,Option正適合處理這種情況,我在部落格《並非Null Object這麼簡單》中介紹了Option的本質,這裡不再贅述。這個例子是希望通過資料庫配置資訊建立連線。由於配置資訊可能有誤,建立的連線可能為null,因而使用Option的api會更加健壯:

def createConnection(conn_url: Option[String],
                     conn_user: Option[String],
                     conn_pw: Option[String]): Option[Connection] = 
    for {
        url <- conn_url
        user <- conn_user
        pw <- conn_pw
    } yield DriverManager.getConnection(url, user, pw)

現在,我們將這個函式無限抽象化,那就是要去掉一些複雜而冗餘的具象資訊,就好像過濾掉讓人眼花繚亂的繽紛顏色,僅僅呈現最樸素的黑白二色一般。首先,我們抹掉“建立連線”的特徵,然後再抹掉型別資訊。我們可以看到createConnection實則是對DriverManager.getConnection的轉換,經此轉換後,若要建立連線,就可以傳入三個Option[String]型別的引數,獲得Option[Connection]型別的結果。然後再去掉具體的String型別,就可以抽象出如下的“轉換”操作:

(A, B, C): => D     轉換為      (Option[A], Option[B], Option[C]) => Option[D]

注意,這個轉換操作是函式到函式的轉換。

書中找到了一個正確的概念來恰如其分地描述這一“轉換”操作,即為lift(提升):

def lift[A, B, C, D](f: Function3[A, B, C, D]): Function3[Option[A], Option[B], Option[C], Option[D]] = 
    (oa: Option[A], ob: Option[B], oc: Option[C]) => 
        for (a <- oa; b <- ob; c <- oc) yield f(a, b, c)

Function3事實上是Scala中對(A, B, C) => D函式的封裝。相對而言,我更喜歡高階函式的形式:

def lift[A, B, C, D](f: (A, B, C) => D): (Option[A], Option[B], Option[C]) => Option[D] =
    (oa: Option[A], ob: Option[B], oc: Option[C]) => 
        for (a <- oa; b <- ob; c <- oc) yield f(a, b, c)

lift函式是寬泛的抽象,之前的DriverManager.getConnection()函式則為一個具體的被轉換物件。它可以作為引數傳入到lift函式中:

val createConnection1 = lift(DriverManager.getConnection)

lift函式返回的實則是一個函式,它本質上等同於之前定義的createConnection()函式。由於lift抹掉了具體的型別資訊,使得它不僅僅可以將getConnection提升為具有Option的函式,還能針對所有形如(A, B, C) => D格式的函式。讓我們來自定義一個combine函式:

def combine(prefix: String, number: Int, suffix: String): String =
    s"$prefix - $number - $suffix"

val optionCombine = lift(combine)

區分combine函式與opitonCombine函式的執行結果:
函式式非凡的抽象能力

諸如fold或lift這樣的終極抽象在函式式語言的api中可謂俯拾皆是,如針對集合的monad操作filter, flatMap, map,又例如函式組合的操作sequence,andThen等。我們還可以結合轉換語義為這種基本轉換命名,使得程式碼更加簡略可讀。例如針對如下的三個函式定義:

def intDouble(rng: RNG): ((Int,Double), RNG)
def doubleInt(rng: RNG): ((Double,Int), RNG)
def double3(rng: RNG): ((Double,Double,Double), RNG)

我們可以抽象出RNG => (A, RNG)的通用模式,然後從語義上將其命名為Rand,那麼,在scala中可以利用type關鍵字為這種轉換定義別名:

type Rand[+A] = RNG => (A, RNG)

當我們將函式作為基本的抽象單元后,再對物件導向思想做一次回眸,會發現OO中的多數設計原則與設計模式,都可以簡化為函式。Scott Wlaschin在Functional Design Patterns的演講中給出了非常形象的對比:

函式式非凡的抽象能力

顯然,函式才是最為純粹的抽象。正所謂“大道至簡”,有時候,簡單可能就意味著一切。

相關文章