Kotlin知識歸納(五) —— Lambda

大棋發表於2019-06-21

前序

      在Kotlin中,函式作為一等公民存在,函式可以像值一樣被傳遞。lambda就是將一小段程式碼封裝成匿名函式,以引數值的方式傳遞到函式中,供函式使用。

初識lambda

      在Java8之前,當外部需要設定一個類中某種事件的處理邏輯時,往往需要定義一個介面(類),並建立其匿名例項作為引數,具體的處理邏輯存放到某個對應的方法中來實現:

mName.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
    }
});
複製程式碼

Kotlin知識歸納(五) —— Lambda
但Kotlin說,太TM囉嗦了,我直接將處理邏輯(程式碼塊)傳遞給你:

mName.setOnClickListener { 
}
複製程式碼

      上面的語法為Kotlin的lambda表示式,都說lambda是匿名函式,匿名是知道了,但引數列表和返回型別呢?那如果這樣寫呢:

val sum = { x:Int, y:Int -> 
    x + y
} 
複製程式碼

      lambda表示式始終花括號包圍,並用 -> 將引數列表和函式主體分離。當lambda自行進行型別推導時,最後一行表示式返回值型別作為lambda的返回值型別。現在一個函式必需的引數列表、函式體和返回型別都一一找出來了。

函式型別

      都說可以將函式作為變數值傳遞,那該變數的型別如何定義呢?函式變數的型別統稱函式型別,所謂函式型別就是宣告該函式的引數型別列表和函式返回值型別。

先看個簡單的函式型別:

() -> Unit
複製程式碼

      函式型別和lambda一樣,使用 -> 作分隔符,但函式型別是將引數型別列表和返回值型別分開,所有函式型別都有一個圓括號括起來的引數型別列表和返回值型別。

一些相對簡單的函式型別:

//無參、無返回值的函式型別(Unit 返回型別不可省略)
() -> Unit
//接收T型別引數、無返回值的函式型別
(T) -> Unit
//接收T型別和A型別引數、無返回值的函式型別(多個引數同理)
(T,A) -> Unit
//接收T型別引數,並且返回R型別值的函式型別
(T) -> R
//接收T型別和A型別引數、並且返回R型別值的函式型別(多個引數同理)
(T,A) -> R
複製程式碼

較複雜的函式型別:

(T,(A,B) -> C) -> R
複製程式碼

一看有點複雜,先將(A,B) -> C抽出來,當作一個函式型別Y,Y = (A,B) -> C,整個函式型別就變成(T,Y) -> R。

      當顯示宣告lambda的函式型別時,可以省去lambda引數列表中引數的型別,並且最後一行表示式的返回值型別必須與宣告的返回值型別一致:

val min:(Int,Int) -> Int = { x,y ->
    //只能返回Int型別,最後一句表示式的返回值必須為Int
    //if表示式返回Int
    if (x < y){
        x
    }else{
        y
    }
}
複製程式碼

      掛起函式屬於特殊的函式型別,掛起函式的函式型別中擁有 suspend 修飾符 ,例如 suspend () -> Unit 或者 suspend A.(B) -> C。(掛機函式屬於協程的知識,可以暫且放過)

型別別名

      型別別名為現有型別提供替代名稱。如果型別名稱太長,可以另外引入較短的名稱,並使用新的名稱替代原型別名。型別別名不會引入新型別,它等效於相應的底層型別。使用型別別名為函式型別起別稱:

typealias alias = (String,(Int,Int) -> String) -> String
typealias alias2 = () -> Unit
複製程式碼

除了函式型別外,也可以為其他型別起別名:

typealias FileTable<K> = MutableMap<K, MutableList<File>>
複製程式碼

lambda語句簡化

      由於Kotlin會根據上下文進行型別推導,我們可以使用更簡化的lambda,來實現更簡潔的語法。以maxBy函式為例,該函式接受一個函式型別為(T) -> R的引數:

data class Person(val age:Int,val name:String)
val persons = listOf(Person(17,"daqi"),Person(20,"Bob"))
//尋找年齡最大的Person物件
//花括號的程式碼片段代表lambda表示式,作為引數傳遞到maxBy()方法中。
persons.maxBy( { person: Person -> person.age } )
複製程式碼
  • 當lambda表示式作為函式呼叫的最後一個實參,可以將它放在括號外邊:
persons.maxBy() { person: Person -> 
    person.age 
}
複製程式碼
persons.joinToString (" "){person -> 
    person.name
}
複製程式碼
  • 當lambda是函式唯一的實參時,還可以將函式的空括號去掉:
persons.maxBy{ person: Person -> 
    person.age 
}
複製程式碼
  • 跟區域性變數一樣,lambda引數的型別可以被推導處理,可以不顯式的指定引數型別:
