Kotlin | 作用域函式
什麼是作用域函式(Scope Functions)?
Kotlin 標準庫包含了幾個特殊的函式,其目的是在呼叫物件的上下文環境(context)中執行程式碼塊。當你在提供了 lambda 表示式的物件上呼叫此類函式時,它會形成一個臨時作用域。在此作用域內,你可以在不使用其名稱的情況下訪問該物件,這些函式被稱為作用域函式。在 Kotlin 中,作用域函式總共有五個,分別是:let
、run
、with
、apply
、also
。接下來我們逐個詳細分析。
開始分析之前,你可能需要簡單瞭解下它大概長什麼樣,下面是個簡單示例
data class Person(var name:String){ fun say(words:String){ println("$name says $words") } } fun main() { Person("skyrin").let{ it.say("hello") println(it) } }
如果不使用let
的話,你需要先創建出物件,然後再執行呼叫
val person = Person("skyrin") person.say("hello") println(person)
所以,作用域函式的目的就是儘可能的讓你的程式碼變得更簡潔更具可讀性,儘可能少的建立物件,僅此而已。
由於這 5 個作用域函式的性質有些相似,所以大家可能經常不知道在哪種情況下該使用哪個函式,以至於最終放棄使用作用域函式:joy:,所以為了避免類似悲劇發生,我們首先來討論一下他們之間的區別以及使用場景。
區別
由於作用域函式本質上非常相似,因此理解它們之間的差異非常重要。每個作用域函式有兩個主要區別:
- 引用上下文物件的方式
- 返回值
上下文物件(Context):this 還是 it
this
run
、with
和apply
通過this
關鍵字引用一個 context 物件作為 lambda 接收者。於是,在他們的 lambda 中,this 物件可用於普通類函式中。大多數情況下,在訪問接收者的成員時,可以省略this
關鍵字,讓程式碼保持簡潔。另一方面,如果省略了this
,你就很難區分你操作的函式或變數是外部物件的還是接收者的了,所以,context 物件作為一個接收者(this)這種方式推薦用於呼叫接收者(this) 的成員變數或函式。示例如下
data class Person(var name: String,var age: Int = 0,var city: String = "") fun main() { val person = Person("Skyrin").apply { age = 18// 等價於 this.age = 18 或閉包外部的 person.age = 18 city = "Beijing" } // 如上寫法可替代如下寫法 // person.age = 18 // person.city = "Beijing" println(person) }
it
let
、also
有一個作為 lambda 引數傳入的 context 物件,如果不指定引數名,則可以通過該 context 物件的隱式預設名稱it
來訪問它,it
比this
看上去更簡潔,用於表示式中也會使程式碼更加清晰易讀。但是,當你訪問 context 物件的函式或者屬性時,不能像apply
那樣省略this
,因此,當 context 物件主要用作引數被其他函式呼叫時,用it
更好一些。
import kotlin.random.Random fun writeToLog(message: String) { println("INFO: $message") } fun getRandomInt(): Int { return Random.nextInt(100).also { writeToLog("getRandomInt() generated value $it") } } fun main() { val i = getRandomInt() }
你也可以為 context 物件指定任意引數名
import kotlin.random.Random fun writeToLog(message: String) { println("INFO: $message") } fun getRandomInt(): Int { return Random.nextInt(100).also { value -> // use value replace it writeToLog("getRandomInt() generated value $value") } } fun main() { val i = getRandomInt() }
返回值:Context 物件還是 Lambda 結果
作用域函式的返回值不同:
-
applay
和also
返回 context 物件 -
let
、run
、with
返回閉包的運算結果
返回 Context 物件
applay
和also
返回 context 物件,因此,它們可以結合起來進行鏈式呼叫
fun main() { val memberList = mutableListOf<Int>() memberList.also { println("填充 $it") }.apply { add(35) add(98) add(1) add(18) }.also { println("排序並列印 $it") }.also { it.sort() println(it) } }
也可以在 return 語句中使用,將 context 物件作為函式的返回值
import kotlin.random.Random fun main() { fun getRandomInt(): Int { return Random.nextInt(100).also { value -> writeToLog("getRandomInt() generated value $value") } } val i = getRandomInt() } fun writeToLog(message: String) { println("INFO: $message") }
返回 Lambda 閉包結果
let
、run
、with
返回 lambda 閉包結果。所以,你可以將其執行結果賦值給任意變數
fun main() { val numbers = mutableListOf(1, 3, 5, 6, 7, 9) val biggerThan6 = numbers.run { add(10) add(12) filter { it > 6 } } println("The result of bigger than 6 is $biggerThan6") }
此外,你可以忽略返回值,使用with
作用域函式來為變數建立一個臨時作用域
fun main() { val numbers = mutableListOf(1, 3, 5, 6, 7, 9) with(numbers){ val first = first() val last = last() println("first item is $first and last item is $last") } }
使用場景
下面介紹如何適當的選擇作用域函式,從技術上來說,它們的功能在很多情況下都是可以互相轉換的,所以下面的例子只是展示了一種通用做法,具體選擇還是要看你的業務場景更適合哪種情況。
let
context 物件作為閉包引數(it)傳入,返回值是閉包結果。
let
可用於在呼叫鏈的結果上呼叫一個或多個函式。例如,以下程式碼列印集合上的兩個操作的結果
fun main() { val numbers = mutableListOf("one", "two", "three", "four", "five") val resultList = numbers.map { it.length }.filter { it > 3 } println(resultList) }
使用let
可以重寫為
fun main() { val numbers = mutableListOf("one", "two", "three", "four", "five") numbers.map { it.length }.filter { it > 3}.let { println(it) // 執行更多方法呼叫 } }
如果閉包模組只有一個函式將 context 作為引數傳入,你可以使用(::)替換 lambda
fun main() { val numbers = mutableListOf("one", "two", "three", "four", "five") numbers.map { it.length }.filter { it > 3}.let(::print) }
let
也經常被用於執行閉包程式碼塊中使用非空值的函式,要對非空物件執行操作,使用安全呼叫操作符?.
後跟let
閉包,在此閉包中,原來的可空物件就可以被轉換為非空物件執行操作
fun processNonNullString(str: String) { println(str.length) } fun main() { val str: String? = "Hello" //processNonNullString(str)// 編譯錯誤: str 為可空物件,要求引數為不可空物件 val length = str?.let { println("let() called on $it") processNonNullString(it)// 正常執行: 'it' 在 '?.let { }' 中為不可空物件 it.length } println("result for let is $length") }
let
的另一種使用場景是引入區域性變數,限制其作用域範圍,以提高程式碼可讀性。
fun main() { val numbers = listOf("one", "two", "three", "four") val modifiedFirstItem = numbers.first().let { firstItem -> println("The first item of the list is '$firstItem'") if (firstItem.length >= 5) firstItem else "!$firstItem!" }.toUpperCase() println("First item after modifications: '$modifiedFirstItem'") }
with
非拓展函式。context 物件作為引數傳遞,但在 lambda 內部,它可用作接收器(this),返回值為 lambda 結果
官方建議是使用 context 物件呼叫函式而不提供 lambda 結果。在程式碼中,你可以簡單的把with
函式理解為 “使用此物件,執行以下操作”
fun main() { val numbers = mutableListOf("one", "two", "three") with(numbers) { // 使用 numbers 物件,執行 {} 中的操作 println("'with' is called with argument $this") println("It contains $size elements") } }
with
的另一個用例是引入一個輔助物件,我們可以方便的使用此物件的屬性或函式來計算值
fun main() { val numbers = mutableListOf("one", "two", "three") val firstAndLast = with(numbers) { "The first element is ${first()}," + " the last element is ${last()}" } println(firstAndLast) }
run
context 物件可用作接收器(this),返回值為 lambda 結果
run
和with
的作用類似,但是呼叫方法和let
一樣 —— 作為 context 物件的拓展函式
當你的 lambda 同時包含了物件初始化和返回值計算時,run
函式非常適合
lass MultiportService(var url: String, var port: Int) { fun prepareRequest(): String = "Default request" fun query(request: String): String = "Result for query '$request'" } fun main() { val service = MultiportService("https://example.kotlinlang.org", 80) val result = service.run { port = 8080 query(prepareRequest() + " to port $port") } // 同樣的程式碼使用 let() 函式重寫: val letResult = service.let { it.port = 8080 it.query(it.prepareRequest() + " to port ${it.port}") } println(result) println(letResult) }
除了在接收器物件上呼叫run之外,還可以將其用作非擴充套件函式。非擴充套件run
允許你執行需要表示式的多個語句塊。
fun main() { val hexNumberRegex = run { val digits = "0-9" val hexDigits = "A-Fa-f" val sign = "+-" Regex("[$sign]?[$digits$hexDigits]+") } for (match in hexNumberRegex.findAll("+1234 -FFFF not-a-number")) { println(match.value) } }
apply
context 物件可用作接收器(this),返回呼叫者本身
使用apply不會返回值的程式碼塊,主要對接收器物件的成員進行操作。 apply的常見情況是物件配置。此類呼叫可以讀作“將以下賦值應用於物件”。
data class Person(var name: String,var age: Int = 0,var city: String = "") fun main() { val person = Person("Skyrin").apply { age = 18 city = "Beijing" } }
將接收器作為返回值,你可以輕鬆進行鏈式呼叫以處理更復雜的操作。
also
context 物件作為引數傳入,返回呼叫者本身
also
適用於執行將 context 物件作為引數進行的一些操作。還可用於不更改物件的其他操作,例如記錄或列印除錯資訊。通常,你可以在不破壞程式邏輯的情況下從呼叫鏈中刪除also
呼叫。
fun main() { val numbers = mutableListOf("one", "two", "three") numbers .also { println("The list elements before adding new one: $it") } .add("four") }
函式選擇
以下是它們之間的差異表,以幫助你選擇合適的作用域函式
函式 | 物件引用 | 返回值 | 擴充套件函式 |
---|---|---|---|
let | it | lambda 結果 | 是 |
run | this | lambda 結果 | 是 |
run | - | lambda 結果 | 否:無 context 物件 |
with | this | lambda 結果 | 否:將 context 物件作為引數 |
apply | this | 呼叫者本身(context) | 是 |
also | it | 呼叫者本身(context) | 是 |
以下是根據預期目的選擇範圍功能的簡短指南:
- 在非 null 物件上執行 lambda:let
- 將表示式作為區域性範圍中的變數引入:let
- 物件配置:apply
- 物件配置並計算結果:run
- 執行需要表示式的語句:非擴充套件 run
- 附加效果:also
- 對函式進行分組呼叫:with
takeIf 和 takeUnless
除了作用域函式之外,標準庫還包含函式 takeIf 和 takeUnless。這些函式允許你在呼叫鏈中嵌入物件狀態的檢查。
這兩個函式的作用是物件過濾器,takeIf
返回滿足條件的物件或 null。takeUnless
則剛好相反,它返回不滿足條件的物件或 null。過濾條件位於函式的 {} 中。
import kotlin.random.* fun main() { val number = Random.nextInt(100) val evenOrNull = number.takeIf { it % 2 == 0 } val oddOrNull = number.takeUnless { it % 2 == 0 } println("偶數: $evenOrNull, 奇數: $oddOrNull") }
在 takeIf 和 takeUnless 之後連結其他函式時,不要忘記執行空檢查或安全呼叫(?.),因為它們的返回值是可空的。
fun main() { val str = "Hello" val caps = str.takeIf { it.isNotEmpty() }?.toUpperCase() //val caps = str.takeIf { it.isNotEmpty() }.toUpperCase() // 編譯出錯 println(caps) }
takeIf 和 takeUnless 與作用域函式一起使用特別有用。一個很好的例子是使用 let 來連結它們,以便在與給定條件匹配的物件上執行程式碼塊。
fun main() { fun displaySubstringPosition(input: String, sub: String) { input.indexOf(sub).takeIf { it >= 0 }?.let { println("The substring $sub is found in $input.") println("Its start position is $it.") } } displaySubstringPosition("010000011", "11") displaySubstringPosition("010000011", "12") }
總結
以上,就是所有作用域函式的功能及使用場景的介紹,你可能已經發現,這其中有幾個函式的功能相似甚至重疊,有人甚至覺得有這個時間去弄明白它們,我早就用其它常規方式實現功能了,但有人就覺得這些函式非常簡潔實用,用過就再也回不去了。我覺得這就是 Kotlin 的一種優點和缺點的體現,優點是它很靈活,靈活的不像 Native 語言,缺點是它太靈活了,太多的語法糖導致你容易忘記寫這些程式碼要實現的目的,所以,雖然作用域函式是使程式碼更簡潔的一種方法,但還是要避免過度使用它們。