高階函數(一)

原文鏈接:https://github.com/enbandari/Kotlin-Tutorials

1. 什麼是高階函數

1.1 高階函數的基本概念

高階函數其實看着挺嚇人,不過就是把函數作爲參數或者返回值的一類函數而已。其實這樣的函數我們都見過很多了,來看個例子:

public inline fun <T> Array<out T>.forEach(action: (T) -> Unit): Unit { 
    for (element in this) action(element) 
} 

這是我們的老朋友了,forEach,傳入了一個 Lambda 表達式,之後在迭代數組的時候調用這個 Lambda。你可千萬別把 Lambda 不當函數,人家可是正兒八經的 FunctionN 的實例,這個我們在前面一篇文章細說 Lambda 表達式已經介紹過了

如果使用 forEach,我們就這麼寫:

array.forEach{ 
    print("$it, ") 
} 

輸出結果就類似:

1, 2, 3, 4,  

forEach 其實就是一個把函數當參數傳入的高階函數了。

1.2 函數引用

下面我們要來思考一個問題。爲什麼 Kotlin 可以有高階函數?

其實這個問題,我們早就有答案了,因爲在 Kotlin 當中,函數是“一等公民”,函數的引用可以自由傳遞,賦值,並在合適的時候調用。爲什麼這麼說呢?難道僅僅是因爲我們可以任性的定義 Lambda 表達式這種匿名函數,並把它賦值爲一個變量,然後可以隨便傳遞和調用嗎?

當然不能完全是這樣了。其實 Kotlin 當中的任何方法、函數都是有其名字和引用的,我們前面其實看到過一個 forEach 的例子,我再給大家拿出來:

array.forEach(::println) 

這個例子當中,我們其實是想要把元素挨個打印一遍,forEach 傳入的是一個 (T) -> Unit,這並不是說它只能傳入一個符合參數和返回值的 Lambda,而是說符合參數和返回值定義的任意函數。println 有很多版本,其中有一個符合上面的條件:

public inline fun println(message: Any?) { 
    System.out.println(message) 
} 

所以我們可以把它當參數傳入。這個意義上講,::println 跟 Function1 是什麼關係呢?很明顯接口實現的關係了,同時 ::println 因爲可以具名引用到一個函數,所以我們也把類似的寫法叫做函數引用。

我們再來看一個類成員的例子:

class Hello{ 
    fun world(){ 
        println("Hello world.") 
    } 
} 
  
val helloWorld: (Hello)-> Unit = Hello::world 

我們同樣可以用 :: 的方式來引用類成員方法,當然擴展方法也是可以的。這個要怎麼用呢?

fun Int.isOdd(): Boolean = this % 1 == 0 
  
... 
val ints = intArrayOf(1,3,4,5,8) 
ints.filter(Int::isOdd) 

注意到 filter 的參數類型:

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

跟我們前面 Hello::World 的例子是不是一模一樣呢?

不過相比包級函數,這種引用在 Kotlin 1.1 以前顯得有些蒼白,爲什麼這麼說呢?

class PdfPrinter{ 
    fun println(any: Any?){ 
        println(any) 
    } 
} 
  
... 
  
array.forEach(PdfPrinter::println) //錯誤!! 

請問,這種情況下,我該如何像 ::println 一樣將 PdfPrinter::println 傳遞給 forEach 呢?我們知道,所有的類成員方法,它們其實都有一個隱含的參數,即類的實例本身,所以它的類型應該是下面這樣:

val pdfPrintln: (PdfPrinter, Any?)-> Unit = PdfPrinter::println 

那麼,有人就會說,我乾脆構造一個 PdfPrinter 的實例,然後這麼寫看看:

array.forEach(PdfPrinter()::println)// Since Kotlin 1.1 

看着很不錯了吧?可惜,這個在 1.1 才支持哦,不過距離 1.1 正式發佈應該不久了!

2 常見的內置高階函數

Kotlin 爲我們內置了不少好用的高階函數,這一節我們就給大家簡要介紹一下。

2.1 map

我們經常用 forEach 來迭代一個集合,如果我們想要把一個集合映射成另外一個集合的話,通常我們會這麼寫:

