淺談Kotlin語法篇之Lambda表示式完全解析(六)

極客熊貓發表於2018-04-25

簡述: 今天帶來的Kotlin淺談系列的第六彈, 一起來聊下Kotlin中的lambda表示式。lambda表示式應該都不陌生,在Java8中引入的一個很重要的特性,將開發者從原來繁瑣的語法中解放出來,可是很遺憾的是隻有Java8版本才能使用。而Kotlin則彌補了這一問題,Kotlin中的lambda表示式與Java混合程式設計可以支援Java8以下的版本。那我們帶著以下幾個問題一起來看下Kotlin中lambda表示式。

  • 1、為什麼要使用Kotlin的lambda表示式(why)?
  • 2、如何去使用Kotlin的lambda表示式(how)?
  • 3、Kotlin的lambda表示式一般用在哪(where)?
  • 4、Kotlin的lambda表示式的作用域變數和變數捕獲
  • 5、Kotlin的lambda表示式的成員引用

一、為什麼要使用Kotlin的lambda表示式?

針對以上為什麼使用Kotlin中的lambda表示式的問題,我覺得有三點主要的原因。

  • 1、Kotlin的lambda表示式以更加簡潔易懂的語法實現功能,使開發者從原有冗餘囉嗦的語法宣告解放出來。可以使用函數語言程式設計中的過濾、對映、轉換等操作符處理集合資料,從而使你的程式碼更加接近函數語言程式設計的風格。
  • 2、Java8以下的版本不支援Lambda表示式,而Kotlin則相容與Java8以下版本有很好互操作性,非常適合Java8以下版本與Kotlin混合開發的模式。解決了Java8以下版本不能使用lambda表示式瓶頸。
  • 3、在Java8版本中使用Lambda表示式是有些限制的,它不是真正意義上支援閉包,而Kotlin中lambda才是真正意義的支援閉包實現。(關於這個問題為什麼下面會有闡述)

二、Kotlin的lambda表示式基本語法

1、lambda表示式分類

在Kotlin實際上可以把Lambda表示式分為兩個大類,一個是普通的lambda表示式,另一個則是帶接收者的lambda表示式(功能很強大,之後會有專門分析的部落格)。這兩種lambda在使用和使用場景也是有很大的不同. 先看下以下兩種lambda表示式的型別宣告:

淺談Kotlin語法篇之Lambda表示式完全解析(六)

針對帶接收者的Lambda表示式在Kotlin中標準庫函式中也是非常常見的比如with,apply標準函式的宣告。

@kotlin.internal.InlineOnly
public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return receiver.block()
}

@kotlin.internal.InlineOnly
public inline fun <T> T.apply(block: T.() -> Unit): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    block()
    return this
}
複製程式碼

看到以上的lambda表示式的分類,你是不是想到之前的擴充套件函式了,有沒有想起之前這張圖?

淺談Kotlin語法篇之Lambda表示式完全解析(六)

是不是和我們之前部落格說普通函式和擴充套件函式類似。普通的Lambda表示式類似對應普通函式的宣告,而帶接收者的lambda表示式則類似對應擴充套件函式。擴充套件函式就是這種宣告接收者型別,然後使用接收者物件呼叫直接類似成員函式呼叫,實際內部是通過這個接收者物件例項直接訪問它的方法和屬性。

2、lambda基本語法

lambda的標準形式基本宣告滿足三個條件:

含有實際引數

含有函式體(儘管函式體為空,也得宣告出來)

以上內部必須被包含在花括號內部

淺談Kotlin語法篇之Lambda表示式完全解析(六)

以上是lambda表示式最標準的形式,可能這種標準形式在以後的開發中可能見到比較少,更多是更加的簡化形式,下面就是會介紹Lambda表示式簡化規則

3、lambda語法簡化轉換

以後開發中我們更多的是使用簡化版本的lambda表示式,因為看到標準的lambda表示式形式還是有些囉嗦,比如實參型別就可以省略,因為Kotlin這門語言支援根據上下文環境智慧推匯出型別,所以可以省略,摒棄囉嗦的語法,下面是lambda簡化規則。