persons.maxBy{ person -> 
    person.age 
}
複製程式碼

      因為maxBy()函式的宣告,引數型別始終與集合的元素型別相同,編譯器知道你對Person集合呼叫maxBy函式,所以能推匯出lambda表示式的引數型別也是Person。

public inline fun <T, R : Comparable<R>> Iterable<T>.maxBy(selector: (T) -> R): T? {
}
複製程式碼

      但如果使用函式儲存lambda表示式,則無法根據上下文推匯出引數型別,這時必須顯式指定引數型別。

val getAge = { p:Person -> p.age }
//或顯式指定變數的函式型別
val getAge:(Person) -> Int = { p -> p.age }
複製程式碼
  • 當lambda表示式中只有一個引數,沒有顯示指定引數名稱,並且這個引數的型別能推匯出來時,會生成預設引數名稱it
persons.maxBy{ 
    it.age
}
複製程式碼

      預設引數名稱it雖然簡潔,但不能濫用。當多個lambda巢狀的情況下,最好顯式地宣告每個lambda表示式的引數,否則很難搞清楚it引用的到底是什麼值,嚴重影響程式碼可讀性。

var persons:List<Person>? = null
//顯式指定引數變數名稱,不使用it
persons?.let { personList ->
    personList.maxBy{ person -> 
        person.age 
    }
}
複製程式碼
  • 可以把lambda作為命名引數傳遞
persons.joinToString (separator = " ",transform = {person ->
    person.name
})
複製程式碼
  • 當函式需要兩個或以上的lambda實參時,不能把超過一個的lambda放在括號外面,這時使用常規傳參語法來實現是最好的選擇。

SAM 轉換

      回看剛開始的setOnClickListener()方法,那接收的引數是一個介面例項,不是函式型別呀!怎麼就可以傳lambda了呢?先了解一個概念:函式式介面:

函式式介面就是隻定義一個抽象方法的介面

      SAM轉換就是將lambda顯示轉換為函式式介面例項,但要求Kotlin的函式型別和該SAM(單一抽象方法)的函式型別一致。SAM轉換一般都是自動發生的。

      SAM構造方法是編譯器為了將lambda顯示轉換為函式式介面例項而生成的函式。SAM建構函式只接收一個引數 —— 被用作函式式介面單抽象方法體的lambda,並返回該函式式介面的例項。

SAM構造方法的名稱和Java函式式介面的名稱一樣。

顯示呼叫SAM構造方法,模擬轉換:

#daqiInterface.java
//定義Java的函式式介面
public interface daqiInterface {
    String absMethod();
}

#daqiJava.java
public class daqiJava {
    public void setDaqiInterface(daqiInterface listener){

    }
}
複製程式碼
#daqiKotlin.kt
//呼叫SAM構造方法
val interfaceObject = daqiInterface {
    //返回String型別值
    "daqi"
}

//顯示傳遞給接收該函式式介面例項的函式
val daqiJava = daqiJava()
//此處不會報錯
daqiJava.setDaqiInterface(interfaceObject)
複製程式碼

對interfaceObject進行型別判斷:

if (interfaceObject is daqiInterface){
    println("該物件是daqiInterface例項")
}else{
    println("該物件不是daqiInterface例項")
}
複製程式碼

Kotlin知識歸納(五) —— Lambda

      當單個方法接收多個函式式介面例項時,要麼全部顯式呼叫SAM構造方法,要麼全部交給編譯器自行轉換:

#daqiJava.java
public class daqiJava {
    public void setDaqiInterface2(daqiInterface listener,Runnable runnable){

    }
}
複製程式碼
#daqiKotlin.kt
val daqiJava = daqiJava()
//全部交由編譯器自行轉換
daqiJava.setDaqiInterface2( {"daqi"} ){

}

//全部手動顯式SAM轉換
daqiJava.setDaqiInterface2(daqiInterface { "daqi" }, Runnable {  })
複製程式碼

注意:

  • SAM轉換隻適用於介面,不適用於抽象類,即使這些抽象類也只有一個抽象方法。
  • SAM轉換 只適用於操作Java類中接收Java函式式介面例項的方法。因為Kotlin具有完整的函式型別,不需要將函式自動轉換為Kotlin介面的實現。因此,需要接收lambda的作為引數的Kotlin函式應該使用函式型別而不是函式式介面。

帶接收者的lambda表示式

      目前講到的lambda都是普通lambda,lambda中還有一種型別:帶接收者的lambda。

帶接受者的lambda的型別定義:

A.() -> C 
複製程式碼