val list = listOf(1,3,4,5,10,6,8,2) 
  
val newlist = ArrayList<Int>() 
list.forEach {  
    val newElement = it * 2 + 3 
    newlist.add(newElement) 
} 

看上去還是挺簡單的,不過終究不夠簡潔,而且還在 Lambda 表達式內部訪問了外部變量,這其實都不是很好的編程習慣。

map 其實就是對類似的操作做了一點封裝,類似的集合映射的操作用 map 再合適不過了:

val newlist  =  list.map { 
    it * 2 + 3 
} 

Lambda 的參數是原集合的元素,返回值是對應位置的新集合的元素,新集合是 map 的返回值。我們再來看個例子:

val stringlist = list.map(Int::toString) 

上面這個例子,我們把一個整型的集合映射成了一個字符串類型的集合。不管你做何種變換,map 的返回值始終是一個大小與原集合相同的集合。

2.2 flatMap

如果我手頭有一個整型集合的集合,我想把他們打平,變成一個整型集合,用我們傳統的方法就是兩層循環。如果我還想要做點兒變換,那麼這代碼寫起來就更醜了。

如果我們要用 flatMap,那麼這個故事就直截了當得多:

val list = listOf( 
        1..20, 
        2..5, 
        100..232 
) 
  
val flatList = list.flatMap { it } 
println(flatList) 

flatMap 後面的 Lambda 參數是 list 的元素,也就 1..20、2..5 這些 range,返回的值呢是一個 Iterable,flatMap 會把這些 Lambda 返回的 Iterable 統統添加到它自己的返回值也就是 flatList 當中,這樣就相當於把 list 做了一次打平。

結果:

[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 2, 3, 4, 5, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232] 

那麼這麼直白的打平也不見得是我們的目標,比如我們要把這些數都做一些運算再打平,那麼這個也簡單:

val flatList = list.flatMap { iterable ->  
    iterable.map { element -> 
        element * 2 + 3 
    } 
} 

我們只需要對 iterable 做一次 map 即可。

2.3 fold / reduce

其實 fold 就是摺疊的意思嘛,把一個集合的元素摺疊起來的並得到一個最終的結果,這就是 fold 要做的事情。

fun main(args: Array<String>) { 
    val ints = intArrayOf(1,2,3,4,5) 
    val r = ints.fold(5){ 
        sum, element -> 
        println("$sum, $element") 
        sum + element 
    } 
    println(r) 
} 

結果呢?就是從 5 開始,每次返回的結果又成了 sum,一直這麼摺疊下去,直到最後輸出 20。

5, 1 
6, 2 
8, 3 
11, 4 
15, 5 
20 

當然,對於fold來說,我們還可以得到其他類型的結果,不一定要與集合的元素類型相同:

val r1 = ints.fold(StringBuilder()){ 
    sb, element-> 
    sb.append(element).append(",") 
} 
  
println(r1) 

大家看到,我們的初始值實際上是一個 StringBuilder,後續一直在做字符串追加的操作,最後得到的 r1 其實就是一個追加了所有元素的 StringBuilder,我們把它打印出來:

1,2,3,4,5, 

我們再來看下 reduce。

val r2 = ints.reduce { sum, element -> sum + element } 
println(r2) 

輸出的最終結果是 15,也即元素之和。顯然,reduce 每次求值的結果都作爲下一次迭代時傳入的 sum,這個看上去跟 fold 極其的類似,只不過 reduce 沒有額外的初始值,並且返回值類型也需要保持與集合的元素相同。

如果我們要求一個數的階乘,那代碼其實很容易寫:

fun factorial(n: Int): Int{ 
    if(n == 0) return 1 
    return (1..n).reduce { factorial, element -> factorial * element } 
} 

2.4 filter / takeWhile

如果我們有一個很大的集合,想要過濾掉其中的一些元素,那麼通常的做法也是構造一個新集合來,然後遍歷原集合。

顯然,我們有更好的寫法:

val evens = (1..100).filter {  
    it % 2 == 0 
} 

找出 1 到 100 之間的所有偶數,我們只需要用 filter,並傳入判斷條件,那麼符合條件的元素就會被保留到返回的集合當中。