淺談Kotlin語法篇之Lambda表示式完全解析(六)

注意:語法簡化是把雙刃劍,簡化固然不錯,使用簡單方便,但是不能濫用,也需要考慮到程式碼的可讀性.上圖中Lambda化簡成的最簡單形式用it這種,一般在多個Lambda巢狀的時候不建議使用,嚴重造成程式碼可讀性,到最後估計連開發者都不知道it指代什麼了。比如以下程式碼:

這是Kotlin庫中的joinToString擴充套件函式,最後一個引數是一個接收一個集合元素型別T的引數返回一個CharSequence型別的lambda表示式。

//joinToString內部宣告
public fun <T> Iterable<T>.joinToString(separator: CharSequence = ", ", prefix: CharSequence = "", postfix: CharSequence = "", limit: Int = -1, truncated: CharSequence = "...", transform: ((T) -> CharSequence)? = null): String {
    return joinTo(StringBuilder(), separator, prefix, postfix, limit, truncated, transform).toString()
}


fun main(args: Array<String>) {
    val num = listOf(1, 2, 3)
    println(num.joinToString(separator = ",", prefix = "<", postfix = ">") {
        return@joinToString "index$it"
    })
}
複製程式碼

我們可以看到joinToString的呼叫地方是使用了lambda表示式作為引數的簡化形式,將它從圓括號中提出來了。這個確實給呼叫帶來一點小疑惑,因為並沒有顯示錶明lambda表示式應用到哪裡,所以不熟悉內部實現的開發者很難理解。對於這種問題,Kotlin實際上給我們提供解決辦法,也就是我們之前部落格提到過的命名引數。使用命名引數後的程式碼

//joinToString內部宣告
public fun <T> Iterable<T>.joinToString(separator: CharSequence = ", ", prefix: CharSequence = "", postfix: CharSequence = "", limit: Int = -1, truncated: CharSequence = "...", transform: ((T) -> CharSequence)? = null): String {
    return joinTo(StringBuilder(), separator, prefix, postfix, limit, truncated, transform).toString()
}
fun main(args: Array<String>) {
    val num = listOf(1, 2, 3)
    println(num.joinToString(separator = ",", prefix = "<", postfix = ">", transform = { "index$it" }))
}
複製程式碼

4、lambda表示式的返回值

lambda表示式返回值總是返回函式體內部最後一行表示式的值

package com.mikyou.kotlin.lambda

fun main(args: Array<String>) {

    val isOddNumber = { number: Int ->
        println("number is $number")
        number % 2 == 1
    }

    println(isOddNumber.invoke(100))
}
複製程式碼

淺談Kotlin語法篇之Lambda表示式完全解析(六)

將函式體內的兩個表示式互換位置後

package com.mikyou.kotlin.lambda

fun main(args: Array<String>) {

    val isOddNumber = { number: Int ->
        number % 2 == 1
        println("number is $number")
    }

    println(isOddNumber.invoke(100))
}
複製程式碼

淺談Kotlin語法篇之Lambda表示式完全解析(六)

通過上面例子可以看出lambda表示式是返回函式體內最後一行表示式的值,由於println函式沒有返回值,所以預設列印出來的是Unit型別,那它內部原理是什麼呢?實際上是通過最後一行表示式返回值型別作為了invoke函式的返回值的型別,我們可以對比上述兩種寫法的反編譯成java的程式碼:

//互換位置之前的反編譯程式碼
package com.mikyou.kotlin.lambda;

import kotlin.jvm.internal.Lambda;

@kotlin.Metadata(mv = {1, 1, 10}, bv = {1, 0, 2}, k = 3, d1 = {"\000\016\n\000\n\002\020\013\n\000\n\002\020\b\n\000\020\000\032\0020\0012\006\020\002\032\0020\003H\n¢\006\002\b\004"}, d2 = {"<anonymous>", "", "number", "", "invoke"})
final class LambdaReturnValueKt$main$isOddNumber$1 extends Lambda implements kotlin.jvm.functions.Function1<Integer, Boolean> {
    public final boolean invoke(int number) {//此時invoke函式返回值的型別是boolean,對應了Kotlin中的Boolean
        String str = "number is " + number;
        System.out.println(str);
        return number % 2 == 1;
    }

