函數式編程

原文鏈接:https://github.com/EasyKotlin

第8章 函數式編程(FP)

值就是函數,函數就是值。所有函數都消費函數,所有函數都生產函數。

“函數式編程”, 又稱泛函編程, 是一種”編程範式”(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 函數式編程概述

Kotlin極簡教程

函數式編程思想是一個非常古老的思想。我們簡述如下:

  • 我們就從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的多態性的約束規則下,對這些函數指針的調用模式的總結。

很多設計模式,在函數式編程中都可以用高階函數來代替實現:

Kotlin極簡教程

面向函數編程(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)來創造的。

我們在編程中,不可能使用純的對象(對象的行爲方法其實就是函數),或者純的函數(調用函數的對象、函數操作的數據其實就是數據結構)來創造一個完整的世界。如果數據結構算法,那麼在解決實際問題中,往往是陰陽交合而成世界。還是那句經典的:

程序 = 勻稱的數據結構 + 勻稱的算法

我們用一幅圖來簡單說明:

OOP vs FP (2).png

函數與映射

一切皆是映射。函數式編程的代碼主要就是“對映射的描述”。我們說組合是編程的本質,其實,組合就是建立映射關係。

一個函數無非就是從輸入到輸出的映射,寫成數學表達式就是:

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) 就是一幅美妙的抽象邏輯之塔的 映射圖 , 也就是我們編程創造的世界:

Kotlin極簡教程

函數是”第一等公民”

