Kotlin 知識梳理(10) 高階函式:Lambda 作為形參或返回值

澤毛發表於2017-12-13

一、本文概要

本文是對<<Kotlin in Action>>的學習筆記,如果需要執行相應的程式碼可以訪問線上環境 try.kotlinlang.org,這部分的思維導圖為:

Kotlin 知識梳理(10)   高階函式:Lambda 作為形參或返回值
Kotlin 知識梳理(5) - lambda 表示式和成員引用 中我們初步認識了lambda,這一章我們將學到如何建立 高階函式:使用lambda作為 引數或者返回值 的函式。高階函式有助於簡化程式碼,去除程式碼重複,以及構建漂亮的抽象概念。

二、宣告高階函式

按照定義,高階函式就是 以另一個函式作為引數或者返回值的函式,在Kotlin中,函式可以用lambda或者函式引用來表示。例如,標準庫中的filter函式將一個判斷式函式作為引數,因此它就是一個高階函式。

list.filter { x > 0 }
複製程式碼

2.1 函式型別

為了宣告一個以lambda作為實參的函式,你需要知道如何宣告 對應形參的型別。下面我們先看一個簡單的例子,把lambda表示式儲存在區域性變數當中:

val sum = { x : Int, y : Int -> x + y }
val action = { println(42) }
複製程式碼

在上面的例子中,我們省去了型別的宣告。但是編譯器可以推匯出sumaction這兩個 變數具有函式型別,這些變數的顯示宣告為:

//有兩個 Int 型引數和 Int 型返回值的函式
val sum : (Int, Int) -> Int = {x, y -> x + y}
//沒有引數和返回值的函式
val action : () -> Unit = { println(42) }
複製程式碼

宣告函式型別,需要 將函式引數型別放在括號中,緊接著是一個箭頭和函式的返回型別

(Int, String) -> Unit
複製程式碼

Unit型別用於表示函式不返回任何有用的值,在宣告一個普通的函式時,Unit型別的返回值是可以忽略的,但是一個 函式型別宣告總是需要一個顯示的返回型別,所以在這種場景下Unit是不能省略的。

{x, y -> x + y}中,因為它們的型別已經在函式型別的變數宣告部分指定了,不需要在lambda當中重複宣告。

就像其它方法一樣,函式型別的返回值也可以標記為可空型別:

var canReturnNull : (Int, Int) -> Int? = { null }
複製程式碼

也可以定義一個 函式型別的可空變數,為了明確表示 變數本身可空,而不是函式型別的返回型別可空,你需要 將整個函式型別的定義包含在括號內並在括號後新增一個問號

var funOrNull : ((Int, Int) -> Int)? = null
複製程式碼

函式型別的引數名

可以為函式型別宣告中的引數指定名字:

//函式型別的引數現在有了名字...
fun performRequest(url : String, callback : (code : Int, content : String) -> Unit) {
    //....
}
複製程式碼

呼叫方法為:

>> val url = "http://kotl.in"
//可以使用 API 中提供的引數名字作為 lambda 引數的名字....
>> performRequest(url) { code, content -> / *...* / }
>> performRequest(url) { code, page -> / *...* / }
複製程式碼

引數名稱不會影響型別的匹配,當你宣告一個lambda時,不必使用和函式型別宣告中一模一樣的引數名稱,但命名會提升程式碼的可讀性並且能用於IDE的程式碼補全。

2.2 呼叫作為引數的函式

下面我們討論如何實現一個高階函式,這個例子會盡量簡單並且使用之前的lambda sum同樣的宣告,這個函式實現對於兩個整數的任意操作,然後列印出結果:

fun twoAndThree(operation: (Int, Int) -> Int) {
    val result = operation(2, 3)
    println("The result is $result")
}

fun main(args: Array<String>) {
    twoAndThree { a, b -> a + b }
    twoAndThree { a, b -> a * b }
}
複製程式碼

執行結果為:

>> The result is 5
>> The result is 6
複製程式碼

呼叫作為引數的函式operation和呼叫普通函式的語法是一樣:把括號放在函式名後,並把引數放在括號內。下面,讓我們實現一個標準的庫函式:filter函式。它會過濾掉字串中不屬於a..z範圍內的字母。