    public static final 1INSTANCE =new 1();

    LambdaReturnValueKt$main$isOddNumber$1() {
        super(1);
    }
}


複製程式碼
//互換位置之後的反編譯程式碼
package com.mikyou.kotlin.lambda;

import kotlin.jvm.internal.Lambda;

@kotlin.Metadata(mv = {1, 1, 10}, bv = {1, 0, 2}, k = 3, d1 = {"\000\016\n\000\n\002\020\002\n\000\n\002\020\b\n\000\020\000\032\0020\0012\006\020\002\032\0020\003H\n¢\006\002\b\004"}, d2 = {"<anonymous>", "", "number", "", "invoke"})
final class LambdaReturnValueKt$main$isOddNumber$1 extends Lambda implements kotlin.jvm.functions.Function1<Integer, kotlin.Unit> {
    public final void invoke(int number) {//此時invoke函式返回值的型別是void,對應了Kotlin中的Unit
        if (number % 2 != 1) {
        }
        String str = "number is " + number;
        System.out.println(str);
    }

    public static final 1INSTANCE =new 1();

    LambdaReturnValueKt$main$isOddNumber$1() {
        super(1);
    }
}

複製程式碼

5、lambda表示式型別

Kotlin中提供了簡潔的語法去定義函式的型別.

() -> Unit//表示無引數無返回值的Lambda表示式型別

(T) -> Unit//表示接收一個T型別引數,無返回值的Lambda表示式型別

(T) -> R//表示接收一個T型別引數,返回一個R型別值的Lambda表示式型別

(T, P) -> R//表示接收一個T型別和P型別的引數,返回一個R型別值的Lambda表示式型別

(T, (P,Q) -> S) -> R//表示接收一個T型別引數和一個接收P、Q型別兩個引數並返回一個S型別的值的Lambda表示式型別引數,返回一個R型別值的Lambda表示式型別
複製程式碼

上面幾種型別前面幾種應該好理解,估計有點難度是最後一種,最後一種實際上已經屬於高階函式的範疇。不過這裡說下個人看這種型別的一個方法有點像剝洋蔥一層一層往內層拆分,就是由外往裡看,然後做拆分,對於本身是一個Lambda表示式型別的,先暫時看做一個整體,這樣就可以確定最外層的Lambda型別,然後再用類似方法往內部拆分。

淺談Kotlin語法篇之Lambda表示式完全解析(六)

6、使用typealias關鍵字給Lambda型別命名

我們試想一個場景就是可能會用到多個lambda表示式,但是這些lambda表示式的型別很多相同,我們就很容易把所有相同一大串的Lambda型別重複宣告或者你的lambda型別宣告太長不利於閱讀。實際上不需要,對於Kotlin這門反對一切囉嗦語法的語言來說,它都給你提供一系列的解決辦法,讓你簡化程式碼的同時又不降低程式碼的可讀性。

fun main(args: Array<String>) {
    val oddNum:  (Int) -> Unit = {
        if (it % 2 == 1) {
            println(it)
        } else {
            println("is not a odd num")
        }
    }

    val evenNum:  (Int) -> Unit = {
        if (it % 2 == 0) {
            println(it)
        } else {
            println("is not a even num")
        }
    }

    oddNum.invoke(100)
    evenNum.invoke(100)
}
複製程式碼

使用typealias關鍵字宣告(Int) -> Unit型別

package com.mikyou.kotlin.lambda

typealias NumPrint = (Int) -> Unit//注意:宣告的位置在函式外部,package內部

fun main(args: Array<String>) {
    val oddNum: NumPrint = {
        if (it % 2 == 1) {
            println(it)
        } else {
            println("is not a odd num")
        }
    }

    val evenNum: NumPrint = {
        if (it % 2 == 0) {
            println(it)
        } else {
            println("is not a even num")
        }
    }

    oddNum.invoke(100)
    evenNum.invoke(100)
}
複製程式碼