表示可以在A型別的接收者物件上呼叫並返回一個C型別值的函式。

      帶接收者的lambda好處是,在lambda函式體可以無需任何額外的限定符的情況下,直接使用接收者物件的成員(屬性或方法),亦可使用this訪問接收者物件。

      似曾相識的擴充套件函式中,this關鍵字也執行擴充套件類的例項物件,而且也可以被省略掉。擴充套件函式某種意義上就是帶接收者的函式。

      擴充套件函式和帶接收者的lambda極為相似,雙方都需要一個接收者物件,雙方都可以直接呼叫該物件的成員。如果將普通lambda當作普通函式的匿名方式來看看待,那麼帶接收者型別的lambda可以當作擴充套件函式的匿名方式來看待。

Kotlin的標準庫中就有提供帶接收者的lambda表示式:with和apply

val stringBuilder = StringBuilder()
val result = with(stringBuilder){
    append("daqi在努力學習Android")
    append("daqi在努力學習Kotlin")
    //最後一個表示式作為返回值返回
    this.toString()
}
//列印結果便是上面新增的字串
println(result)
複製程式碼

with函式,顯式接收接收者,並將lambda最後一個表示式的返回值作為with函式的返回值返回

檢視with函式的定義:

public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
}
複製程式碼

      其lambda的函式型別表示,引數型別和返回值型別可以為不同值,也就是說可以返回與接收者型別不一致的值。

      apply函式幾乎和with函式一模一樣,唯一區別是apply始終返回接收者物件。對with的程式碼進行重構:

val stringBuilder = StringBuilder().apply {
    append("daqi在努力學習Android")
    append("daqi在努力學習Kotlin")
}
println(stringBuilder.toString())
複製程式碼

檢視apply函式的定義:

public inline fun <T> T.apply(block: T.() -> Unit): T {
}
複製程式碼

      函式被宣告為T型別的擴充套件函式,並返回T型別的物件。由於其泛型的緣故,可以在任何物件上使用apply。

      apply函式在建立一個物件並需要對其進行初始化時非常有效。在Java中,一般藉助Builder物件。

lambda表示式的使用場景

  • 場景一:lambda和集合一起使用,是lambda最經典的用途。可以對集合進行篩選、對映等其他操作。
val languages = listOf("Java","Kotlin","Python","JavaScript")
languages.filter {
    it.contains("Java")
}.forEach{
    println(it)
}
複製程式碼

Kotlin知識歸納(五) —— Lambda

  • 場景二:替代函式式介面例項
//替代View.OnClickListener介面
mName.setOnClickListener { 

}
//替代Runnable介面
mHandler.post {

}
複製程式碼
  • 場景三:需要接收函式型別變數的函式
//定義函式
fun daqi(string:(Int) -> String){

}

//使用
daqi{
    
}
複製程式碼

有限返回

      前面說lambda一般是將lambda中最後一個表示式的返回值作為lambda的返回值,這種返回是隱式發生的,不需要額外的語法。但當多個lambda巢狀,需要返回外層lambda時,可以使用有限返回。

有限返回就是帶標籤的return
複製程式碼

      標籤一般是接收lambda實參的函式名。當需要顯式返回lambda結果時,可以使用有限返回的形式將結果返回。例子:

val array = listOf("Java","Kotlin")
val buffer = with(StringBuffer()) {
    array.forEach { str ->
        if (str.equals("Kotlin")){
            //返回新增Kotlin字串的StringBuffer
            return@with this.append(str)
        }
    }
}
println(buffer.toString())
複製程式碼

      lambda表示式內部禁止使用裸return,因為一個不帶標籤的return語句總是在用fun關鍵字宣告的函式中返回。這意味著lambda表示式中的return將從包含它的函式返回。

fun main(args: Array<String>) {
    StringBuffer().apply {
        //列印第一個daqi
        println("daqi")
       return
    }
    //列印第二個daqi
    println("daqi")
}
複製程式碼

結果是:第一次列印完後,便退出了main函式。

Kotlin知識歸納(五) —— Lambda

匿名函式

      lambda表示式語法缺少指定函式的返回型別的能力,當需要顯式指定返回型別時,可以使用匿名函式。匿名函式除了名稱省略,其他和常規函式宣告一致。

fun(x: Int, y: Int): Int {
    return x + y
}
複製程式碼

與lambda不同,匿名函式中的return是從匿名函式中返回。

lambda變數捕捉

      在Java中,當函式內宣告一個匿名內部類或者lambda時候,匿名內部類能引用這個函式的引數和區域性變數,但這些引數和區域性變數必須用final修飾。Kotlin的lambda一樣也可以訪問函式引數和區域性變數,並且不侷限於final變數,甚至能修改非final的區域性變數!Kotlin的lambda表示式是真正意思上的閉包。

fun daqi(func:() -> Unit){
    func()
}