fun String.filter(predicate: (Char) -> Boolean): String {
    val sb = StringBuilder()
    for (index in 0 until length) {
        val element = get(index)
        if (predicate(element)) sb.append(element)
    }
    return sb.toString()
}
複製程式碼

filter函式以一個判斷式作為引數,判斷式的型別是一個函式,以字串作為引數並返回boolean型別的值。

fun main(args: Array<String>) {
    println("ab1c".filter { it in 'a'..'z' })
}
複製程式碼

執行結果:

>> abc
複製程式碼

2.3 在 Java 中使用函式

背後的原理

背後的原理是:

  • 函式型別被宣告為普通的介面:一個函式型別的變數是FunctionN介面的一個實現。Kotlin標準庫定義了一系列的介面:Function0<R>表示沒有引數的函式,Function1<P1, R>表示一個引數的函式。
  • 一個函式型別的變數就是實現了對應的Function介面的實現類的例項,每個介面定義了一個invoke方法,實現類的invoke方法包含了lambda函式體,呼叫這個方法就會執行函式。

Java中可以很簡單地呼叫使用了函式型別的Kotlin函式,Java 8lambda會被自動轉換為函式型別的值:

//Kotlin 宣告
fun processTheAnswer(f : (Int) -> Int) {
    println(f(42))
}
複製程式碼
//Java
processTheAnswer(number -> number + 1)
複製程式碼

在舊版的Java中,可以傳遞一個實現了函式介面中的invoke方法的匿名內部類的例項:

>> processTheAnswer(
    new Function1<Integer, Integer>() {
        @override
        public Integer invoke(Integer number) {
            System.out.println(number);
            return number + 1;
        }
    }
)
複製程式碼

Java中可以很容易地使用Kotlin標準庫中以lambda作為引數的擴充套件函式,但是必須要 顯示地傳遞一個接收者作為第一個引數

List<String> strings = new ArrayList();
strings.add("42");
CollectionsKt.forEach(strings, s -> {
    System.out.println(s);
    retrun Unit.INSTANCE;
});
複製程式碼

Java中,函式或者lambda可以返回Unit。但因為在KotlinUnit型別是有一個值的,所以需要顯示地返回它。一個返回voidlambda不能作為返回Unit的函式型別的實參,就像之前的例子中的(String) -> Unit

2.4 函式型別的引數預設值和 null 值

2.4.1 函式型別的引數預設值

joinToString函式為例,我們除了可以定義字首、字尾和分隔符以外,還可以通過最後一個 函式型別的引數 指定如何將集合當中的每個元素轉換成為String,這是一個泛型函式:它有一個型別引數T表示集合中的元素的型別,Lambda transform將接收這個型別的引數,下面我們來看一下如何為它指定一個lambda作為預設值:

fun <T> Collection<T>.joinToString(
        separator: String = ", ",
        prefix: String = "",
        postfix: String = "",
        //為函式型別的引數提供預設值。
        transform: (T) -> String = { it.toString() }
): String {
    val result = StringBuilder(prefix)

    for ((index, element) in this.withIndex()) {
        if (index > 0) result.append(separator)
        //呼叫傳入的函式。
        result.append(transform(element))
    }

    result.append(postfix)
    return result.toString()
}

fun main(args: Array<String>) {
    val letters = listOf("Alpha", "Beta")
    println(letters.joinToString())
    println(letters.joinToString { it.toLowerCase() })
    println(letters.joinToString(separator = "! ", postfix = "! ",
           transform = { it.toUpperCase() }))
}
複製程式碼

執行結果為:

>> Alpha, Beta
>> alpha, beta
>> ALPHA! BETA! 
複製程式碼

2.4.2 宣告一個引數可為空的函式型別

當宣告一個引數為可空的函式型別時,不能直接呼叫作為引數傳遞進來的函式:Kotlin會因為檢測到潛在的空指標而導致編譯失敗,在這種情況下有兩種處理方式:

  • 顯示地檢查 null 顯示地檢查null是一種比較容易理解的方法:
fun foo(callback : (() _ Unit)?) {
    if (callback != null) {
        callback()
    }
}
複製程式碼
  • 通過安全呼叫語法呼叫 除此之外,因為函式型別是一個包含invoke方法的介面的具體實現,作為一個普通方法,invoke可以通過安全呼叫語法呼叫:
callback?.invoke() ?: /* 預設實現 */
複製程式碼

2.5 返回函式的函式

從函式中返回另一個函式適用於下面的場景:程式中的一段邏輯可能會因為程式的狀態或者其他條件而產生變化,比如說下面的例子,運輸費用的計算依賴於選擇的運輸方式:

//宣告一個列舉型別。
enum class Delivery { STANDARD, EXPIRED }

class Order(val itemCount : Int)

//返回的函式型別為:形參為 Order 類,返回型別為 Double。
fun getShippingCalculator(delivery : Delivery) : (Order) -> Double {
    if (delivery == Delivery.EXPIRED) {
        return { order -> 6 + 2.1 * order.itemCount }
    }
    return { order -> 1.2 * order.itemCount }
}

fun main(args: Array<String>) {
	val calculator = getShippingCalculator(Delivery.EXPIRED)
    println("cost ${calculator(Order(3))}")
}
複製程式碼

在上面的例子中,getShippingCalculator返回了一個函式,這個函式以Order作為引數並返回一個Double型別的值,要返回一個函式,需要寫一個return表示式,跟上一個lambda、一個成員引用,或者其他的函式型別的表示式。

下面,我們來看一個過濾器的例子:

data class Person(val firstName : String, val phoneNumber : String?)

class ContactListFilter {
    var prefix : String = ""
    var onlyWithPhoneNumber : Boolean = false
    
    fun getPredicate() : (Person) -> Boolean {
        val startWithPrefix = { p : Person ->
            p.firstName.startsWith(prefix)
        }
        if (!onlyWithPhoneNumber) {
            return startWithPrefix
        }
        return { startWithPrefix(it) && it.phoneNumber != null }
    }
}

fun main(args: Array<String>) {
	val contacts = listOf(Person("Dmitry", "123-4567"),
                         Person("Svelana", null))
    val contactListFilters = ContactListFilter()
    contactListFilters.prefix = "S"
    contactListFilters.onlyWithPhoneNumber = false
    println(contacts.filter(contactListFilters.getPredicate()))
}
複製程式碼

執行結果為:

>> [Person(firstName=Svelana, phoneNumber=null)]
複製程式碼

2.6 通過 lambda 去除重複程式碼

我們來看一個分析網站的例子,SiteVisit類用來儲存每次訪問的路徑、持續時間和使用者的作業系統。

data class SiteVisit(
    val path: String,
    val duration: Double,
    val os: OS
)

enum class OS { WINDOWS, LINUX, MAC, IOS, ANDROID }

val log2 = listOf(
    SiteVisit("/", 34.0, OS.WINDOWS),
    SiteVisit("/", 22.0, OS.MAC),
    SiteVisit("/login", 12.0, OS.WINDOWS),
    SiteVisit("/signup", 8.0, OS.IOS),
    SiteVisit("/", 16.3, OS.ANDROID)
)
複製程式碼

接下來,我們通過擴充套件函式的方式,定義一個方法用於統計 符合特定條件 的作業系統使用者的平均使用時長。

fun List<SiteVisit>.averageDuration(predicate : (SiteVisit) -> Boolean) = 
    filter(predicate).map(SiteVisit::duration).average()
複製程式碼

執行下面的程式碼:

fun main(args: Array<String>) {
    println(log2.averageDuration {it.os in setOf(OS.WINDOWS, OS.ANDROID) })
}
複製程式碼

對於一些廣為人知的設計模式可以使用函式型別和lambda表示式進行簡化,比如策略模式。沒有lambda表示式的情況下,你需要宣告一個介面,併為每一種可能的策略提供實現類。使用函式型別,可以用一個通用的函式型別來描述策略,然後傳遞不同的lambda表示式作為不同的策略。


更多文章,歡迎訪問我的 Android 知識梳理系列:

相關文章