三、Kotlin的lambda表示式經常使用的場景

  • 場景一: lambda表示式與集合一起使用,是最常見的場景,可以各種篩選、對映、變換操作符和對集合資料進行各種操作,非常靈活,相信使用過RxJava中的開發者已經體會到這種快感,沒錯Kotlin在語言層面,無需增加額外庫,就給你提供了支援函數語言程式設計API。
package com.mikyou.kotlin.lambda

fun main(args: Array<String>) {
    val nameList = listOf("Kotlin", "Java", "Python", "JavaScript", "Scala", "C", "C++", "Go", "Swift")
    nameList.filter {
        it.startsWith("K")
    }.map {
        "$it is a very good language"
    }.forEach {
        println(it)
    }

}
複製程式碼

淺談Kotlin語法篇之Lambda表示式完全解析(六)

  • 場景二: 替代原有匿名內部類,但是需要注意一點就是隻能替代含有單抽象方法的類。
	findViewById(R.id.submit).setOnClickListener(new View.OnClickListener() {
			@Override
			public void onClick(View v) {
				...
			}
		});
複製程式碼

用kotlin lambda實現

findViewById(R.id.submit).setOnClickListener{
    ...
}
複製程式碼
  • 場景三: 定義Kotlin擴充套件函式或者說需要把某個操作或函式當做值傳入的某個函式的時候。
fun Context.showDialog(content: String = "", negativeText: String = "取消", positiveText: String = "確定", isCancelable: Boolean = false, negativeAction: (() -> Unit)? = null, positiveAction: (() -> Unit)? = null) {
	AlertDialog.build(this)
			.setMessage(content)
			.setNegativeButton(negativeText) { _, _ ->
				negativeAction?.invoke()
			}
			.setPositiveButton(positiveText) { _, _ ->
				positiveAction?.invoke()
			}
			.setCancelable(isCancelable)
			.create()
			.show()
}

fun Context.toggleSpFalse(key: String, func: () -> Unit) {
	if (!getSpBoolean(key)) {
		saveSpBoolean(key, true)
		func()
	}
}

fun <T : Any> Observable<T>.subscribeKt(success: ((successData: T) -> Unit)? = null, failure: ((failureError: RespException?) -> Unit)? = null): Subscription? {
	return transformThread()
			.subscribe(object : SBRespHandler<T>() {
				override fun onSuccess(data: T) {
					success?.invoke(data)
				}

				override fun onFailure(e: RespException?) {
					failure?.invoke(e)
				}
			})
}

複製程式碼

四、Kotlin的lambda表示式的作用域中訪問變數和變數捕獲

1、Kotlin和Java內部類或lambda訪問區域性變數的區別

  • 在Java中在函式內部定義一個匿名內部類或者lambda,內部類訪問的函式區域性變數必須需要final修飾,也就意味著在內部類內部或者lambda表示式的內部是無法去修改函式區域性變數的值。可以看一個很簡單的Android事件點選的例子
public class DemoActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_demo);
        final int count = 0;//需要使用final修飾
        findViewById(R.id.btn_click).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                System.out.println(count);//在匿名OnClickListener類內部訪問count必須要是final修飾
            }
        });
    }
}

複製程式碼
  • 在Kotlin中在函式內部定義lambda或者內部類,既可以訪問final修飾的變數,也可以訪問非final修飾的變數,也就意味著在Lambda的內部是可以直接修改函式區域性變數的值。以上例子Kotlin實現

訪問final修飾的變數

class Demo2Activity : AppCompatActivity() {

	override fun onCreate(savedInstanceState: Bundle?) {
		super.onCreate(savedInstanceState)
		setContentView(R.layout.activity_demo2)
		val count = 0//宣告final
		btn_click.setOnClickListener {
			println(count)//訪問final修飾的變數這個是和Java是保持一致的。
		}
	}
}

複製程式碼

訪問非final修飾的變數,並修改它的值

class Demo2Activity : AppCompatActivity() {

