Logo
Published on

Kotlin Scope Functions:用兩個問題選對函式

在 Kotlin 的各種範例程式碼中,letrunapplyalsowith 幾乎無所不在。功能看起來非常相近,某些情況下甚至可以互換,讓人難以判斷該選哪一個

什麼是 Scope Function?

Scope Function 可以理解為「作用域函式」,讓我們可以在某個物件的範圍內執行一段程式碼,不需要重複寫變數名稱

假設有一個 Person 物件

val person = Person()
person.name = "Cash"
person.age = 30
person.city = "Taichung"
println(person)

用 Scope Function 改寫後

val person = Person().apply {
    name = "Cash"
    age = 30
    city = "Taichung"
}.also {
    println(it)
}

兩個關鍵差異

理解這五個函式,核心差異只有兩個維度

差異一:用 this 還是 it 引用物件?

  • this(lambda receiver):可以直接呼叫物件的屬性和方法,不用加前綴,就像「進入」了那個物件裡面
  • it(lambda argument):需要透過 it(或自訂名稱)來引用物件,就像拿到了一個參數
// this → 直接存取,不用前綴
person.run {
    println(name)   // 等同於 this.name
    println(age)    // 等同於 this.age
}

// it → 透過 it 來引用
person.let {
    println(it.name)
    println(it.age)
}

差異二:回傳物件本身,還是 lambda 的執行結果?

  • 回傳物件本身:方便做鏈式呼叫,適合「設定完繼續用」的場景
  • 回傳 lambda 結果:適合轉換或計算的場景
// 回傳物件本身 → 後面可以繼續接著用
val person = Person().apply {
    name = "Cash"
}
// person 是 Person 物件

// 回傳 lambda 結果 → 回傳最後一行的值
val nameLength = person.let {
    it.name.length
}
// nameLength 是 Int

五個函式對照

引用方式回傳值適合場景
letitlambda 結果null 安全處理、轉換
runthislambda 結果物件運算、取得結果
applythis物件本身物件初始化、配置
alsoit物件本身附加的副作用
withthislambda 結果對物件做多次操作

矩陣來看更直觀

回傳 lambda 結果回傳 物件本身
thisrunapply
itletalso

with 稍後單獨說明

要死記嗎?其實不用

很多初學者看到這五個函式會覺得「名字都很抽象,像在背單字」,但它們其實有語意,命名也對應了 Kotlin 標準函式庫的設計目的

可以先把它們當成「英文動作 + 回傳值」來記

函式名稱語感設計重點
let讓這個值去做一件事把物件當參數傳進 lambda,回傳結果
run執行一段程式碼在物件作用域中執行,回傳結果
apply套用設定在物件作用域中設定,回傳物件本身
also另外順便做一件事以參數形式做副作用,回傳物件本身
with帶著某個物件一起做事不是 extension function,回傳結果

如果你只想先記最實用的版本,可以用這句口訣

  • 改設定並保留物件apply
  • 順便做副作用並保留物件also
  • 做轉換拿結果let / run

換句話說,不是死記,而是先理解每個動詞在語意上「想做什麼」,再用 this/it 與回傳值去收斂選擇

逐一介紹

let — 拿到物件,做點事,回傳結果

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

it 引用物件,回傳 lambda 結果

最常見的用法是搭配 ?. 做 null 安全處理

val name: String? = findUserName()

// ❌ 傳統寫法
if (name != null) {
    println(name.length)
}

// ✅ 用 let
name?.let {
    println(it.length)
}

?.let 的意思是「如果不是 null,就拿它來做某件事」,是 Kotlin 中最常見的慣用寫法之一

也可以用於轉換

val numbers = listOf(1, 2, 3)
val firstDoubled = numbers.first().let { it * 2 }
// firstDoubled = 2

lambda 內的邏輯有多行時,建議用自訂名稱取代 it,可讀性會明顯提升

name?.let { userName ->
    println("Hello, $userName")
}

run — 進入物件裡面,做完事,回傳結果

public inline fun <T, R> T.run(block: T.() -> R): R

this 引用物件,回傳 lambda 結果

存取多個屬性並回傳計算結果時很好用

val greeting = person.run {
    // 直接用 name,不用寫 it.name 或 person.name
    "Hello, I'm $name, $age years old, from $city"
}

同樣支援 null 安全

val result = person?.run {
    "$name ($age)"
} ?: "Unknown"

run 也有一個不需要物件的版本,可以建立一個區域作用域