類似的,takeWhile 則返回的集合是原集合中從第一個元素開始到第一個不符合條件的元素之前的所有元素。例如:

println((1..10).takeWhile { it % 5 != 0 }) 

這表明,從 1..10 當中取元素,只要遇到一個是 5 的倍數的元素,那麼立即返回,即結果爲:

[1, 2, 3, 4] 

2.5 let

let 實際上比較簡單,我們先來看下它的定義:

public inline fun <T, R> T.let(block: (T) -> R): R = block(this) 

我們看到 let 實際上傳入了一個 Lambda,而這個 Lambda 傳入的參數就是 let 的調用者本身,返回值隨便你。這個 let 有什麼用呢?

val person: Person? = findPerson() 

我們看到 person 這個變量是可空的,我們需要做一些判斷才能對其進行操作。通常的寫法可能是這樣的:

person?.name = "張三" 
person?.age = 18 
... 

不過,這種問好滿天飛的寫法,看着其實並不是很讓人舒服。

我們還可以這麼寫:

if(person != null){ 
    person.name = "張三" // person 被智能轉換成 Person 類型 
    person.age = 18 
    ... 
} 

當然,我們還有一種寫法就是:

person?.let{ 
    it.name = "張三" 
    it.age = 18 
    ... 
} 

let 比較簡單,其用法也是很靈活的,大家可以自行發揮。

2.6 apply / with

下面我們來看 apply。

public inline fun <T> T.apply(block: T.() -> Unit): T { block(); return this } 

注意到 apply 傳入的 Lambda 也是 apply 的調用者的擴展方法,所以,apply 相當於給了我們一個靈活切換上下文的機會,

class Options{ 
    var scale: Float = 1f 
    var offsetX: Double = 0.0 
    var offsetY: Double = 0.0 
    var rotationX: Float = 0f 
    var rotationY: Float = 0f 
} 

假設我們有這麼一個類,我們在操作一個地圖變換的時候需要傳入這個東西,告訴地圖該怎麼變換。

mapView.animateChange(Options().apply {  
    //Options 的作用域 
    scale = 2f 
    rotationX = 180f 
}) 

而 with 呢?

public inline fun <T, R> with(receiver: T, block: T.() -> R): R = receiver.block() 

跟 apply 比較類似,不同之處在與 Lambda 返回值。with 只是單純的獲取 receiver 的上下文,而 apply 則同時也把它本身返回了。

val br = BufferedReader(FileReader("hello.txt")) 
with(br){ 
    var line: String? 
    while (true){ 
        line = readLine()?: break 
    } 
    close() 
} 

我們看到在 with 當中,readLine 和 close 方法可以直接調用。

內置高階函數其實非常多,這幾個比較常用,剩下的大家可以自行學習。

3 尾遞歸優化

遞歸大家都熟悉,一說遞歸大家都容易哆嗦:你可別遞歸的層次太深了啊,不然小心 StackOverflow!沒錯,StackOverflow 這個異常實在是太常見了,所以你最熟悉的程序員社交網站當中就有一個叫 StackOverflow 的。

其實大家肯定也都知道,遞歸能實現的,用迭代也基本上能實現,這個感覺就好像做小學數學題,用遞歸就好比設個 x,列個方程求解;而用迭代呢,就好比用算式生生的去把結果給算出來。前者思考起來比較直接,編寫起來也自然更符合人的思維模式,後者呢往往編寫困難,代碼可讀性差。

如果有一天,我能寫出遞歸程序,編譯器呢,卻能夠按照迭代的方式給我運行,那麼我豈不是既能獲得遞歸的簡潔性,又不失迭代的運行效率,那該。。。想得美啊。

我們今天就要給大家看一種特定條件下的編譯優化措施。其實前面我們的設想並不是完全做不到,對於某些比較簡單的場景,編譯器是可以直接把我們的遞歸代碼翻譯成迭代代碼的,而這種場景其實就是“尾遞歸”。

什麼叫“尾遞歸”?函數在調用自己之後,沒有任何操作的情形就是尾遞歸。

比如:

data class ListNode(val value: Int, var next: ListNode?) 
  