	override fun onCreate(savedInstanceState: Bundle?) {
		super.onCreate(savedInstanceState)
		setContentView(R.layout.activity_demo2)
		var count = 0//宣告非final型別
		btn_click.setOnClickListener {
			println(count++)//直接訪問和修改非final型別的變數
		}
	}
}

複製程式碼

通過以上對比會發現Kotlin中使用lambda會比Java中使用lambda更靈活,訪問受到限制更少,這也就回答本部落格最開始說的一句話,Kotlin中的lambda表示式是真正意義上的支援閉包,而Java中的lambda則不是。Kotlin中的lambda表示式是怎麼做到這一點的呢?請接著看

2、Kotlin中lambda表示式的變數捕獲及其原理

  • 什麼是變數捕獲?

通過上述例子,我們知道在Kotlin中既能訪問final的變數也能訪問或修改非final的變數。原理是怎樣的呢?在此之前先丟擲一個高大上的概念叫做lambdab表示式的變數捕獲。實際上就是lambda表示式在其函式體內可以訪問外部的變數,我們就稱這些外部變數被lambda表示式給捕獲了。有了這個概念我們可以把上面的結論變得高大上一些:

第一在Java中lambda表示式只能捕獲final修飾的變數

第二在Kotlin中lambda表示式既能捕獲final修飾的變數也能訪問和修改非final的變數

  • 變數捕獲實現的原理

我們都知道函式的區域性變數生命週期是屬於這個函式的,當函式執行完畢,區域性變數也就是銷燬了,但是如果這個區域性變數被lambda捕獲了,那麼使用這個區域性變數的程式碼將會被儲存起來等待稍後再次執行,也就是被捕獲的區域性變數是可以延遲生命週期的,針對lambda表示式捕獲final修飾的區域性變數原理是區域性變數的值和使用這個值的lambda程式碼會被一起儲存起來;而針對於捕獲非final修飾的區域性變數原理是非final區域性變數會被一個特殊包裝器類包裝起來,這樣就可以通過包裝器類例項去修改這個非final的變數,那麼這個包裝器類例項引用是final的會和lambda程式碼一起儲存

以上第二條結論在Kotlin的語法層面來說是正確的,但是從真正的原理上來說是錯誤的,只不過是Kotlin在語法層面把這個遮蔽了而已,實質的原理lambda表示式還是隻能捕獲final修飾變數,而為什麼kotlin卻能做到修改非final的變數的值,實際上kotlin在語法層面做了一個橋接包裝,它把所謂的非final的變數用一個Ref包裝類包裝起來,然後外部保留著Ref包裝器的引用是final的,然後lambda會和這個final包裝器的引用一起儲存,隨後在lambda內部修改變數的值實際上是通過這個final的包裝器引用去修改的。

淺談Kotlin語法篇之Lambda表示式完全解析(六)

最後通過檢視Kotlin修改非final區域性變數的反編譯成的Java程式碼就是一目瞭然了

class Demo2Activity : AppCompatActivity() {

	override fun onCreate(savedInstanceState: Bundle?) {
		super.onCreate(savedInstanceState)
		setContentView(R.layout.activity_demo2)
		var count = 0//宣告非final型別
		btn_click.setOnClickListener {
			println(count++)//直接訪問和修改非final型別的變數
		}
	}
}

複製程式碼
@Metadata(
   mv = {1, 1, 9},
   bv = {1, 0, 2},
   k = 1,
   d1 = {"\u0000\u0018\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\b\u0002\n\u0002\u0010\u0002\n\u0000\n\u0002\u0018\u0002\n\u0000\u0018\u00002\u00020\u0001B\u0005¢\u0006\u0002\u0010\u0002J\u0012\u0010\u0003\u001a\u00020\u00042\b\u0010\u0005\u001a\u0004\u0018\u00010\u0006H\u0014¨\u0006\u0007"},
   d2 = {"Lcom/shanbay/prettyui/prettyui/Demo2Activity;", "Landroid/support/v7/app/AppCompatActivity;", "()V", "onCreate", "", "savedInstanceState", "Landroid/os/Bundle;", "production sources for module app"}
)
public final class Demo2Activity extends AppCompatActivity {
   private HashMap _$_findViewCache;

