Logo
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 }
}

reifiedT 在執行階段保有型別資訊,this is Tit 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}"
    }
}

函式主體裡,thisString(extension receiver),this@HostHost 實例(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 使用場景

buildStringapplywith 都是這樣設計的,讓函式只在特定物件的作用域內有效,跑到外面就用不了

靜態解析 (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"

sc 指向同一種物件,但宣告型別不同,呼叫的擴充函式也不同。編譯器看的是宣告型別,不是執行時的實際型別。這和 override 完全不同,override 是多型的,執行階段才決定版本

Member 優先於 Extension

類別裡的成員函式和擴充函式簽名相同時,成員函式永遠優先

class Example {
    fun greet() = "Member"
}
fun Example.greet() = "Extension"
println(Example().greet()) // "Member"

擴充函式沒辦法覆蓋成員函式,只能加新的,不能動原本有的

總結

Extensions 在 Kotlin 不只是簡單的語法糖,而是整個標準函式庫的設計基礎

letrunapplyalsowith 都是 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 產生