fun findListNode(head: ListNode?, value: Int): ListNode?{ 
    head?: return null 
    if(head.value == value) return head 
    return findListNode(head.next, value) 
} 

我們隨便定義了一個鏈表,ListNode 是它的元素,findListNode 目的是找到對應值的元素。我們看到最後一行只有 findListNode 的調用,沒有其他任何操作,這就是尾遞歸。

我們再來看幾個例子:

fun factorial(n: Long): Long{ 
    return n * factorial(n - 1) 
} 

求階乘,因爲 factorial 調用之後還有乘以 n 的操作,所以這個不是尾遞歸。

data class TreeNode(val value: Int){ 
    var left: TreeNode? = null 
    var right: TreeNode? = null 
} 
  
fun findTreeNode(root: TreeNode?, value: Int): TreeNode?{ 
    root?: return null 
    if(root.value == value) return root 
    return findTreeNode(root.left, value) ?: return findTreeNode(root.right, value) 
} 

這個算不算尾遞歸呢?好像最後一行的兩個 return 都是之調用了 findTreeNode,沒有其他操作了啊,這個應該是尾遞歸吧?答案當然不是。。因爲第一個 findTreeNode 的結果拿到之後,我們要看下他是不是爲 null,實際上這個判斷操作在 findTreeNode 之後,所以不能算尾遞歸。對於不是尾遞歸的情況,編譯器是沒有辦法做優化的。

而對於尾遞歸的情況,我們該如何啓用編譯器優化呢?

要說告訴編譯器需要尾遞歸優化,其實非常簡單,加一個關鍵字即可:

tailrec fun findListNode(head: ListNode?, value: Int): ListNode?{ 
    head?: return null 
    if(head.value == value) return head 
    return findListNode(head.next, value) 
} 

這個看起來真的很簡單,簡單到沒有說服力。我們看一段小程序:

val MAX_NODE_COUNT = 100000 
val head = ListNode(0) 
var p = head 
for (i in 1..MAX_NODE_COUNT){ 
    p.next = ListNode(i) 
    p = p.next!! 
} 
//前面先構造了一個鏈表,節點個數有 10 萬個 
//後面進行查找,查找值爲 MAX_NODE_COUNT - 2 的節點 
println(findListNode(head, MAX_NODE_COUNT - 2)?.value) 

對於沒有 tailrec 關鍵字的版本,結果非常抱歉:

Exception in thread "main" java.lang.StackOverflowError 
    at net.println.kotlin.RecursiveKt.findListNode(Recursive.kt:34) 
    at net.println.kotlin.RecursiveKt.findListNode(Recursive.kt:34) 

而對於有 tailrec 的版本,結果是:

99998 

顯然,對於尾遞歸優化的版本,即使你遞歸再多的層次,都不會有 StackOverflow,原因也很簡單,編譯器其實已經把這種遞歸編譯成迭代來運行了,迭代怎麼會有 StackOverflow 呢?

接着我們再來討論一下非尾遞歸代碼可以改寫爲尾遞歸代碼的條件。大家仔細觀察我們前面給出的兩個例子,一個是求階乘,一個是超找樹的節點。二者最後一句:

return n * factorial(n - 1) 
return findTreeNode(root.left, value) ?: return findTreeNode(root.right, value) 

雖然都不是尾遞歸,但還是有差異的。前者在調用完自己之後進行了跟調用自己無關的運算;後者調用完一次自己之後,還有可能調用一次自己。注意,如果調用完自己,又進行了其他操作,也即沒有再次調用自己,那麼這種遞歸其實有希望轉換爲尾遞歸代碼,下面我們就改寫一下求階乘的代碼,讓它變成尾遞歸代碼。

fun factorial(n: Long): Long{ 
    class Result(var value: Long) 
  
    tailrec fun factorial0(n: Long, result: Result){ 
        if(n > 0) { 
            result.value *= n 
            factorial0(n - 1, result) 
        } 
    } 
    val result = Result(1) 
    factorial0(n, result) 
    return result.value 
} 

這個例子當中有一些比較有意思的概念哈,我們在一個函數當中定義了一個函數和一個類,它們被稱作“本地函數”和“本地類”,由於定義在函數內部,因此在外部無法使用它們。接着我們對內部的 factorial0 加了 tailrec 關鍵字,由於最後一行只有對自己的調用,因此符合尾遞歸優化的條件。

