- Published on
Kotlin Extensions:型別缺什麼方法,自己加就好

想替 String 加一個驗證 email 格式的方法,但 String 是標準函式庫的型別,我們沒辦法改它的原始碼。在 Java 裡,通常的做法是寫一個 StringUtils 工具類別,然後呼叫 StringUtils.isValidEmail(str)。能解決問題,但讀起來有點彆扭
Kotlin 的 Extensions 讓我們直接把方法加進現有的型別,寫成 str.isValidEmail(),不需要繼承,也不需要工具類別
Extension Function — 在型別外面加方法
基本語法
在函式名稱前面加上要擴充的型別(receiver type),就完成了宣告
fun String.addExclamation(): String {
return "$this!"
}
val greeting = "Hello".addExclamation()
println(greeting) // Hello!
函式主體裡的 this 代表呼叫這個函式的物件本身。雖然定義在類別外面,但寫起來和在類別內部沒什麼差別
想像你租了一間公寓,格局是房東決定的,牆壁不能動。但你可以買一張書桌放進去。Extension Function 就是這張書桌
實際應用場景
fun String.isValidEmail(): Boolean {
return this.contains("@") && this.contains(".")
}
fun Int.toCurrencyString(): String {
return "NT$ ${String.format("%,d", this)}"
}
String 多了驗證 email 的能力,Int 可以直接轉成台幣格式的字串
這裡的 email 驗證只是示意用的,實務上要驗證 email 還是要更嚴謹
Extension Property — 幫型別加屬性
不只函式,屬性也可以擴充
基本用法
val String.wordCount: Int
get() = this.split(" ").size
宣告之後就可以用 "hello world".wordCount 取得單字數量
限制:沒有 backing field
Extension Property 沒有 backing field,無法在擴充屬性裡儲存實際的狀態
// ❌ 這會編譯錯誤
val String.tag: String = "default"
// ✅ 必須透過 getter
val String.tag: String
get() = "<$this>"
擴充屬性只是語法糖,底層是靜態函式,沒有地方存值,所以只能讓讀取語法更好看,不是真的替物件加狀態
var 屬性說明
如果真的要存狀態,得借用外部空間
private val labels = mutableMapOf<Int, String>()
var Int.label: String
get() = labels[this] ?: "unknown"
set(value) { labels[this] = value }
語法上看起來像是 Int 本身有了 label 屬性,但狀態其實存在外部的 labels Map 裡。多執行緒環境下要另外處理同步問題
Nullable Receiver — 對可空型別擴充
receiver type 後面加上 ?,就可以直接在 null 上呼叫這個函式
fun Int?.orZero(): Int = this ?: 0
fun String?.toDisplayName(): String = this ?: "(未命名)"
不用在外面包一層 null 判斷
val count: Int? = null
println(count.orZero()) // 0
val name: String? = null
println(name.toDisplayName()) // (未命名)
標準函式庫裡的 String?.orEmpty() 就是這樣實作的,receiver 是 String?,函式裡直接 this ?: ""
泛型擴充函式
擴充函式支援泛型,語法和一般泛型函式一樣,只是多了 receiver
基本泛型擴充
fun <T> MutableList<T>.swap(index1: Int, index2: Int) {
val tmp = this[index1]
this[index1] = this[index2]
this[index2] = tmp
}
Int list、String list、自訂型別 list,都能直接呼叫 swap()
型別約束
T : Comparable<T> 限制 T 必須是可比較的型別,函式內部才能使用 <=
fun <T : Comparable<T>> List<T>.isSorted(): Boolean {
return this.zipWithNext().all { (a, b) -> a <= b }
}
泛型擴充屬性
屬性也可以帶泛型參數
val <T> List<T>.secondOrNull: T?
get() = if (this.size >= 2) this[1] else null
特化特定型別
receiver 也可以寫成具體型別,這樣函式只對特定的清單有效
fun List<Int>.sumOfSquares(): Int {
return this.sumOf { it * it }
}
這個函式只適用於 List<Int>,傳入 List<String> 的話編譯就過不了
reified 型別參數
泛型在執行階段因為 type erasure 會失去型別資訊,this is T 這種判斷無法編譯。這時候 inline + reified 就派上用場了
inline fun <reified T> Any.isType(): Boolean = this is T
inline fun <reified T> List<Any>.filterByType(): List<T> {
return this.filter { it is T }.map { it as T }
}
reified 讓 T 在執行階段保有型別資訊,this is T 和 it as T 才能正常運作
Infix 搭配 Nullable Extension
infix 函式讓我們省略點號和括號,呼叫方式更像在寫英文句子
基本範例
infix fun String?.or(default: String): String = this ?: default
呼叫時可以寫成
val name: String? = null
val result = name or "Guest" // 等同於 name.or("Guest")
泛型版本
infix fun <T> T?.orDefault(default: T & Any): T & Any = this ?: default
T & Any 表示 T 同時是非 null 的,確保回傳值不為 null
結合測試場景
infix 在測試斷言上特別好用
infix fun <T> T?.shouldBe(expected: T?) {
if (this != expected) {
throw AssertionError("Expected: $expected, but got: $this")
}
}
測試程式碼可以寫成
result shouldBe "Hello"
比 assertEquals("Hello", result) 更直觀,主詞和預期值的順序也不會搞混
要注意的地方
name or "Guest" 不像 name ?: "Guest" 那樣一眼看出在處理 null。不知道 or 定義的人讀到這行可能要停下來找一下
優先順序也要留意,infix 的優先順序比算術運算子低
val a = 1 + 2 shouldBe 3 // (1 + 2) shouldBe 3
val b = "a" or "b" + "c" // "a" or ("b" + "c"),這裡 or 是前面自訂的 infix 函式
這兩行的求值順序和直覺不太一樣,混用時要加括號確認
一般業務邏輯不建議用 infix,維護的人需要先找到定義才看得懂
Extension on Companion Object
替 companion object 寫擴充函式,從外部看起來就像呼叫靜態方法
class MyClass {
companion object {}
}
fun MyClass.Companion.create(): MyClass = MyClass()
val instance = MyClass.create()
MyClass.create() 讀起來很像 Java 的靜態工廠方法,但實際上是擴充函式
class 必須明確宣告 companion object 才能這樣用,即使是空的也需要。沒宣告的話 MyClass.Companion 這個型別不存在,編譯器根本找不到擴充的對象
Member Extension — 類別內部的擴充
擴充函式也可以宣告在類別內部,這時候它就是 member extension
class Formatter {
fun String.addBrackets(): String = "[$this]"
fun format(text: String): String {
return text.addBrackets()
}
}
addBrackets() 只在 Formatter 的作用域裡有效,外部無法直接呼叫 "hello".addBrackets()
兩個 Receiver
Member extension 同時有兩個 receiver:dispatch receiver(包含它的類別實例)和 extension receiver(被擴充的型別實例)
class Host(val name: String) {
fun String.greet(): String {
return "Hello, $this! From ${this@Host.name}"
}
}
函式主體裡,this 是 String(extension receiver),this@Host 是 Host 實例(dispatch receiver)。兩個 receiver 並存,需要用 labeled this 來區分
兩者角色不同:dispatch receiver 是持有這個 member extension 的類別(即 Host),決定它在哪個作用域有效;extension receiver 是被呼叫的對象(即 String),也就是一般擴充函式裡的 this。
this 預設指向 extension receiver,取 dispatch receiver 要用 labeled this
名稱衝突時 extension receiver 優先,要拿 dispatch receiver 的版本得明確加 label
class Printer(val name: String) {
fun String.printInfo() {
println(length) // String 的 length(extension receiver)
println(this@Printer.name) // Printer 的 name(dispatch receiver)
}
}
如果 String 也有 name,直接寫 name 取到的是 String 的,要取 Printer.name 就加 this@Printer
member extension 只在 dispatch receiver 的作用域內有效,外部要呼叫得先拿到那個類別的實例
DSL 使用場景
buildString、apply、with 都是這樣設計的,讓函式只在特定物件的作用域內有效,跑到外面就用不了
靜態解析 (Static Dispatch)
呼叫哪個擴充函式,在編譯階段就決定了,依據的是變數的宣告型別,不是執行時的實際型別。這是 Extensions 最容易踩到的坑
與 override 的差異
open class Shape
class Circle : Shape()
fun Shape.name() = "Shape"
fun Circle.name() = "Circle"
val c: Circle = Circle()
val s: Shape = Circle() // 同樣是 Circle 實例,宣告型別是 Shape
println(c.name()) // "Circle"
println(s.name()) // "Shape",不是 "Circle"
s 和 c 指向同一種物件,但宣告型別不同,呼叫的擴充函式也不同。編譯器看的是宣告型別,不是執行時的實際型別。這和 override 完全不同,override 是多型的,執行階段才決定版本
Member 優先於 Extension
類別裡的成員函式和擴充函式簽名相同時,成員函式永遠優先
class Example {
fun greet() = "Member"
}
fun Example.greet() = "Extension"
println(Example().greet()) // "Member"
擴充函式沒辦法覆蓋成員函式,只能加新的,不能動原本有的
總結
Extensions 在 Kotlin 不只是簡單的語法糖,而是整個標準函式庫的設計基礎
let、run、apply、also、with 都是 Extension Function,加上 lambda with receiver 的組合,讓物件在 lambda 內部直接當 this 用,這是 Kotlin DSL 能寫得這麼流暢的原因
和 C# 的 Extension Methods 比起來,Kotlin 多了 Extension Property、Nullable Receiver、Infix、Companion Object Extension、Member Extension 這些變體,覆蓋的場景更廣,也更貼近日常開發需求
幾個容易踩的地方:Extension Property 存不了狀態;Nullable Receiver 要在函式裡自己處理 null;靜態解析看的是宣告型別,和 override 不一樣;成員函式和擴充函式簽名相同時,成員函式優先
圖片來源:AI 產生