中階:屬性
在初學者導覽中,您學習了如何使用屬性來宣告類別執行個體的特性,以及如何存取它們。本章節將深入探討 Kotlin 中屬性的運作方式,並探索您可以在程式碼中使用的其他方式。
支援欄位 (Backing fields)
在 Kotlin 中,屬性具有預設的 get() 和 set() 函式(稱為屬性存取子),用於處理值的檢索和修改。雖然這些預設函式在程式碼中不明顯可見,但編譯器會自動產生它們,以便在後台管理屬性存取。這些存取子使用 支援欄位 來儲存實際的屬性值。
如果符合以下任一條件,則會存在支援欄位:
- 您為屬性使用預設的
get()或set()函式。 - 您嘗試透過在程式碼中使用
field關鍵字來存取屬性值。
get()和set()函式也被稱為 getter 和 setter。
例如,這段程式碼具有 category 屬性,它沒有自訂的 get() 或 set() 函式,因此使用預設實作:
class Contact(val id: Int, var email: String) {
var category: String = ""
}在底層,這相當於以下虛擬碼:
class Contact(val id: Int, var email: String) {
var category: String = ""
get() = field
set(value) {
field = value
}
}在此範例中:
get()函式從欄位中檢索屬性值:""。set()函式接受value作為參數,並將其指派給欄位,其中value為""。
當您想在 get() 或 set() 函式中加入額外邏輯而又不造成無窮迴圈時,存取支援欄位非常有用。例如,您有一個帶有 name 屬性的 Person 類別:
class Person {
var name: String = ""
}您想要確保 name 屬性的首字母大寫,因此您建立了一個自訂的 set() 函式,該函式使用 .replaceFirstChar() 和 .uppercase() 擴充函式。然而,如果您在 set() 函式中直接引用屬性,將會建立一個無窮迴圈,並在執行期看到 StackOverflowError:
class Person {
var name: String = ""
set(value) {
// 這會導致執行期錯誤
name = value.replaceFirstChar { firstChar -> firstChar.uppercase() }
}
}
fun main() {
val person = Person()
person.name = "kodee"
println(person.name)
// Exception in thread "main" java.lang.StackOverflowError
}要解決此問題,您可以改為在 set() 函式中使用支援欄位,透過 field 關鍵字引用它:
class Person {
var name: String = ""
set(value) {
field = value.replaceFirstChar { firstChar -> firstChar.uppercase() }
}
}
fun main() {
val person = Person()
person.name = "kodee"
println(person.name)
// Kodee
}當您想要新增日誌記錄、在屬性值變更時發送通知,或使用比較屬性新舊值的額外邏輯時,支援欄位也很有用。
欲了解更多資訊,請參閱 支援欄位。
擴充屬性 (Extension properties)
就像擴充函式一樣,也存在擴充屬性。擴充屬性允許您在不修改原始碼的情況下,向現有類別新增屬性。然而,Kotlin 中的擴充屬性 不 具有支援欄位。這意味著您需要自行撰寫 get() 和 set() 函式。此外,由於缺乏支援欄位,這意味著它們無法持有任何狀態。
要宣告擴充屬性,請寫下您要擴充的類別名稱,後接一個 . 和您的屬性名稱。就像一般類別屬性一樣,您需要為屬性宣告型別。 例如:
val String.lastChar: Char當您希望屬性包含計算值而又不使用繼承時,擴充屬性最為有用。您可以將擴充屬性想像成只有一個參數的函式:接收者。
例如,假設您有一個名為 Person 的資料類別,具有兩個屬性:firstName 和 lastName。
data class Person(val firstName: String, val lastName: String)您希望能夠在不修改 Person 資料類別或繼承它的情況下,存取人員的全名。您可以透過建立具有自訂 get() 函式的擴充屬性來實現此目的:
data class Person(val firstName: String, val lastName: String)
// 擴充屬性以取得全名
val Person.fullName: String
get() = "$firstName $lastName"
fun main() {
val person = Person(firstName = "John", lastName = "Doe")
// 使用擴充屬性
println(person.fullName)
// John Doe
}擴充屬性無法覆寫類別中既有的屬性。
就像擴充函式一樣,Kotlin 標準函式庫廣泛使用了擴充屬性。例如,請參閱 CharSequence 的 lastIndex 屬性。
委派屬性 (Delegated properties)
您已經在類別與介面章節中了解過委派。您也可以對屬性使用委派,將其屬性存取子委派給另一個物件。當您對儲存屬性有更複雜的需求,而簡單的支援欄位無法處理時(例如將值儲存在資料庫資料表、瀏覽器工作階段或 Map 中),這非常有用。使用委派屬性還能減少樣板程式碼,因為獲取和設定屬性的邏輯僅包含在您委派給的物件中。
語法類似於對類別使用委派,但運作層級不同。宣告您的屬性,後接 by 關鍵字和您要委派給的物件。例如:
val displayName: String by Delegate在這裡,委派屬性 displayName 將其屬性存取子指向 Delegate 物件。
您委派給的每個物件 必須 具有一個 getValue() 運算子函式,Kotlin 使用該函式來檢索委派屬性的值。如果屬性是可變的,它還必須具有一個 setValue() 運算子函式,供 Kotlin 設定其值。
預設情況下,getValue() 和 setValue() 函式具有以下結構:
operator fun getValue(thisRef: Any?, property: KProperty<*>): String {}
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {}在這些函式中:
operator關鍵字將這些函式標記為運算子函式,使其能夠多載get()和set()函式。thisRef參數指的是 包含 委派屬性的物件。預設情況下,型別設定為Any?,但您可能需要宣告更具體的型別。property參數指的是被存取或更改其值的屬性。您可以使用此參數來存取屬性名稱或型別等資訊。預設情況下,型別設定為KProperty<*>,但您也可以使用Any?。您不需要擔心在程式碼中更改此設定。
getValue() 函式預設的傳回型別為 String,但您可以根據需要進行調整。
setValue() 函式有一個額外的參數 value,用於持有被指派給屬性的新值。
那麼,這在實踐中看起來如何?假設您想要一個計算屬性(例如使用者的顯示名稱),該屬性僅計算一次,因為該操作開銷很大且您的應用程式對效能敏感。您可以使用委派屬性來快取顯示名稱,這樣它只會被計算一次,但可以隨時存取而不會影響效能。
首先,您需要建立要委派給的物件。在這種情況下,該物件將是 CachedStringDelegate 類別的執行個體:
class CachedStringDelegate {
var cachedValue: String? = null
}cachedValue 屬性包含快取的值。在 CachedStringDelegate 類別中,將您希望委派屬性的 get() 函式具備的行為,新增到 getValue() 運算子函式主體中:
class CachedStringDelegate {
var cachedValue: String? = null
operator fun getValue(thisRef: Any?, property: Any?): String {
if (cachedValue == null) {
cachedValue = "Default Value"
println("Computed and cached: $cachedValue")
} else {
println("Accessed from cache: $cachedValue")
}
return cachedValue ?: "Unknown"
}
}getValue() 函式檢查 cachedValue 屬性是否為 null。如果是,則函式會指派 "Default value" 並列印一個字串以便記錄。如果 cachedValue 屬性已經計算過,則屬性不為 null。在這種情況下,會列印另一個字串以便記錄。最後,函式使用 Elvis 運算子傳回快取值,如果值為 null 則傳回 "Unknown"。
現在,您可以將您想要快取的屬性 (val displayName) 委派給 CachedStringDelegate 類別的執行個體:
class CachedStringDelegate {
var cachedValue: String? = null
operator fun getValue(thisRef: User, property: Any?): String {
if (cachedValue == null) {
cachedValue = "${thisRef.firstName} ${thisRef.lastName}"
println("Computed and cached: $cachedValue")
} else {
println("Accessed from cache: $cachedValue")
}
return cachedValue ?: "Unknown"
}
}
class User(val firstName: String, val lastName: String) {
val displayName: String by CachedStringDelegate()
}
fun main() {
val user = User("John", "Doe")
// 第一次存取會計算並快取值
println(user.displayName)
// Computed and cached: John Doe
// John Doe
// 後續存取則從快取中檢索值
println(user.displayName)
// Accessed from cache: John Doe
// John Doe
}此範例:
- 建立了一個
User類別,其標頭中有兩個屬性firstName和lastName,類別主體中有一個屬性displayName。 - 將
displayName屬性委派給CachedStringDelegate類別的執行個體。 - 建立了
User類別的執行個體user。 - 列印存取
user執行個體上的displayName屬性的結果。
請注意,在 getValue() 函式中,thisRef 參數的型別從 Any? 型別縮小為物件型別:User。這是為了讓編譯器可以存取 User 類別的 firstName 和 lastName 屬性。
標準委派
Kotlin 標準函式庫提供了一些有用的委派,因此您不一定總是需要從頭開始建立自己的委派。如果您使用這些委派之一,則不需要定義 getValue() 和 setValue() 函式,因為標準函式庫會自動提供它們。
延遲載入屬性 (Lazy properties)
要僅在屬性首次被存取時才初始化它,請使用延遲載入屬性。標準函式庫提供了 Lazy 介面用於委派。
要建立 Lazy 介面的執行個體,請使用 lazy() 函式,並提供一個 Lambda 運算式,以便在首次呼叫 get() 函式時執行。後續任何對 get() 函式的呼叫都會傳回第一次呼叫時提供的相同結果。延遲載入屬性使用尾隨 Lambda 語法來傳遞 Lambda 運算式。
例如:
class Database {
fun connect() {
println("Connecting to the database...")
}
fun query(sql: String): List<String> {
return listOf("Data1", "Data2", "Data3")
}
}
val databaseConnection: Database by lazy {
val db = Database()
db.connect()
db
}
fun fetchData() {
val data = databaseConnection.query("SELECT * FROM data")
println("Data: $data")
}
fun main() {
// 第一次存取 databaseConnection
fetchData()
// Connecting to the database...
// Data: [Data1, Data2, Data3]
// 後續存取則使用現有連線
fetchData()
// Data: [Data1, Data2, Data3]
}在此範例中:
- 有一個具有
connect()和query()成員函式的Database類別。 connect()函式將字串列印到主控台,而query()函式接受 SQL 查詢並傳回一個清單。- 有一個
databaseConnection屬性,它是一個延遲載入屬性。 - 提供給
lazy()函式的 Lambda 運算式:- 建立了
Database類別的執行個體。 - 在此執行個體 (
db) 上呼叫connect()成員函式。 - 傳回該執行個體。
- 建立了
- 有一個
fetchData()函式:- 透過在
databaseConnection屬性上呼叫query()函式來建立 SQL 查詢。 - 將 SQL 查詢指派給
data變數。 - 將
data變數列印到主控台。
- 透過在
main()函式呼叫fetchData()函式。第一次呼叫時,延遲載入屬性會被初始化。第二次呼叫時,傳回與第一次呼叫相同的結果。
延遲載入屬性不僅在初始化資源密集時很有用,在程式碼中可能不會使用到該屬性時也很有用。此外,延遲載入屬性預設是執行緒安全的,這在您於並行環境中工作時特別有益。
欲了解更多資訊,請參閱 延遲載入屬性。
可觀察屬性 (Observable properties)
要監控屬性值是否發生變化,請使用可觀察屬性。當您想要偵測屬性值的變更並利用此資訊來觸發反應時,可觀察屬性非常有用。標準函式庫提供了 Delegates 物件用於委派。
要建立可觀察屬性,您必須先匯入 kotlin.properties.Delegates.observable。然後,使用 observable() 函式並提供一個 Lambda 運算式,以便在屬性變更時執行。就像延遲載入屬性一樣,可觀察屬性使用尾隨 Lambda 語法來傳遞 Lambda 運算式。
例如:
import kotlin.properties.Delegates.observable
class Thermostat {
var temperature: Double by observable(20.0) { _, old, new ->
if (new > 25) {
println("Warning: Temperature is too high! ($old°C -> $new°C)")
} else {
println("Temperature updated: $old°C -> $new°C")
}
}
}
fun main() {
val thermostat = Thermostat()
thermostat.temperature = 22.5
// Temperature updated: 20.0°C -> 22.5°C
thermostat.temperature = 27.0
// Warning: Temperature is too high! (22.5°C -> 27.0°C)
}在此範例中:
- 有一個
Thermostat類別,包含一個可觀察屬性:temperature。 observable()函式接受20.0作為參數,並用它來初始化屬性。- 提供給
observable()函式的 Lambda 運算式:- 具有三個參數:
_:指的是屬性本身。old:屬性的舊值。new:屬性的新值。
- 檢查
new參數是否大於25,並根據結果將字串列印到主控台。
- 具有三個參數:
main()函式:- 建立了一個名為
thermostat的Thermostat類別執行個體。 - 將該執行個體的
temperature屬性值更新為22.5,這會觸發包含溫度更新的列印陳述式。 - 將該執行個體的
temperature屬性值更新為27.0,這會觸發包含警告的列印陳述式。
- 建立了一個名為
可觀察屬性不僅對日誌記錄和偵錯有用。您還可以用於像是更新 UI 或執行額外檢查(例如驗證資料有效性)等使用案例。
欲了解更多資訊,請參閱 可觀察屬性。
練習
練習 1
您在一家書店管理庫存系統。庫存儲存在一個清單中,每個項目代表特定書籍的數量。例如,listOf(3, 0, 7, 12) 表示書店有 3 本第一本書、0 本第二本、7 本第三本以及 12 本第四本。
寫一個名為 findOutOfStockBooks() 的函式,傳回所有缺貨書籍的索引清單。
提示 1
indices 擴充屬性。 提示 2
buildList() 函式來建立和管理清單,而不是手動建立並傳回一個可變清單。buildList() 函式使用了一個帶接收者的 Lambda,這是在之前的章節中學過的。 |--|--|
fun findOutOfStockBooks(inventory: List<Int>): List<Int> {
// 在此處撰寫您的程式碼
}
fun main() {
val inventory = listOf(3, 0, 7, 0, 5)
println(findOutOfStockBooks(inventory))
// [1, 3]
}fun findOutOfStockBooks(inventory: List<Int>): List<Int> {
val outOfStockIndices = mutableListOf<Int>()
for (index in inventory.indices) {
if (inventory[index] == 0) {
outOfStockIndices.add(index)
}
}
return outOfStockIndices
}
fun main() {
val inventory = listOf(3, 0, 7, 0, 5)
println(findOutOfStockBooks(inventory))
// [1, 3]
}範例解答 2
fun findOutOfStockBooks(inventory: List<Int>): List<Int> = buildList {
for (index in inventory.indices) {
if (inventory[index] == 0) {
add(index)
}
}
}
fun main() {
val inventory = listOf(3, 0, 7, 0, 5)
println(findOutOfStockBooks(inventory))
// [1, 3]
}練習 2
您有一個旅遊應用程式,需要同時以公里和英里顯示距離。為 Double 型別建立一個名為 asMiles 的擴充屬性,將以公里為單位的距離轉換為英里:
將公里轉換為英里的公式為
miles = kilometers * 0.621371。
提示
get() 函式。 val // 在此處撰寫您的程式碼
fun main() {
val distanceKm = 5.0
println("$distanceKm km is ${distanceKm.asMiles} miles")
// 5.0 km is 3.106855 miles
val marathonDistance = 42.195
println("$marathonDistance km is ${marathonDistance.asMiles} miles")
// 42.195 km is 26.218757 miles
}範例解答
val Double.asMiles: Double
get() = this * 0.621371
fun main() {
val distanceKm = 5.0
println("$distanceKm km is ${distanceKm.asMiles} miles")
// 5.0 km is 3.106855 miles
val marathonDistance = 42.195
println("$marathonDistance km is ${marathonDistance.asMiles} miles")
// 42.195 km is 26.218757 miles
}練習 3
您有一個系統健康檢查程式,可以判斷雲端系統的狀態。然而,它執行健康檢查的兩個函式效能消耗很大。使用延遲載入屬性來初始化檢查,以便僅在需要時才執行這些高昂的函式:
fun checkAppServer(): Boolean {
println("Performing application server health check...")
return true
}
fun checkDatabase(): Boolean {
println("Performing database health check...")
return false
}
fun main() {
// 在此處撰寫您的程式碼
when {
isAppServerHealthy -> println("Application server is online and healthy")
isDatabaseHealthy -> println("Database is healthy")
else -> println("System is offline")
}
// Performing application server health check...
// Application server is online and healthy
}範例解答
fun checkAppServer(): Boolean {
println("Performing application server health check...")
return true
}
fun checkDatabase(): Boolean {
println("Performing database health check...")
return false
}
fun main() {
val isAppServerHealthy by lazy { checkAppServer() }
val isDatabaseHealthy by lazy { checkDatabase() }
when {
isAppServerHealthy -> println("Application server is online and healthy")
isDatabaseHealthy -> println("Database is healthy")
else -> println("System is offline")
}
// Performing application server health check...
// Application server is online and healthy
}練習 4
您正在構建一個簡單的預算追蹤應用程式。該應用程式需要觀察使用者剩餘預算的變化,並在預算低於特定門檻時通知他們。您有一個 Budget 類別,它使用包含初始預算金額的 totalBudget 屬性進行初始化。在類別中,建立一個名為 remainingBudget 的可觀察屬性,列印:
- 當值低於初始預算的 20% 時發出警告。
- 當預算比前一個值增加時發出鼓勵訊息。
import kotlin.properties.Delegates.observable
class Budget(val totalBudget: Int) {
var remainingBudget: Int // 在此處撰寫您的程式碼
}
fun main() {
val myBudget = Budget(totalBudget = 1000)
myBudget.remainingBudget = 800
myBudget.remainingBudget = 150
// Warning: Your remaining budget (150) is below 20% of your total budget.
myBudget.remainingBudget = 50
// Warning: Your remaining budget (50) is below 20% of your total budget.
myBudget.remainingBudget = 300
// Good news: Your remaining budget increased to 300.
}範例解答
import kotlin.properties.Delegates.observable
class Budget(val totalBudget: Int) {
var remainingBudget: Int by observable(totalBudget) { _, oldValue, newValue ->
if (newValue < totalBudget * 0.2) {
println("Warning: Your remaining budget ($newValue) is below 20% of your total budget.")
} else if (newValue > oldValue) {
println("Good news: Your remaining budget increased to $newValue.")
}
}
}
fun main() {
val myBudget = Budget(totalBudget = 1000)
myBudget.remainingBudget = 800
myBudget.remainingBudget = 150
// Warning: Your remaining budget (150) is below 20% of your total budget.
myBudget.remainingBudget = 50
// Warning: Your remaining budget (50) is below 20% of your total budget.
myBudget.remainingBudget = 300
// Good news: Your remaining budget increased to 300.
}