fun sum(x:Int,y:Int){
    var count = x + y
    daqi{
        count++
        println("$x + $y +1 = $count")
    }
}
複製程式碼

      正常情況下,區域性變數的生命週期都會被限制在宣告該變數的函式中,區域性變數在函式被執行完後就會被銷燬。但區域性變數或引數被lambda捕捉後,使用該變數的程式碼塊可以被儲存並延遲執行。這是為什麼呢?

      當捕捉final變數時,final變數會被拷貝下來與使用該final變數的lambda程式碼一起儲存。而對於非final變數會被封裝在一個final的Ref包裝類例項中,然後和final變數一樣,和使用該變數lambda一起儲存。當需要修改這個非final引用時,通過獲取Ref包裝類例項,進而改變儲存在該包裝類中的佈局變數。所以說lambda還是隻能捕捉final變數,只是Kotlin遮蔽了這一層包裝。

檢視原始碼:

public static final void sum(final int x, final int y) {
  //建立一個IntRef包裝類物件,將變數count儲存進去
  final IntRef count = new IntRef();
  count.element = x + y;
  daqi((Function0)(new Function0() {
     public Object invoke() {
        this.invoke();
        return Unit.INSTANCE;
     }

     public final void invoke() {
        //通過包裝類物件對內部的變數進行讀和修改
        int var10001 = count.element++;
        String var1 = x + " + " + y + " +1 = " + count.element;
        System.out.println(var1);
     }
  }));
}
複製程式碼

注意: 對於lambda修改區域性變數,只有在該lambda表示式被執行的時候觸發。

成員引用

      lambda可以將程式碼塊作為引數傳遞給函式,但當我需要傳遞的程式碼已經被定義為函式時,該怎麼辦?難不成我寫一個呼叫該函式的lambda?Kotlin和Java8允許你使用成員引用將函式轉換成一個值,然後傳遞它。

成員引用用來建立一個呼叫單個方法或者訪問單個屬性的函式值。
複製程式碼
data class Person(val age:Int,val name:String)

fun daqi(){
    val persons = listOf(Person(17,"daqi"),Person(20,"Bob"))
    persons.maxBy({person -> person.age })
}
複製程式碼

      Kotlin中,當你宣告屬性的時候,也就宣告瞭對應的訪問器(即get和set)。此時Person類中已存在age屬性的訪問器方法,但我們在呼叫訪問器時,還在外面巢狀了一層lambda。使用成員引用進行優化:

data class Person(val age:Int,val name:String)

fun daqi(){
    val persons = listOf(Person(17,"daqi"),Person(20,"Bob"))
    persons.maxBy(Person::age)
}
複製程式碼

成員引用由類、雙冒號、成員三個部分組成:

Kotlin知識歸納(五) —— Lambda

頂層函式和擴充套件函式都可以使用成員引用來表示:

//頂層函式
fun daqi(){
}

//擴充套件函式
fun Person.getPersonAge(){
}

fun main(args: Array<String>) {
    //頂層函式的成員引用(不附屬於任何一個類,類省略)
   run(::daqi)
   //擴充套件函式的成員引用
   Person(17,"daqi").run(Person::getPersonAge)
}
複製程式碼

還可以對建構函式使用成員引用來表示:

val createPerson = ::Person
val person = createPerson(17,"daqi")
複製程式碼

Kotlin知識歸納(五) —— Lambda

Kotlin1.1後,成員引用語法支援捕捉特定例項物件上的方法引用:

val personAge = Person(17,"name")::age
複製程式碼

lambda的效能優化

      自Kotlin1.0起,每一個lambda表示式都會被編譯成一個匿名類,帶來額外的開銷。可以使用行內函數來優化lambda帶來的額外消耗。

      所謂的行內函數,就是使用inline修飾的函式。在函式被使用的地方編譯器並不會生成函式呼叫的程式碼,而是將函式實現的真實程式碼替換每一次的函式呼叫。Kotlin中大多數的庫函式都標記成了inline。

參考資料:

android Kotlin系列:

Kotlin知識歸納(一) —— 基礎語法

Kotlin知識歸納(二) —— 讓函式更好呼叫

Kotlin知識歸納(三) —— 頂層成員與擴充套件

Kotlin知識歸納(四) —— 介面和類

Kotlin知識歸納(五) —— Lambda

Kotlin知識歸納(六) —— 型別系統

Kotlin知識歸納(七) —— 集合

Kotlin知識歸納(八) —— 序列

Kotlin知識歸納(九) —— 約定

Kotlin知識歸納(十) —— 委託

Kotlin知識歸納(十一) —— 高階函式

Kotlin知識歸納(十二) —— 泛型

Kotlin知識歸納(十三) —— 註解

Kotlin知識歸納(十四) —— 反射

相關文章