函數式編程(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都是函數,其返回值也是函數。

圖示如下:

Kotlin極簡教程

這裏的

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的被過濾出來。

有了高階函數,我們可以用優雅的方式進行模塊化編程。

另外,高階函數滿足結合律:

Kotlin極簡教程

λ演算 (Lambda calculus 或者 λ-calculus)

�� 演算是函數式語言的基礎。在λ-演算的基礎上,發展起來的π-演算、χ-演算,成爲近年來的併發程序的理論工具之一,許多經典的併發程序模型就是以π-演算爲框架的。λ 演算神奇之處在於,通過最基本的函數抽象和函數應用法則,配套以適當的技巧,便能夠構造出任意複雜的可計算函數。

λ演算是一套用於研究函數定義、函數應用和遞歸的形式系統。它由 阿隆佐·丘奇(Alonzo Church,1903~1995)和 Stephen Cole Kleene 在 20 世紀三十年代引入。當時的背景是解決函數可計算的本質性問題,初期λ演算成功的解決了在可計算理論中的判定性問題,後來根據Church–Turing thesis,證明了λ演算與圖靈機是等價的。

λ 演算可以被稱爲最小的通用程序設計語言。它包括一條變換規則 (變量替換) 和一條函數定義方式,λ演算之通用在於,任何一個可計算函數都能用這種形式來表達和求值。

λ演算強調的是變換規則的運用,這裏的變換規則本質上就是函數映射。

Lambda 表達式(Lambda Expression) 是 λ演算 的一部分。

λ演算中一切皆函數,全體λ表達式構成Λ空間,λ表達式爲Λ空間到Λ空間的函數。

例如,在 lambda 演算中有許多方式都可以定義自然數,最常見的是Church 整數,定義如下:

0 = λ f. λ x. x
1 = λ f. λ x. f x
2 = λ f. λ x. f (f x)
3 = λ f. λ x. f (f (f x))
...

數學家們都崇尚簡潔,只用一個關鍵字 ‘λ’ 來表示對函數的抽象。

其中的λ f. λ x.λ f 是抽象出來的函數, λ x是輸入參數, . 語法用來分割參數表和函數體。 爲了更簡潔,我們簡記爲F, 那麼上面的Church 整數定義簡寫爲:

0 = F x
1 = F f x
2 = F f (f x)
3 = F f (f (f x))
...

使用λ演算定義布爾值:

TRUE = λ x. λ y. x
FALSE = λ x. λ y. y

用圖示如下:

Kotlin極簡教程

Kotlin極簡教程

在λ演算中只有函數,一門編程語言中的數據類型,比如boolean、number、list等,都可以使用純λ演算來實現。我們不用去關心數據的值是什麼,重點是我們能對這個值做什麼操作(apply function)。

使用λ演算定義一個恆等函數I :

 I = λ x . x

使用Kotlin代碼來寫,如下:

>>> val I = {x:Int -> x}
>>> I(0)
0
>>> I(1)
1
>>> I(100)
100

對 I 而言任何一個 x 都是它的不動點(即對某個函數 f(x) 存在這樣的一個輸入 x,使得函數的輸出仍舊等於輸入的 x 。形式化的表示即爲 f(x) = x )。

再例如,下面的 λ 表達式表示將x映射爲 x+1 :

 λ x . x + 1

測試代碼:

( λ x . x + 1) 5

將輸出6 。

這樣的表達式,在Kotlin中, 如果使用Lambda表達式我們這樣寫:

>>> val addOneLambda = {
...         x: Int ->
...         x + 1
...     }
>>> addOneLambda(1)
2

如果使用匿名函數,這樣寫:

>>> val addOneAnonymouse = (fun(x: Int): Int {
...         return x + 1
...     })
>>> addOneAnonymouse(1)
2

在一些古老的編程語言中,lambda表達式還是比較接近lambda演算的表達式的。在現代程序語言中的lambda表達式,只是取名自lambda演算,已經與原始的lambda演算有很大差別了。例如:

Kotlin極簡教程

在Javascript裏沒有任何語法專門代表lambda, 只寫成這樣的嵌套函數function{ return function{...} }

函數柯里化(Currying)

很多基於 lambda calculus 的程序語言,比如 ML 和 Haskell,都習慣用currying 的手法來表示函數。比如,如果你在 Haskell 裏面這樣寫一個函數:
kotlin
f x y = x + y

然後你就可以這樣把鏈表裏的每個元素加上 2:

map (f 2) [1, 2, 3] 

它會輸出 [3, 4, 5]。

Currying 用一元函數,來組合成多元函數。比如,上面的函數 f 的定義在 Scheme 裏面相當於:

(define f (lambda (x) (lambda (y) (+ x y)))) 

它是說,函數 f,接受一個參數 x,返回另一個函數(沒有名字)。這個匿名函數,如果再接受一個參數 y,就會返回 x + y。所以上面的例子裏面,(f 2) 返回的是一個匿名函數,它會把 2 加到自己的參數上面返回。所以把它 map 到 [1, 2, 3],我們就得到了 [3, 4, 5]。

我們再使用Kotlin中的函數式編程來舉例說明。

首先,我們看下普通的二元函數的寫法:

fun add(x: Int, y: Int): Int {
    return x + y
}

add(1, 2) // 輸出3

這種寫法最簡單,只有一層映射。

柯里化的寫法:

fun curryAdd(x: Int): (Int) -> Int {
    return { y -> x + y }
}

curryAdd(1)(2)// 輸出3

我們先傳入參數x = 1, 返回函數 curryAdd(1) = 1 + y;然後傳入參數 y = 2, 返回最終的值 curryAdd(1)(2) = 3。

當然,我們也有 λ 表達式的寫法:

val lambdaCurryAdd = {
        x: Int ->
        {
            y: Int ->
            x + y
        }
    }

lambdaCurryAdd(1)(2)  // 輸出 3

這個做法其實來源於最早的 lambda calculus 的設計。因爲 lambda calculus 的函數都只有一個參數,所以爲了能夠表示多參數的函數, Haskell Curry (數學家和邏輯學家),發明了這個方法。

不過在編碼實踐中,Currying 的工程實用性、簡潔性上不是那麼的友好。大量使用 Currying,會導致代碼可讀性降低,複雜性增加,並且還可能因此引起意想不到的錯誤。 所以在我們的講求工程實踐性能的Kotlin語言中,

古老而美麗的理論,也許能夠給我帶來思想的啓迪,但是在工程實踐中未必那麼理想。

閉包(Closure)

閉包簡單講就是一個代碼塊,用{ }包起來。此時,程序代碼也就成了數據,可以被一個變量所引用(與C語言的函數指針比較類似)。閉包的最典型的應用是實現回調函數(callback)。

閉包包含以下兩個組成部分:

  • 要執行的代碼塊(由於自由變量被包含在代碼塊中,這些自由變量以及它們引用的對象沒有被釋放)
  • 自由變量的作用域

在PHP、Scala、Scheme、Common Lisp、Smalltalk、Groovy、JavaScript、Ruby、 Python、Go、Lua、objective c、swift 以及Java(Java8及以上)等語言中都能找到對閉包不同程度的支持。

Lambda表達式可以表示閉包。

惰性計算

除了高階函數、閉包、Lambda表達式的概念,FP 還引入了惰性計算的概念。惰性計算(儘可能延遲表達式求值)是許多函數式編程語言的特性。惰性集合在需要時提供其元素,無需預先計算它們,這帶來了一些好處。首先,您可以將耗時的計算推遲到絕對需要的時候。其次,您可以創造無限個集合,只要它們繼續收到請求,就會繼續提供元素。第三,map 和 filter 等函數的惰性使用讓您能夠得到更高效的代碼(請參閱 參考資料 中的鏈接,加入由 Brian Goetz 組織的相關討論)。

在惰性計算中,表達式不是在綁定到變量時立即計算,而是在求值程序需要產生表達式的值時進行計算。

一個惰性計算的例子是生成無窮 Fibonacci 列表的函數,但是對 第 n 個Fibonacci 數的計算相當於只是從可能的無窮列表中提取一項。

遞歸函數

遞歸指的是一個函數在其定義中直接或間接調用自身的一種方法, 它通常把一個大型的複雜的問題轉化爲一個與原問題相似的規模較小的問題來解決(複用函數自身), 這樣可以極大的減少代碼量。遞歸分爲兩個階段:

  1. 遞推:把複雜的問題的求解推到比原問題簡單一些的問題的求解;
  2. 迴歸:當獲得最簡單的情況後,逐步返回,依次得到複雜的解。

遞歸的能力在於用有限的語句來定義對象的無限集合。

使用遞歸要注意的有兩點:

  1. 遞歸就是在過程或函數裏面調用自身;
  2. 在使用遞歸時,必須有一個明確的遞歸結束條件,稱爲遞歸出口。

下面我們舉例說明。

階乘函數 fact(n) 一般這樣遞歸地定義:

fact(n) = if n=0 then 1 else n * fact(n-1)

我們使用Kotlin代碼實現這個函數如下:

fun factorial(n: Int): Int {
    println("factorial() called!  n=$n")
    if (n == 0) return 1;
    return n * factorial(n - 1);
}

測試代碼:

@Test
fun testFactorial() {
    Assert.assertTrue(factorial(0) == 1)
    Assert.assertTrue(factorial(1) == 1)
    Assert.assertTrue(factorial(3) == 6)
    Assert.assertTrue(factorial(10) == 3628800)
}

輸出:

factorial() called!  n=0
factorial() called!  n=1
factorial() called!  n=0
factorial() called!  n=3
factorial() called!  n=2
factorial() called!  n=1
factorial() called!  n=0
factorial() called!  n=10
factorial() called!  n=9
factorial() called!  n=8
factorial() called!  n=7
factorial() called!  n=6
factorial() called!  n=5
factorial() called!  n=4
factorial() called!  n=3
factorial() called!  n=2
factorial() called!  n=1
factorial() called!  n=0
BUILD SUCCESSFUL in 24s
6 actionable tasks: 5 executed, 1 up-to-date

我們可以看到在factorial計算的過程中,函數不斷的調用自身,然後不斷的展開,直到最後到達了終止的n==0,這是遞歸的原則之一,就是在遞歸的過程中,傳遞的參數一定要不斷的接近終止條件,在上面的例子中就是n的值不斷減少,直至最後爲0。

再舉個Fibonacci數列的例子。

Fibonacci數列用數學中的數列的遞歸表達式定義如下:

fibonacci (0) = 0
fibonacci (1) = 1
fibonacci (n) = fibonacci (n - 1) + fibonacci (n - 2)

我們使用Kotlin代碼實現它:

fun fibonacci(n: Int): Int {
    if (n == 1 || n == 2) return 1;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

測試代碼:

@Test
fun testFibonacci() {
    Assert.assertTrue(fibonacci(1) == 1)
    Assert.assertTrue(fibonacci(2) == 1)
    Assert.assertTrue(fibonacci(3) == 2)
    Assert.assertTrue(fibonacci(4) == 3)
    Assert.assertTrue(fibonacci(5) == 5)
    Assert.assertTrue(fibonacci(6) == 8)
}

外篇: Scheme中的遞歸寫法

因爲Scheme 程序中充滿了一對對嵌套的小括號,這些嵌套的符號體現了最基本的數學思想——遞歸。所以,爲了多維度的來理解遞歸,我們給出Scheme中的遞歸寫法:

(define factorial
  (lambda (n)
    (if (= n 0)
        1
        (* n (factorial (- n 1))))))

(define fibonacci
  (lambda (n)
    (cond ((= n 0) 0)
          ((= n 1) 1)
          (else (+ (fibonacci (- n 1)) (fibonacci (- n 2)))))))

其中關鍵字lambda, 表明我們定義的(即任何封閉的開括號立即離開λ及其相應的關閉括號)是一個函數。

Lambda演算和函數式語言的計算模型天生較爲接近,Lambda表達式一般是這些語言必備的基本特性。

Scheme是Lisp方言,遵循極簡主義哲學,有着獨特的魅力。Scheme的一個主要特性是可以像操作數據一樣操作函數調用。

Y組合子(Y - Combinator)

在現代編程語言中,函數都是具名的,而在傳統的Lambda Calculus中,函數都是沒有名字的。這樣就出現了一個問題 —— 如何在Lambda Calculus中實現遞歸函數,即匿名遞歸函數。Haskell B. Curry (編程語言 Haskell 就是以此人命名的)發現了一種不動點組合子 —— Y Combinator,用於解決匿名遞歸函數實現的問題。Y 組合子(Y Combinator),其定義是:

Y = λf.(λx.f (x x)) (λx.f (x x))

對於任意函數 g,可以通過推導得到Y g = g (Y g) ((高階)函數的不動點 ),從而證明 λ演算圖靈完備 的。 Y 組合子 的重要性由此可見一斑。

她讓人絞盡腦汁,也琢磨不定!她讓人心力憔悴,又百般回味!
她,看似平淡,卻深藏玄機!她,貌不驚人,卻天下無敵!
她是誰?她就是 Y 組合子:Y = λf.(λx.f (x x)) (λx.f (x x)),不動點組合子中最著名的一個。

Y 組合子讓我們可以定義匿名的遞歸函數。Y組合子是Lambda演算的一部分,也是函數式編程的理論基礎。僅僅通過Lambda表達式這個最基本的 原子 實現循環迭代。Y 組合子本身是函數,其輸入也是函數(在 Lisp 中連程序都是函數)。

頗有道生一、一生二、二生三、三生萬物的韻味。

舉個例子說明: 我們先使用類C語言中較爲熟悉的JavaScript來實現一個Y組合子函數, 因爲JavaScript語言的動態特性,使得該實現相比許多需要聲明各種類型的語言要簡潔許多:

function Y(f) {
    return (function (g) {
        return g(g);
    })(function (g) {
        return f(function (x) {
            return g(g)(x);
        });
    });
}

var fact = Y(function (rec) {
    return function (n) {
        return n == 0 ? 1 : n * rec(n - 1);
    };
});

我們使用了Y函數組合一段匿名函數代碼,實現了一個匿名的遞歸階乘函數。

直接將這兩個函數放到瀏覽器的Console中去執行,我們將看到如下輸出:

fact(10)
3628800

Kotlin極簡教程

這個Y函數相當繞腦。要是在Clojure(JVM上的Lisp方言)中,這個Y函數實現如下:

(defn Y [r]
 ((fn [f] (f f))
 (fn [f]
 (r (fn [x] ((f f) x))))))

使用Scheme語言來表達:

(define Y 
  (lambda (f)
    ((lambda (x) (f (lambda (y) ((x x) y))))
     (lambda (x) (f (lambda (y) ((x x) y)))))))

我們可以看出,使用Scheme語言表達的Y組合子跟 原生的 λ演算 表達式基本一樣。

用CoffeeScript實現一個 Y combinator就長這樣:

coffee> Y = (f) -> ((x) -> (x x)) ((x) -> (f ((y) -> ((x x) y))))
[Function]

這個看起就相當簡潔優雅了。我們使用這個 Y combinator 實現一個匿名遞歸的Fibonacci函數:

coffee> fib = Y (f) -> (n) ->  if n < 2 then n else f(n-1) + f(n-2)
[Function]
coffee> index = [0..10]
[ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
coffee> index.map(fib)
[ 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55 ]

實現一個匿名遞歸階乘函數:

coffee> fact = Y (f)  ->(n) -> if n==0 then 1 else n*f(n-1)
[Function]
coffee> fact(10)
3628800

上面的Coffee代碼的命令行REPL運行環境搭建非常簡單:

$ npm install -g coffee-script
$ coffee
coffee>

對CoffeeScript感興趣的讀者,可以參考:http://coffee-script.org/

但是,這個Y組合子 要是 使用 OOP 語言編程範式, 就要顯得複雜許多。爲了更加深刻地認識OOP 與 FP編程範式,我們使用Java 8 以及 Kotlin 的實例來說明。這裏使用Java給出示例的原因,是爲了給出Kotlin與Java語言上的對比,在下一章節中,我們將要學習Kotlin與Java的互操作。

首先我們使用Java的匿名內部類實現Y組合子 :

package com.easy.kotlin;

public class YCombinator {
    public static Lambda<Lambda> yCombinator(final Lambda<Lambda> f) {
        return new Lambda<Lambda>() {
            @Override
            public Lambda call(Object input) {
                final Lambda<Lambda> u = (Lambda<Lambda>)input;
                return u.call(u);
            }
        }.call(new Lambda<Lambda>() {
            @Override
            public Lambda call(Object input) {
                final Lambda<Lambda> x = (Lambda<Lambda>)input;
                return f.call(new Lambda<Object>() {
                    @Override
                    public Object call(Object input) {
                        return x.call(x).call(input);
                    }
                });
            }
        });
    }

    public static void main(String[] args) {
        Lambda<Lambda> y = yCombinator(new Lambda<Lambda>() {
            @Override
            public Lambda call(Object input) {
                final Lambda<Integer> fab = (Lambda<Integer>)input;
                return new Lambda<Integer>() {
                    @Override
                    public Integer call(Object input) {
                        Integer n = Integer.parseInt(input.toString());
                        if (n < 2) {
                            return Integer.valueOf(1);
                        } else {
                            return n * fab.call(n - 1);
                        }
                    }
                };
            }
        });
        System.out.println(y.call(10));//輸出: 3628800
    }

    interface Lambda<E> {
        E call(Object input);
    }
}

這裏定義了一個Lambda<E>類型, 然後通過E call(Object input)方法實現自調用,方法實現裏有多處轉型以及嵌套調用。邏輯比較繞,代碼可讀性也比較差。當然,這個問題本身也比較複雜。

我們使用Java 8的Lambda表達式來改寫下匿名內部類:

package com.easy.kotlin;

public class YCombinator2 {

    public static Lambda<Lambda> yCombinator2(final Lambda<Lambda> f) {
        return ((Lambda<Lambda>)(Object input) -> {
            final Lambda<Lambda> u = (Lambda<Lambda>)input;
            return u.call(u);
        }).call(
            ((Lambda<Lambda>)(Object input) -> {
                final Lambda<Lambda> v = (Lambda<Lambda>)input;
                return f.call((Lambda<Object>)(Object p) -> {
                    return v.call(v).call(p);
                });
            })
        );

    }

    public static void main(String[] args) {
        Lambda<Lambda> y2 = yCombinator2(
            (Lambda<Lambda>)(Object input) -> {
                Lambda<Integer> fab = (Lambda<Integer>)input;
                return (Lambda<Integer>)(Object p) -> {
                    Integer n = Integer.parseInt(p.toString());
                    if (n < 2) {
                        return Integer.valueOf(1);
                    } else {
                        return n * fab.call(n - 1);
                    }
                };
            });

        System.out.println(y2.call(10));//輸出: 3628800
    }

    interface Lambda<E> {
        E call(Object input);
    }

}

最後,我們使用Kotlin的對象表達式(順便複習回顧一下上一章節的相關內容)實現Y組合子:

package com.easy.kotlin

// lambda f. (lambda x. (f(x x)) lambda x. (f(x x)))
object YCombinatorKt {

    fun yCombinator(f: Lambda<Lambda<*>>): Lambda<Lambda<*>> {

        return object : Lambda<Lambda<*>> {

            override fun call(n: Any): Lambda<*> {
                val u = n as Lambda<Lambda<*>>
                return u.call(u)
            }
        }.call(object : Lambda<Lambda<*>> {

            override fun call(n: Any): Lambda<*> {
                val x = n as Lambda<Lambda<*>>

                return f.call(object : Lambda<Any> {
                    override fun call(n: Any): Any {
                        return x.call(x).call(n)!!
                    }
                })
            }

        }) as Lambda<Lambda<*>>
    }

    @JvmStatic fun main(args: Array<String>) {

        val y = yCombinator(object : Lambda<Lambda<*>> {

            override fun call(n: Any): Lambda<*> {
                val fab = n as Lambda<Int>

                return object : Lambda<Int> {

                    override fun call(n: Any): Int {
                        val n = Integer.parseInt(n.toString())
                        if (n < 2) {
                            return Integer.valueOf(1)
                        } else {
                            return n * fab.call(n - 1)
                        }
                    }
                }
            }
        })

        println(y.call(10)) //輸出: 3628800
    }

    interface Lambda<E> {
        fun call(n: Any): E
    }
}

關於Y combinator的更多實現,可以參考:https://gist.github.com/Jason-Chen-2017/88e13b63fa5b7c612fddf999739964b0 ; 另外,關於Y combinator的原理介紹,推薦看《The Little Schemer 》這本書。

從上面的例子,我們可以看出OOP中的對接口以及多態類型,跟FP中的函數的思想表達的,本質上是一個東西,這個東西到底是什麼呢?我們姑且稱之爲“編程之道”罷!

Y combinator 給我們提供了一種方法,讓我們在一個只支持first-class函數,但是沒有內建遞歸的編程語言裏完成遞歸。所以Y combinator給我們展示了一個語言完全可以定義遞歸函數,即使這個語言的定義一點也沒提到遞歸。它給我們展示了一件美妙的事:僅僅函數式編程自己,就可以讓我們做到我們從來不認爲可以做到的事(而且還不止這一個例子)。

嚴謹而精巧的lambda演算體系,從最基本的概念“函數”入手,創造出一個絢爛而宏偉的世界,這不能不說是人類思維的驕傲。

沒有”副作用”

Kotlin極簡教程

所謂”副作用”(side effect),指的是函數內部與外部互動(最典型的情況,就是修改全局變量的值),產生運算以外的其他結果。

函數式編程強調沒有”副作用”,意味着函數要保持獨立,所有功能就是返回一個新的值,沒有其他行爲,尤其是不得修改外部變量的值。

函數式編程的動機,一開始就是爲了處理運算(computation),不考慮系統的讀寫(I/O)。”語句”屬於對系統的讀寫操作,所以就被排斥在外。

當然,實際應用中,不做I/O是不可能的。因此,編程過程中,函數式編程只要求把I/O限制到最小,不要有不必要的讀寫行爲,保持計算過程的單純性。

函數式編程只是返回新的值,不修改系統變量。因此,不修改變量,也是它的一個重要特點。

在其他類型的語言中,變量往往用來保存”狀態”(state)。不修改變量,意味着狀態不能保存在變量中。函數式編程使用參數保存狀態,最好的例子就是遞歸。

引用透明性

函數程序通常還加強引用透明性,即如果提供同樣的輸入,那麼函數總是返回同樣的結果。就是說,表達式的值不依賴於可以改變值的全局狀態。這樣我們就可以從形式上邏輯推斷程序行爲。因爲表達式的意義只取決於其子表達式而不是計算順序或者其他表達式的副作用。這有助於我們來驗證代碼正確性、簡化算法,有助於找出優化它的方法。

8.2 在Kotlin中使用函數式編程

好了親,前文中我們在函數式編程的世界裏遨遊了一番,現在我們把思緒收回來,放到在Kotlin中的函數式編程中來。

嚴格的面向對象的觀點,使得很多問題的解決方案變得較爲笨拙。爲了將一行有用的代碼包裝到Runnable或者Callable 這兩個Java中最流行的函數式示例中,我們不得不去寫五六行模板範例代碼。爲了讓事情簡單化(在Java 8中,增加Lambda表達式的支持),我們在Kotlin中使用普通的函數來替代函數式接口。事實上,函數式編程中的函數,比C語言中的函數或者Java中的方法都要強大的多。

在Kotlin中,支持函數作爲一等公民。它支持高階函數、Lambda表達式等。我們不僅可以把函數當做普通變量一樣傳遞、返回,還可以把它分配給變量、放進數據結構或者進行一般性的操作。它們可以是未經命名的,也就是匿名函數。我們也可以直接把一段代碼丟到 {}中,這就是閉包。

在前面的章節中,其實我們已經涉及到一些關於函數的地方,我們將在這裏系統地學習一下Kotlin的函數式編程。

8.2.1 Kotlin中的函數

首先,我們來看下Kotlin中函數的概念。

函數聲明

Kotlin 中的函數使用 fun 關鍵字聲明

fun double(x: Int): Int {
    return 2*x
}

函數用法

調用函數使用傳統的方法

fun test() {
    val doubleTwo = double(2)
    println("double(2) = $doubleTwo")
}

輸出:double(2) = 4

調用成員函數使用點表示法

object FPBasics {

    fun double(x: Int): Int {
        return 2 * x
    }

    fun test() {
        val doubleTwo = double(2)
        println("double(2) = $doubleTwo")
    }
}

fun main(args: Array<String>) {
    FPBasics.test()
}

我們這裏直接用object對象FPBasics來演示。

8.2.2 擴展函數

通過 擴展 聲明完成一個類的新功能 擴展 ,而無需繼承該類或使用設計模式(例如,裝飾者模式)。

一個擴展String類的swap函數的例子:

fun String.swap(index1: Int, index2: Int): String {
    val charArray = this.toCharArray()
    val tmp = charArray[index1]
    charArray[index1] = charArray[index2]
    charArray[index2] = tmp

    return charArrayToString(charArray)
}

fun charArrayToString(charArray: CharArray): String {
    var result = ""
    charArray.forEach { it -> result = result + it }
    return result
}

這個 this 關鍵字在擴展函數內部對應到接收者對象(傳過來的在點符號前的對象)。 現在,我們對任意 String 調用該函數了:

val str = "abcd"
val swapStr = str.swap(0, str.lastIndex)
println("str.swap(0, str.lastIndex) = $swapStr")

輸出: str.swap(0, str.lastIndex) = dbca

8.2.3 中綴函數

在以下場景中,函數還可以用中綴表示法調用:

  • 成員函數或擴展函數
  • 只有一個參數
  • infix 關鍵字標註

例如,給 Int 定義擴展

infix fun Int.shl(x: Int): Int {
 ...
}

用中綴表示法調用擴展函數:

1 shl 2

等同於這樣

1.shl(2)

8.2.4 函數參數

函數參數使用 Pascal 表示法定義,即 name: type。參數用逗號隔開。每個參數必須顯式指定其類型。

fun powerOf(number: Int, exponent: Int): Int {
    return Math.pow(number.toDouble(), exponent.toDouble()).toInt()
}

測試代碼:

val eight = powerOf(2, 3)
println("powerOf(2,3) = $eight")

輸出:powerOf(2,3) = 8

默認參數

函數參數可以有默認值,當省略相應的參數時使用默認值。這可以減少重載數量。

fun add(x: Int = 0, y: Int = 0): Int {
    return x + y
}

默認值通過類型後面的 = 及給出的值來定義。

測試代碼:

val zero = add()
val one = add(1)
val two = add(1, 1)
println("add() = $zero")
println("add(1) = $one")
println("add(1, 1) = $two")

輸出:

add() = 0
add(1) = 1
add(1, 1) = 2

另外,覆蓋帶默認參數的函數時,總是使用與基類型方法相同的默認參數值。
當覆蓋一個帶有默認參數值的方法時,簽名中不帶默認參數值:

open class DefaultParamBase {
    open fun add(x: Int = 0, y: Int = 0): Int {
        return x + y
    }
}

class DefaultParam : DefaultParamBase() {
    override fun add(x: Int, y: Int): Int { // 不能有默認值
        return super.add(x, y)
    }
}

命名參數

可以在調用函數時使用命名的函數參數。當一個函數有大量的參數或默認參數時這會非常方便。

給定以下函數

fun reformat(str: String,
    normalizeCase: Boolean = true,
    upperCaseFirstLetter: Boolean = true,
    divideByCamelHumps: Boolean = false,
    wordSeparator: Char = ' ') {
}

我們可以使用默認參數來調用它

reformat(str)

然而,當使用非默認參數調用它時,該調用看起來就像

reformat(str, true, true, false, '_')

使用命名參數我們可以使代碼更具有可讀性

reformat(str,
    normalizeCase = true,
    upperCaseFirstLetter = true,
    divideByCamelHumps = false,
    wordSeparator = '_'
)

並且如果我們不需要所有的參數

reformat(str, wordSeparator = '_')

可變數量的參數(Varargs)

函數的參數(通常是最後一個)可以用 vararg 修飾符標記:

fun <T> asList(vararg ts: T): List<T> {
    val result = ArrayList<T>()
    for (t in ts) // ts is an Array
        result.add(t)
    return result
}

允許將可變數量的參數傳遞給函數:

val list = asList(1, 2, 3)

8.2.5 函數返回類型

函數返回類型需要顯式聲明

具有塊代碼體的函數必須始終顯式指定返回類型,除非他們旨在返回 Unit

Kotlin 不推斷具有塊代碼體的函數的返回類型,因爲這樣的函數在代碼體中可能有複雜的控制流,並且返回類型對於讀者(有時對於編譯器)也是不明顯的。

返回 Unit 的函數

如果一個函數不返回任何有用的值,它的返回類型是 UnitUnit 是一種只有一個Unit 值的類型。這個值不需要顯式返回:

fun printHello(name: String?): Unit {
    if (name != null)
        println("Hello ${name}")
    else
        println("Hi there!")
    // `return Unit` 或者 `return` 是可選的
}

Unit 返回類型聲明也是可選的。上面的代碼等同於

fun printHello(name: String?) {
    .....
}

8.2.6 單表達式函數

當函數返回單個表達式時,可以省略花括號並且在 = 符號之後指定代碼體即可

fun double(x: Int): Int = x * 2

當返回值類型可由編譯器推斷時,顯式聲明返回類型是可選的:

fun double(x: Int) = x * 2

8.2.7 函數作用域

在 Kotlin 中函數可以在文件頂層聲明,這意味着你不需要像一些語言如 Java、C# 或 Scala 那樣創建一個類來保存一個函數。此外除了頂層函數,Kotlin 中函數也可以聲明在局部作用域、作爲成員函數以及擴展函數。

局部函數(嵌套函數)

Kotlin 支持局部函數,即一個函數在另一個函數內部

fun sum(x: Int, y: Int, z: Int): Int {
    val delta = 0;
    fun add(a: Int, b: Int): Int {
        return a + b + delta
    }
    return add(x + add(y, z))
}

局部函數可以訪問外部函數(即閉包)中的局部變量delta。

println("sum(1,2,3) = ${sum(0, 1, 2, 3)}")

輸出:sum(1,2,3) = 6

成員函數

成員函數是在類或對象內部定義的函數

class Sample() {
    fun foo() { print("Foo") }
}

成員函數以點表示法調用

Sample().foo() // 創建類 Sample 實例並調用 foo

8.2.8 泛型函數

函數可以有泛型參數,通過在函數名前使用尖括號指定。

例如Iterable的map函數:

public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> {
    return mapTo(ArrayList<R>(collectionSizeOrDefault(10)), transform)
}

8.2.9 高階函數

高階函數是將函數用作參數或返回值的函數。例如,Iterable的filter函數:

public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
    return filterTo(ArrayList<T>(), predicate)
}

它的輸入參數predicate: (T) -> Boolean就是一個函數。其中,函數類型聲明的語法是:

(X)->Y

表示這個函數是從類型X到類型Y的映射。即這個函數輸入X類型,輸出Y類型。

這個函數我們這樣調用:

fun isOdd(x: Int): Boolean {
    return x % 2 == 1
}

val list = listOf(1, 2, 3, 4, 5)
list.filter(::isOdd)

其中,::用來引用一個函數。

8.2.10 匿名函數

我們也可以使用匿名函數來實現這個predicate函數:

list.filter((fun(x: Int): Boolean {
    return x % 2 == 1
}))

8.2.11 Lambda 表達式

我們也可以直接使用更簡單的Lambda表達式來實現一個predicate函數:

list.filter {
    it % 2 == 1
}
  • lambda 表達式總是被大括號 {} 括着
  • 其參數(如果有的話)在 -> 之前聲明(參數類型可以省略)
  • 函數體(如果存在的話)在 -> 後面

上面的寫法跟:

list.filter({
    it % 2 == 1
})

等價,如果 lambda 是該調用的唯一參數,則調用中的圓括號可以省略。

使用Lambda表達式定義一個函數字面值:

>>> val sum = { x: Int, y: Int -> x + y }
>>> sum(1,1)
2

我們在使用嵌套的Lambda表達式來定義一個柯里化的sum函數:

>>> val sum = {x:Int ->  {y:Int -> x+y }}
>>> sum
(kotlin.Int) -> (kotlin.Int) -> kotlin.Int
>>> sum(1)(1)
2

8.2.11 it:單個參數的隱式名稱

Kotlin中另一個有用的約定是,如果函數字面值只有一個參數,
那麼它的聲明可以省略(連同 ->),其名稱是 it

代碼示例:

>>> val list = listOf(1,2,3,4,5)
>>> list.map { it * 2 }
[2, 4, 6, 8, 10]

8.2.12 閉包(Closure)

Lambda 表達式或者匿名函數,以及局部函數和對象表達式(object declarations)可以訪問其 閉包 ,即在外部作用域中聲明的變量。 與 Java 不同的是可以修改閉包中捕獲的變量:

fun sumGTZero(c: Iterable<Int>): Int {
    var sum = 0
    c.filter { it > 0 }.forEach {
        sum += it
    }
    return sum
}

val list = listOf(1, 2, 3, 4, 5)
sumGTZero(list) // 輸出 15

我們再使用閉包來寫一個使用Java中的Thread接口的例子:

fun closureDemo() {
    Thread({
        for (i in 1..10) {
            println("I = $i")
            Thread.sleep(1000)
        }

    }).start()

    Thread({
        for (j in 10..20) {
            println("J = $j")
            Thread.sleep(2000)
        }
        Thread.sleep(1000)
    }).start()
}

一個輸出:

I = 1
J = 10
I = 2
I = 3
...
J = 20

8.2.13 帶接收者的函數字面值

Kotlin 提供了使用指定的 接收者對象 調用函數字面值的功能。

使用匿名函數的語法,我們可以直接指定函數字面值的接收者類型。

下面我們使用帶接收者的函數類型聲明一個變量,並在之後使用它。代碼示例:

>>> val sum = fun Int.(other: Int): Int = this + other
>>> 1.sum(1)
2

當接收者類型可以從上下文推斷時,lambda 表達式可以用作帶接收者的函數字面值。

class HTML {
    fun body() {
        println("HTML BODY")
    }
}

fun html(init: HTML.() -> Unit): HTML { // HTML.()中的HTML是接受者類型
    val html = HTML()  // 創建接收者對象
    html.init()        // 將該接收者對象傳給該 lambda
    return html
}

測試代碼:

html {
    body()
}

輸出:HTML BODY

使用這個特性,我們可以構建一個HTML的DSL語言。

8.2.14 具體化的類型參數

有時候我們需要訪問一個參數類型:

fun <T> TreeNode.findParentOfType(clazz: Class<T>): T? {
    var p = parent
    while (p != null && !clazz.isInstance(p)) {
        p = p.parent
    }
    @Suppress("UNCHECKED_CAST")
    return p as T?
}

在這裏我們向上遍歷一棵樹並且檢查每個節點是不是特定的類型。
這都沒有問題,但是調用處不是很優雅:

treeNode.findParentOfType(MyTreeNode::class.java)

我們真正想要的只是傳一個類型給該函數,即像這樣調用它:

treeNode.findParentOfType<MyTreeNode>()

爲能夠這麼做,內聯函數支持具體化的類型參數,於是我們可以這樣寫:

inline fun <reified T> TreeNode.findParentOfType(): T? {
    var p = parent
    while (p != null && p !is T) {
        p = p.parent
    }
    return p as T?
}

我們使用 reified 修飾符來限定類型參數,現在可以在函數內部訪問它了,
幾乎就像是一個普通的類一樣。由於函數是內聯的,不需要反射,正常的操作符如 !isas 現在都能用了。

雖然在許多情況下可能不需要反射,但我們仍然可以對一個具體化的類型參數使用它:

inline fun <reified T> membersOf() = T::class.members

fun main(s: Array<String>) {
    println(membersOf<StringBuilder>().joinToString("\n"))
}

普通的函數(未標記爲內聯函數的)沒有具體化參數。

8.2.10 尾遞歸tailrec

Kotlin 支持一種稱爲尾遞歸的函數式編程風格。 這允許一些通常用循環寫的算法改用遞歸函數來寫,而無堆棧溢出的風險。 當一個函數用 tailrec 修飾符標記並滿足所需的形式時,編譯器會優化該遞歸,生成一個快速而高效的基於循環的版本。

tailrec fun findFixPoint(x: Double = 1.0): Double
        = if (x == Math.cos(x)) x else findFixPoint(Math.cos(x)) // 函數必須將其自身調用作爲它執行的最後一個操作

這段代碼計算餘弦的不動點(fixpoint of cosine),這是一個數學常數。 它只是重複地從 1.0 開始調用 Math.cos,直到結果不再改變,產生0.7390851332151607的結果。最終代碼相當於這種更傳統風格的代碼:

private fun findFixPoint(): Double {
    var x = 1.0
    while (true) {
        val y = Math.cos(x)
        if (x == y) return y
        x = y
    }
}

要符合 tailrec 修飾符的條件的話,函數必須將其自身調用作爲它執行的最後一個操作。在遞歸調用後有更多代碼時,不能使用尾遞歸,並且不能用在 try/catch/finally 塊中。尾部遞歸在 JVM 後端中支持。

Kotlin 還爲集合類引入了許多擴展函數。例如,使用 map() 和 filter() 函數可以流暢地操縱數據,具體的函數的使用以及示例我們已經在 集合類 章節中介紹。

本章小結

本章我們一起學習了函數式編程的簡史、Lambda演算、Y組合子與遞歸等核心函數式的編程思想等相關內容。然後重點介紹了在Kotlin中如何使用函數式風格編程,其中重點介紹了Kotlin中函數的相關知識,以及高階函數、Lambda表達式、閉包等核心語法,並給出相應的實例說明。

我們將在下一章 中介紹Kotlin的 輕量級線程:協程(Coroutines)的相關知識,我們將看到在Kotlin中,程序的邏輯可以在協程中順序地表達,而底層庫會爲我們解決其異步性。

本章示例代碼工程:https://github.com/EasyKotlin/chapter8_fp

發佈了273 篇原創文章 · 獲贊 266 · 訪問量 123萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章