用 Kotlin 的函數式編程 替代 GOF 設計模式
函數式編程(FP)
《Kotlin極簡教程》正式上架:
點擊這里 > 去京東商城購買閱讀
點擊這里 > 去天貓商城購買閱讀
非常感謝您親愛的讀者,大家請多支持!!!有任何問題,歡迎隨時與我交流~
值就是函數,函數就是值。所有函數都消費函數,所有函數都生產函數。
"函數式編程", 又稱泛函編程, 是一種"編程范式"(programming paradigm),也就是如何編寫程序的方法論。它的基礎是 λ 演算(lambda calculus)。λ演算可以接受函數當作輸入(參數)和輸出(返回值)。
和指令式編程相比,函數式編程的思維方式更加注重函數的計算。它的主要思想是把問題的解決方案寫成一系列嵌套的函數調用。
就像在OOP中,一切皆是對象,編程的是由對象交合創造的世界;
在FP中,一切皆是函數,編程的世界是由函數交合創造的世界。
函數式編程中最古老的例子莫過于1958年被創造出來的Lisp了。Lisp由約翰·麥卡錫(John McCarthy,1927-2011)在1958年基于λ演算所創造,采用抽象數據列表與遞歸作符號演算來衍生人工智能。較現代的例子包括Haskell、ML、Erlang等。現代的編程語言對函數式編程都做了不同程度的支持,例如:JavaScript, Coffee Script,PHP,Perl,Python, Ruby, C# , Java 等等(這將是一個不斷增長的列表)。
函數式語言在Java 虛擬機(JVM)平臺上也迅速地嶄露頭角,例如Scala 、Clojure ; .NET 平臺也不例外,例如:F# 。
函數作為Kotlin中的一等公民,可以像其他對象一樣作為函數的輸入與輸出。關于對函數式編程的支持,相對于Scala的學院派風格,Kotlin則是純的的工程派:實用性、簡潔性上都要比Scala要好。
本章我們來一起學習函數式編程以及在Kotlin中使用函數式編程的相關內容。
8.1 函數式編程概述
函數式編程思想是一個非常古老的思想。我們簡述如下:
我們就從1900 年 David Hilbert 的第 10 問題(能否通過有限步驟來判定不定方程是否存在有理整數解?) 開始說起吧。
1920,Sch?nfinkel,組合子邏輯(combinatory logic)。直到 Curry Haskell 1927 在普林斯頓大學當講師時重新發現了 Moses Sch?nfinkel 關于組合子邏輯的成果。Moses Sch?nfinkel的成果預言了很多 Curry 在做的研究,于是他就跑去哥廷根大學與熟悉Moses Sch?nfinkel工作的Heinrich Behmann、Paul Bernays兩人一起工作,并于 1930 年以一篇組合子邏輯的論文拿到了博士學位。Curry Brooks Haskell 整個職業生涯都在研究組合子,實際開創了這個研究領域,λ演算中用單參數函數來表示多個參數函數的方法被稱為 Currying (柯里化),雖然 Curry 同學多次指出這個其實是 Sch?nfinkel 已經搞出來的,不過其他人都是因為他用了才知道,所以這名字就這定下來了;并且有三門編程語言以他的名字命名,分別是:Curry, Brooks, Haskell。Curry 在 1928 開始開發類型系統,他搞的是基于組合子的 polymorphic,Church 則建立了基于函數的簡單類型系統。
1929, 哥德爾(Kurt G?del )完備性定理。G?del 首先證明了一個形式系統中的所有公式都可以表示為自然數,并可以從一自然數反過來得出相應的公式。這對于今天的程序員都來說,數字編碼、程序即數據計算機原理最核心、最基本的常識,在那個時代卻腦洞大開的創見。
1933,λ 演算。 Church 在 1933 年搞出來一套以純λ演算為基礎的邏輯,以期對數學進行形式化描述。 λ 演算和遞歸函數理論就是函數式編程的基礎。
-
1936,確定性問題(decision problem,德文 Entscheidungsproblem (發音 [?nt??a??d??sp?o?ble?m])。 Alan Turing 和 Alonzo Church,兩人在同在1936年獨立給出了否定答案。
1935-1936這個時間段上,我們有了三個有效計算模型:通用圖靈機、通用遞歸函數、λ可定義。Rosser 1939 年正式確認這三個模型是等效的。
1953-1957,FORTRAN (FORmula TRANslating ),John Backus。1952 年 Halcombe Laning 提出了直接輸入數學公式的設想,并制作了 GEORGE編譯器演示該想法。受這個想法啟發,1953 年 IBM 的 John Backus 團隊給 IBM 704 主機研發數學公式翻譯系統。第一個 FORTRAN (FORmula TRANslating 的縮寫)編譯器 1957.4 正式發行。FORTRAN 程序的代碼行數比匯編少20倍。FORTRAN 的成功,讓很多人認識到直接把代數公式輸入進電腦是可行的,并開始渴望能用某種形式語言直接把自己的研究內容輸入到電腦里進行運算。John Backus 在1970年代搞了 FP 語言,1977 年發表。雖然這門語言并不是最早的函數式編程語言,但他是 Functional Programming 這個詞兒的創造者, 1977 年他的圖靈獎演講題為[“Can Programming Be Liberated From the von Neumann Style? A Functional Style and its Algebra of Programs”]
1956, LISP, John McCarthy。John McCarthy 1956年在 Dartmouth一臺 IBM 704 上搞人工智能研究時,就想到要一個代數列表處理(algebraic list processing)語言。他的項目需要用某種形式語言來編寫語句,以記錄關于世界的信息,而他感覺列表結構這種形式挺合適,既方便編寫,也方便推演。于是就創造了LISP。正因為是在 IBM 704 上開搞的,所以 LISP 的表處理函數才會有奇葩的名字: car/cdr 什么的。其實是取 IBM704 機器字的不同部分,c=content of,r=register number, a=address part, d=decrement part 。
8.1.1 面向對象編程(OOP)與面向函數編程(FOP)
面向對象編程(OOP)
在OOP中,一切皆是對象。
在面向對象的命令式(imperative)編程語言里面,構建整個世界的基礎是類和類之間溝通用的消息,這些都可以用類圖(class diagram)來表述。《設計模式:可復用面向對象軟件的基礎》(Design Patterns: Elements of Reusable Object-Oriented Software,作者ErichGamma、Richard Helm、Ralph Johnson、John Vlissides)一書中,在每一個模式的說明里都附上了至少一幅類圖。
OOP 的世界提倡開發者針對具體問題建立專門的數據結構,相關的專門操作行為以“方法”的形式附加在數據結構上,自頂向下地來構建其編程世界。
OOP追求的是萬事萬物皆對象的理念,自然地弱化了函數。例如:函數無法作為普通數據那樣來傳遞(OOP在函數指針上的約束),所以在OOP中有各種各樣的、五花八門的設計模式。
GoF所著的《設計模式-可復用面向對象軟件的基礎》從面向對象設計的角度出發的,通過對封裝、繼承、多態、組合等技術的反復使用,提煉出一些可重復使用的面向對象設計技巧。而多態在其中又是重中之重。
多態、面向接口編程、依賴反轉等術語,描述的思想其實是相同的。這種反轉模式實現了模塊與模塊之間的解耦。這樣的架構是健壯的, 而為了實現這樣的健壯系統,在系統架構中基本都需要使用多態性。
絕大部分設計模式的實現都離不開多態性的思想。換一種說法就是,這些設計模式背后的本質其實就是OOP的多態性,而OOP中的多態本質上又是受約束的函數指針。
引用Charlie Calverts對多態的描述: “多態性是允許你將父對象設置成為和一個或更多的他的子對象相等的技術,賦值之后,父對象就可以根據當前賦值給它的子對象的特性以不同的方式運作。”
簡單的說,就是一句話:允許將子類類型的指針賦值給父類類型的指針。而我們在OOP中的那么多的設計模式,其實就是在OOP的多態性的約束規則下,對這些函數指針的調用模式的總結。
很多設計模式,在函數式編程中都可以用高階函數來代替實現:
面向函數編程(FOP)
在FP中,一切皆是函數。
函數式編程(FP)是關于不變性和函數組合的一種編程范式。
函數式編程語言實現重用的思路很不一樣。函數式語言提倡在有限的幾種關鍵數據結構(如list、set、map)上 , 運用函數的組合 ( 高階函數) 操作,自底向上地來構建世界。
當然,我們在工程實踐中,是不能極端地追求純函數式的編程的。一個簡單的原因就是:性能和效率。例如:對于有狀態的操作,命令式操作通常會比聲明式操作更有效率。純函數式編程是解決某些問題的偉大工具,但是在另外的一些問題場景中,并不適用。因為副作用總是真實存在。
OOP喜歡自頂向下架構層層分解(解構),FP喜歡自底向上層層組合(復合)。 而實際上,編程的本質就是次化分解與復合的過程。通過這樣的過程,創造一個美妙的邏輯之塔世界。
我們經常說一些代碼片段是優雅的或美觀的,實際上意味著它們更容易被人類有限的思維所處理。
對于程序的復合而言,好的代碼是它的表面積要比體積增長的慢。
代碼塊的“表面積”是是我們復合代碼塊時所需要的信息(接口API協議定義)。代碼塊的“體積”就是接口內部的實現邏輯(API內部的實現代碼)。
在OOP中,一個理想的對象應該是只暴露它的抽象接口(純表面, 無體積),其方法則扮演箭頭的角色。如果為了理解一個對象如何與其他對象進行復合,當你發現不得不深入挖掘對象的實現之時,此時你所用的編程范式的原本優勢就蕩然無存了。
FP通過函數組合來構造其邏輯系統。FP傾向于把軟件分解為其需要執行的行為或操作,而且通常采用自底向上的方法。函數式編程也提供了非常強大的對事物進行抽象和組合的能力。
在FP里面,函數是“一類公民”(first-class)。它們可以像1, 2, "hello",true,對象…… 之類的“值”一樣,在任意位置誕生,通過變量,參數和數據結構傳遞到其它地方,可以在任何位置被調用。
而在OOP中,很多所謂面向對象設計模式(design pattern),都是因為面向對象語言沒有first-class function(對應的是多態性),所以導致了每個函數必須被包在一個對象里面(受約束的函數指針)才能傳遞到其它地方。
勻稱的數據結構 + 勻稱的算法
在面向對象式的編程中,一切皆是對象(偏重數據結構、數據抽象,輕算法)。我們把它叫做:胖數據結構-瘦算法(FDS-TA)。
在面向函數式的編程中,一切皆是函數(偏重算法,輕數據結構)。我們把它叫做:瘦數據結構-胖算法(TDS-FA)。
可是,這個世界很復雜,你怎么能說一切皆是啥呢?真實的編程世界,自然是勻稱的數據結構結合勻稱的算法(SDS-SA)來創造的。
我們在編程中,不可能使用純的對象(對象的行為方法其實就是函數),或者純的函數(調用函數的對象、函數操作的數據其實就是數據結構)來創造一個完整的世界。如果數據結構
是陰
,算法
是陽
,那么在解決實際問題中,往往是陰陽交合而成世界。還是那句經典的:
程序 = 勻稱的數據結構 + 勻稱的算法
我們用一幅圖來簡單說明:
函數與映射
一切皆是映射。函數式編程的代碼主要就是“對映射的描述”。我們說組合是編程的本質,其實,組合就是建立映射關系。
一個函數無非就是從輸入到輸出的映射,寫成數學表達式就是:
f: X -> Y
p:Y -> Z
p(f) : X ->Z
用編程語言表達就是:
fun f(x:X) : Y{}
fun p(y:Y) : Z{}
fun fp(f: (X)->Y, p: (Y)->Z) : Z {
return {x -> p(f(x))}
}
8.1.2 函數式編程基本特性
在經常被引用的論文 “Why Functional Programming Matters” 中,作者 John Hughes 說明了模塊化是成功編程的關鍵,而函數編程可以極大地改進模塊化。
在函數編程中,我們有一個內置的框架來開發更小的、更簡單的和更一般化的模塊, 然后將它們組合在一起。
函數編程的一些基本特點包括:
- 函數是"第一等公民"。
- 閉包(Closure)和高階函數(Higher Order Function)。
- Lambda演算與函數柯里化(Currying)。
- 懶惰計算(lazy evaluation)。
- 使用遞歸作為控制流程的機制。
- 引用透明性。
- 沒有副作用。
8.1.3 組合與范疇
函數式編程的本質是函數的組合,組合的本質是范疇(Category)。
和搞編程的一樣,數學家喜歡將問題不斷加以抽象從而將本質問題抽取出來加以論證解決,范疇論就是這樣一門以抽象的方法來處理數學概念的學科,主要用于研究一些數學結構之間的映射關系(函數)。
在范疇論里,一個范疇(category)由三部分組成:
- 對象(object).
- 態射(morphism).
- 組合(composition)操作符,
范疇的對象
這里的對象可以看成是一類東西,例如數學上的群,環,以及有理數,無理數等都可以歸為一個對象。對應到編程語言里,可以理解為一個類型,比如說整型,布爾型等。
態射
態射指的是一種映射關系,簡單理解,態射的作用就是把一個對象 A 里的值 a 映射為 另一個對象 B 里的值 b = f(a),這就是映射的概念。
態射的存在反映了對象內部的結構,這是范疇論用來研究對象的主要手法:對象內部的結構特性是通過與別的對象的映射關系反映出來的,動靜是相對的,范疇論通過研究映射關系來達到探知對象的內部結構的目的。
組合操作符
組合操作符,用點(.)表示,用于將態射進行組合。組合操作符的作用是將兩個態射進行組合,例如,假設存在態射 f: A -> B, g: B -> C, 則 g.f : A -> C.
一個結構要想成為一個范疇, 除了必須包含上述三樣東西,它還要滿足以下三個限制:
結合律: f.(g.h) = (f.g).h 。
封閉律:如果存在態射 f, g,則必然存在 h = f.g 。
同一律:對結構中的每一個對象 A, 必須存在一個單位態射 Ia: A -> A, 對于單位態射,顯然,對任意其它態射 f, 有 f.I = f。
在范疇論里另外研究的重點是范疇與范疇之間的關系,就正如對象與對象之間有態射一樣,范疇與范疇之間也存在映射關系,從而可以將一個范疇映射為另一個范疇,這種映射在范疇論中叫作函子(functor),具體來說,對于給定的兩個范疇 A 和 B, 函子的作用有兩個:
- 將范疇 A 中的對象映射到范疇 B 中的對象。
- 將范疇 A 中的態射映射到范疇 B 中的態射。
顯然,函子反映了不同的范疇之間的內在聯系。跟函數和泛函數的思想是相同的。
而我們的函數式編程探究的問題與思想理念可以說是跟范疇論完全吻合。如果把函數式編程的整個的世界看做一個對象,那么FP真正搞的事情就是建立通過函數之間的映射關系,來構建這樣一個美麗的編程世界。
很多問題的解決(證明)其實都不涉及具體的(數據)結構,而完全可以只依賴映射之間的組合運算(composition)來搞定。這就是函數式編程的核心思想。
如果我們把程序
看做圖論里面的一張圖G,數據結構
當作是圖G的節點Node(數據結構,存儲狀態), 而算法
邏輯就是這些節點Node之間的Edge (數據映射,Mapping), 那么這整幅圖 G(N,E)
就是一幅美妙的抽象邏輯之塔的 映射圖
, 也就是我們編程創造的世界:
函數是"第一等公民"
函數式編程(FP)中,函數是"第一等公民"。
所謂"第一等公民"(first class),有時稱為 閉包或者 仿函數(functor)對象,
指的是函數與其他數據類型一樣,處于平等地位,可以賦值給其他變量,也可以作為參數,傳入另一個函數,或者作為別的函數的返回值。這個以函數為參數的概念,跟C語言中的函數指針類似。
舉例來說,下面代碼中的print變量就是一個函數(沒有函數名),可以作為另一個函數的參數:
>>> val print = fun(x:Any){println(x)}
>>> listOf(1,2,3).forEach(print)
1
2
3
高階函數(Higher order Function)
FP 語言支持高階函數,高階函數就是多階映射。高階函數用另一個函數作為其輸入參數,也可以返回一個函數作為輸出。
代碼示例:
fun isOdd(x: Int) = x % 2 != 0
fun length(s: String) = s.length
fun <A, B, C> compose(f: (B) -> C, g: (A) -> B): (A) -> C {
return { x -> f(g(x)) }
}
測試代碼:
fun main(args: Array<String>) {
val oddLength = compose(::isOdd, ::length)
val strings = listOf("a", "ab", "abc")
println(strings.filter(oddLength)) // [a, abc]
}
這個compose函數,其實就是數學中的復合函數的概念,這是一個高階函數的例子:傳入的兩個參數f , g都是函數,其返回值也是函數。
圖示如下:
這里的
fun <A, B, C> compose(f: (B) -> C, g: (A) -> B): (A) -> C
中類型參數對應:
fun <String, Int, Boolean> compose(f: (Int) -> Boolean, g: (String) -> Int): (String) -> Boolean
這里的(Int) -> Boolean
、(String) -> Int
、 (String) -> Boolean
都是函數類型。
其實,從映射的角度看,就是二階映射。對[a, ab, abc] 中每個元素 x 先映射成長度g(x) = 1, 2, 3 , 再進行第二次映射:f(g(x)) %2 != 0 , 長度是奇數?返回值是true的被過濾出來。
有了高階函數,我們可以用優雅的方式進行模塊化編程。
另外,高階函數滿足結合律:
https://github.com/EasyKotlin/gof-in-kotlin
Kotlin 開發者社區
國內第一Kotlin 開發者社區公眾號,主要分享、交流 Kotlin 編程語言、Spring Boot、Android、React.js/Node.js、函數式編程、編程思想等相關主題。