3.3.7 属性

原文链接: https://kotlinlang.org/docs/kotlin-tour-intermediate-properties.html

3.3.7 属性

在入门导览中,你学会了如何用属性声明类实例的特征以及如何访问它们。本章将深入探讨属性在 Kotlin 中的工作方式,并介绍在代码中使用属性的其他方式。

幕后字段

在 Kotlin 中,属性有默认的 get() 和 set() 函数,称为属性访问器,它们负责读取和修改属性的值。虽然这些默认函数在代码中并不可见,但编译器会自动生成它们,在幕后管理属性的访问。这些访问器使用幕后字段来存储属性的实际值。

只要满足下列任一条件,就会存在幕后字段:

  • 你为属性使用默认的 get() 或 set() 函数。
  • 你在代码中通过 field 关键字访问属性的值。

提示: get() 和 set() 函数也称为 getter 和 setter。

例如,下面这段代码中的 category 属性没有自定义的 get() 或 set() 函数,因此使用默认实现:

1
2
3
class Contact(val id: Int, var email: String) {
    var category: String = ""
}

在底层,它等价于下面这段伪代码:

1
2
3
4
5
6
7
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 类:

1
2
3
class Person {
    var name: String = ""
}

你希望确保 name 属性的首字母大写,于是创建了一个自定义的 set() 函数,使用 .replaceFirstChar() 和 .uppercase() 扩展函数。但是,如果你在 set() 函数中直接引用该属性,就会造成无限循环,并在运行时看到 StackOverflowError:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class Person {
    var name: String = ""
        set(value) {
            // This causes a runtime error
            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 关键字引用它:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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
}

当你想添加日志、在属性值变化时发送通知,或使用比较新旧属性值的额外逻辑时,幕后字段同样很有用。

更多信息请参阅幕后字段。

扩展属性

与扩展函数一样,Kotlin 也有扩展属性。扩展属性允许你在不修改源码的情况下为现有类添加新属性。不过,Kotlin 中的扩展属性没有幕后字段。这意味着你必须自己编写 get() 和 set() 函数。此外,由于没有幕后字段,它们也无法保存任何状态。

要声明扩展属性,请先写出你想扩展的类名,接着写一个 .,然后是属性名。与普通类属性一样,你需要为属性声明类型。例如:

1
val String.lastChar: Char

当你希望某个属性包含一个计算值、又不想使用继承时,扩展属性最有用。你可以把扩展属性理解为一个只有一个参数(接收者)的函数。

例如,假设你有一个名为 Person 的数据类,它有两个属性:firstName 和 lastName。

1
data class Person(val firstName: String, val lastName: String)

你希望在不修改 Person 数据类、也不继承它的情况下访问这个人的全名。你可以创建一个带自定义 get() 函数的扩展属性来实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
data class Person(val firstName: String, val lastName: String)

// Extension property to get the full name
val Person.fullName: String
    get() = "$firstName $lastName"

fun main() {
    val person = Person(firstName = "John", lastName = "Doe")

    // Use the extension property
    println(person.fullName)
    // John Doe
}

注意: 扩展属性不能重写类中已有的属性。

与扩展函数一样,Kotlin 标准库广泛使用了扩展属性。例如,可以看看 CharSequence 的 lastIndex 属性。

委托属性

你已经在类与接口一章中学过委托。你也可以对属性使用委托,把它们的属性访问器委托给另一个对象。当存储属性有更复杂的需求、简单的幕后字段无法满足时(例如把值存到数据库表、浏览器会话或映射中),这会很有用。使用委托属性还能减少样板代码,因为获取和设置属性的逻辑只包含在你所委托的对象中。

语法与对类使用委托类似,但作用的层次不同。先声明你的属性,接着写 by 关键字和你想要委托的对象。例如:

1
val displayName: String by Delegate

这里的委托属性 displayName 会把它的属性访问器交给 Delegate 对象处理。

你委托的每个对象都必须有一个 getValue() 运算符函数,Kotlin 用它来读取委托属性的值。如果该属性是可变的,它还必须有一个 setValue() 运算符函数,供 Kotlin 设置它的值。

默认情况下,getValue() 和 setValue() 函数的结构如下:

1
2
3
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 类的一个实例:

1
2
3
class CachedStringDelegate {
    var cachedValue: String? = null
}

cachedValue 属性保存着缓存的值。在 CachedStringDelegate 类内部,把你希望委托属性的 get() 函数具有的行为添加到 getValue() 运算符函数体中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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 类的一个实例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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")

    // First access computes and caches the value
    println(user.displayName)
    // Computed and cached: John Doe
    // John Doe

    // Subsequent accesses retrieve the value from cache
    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 接口用于委托。

要创建 Lazy 接口的实例,请使用 lazy() 函数,并为它提供一个 lambda 表达式,在第一次调用 get() 函数时执行。之后对 get() 函数的任何调用都返回第一次调用时提供的结果。惰性属性使用尾随 lambda 语法传入 lambda 表达式。

例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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() {
    // First time accessing databaseConnection
    fetchData()
    // Connecting to the database...
    // Data: [Data1, Data2, Data3]

    // Subsequent access uses the existing connection
    fetchData()
    // Data: [Data1, Data2, Data3]
}

在这个示例中:

  • 有一个 Database 类,其中有 connect() 和 query() 两个成员函数。
  • connect() 函数向控制台打印一个字符串,query() 函数接受一条 SQL 查询并返回一个列表。
  • 有一个 databaseConnection 属性,它是一个惰性属性。
  • 传给 lazy() 函数的 lambda 表达式:
  • 创建一个 Database 类的实例。
  • 在该实例(db)上调用 connect() 成员函数。
  • 返回该实例。
  • 有一个 fetchData() 函数,它:
  • 通过在 databaseConnection 属性上调用 query() 函数创建一条 SQL 查询。
  • 把这条 SQL 查询赋给 data 变量。
  • 把 data 变量打印到控制台。
  • main() 函数调用 fetchData() 函数。第一次调用时惰性属性被初始化;第二次调用返回与第一次相同的结果。

惰性属性不仅在初始化开销很大时有用,在某个属性可能在代码中根本用不到时同样有用。此外,惰性属性默认是线程安全的,如果你处在并发环境中,这一点尤其有利。

更多信息请参阅惰性属性。

可观察属性

要监视某个属性的值是否发生变化,请使用可观察属性。当你想要检测属性值的变化,并利用这一信息触发反应时,可观察属性很有用。标准库提供了 Delegates 对象用于委托。

要创建可观察属性,你必须先导入 kotlin.properties.Delegates.observable。然后使用 observable() 函数,并提供一个 lambda 表达式,在属性发生变化时执行。与惰性属性一样,可观察属性使用尾随 lambda 语法传入 lambda 表达式。

例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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,触发一条警告打印语句。

可观察属性不仅用于日志和调试。你也可以把它们用于更新界面,或执行额外检查(例如校验数据的有效性)等场景。

更多信息请参阅可观察属性。

练习

习题 1

你在管理一家书店的库存系统。库存存储在一个列表中,每个项目表示某种书的库存数量。例如,listOf(3, 0, 7, 12) 表示店里第一种书有 3 本、第二种 0 本、第三种 7 本、第四种 12 本。

编写一个名为 findOutOfStockBooks() 的函数,返回所有已售罄书籍的索引列表。

提示 1

使用标准库中的 indices 扩展属性。

提示 2

你可以用 buildList() 函数来创建和管理列表,而不必手动创建并返回一个可变列表。buildList() 函数使用带接收者的 lambda,你在前面几章已经学过。

1
2
3
4
5
6
7
8
9
fun findOutOfStockBooks(inventory: List<Int>): List<Int> {
    // Write your code here
}

fun main() {
    val inventory = listOf(3, 0, 7, 0, 5)
    println(findOutOfStockBooks(inventory))
    // [1, 3]
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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]
}

示例解答 1

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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

习题 2

你有一个旅行应用,需要同时以公里和英里显示距离。请为 Double 类型创建一个名为 asMiles 的扩展属性,把以公里为单位的距离换算成英里:

注意: 公里换算成英里的公式是 miles = kilometers * 0.621371。

提示

记住扩展属性需要自定义的 get() 函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
val // Write your code here

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
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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

你有一个可以判断云系统状态的系统健康检查器。不过,它用来执行健康检查的两个函数开销很大。请使用惰性属性初始化这些检查,使这些开销大的函数只在需要时才运行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
fun checkAppServer(): Boolean {
    println("Performing application server health check...")
    return true
}

fun checkDatabase(): Boolean {
    println("Performing database health check...")
    return false
}

fun main() {
    // Write your code here

    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
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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% 时打印一条警告。
  • 当预算较之前的值增加时打印一条鼓励性消息。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import kotlin.properties.Delegates.observable

class Budget(val totalBudget: Int) {
    var remainingBudget: Int // Write your code here
}

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.
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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.
}

示例解答

另请参阅

上一步

下一步