   protected void onCreate(@Nullable Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
      this.setContentView(2131361820);
      final IntRef count = new IntRef();//IntRef特殊的包裝器類的型別,final修飾的IntRef的count引用
      count.element = 0;//包裝器內部的非final變數element
      ((Button)this._$_findCachedViewById(id.btn_click)).setOnClickListener((OnClickListener)(new OnClickListener() {
         public final void onClick(View it) {
            int var2 = count.element++;//直接是通過IntRef的引用直接修改內部的非final變數的值,來達到語法層面的lambda直接修改非final區域性變數的值
            System.out.println(var2);
         }
      }));
   }

   public View _$_findCachedViewById(int var1) {
      if(this._$_findViewCache == null) {
         this._$_findViewCache = new HashMap();
      }

      View var2 = (View)this._$_findViewCache.get(Integer.valueOf(var1));
      if(var2 == null) {
         var2 = this.findViewById(var1);
         this._$_findViewCache.put(Integer.valueOf(var1), var2);
      }

      return var2;
   }

   public void _$_clearFindViewByIdCache() {
      if(this._$_findViewCache != null) {
         this._$_findViewCache.clear();
      }

   }
}

複製程式碼

3、Kotlin中lambda表示式變數捕獲注意事項

注意: 對於Lambda表示式內部修改區域性變數的值,只會在這個Lambda表示式被執行的時候觸發。

五、Kotlin的lambda表示式的成員引用

1、為什麼要使用成員引用

我們知道在Lambda表示式可以直接把一個程式碼塊作為一個引數傳遞給函式,但是有沒有遇到過這樣一個場景就是我要傳遞過去的程式碼塊,已經是作為了一個命名函式存在了,此時你還需要重複寫一個程式碼塊傳遞過去嗎?肯定不是,Kotlin拒絕囉嗦重複的程式碼。所以只需要成員引用替代即可。

fun main(args: Array<String>) {
    val persons = listOf(Person(name = "Alice", age = 18), Person(name = "Mikyou", age = 20), Person(name = "Bob", age = 16))
    println(persons.maxBy({ p: Person -> p.age }))
}
複製程式碼

可以替代為

fun main(args: Array<String>) {
    val persons = listOf(Person(name = "Alice", age = 18), Person(name = "Mikyou", age = 20), Person(name = "Bob", age = 16))
    println(persons.maxBy(Person::age))//成員引用的型別和maxBy傳入的lambda表示式型別一致
}
複製程式碼

2、成員引用的基本語法

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

淺談Kotlin語法篇之Lambda表示式完全解析(六)

3、成員引用的使用場景

  • 成員引用最常見的使用方式就是類名+雙冒號+成員(屬性或函式)
fun main(args: Array<String>) {
    val persons = listOf(Person(name = "Alice", age = 18), Person(name = "Mikyou", age = 20), Person(name = "Bob", age = 16))
    println(persons.maxBy(Person::age))//成員引用的型別和maxBy傳入的lambda表示式型別一致
}
複製程式碼
  • 省略類名直接引用頂層函式(之前部落格有專門分析)
package com.mikyou.kotlin.lambda

fun salute() = print("salute")

fun main(args: Array<String>) {
    run(::salute)
}
複製程式碼
  • 成員引用用於擴充套件函式

fun Person.isChild() = age < 18

fun main(args: Array<String>){
    val isChild = Person::isChild
    println(isChild)
}

複製程式碼

到這裡有關Kotlin lambda的基礎知識就基本淺談完畢了,下一篇會從Lambda實質原理和位元組碼方面分析,以及Lambda表示式使用時效能優化。

淺談Kotlin語法篇之Lambda表示式完全解析(六)

歡迎關注Kotlin開發者聯盟,這裡有最新Kotlin技術文章,每週會不定期翻譯一篇Kotlin國外技術文章。如果你也喜歡Kotlin,歡迎加入我們~~~

相關文章