我們看到,之前 n * 的這部分操作通過 Result 攜帶的中間結果被移到了自身調用的前面,這樣做讓原本的遞歸代碼符合了尾遞歸優化的條件,卻也讓代碼本身複雜了許多。而對於此類操作,我個人更傾向於直接使用迭代。

fun factorial(n: Long): Long{ 
    var result: Long = 1 
    for (i in 1..n){ 
        result *= i 
    } 
    return result 
} 

迭代的代碼顯然也直截了當得多。

總而言之,使用遞歸是爲了讓我們的代碼更直接,更自然,使用迭代往往是爲了追求效率(空間效率)。對於類似查找鏈表節點這樣的場景,它很自然的就是一個尾遞歸的結構,我們可以使用尾遞歸優化來提升它的性能;而對於求階乘這樣的場景,它本來就不是尾遞歸的結構,我們儘管可以通過某種方式改寫它,但這樣做其實根本沒必要;而對於查找樹節點這樣的場景,尾遞歸基本上是無能無力了。

4 閉包

對象是要攜帶狀態的。比如:

val string = "HelloWorld" 

string 這個對象它有值,這個值就是它的狀態。那麼同樣作爲對象的函數,它有什麼狀態呢?我們看個例子:

fun makeFun(): ()->Unit{ 
    var count = 0 
    return fun(){ 
        println(++count) 
    } 
} 
  
... 
  
val x = makeFun() 
x() 
x() 
x() 
x() 

輸出的結果會是什麼呢?從函數當中返回一個函數,這在 Java 當中簡直不能想象,不過這在函數爲“一等公民”的 Groovy、JavaScript 當中確實尋常可見。

1 
2 
3 
4 

每次調用 x,打印的值都不一樣,這說明函數也是可以保存狀態的。受到這個啓發,我們是不是可以繼續寫出這樣的例子:

fun fibonacci(): ()->Long{ 
    var first = 0L 
    var second = 1L 
    return fun(): Long{ 
        val result = second 
        second += first 
        first = second - first 
        return result 
    } 
} 
... 
  
val next = fibonacci() 
for (i in 1..10){ 
    println(next()) 
} 

輸出結果:

1 
1 
2 
3 
5 
8 
13 
21 
34 
55 

我們乾脆再進一步吧:

fun fibonacciGenerator(): Iterable<Long>{ 
    var first = 0L 
    var second = 1L 
    return Iterable { 
        object : LongIterator(){ 
            override fun hasNext() = true 
  
            override fun nextLong(): Long { 
                val result = second 
                second += first 
                first = second - first 
                return result 
            } 
        } 
    } 
} 
  
... 
  
for(x in fibonacciGenerator()){ 
    println(x) 
    if(x > 100) break 
} 

這個例子我們幹得更徹底,通過返回一個 Iterable,我們甚至可以用 for 循環迭代這個結果。

不管我們怎麼寫,請注意,每次調用同一個函數的結果都不一樣,而承載返回結果的 first 和 second 這兩個變量是定義在最外層的函數當中的,按說這個函數一旦運行完畢,它所在的作用域就會被回收,如果真是那樣,前面的這兩段代碼一定是我們產生的幻覺。如果不是幻覺,那隻能說明一個問題:這個作用域沒有被回收。

這個作用域包含了所有函數運行的狀態,包括變量、本地類、本地函數等等,那這個作用域其實就是閉包。

我們再來看個好玩的例子:

fun add(x: Int) = fun(y: Int) = x + y 
  
... 
val add5 = add(5) 
println(add5(2)) 

很顯然,結果是 7,這個 add 的定義其實寫得有些令人迷惑,我把它改寫一下給大家看:

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

很顯然,當我們調用 add(5) 返回 add5 這個函數時,它是持有了 add 函數的運行環境的,不然它怎麼知道 x 的值是多少呢?

通過這幾個小例子,相信大家對閉包有了一定的瞭解。閉包其實就是函數運行的環境。

下週我們還會繼續跟大家討論函數編程相關的一些話題,謝謝大家的關注

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章