val hexColor = run {
    val red = 0xFF
    val green = 0xA5
    val blue = 0x00
    "#${red.toString(16)}${green.toString(16)}${blue.toString(16)}"
}

apply — 進入物件裡面做設定,回傳物件本身

public inline fun <T> T.apply(block: T.() -> Unit): T

this 引用物件,回傳物件本身

apply 是「套用設定」,設定完把物件還給你。就像一個簡易的 Builder Pattern

val person = Person().apply {
    name = "Cash"
    age = 30
    city = "Taichung"
}

Android 開發中很常見

val intent = Intent(this, DetailActivity::class.java).apply {
    putExtra("id", userId)
    putExtra("name", userName)
    addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}

測試的 Arrange 階段也適合

val testUser = User().apply {
    id = 1
    name = "Test User"
    email = "[email protected]"
    isActive = true
}

also — 順便做一件事,物件原封不動還給你

public inline fun <T> T.also(block: (T) -> Unit): T

it 引用物件,回傳物件本身

also 的意思是「還有,順便做一件事」,不影響鏈式呼叫的流程。最常用在加入 log 或驗證

val numbers = mutableListOf(1, 2, 3)
    .also { println("初始清單: $it") }
    .apply { add(4) }
    .also { println("加入元素後: $it") }

輸出

初始清單: [1, 2, 3]
加入元素後: [1, 2, 3, 4]
fun createUser(name: String): User {
    return User(name).also {
        requireNotNull(it.name) { "名稱不能為空" }
        println("建立使用者: ${it.name}")
    }
}

with — 把物件帶進來,做多次操作

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

this 引用物件,回傳 lambda 結果。with 不是 extension function,而是普通函式,需要把物件當參數傳入

val message = with(person) {
    "Hello, I'm $name, $age years old"
}

run 幾乎相同

// 這兩種寫法效果完全相同
with(person) { "$name, $age" }
person.run { "$name, $age" }

with 越來越少被推薦使用,原因有幾個

null 安全呼叫行不通。with 接受的是普通參數,不支援 ?. 語法

val person: Person? = findPerson()

// ❌ with 無法這樣寫
with(person) {  // person 為可空型別,無法安全存取屬性
    println(name)
}

// ✅ run 可以輕鬆處理
person?.run {
    println(name)
}

鏈式呼叫也不合用。with 是普通函式,物件用參數傳入,無法接在呼叫鏈中使用

// ❌ 不順暢
with(getUser()) {
    // ...
}

// ✅ 鏈式風格更自然
getUser().run {
    // ...
}

run 能做到 with 的所有事情,還多了 null 安全和鏈式呼叫的優勢。團隊統一使用 run 取代 with,需要記憶的 API 少一個,程式碼風格也更一致

實務選擇

兩個問題就能定位到對的函式

  • 需要回傳物件本身,還是計算結果? → 物件本身用 apply/also,結果用 let/run
  • 比較想用 it.xxx 還是直接用 xxxitlet/also,直接用就選 run/apply
需要配置物件,回傳物件本身?           → apply
需要做 log/驗證等副作用,回傳物件本身? → also
需要 null 安全處理或轉換?             → let
需要用 this 存取屬性並回傳結果?        → run

注意事項

不要過度嵌套

// ❌ 嵌套太深,可讀性變差
person?.let { p ->
    p.address?.let { addr ->
        addr.city?.let { city ->
            println(city)
        }
    }
}

// ✅ 展開比較好讀
val city = person?.address?.city
if (city != null) {
    println(city)
}

Scope Function 是為了讓程式碼更好讀,嵌套之後反而更難讀的話就不要用

避免在 Scope Function 中修改外部變數

// ❌ 修改外部變數,容易造成 side effect
var result = ""
person.apply {
    result = name
}

// ✅ 讓回傳值做事
val result = person.run {
    name
}

lambda 超過 3-5 行就考慮抽成方法

// ❌ lambda 內做太多事
val user = User().apply {
    name = "Cash"
    age = 30
    email = "[email protected]"
    role = findDefaultRole()
    permissions = calculatePermissions(role)
    lastLogin = LocalDateTime.now()
    validateAndSave()
}

// ✅ 複雜邏輯抽成方法
val user = createDefaultUser("Cash", 30, "[email protected]")

小結

applyalsoletrun 涵蓋了絕大多數使用場景。with 能做的事,run 都可以取代,而且更有彈性,忽略掉它也沒什麼損失


圖片來源:AI 產生