写在开头: 本人打算开始写一个 Kotlin 系列的教程, 一是使自己记忆和理解的更加深刻, 二是可以分享给同样想学习 Kotlin 的同学. 系列文章的知识点会以Kotlin 实战这本书中顺序编写, 在将书中知识点展示出来同时, 我也会添加对应的 Java 代码用于对比学习和更好的理解.
Kotlin 教程 (一) 基础
Kotlin 教程 (二) 函数
Kotlin 教程 (三) 类, 对象和接口
Kotlin 教程 (四) 可空性
Kotlin 教程 (五) 类型
Kotlin 教程(六)Lambda 编程
如你所知, Java 在标准库中有一些与特定的类相关联的语言特性. 例如, 实现了 java.lang.Iterable 接口的独享可以在 for 循环中使用, 实现了 java.lang.AutoCloseable 接口的对象可以在 try-with-resources 语句中使用.
Kotlin 也有许多特性的原理非常类似, 通过调用自己代码中定义的函数, 来实现特定语言结构. 但是, 在 Kotlin 中, 这些功能与特定的函数命名相关, 而不是与特定的类型绑定.
这一章我们会用到一个 UI 框架中常见的类 Point 来演示, 来看下定义:
data class Ponit(val x: Int, val y: Int)
重载算术运算符
在 Java 中, 全套的算数运算只能用于基本数据类型,+ 运算符可以与 String 值一起使用. 但是, 这些运算符在其他一些情况下用起来也很方便. 例如, 在使用哪个 BigInteger 类处理数字的时候, 使用 + 号就比掉用 add 方法显得更为优雅: 给集合添加元素的时候, 你可能也在想要是能用 += 运算符就好了, 在 Kotlin 中, 你就可以这样做.
重载二元算术运算
我们来支持第一个运算, 把两个点加到一起:
- data class Point(val x: Int, val y: Int) {
- operator fun plus(other: Point): Point {
- return Point(x + other.x, y + other.y)
- }
- }
- >>> val p1 = Point(10, 20)
- >>> val p2 = Point(30, 40)
- >>> println(p1 + p2)
- Point(x=40, y=60)
用于重载运算符的所有函数都需要使用 operator 关键字标记, 表示你把这个函数作为相应的约定的实现, 并且不是碰巧地定义了同名函数.
使用 operator 修饰符声明 plus 函数之后, 你就可以直接使用 + 号来求和了. 实际上调用的时 plus 函数 a + b -> a.plus(b). 除了声明成为一个成员函数外, 也可以定义为一个扩展函数, 同样有效:
- operator fun Point.plus(other: Point): Point {
- return Point(x + other.x, y + other.y)
- }
Kotlin 中可重载的二元算术运算符
表达式 | 函数名 |
---|---|
a * b | times |
a / b | div |
a % b | mod |
a + b | plus |
a - b | minus |
自定义类型的运算符, 基本上和与标准数字类型的运算符有着相同的优先级. 例如 a + b * c, 乘法将之中在加号之前执行. 运算符 *, / 和 % 具有相同的优先级, 高于 + 和 - 运算符的优先级.
运算符函数和 Java
从 Java 调用 Kotlin 运算符非常容易: 因为每个重载的运算符都被定义为一个函数, 可以像普通函数那样调用它们. 当从 Kotlin 调用 Java 的时候, 只要 Java 代码中存在函数名和参数数量都匹配的函数, 就可以在 Kotlin 中使用. 如果 Java 已经存在类似的方法, 但是方法名不同, 可以通过扩展函数来修正这个函数名, 用来代替现有的 Java 方法.
当你定义一个运算符的时候, 不要求两个运算数是相同的类型, 例如, 让我们定义一个运算符, 它允许你用一个数字来缩放一个点, 可以用它在不同坐标系之间做转换:
- operator fun Point.times(scale: Double): Point {
- return Point((x * scale).toInt(), (y * scale).toInt())
- }
- >>> val p1 = Point(10, 20)
- >>> println(p1 * 1.5)
- Point(x=15, y=30)
注意, Kotlin 运算符不会自动支持交换性(交换运算符的左右两边). 如果希望用户能够使用 p * 1.5 以外, 还能使用 1.5 * p, 你需要为它定义一个单独的运算符
- operator fun Double.times(p: Point) : Point
- .
运算符函数的返回类型可以不同于任一运算数类型, 例如, 可以定义一个运算符, 通过多次重复单个字符来创建字符串:
- operator fun Char.times(count: Int): String {
- return toString().repeat(count)
- }
- >>> println('a' * 3)
- aaa
这个运算符接收一个 Char 作为左值, Int 作为右值, 然后返回一个 String 类型.
和普通的函数一样, 可以重载 operator 函数: 可以定义多个同名的, 但参数类型不同的方法.
没有用于位运算的特殊运算符
Kotlin 没有为标准数字类型定义任何位运算符. 因此, 也不允许你为自定义类型定义它们, 相反, 它使用支持中缀调用语法的常规函数, 可以为自定义类型定义相似的函数.
以下是 Kotlin 提供的, 用于执行位运算的完整函数列表:
shl -- 带符号左移, 等同 Java 中<<shr -- 带符号右移, 等同 Java 中>>
ushr -- 无符号右移, 等同 Java 中<<<and -- 按位与, 等同 Java 中 &
or -- 按位或, 等同 Java 中 |
xor -- 按位异或, 等同 Java 中 ^
inv -- 按位取反, 等同 Java 中~
重载复合赋值运算符
通常情况下, 当你在定义想 plus 这样的运算符函数时, Kotlin 不止支持 + 号运算, 也支持 +=. 像 +=,-= 等这些运算符被称为复合赋值运算符. 看这个例子:
- >>> var p1 = Point(10, 20)
- >>> p1 += Point(30, 40)
- >>> println(p1)
- Point(x=40, y=60)
这等用于
point = point + Point(30, 40)
的写法. 当然, 这个只对于可变变量有效.
在一些情况下, 定义 += 运算符可以修改使用它的变量所引用的对象, 但不会重新分配引用, 将一个元素添加到可变集合, 就是一个很好的例子:
- >>> val numbers = ArrayList<Int>()
- >>> numbers += 42
- >>> println(numbers)
- 42
如果你定义了一个返回值为 Unit, 名为 plusAssign 的函数, Kotlin 将会在用到 += 运算符的地方调用它, 其他二元算术运算符也有命名相似的对应函数: 如 minusAssign,timeAssign 等.
Kotlin 标准库为可变集合定义了 plusAssign 函数, 我们才能像例子中那样使用 +=:
- operator fun <T> MutableCollection<T> plusAssgin(element: T) {
- this.add(element)
- }
当你在代码中用到 += 的时候, 理论上 plus 和 plusAssign 都可能被调用. 如果在这种情况下, 两个函数都有定义且使用, 编译器会报错! 一种办法是直接使用普通函数的调用方式调用, 另一种办法是用 val 代替 var, 这样 plusAssign 运算就不在适用. 但是更建议只定义一种运算函数, plus 通常定义返回一个新对象, 而 plusAssign 返回的是之前的对象, 根据这个原则选择合适的运算函数定义即可.
Kotlin 标准库支持集合的这两种方法.+ 和 - 运算符总是返回一个新的集合.+= 和 -= 运算符用于可变集合时, 始终就地修改它们: 而它们用于只读集合时, 或返回一个修改过的副本(这意味着只有当引用只读集合的变量被声明为 var 的时候, 才能使用 += 和 -=). 作为它们的运算数, 可以使用单个元素, 也可以使用元素类型一致的其他集合:
- >>> val list = arrayListOf(1, 2)
- >>> list += 3
- >>> val newList = list + listOf(4, 5) // 返回一个新集合
- >>> println(list)
- [1, 2, 3]
- >>> println(newList)
- [1, 2, 3, 4, 5]
重载一元运算符
重载一元运算符的过程与你在前面看到的方式相同: 用预先定义的一个名称来声明(成员函数或扩展函数), 并用修饰符 operator 标记. 我们来看一个例子:
- operator fun Point.unaryMinus(): Point = Point(-x, -y)
- >>> val p = Point(10, 20)
- >>>println(-p)
- Point(x=-10, y=-20)
用于重载一元运算符的函数, 没有任何参数.
可重载的一元算法的运算符
表达式 | 函数名 |
---|---|
+a | unaryPlus |
-a | unaryMinus |
!a | not |
++a, a++ | inc |
--a, a-- | dec |
当你定义 inc 和 dec 函数来重载自增和自减的运算符时, 编译器自动支持与普通数字类型的前缀和后缀自增运算符相同的语义. 考虑一下用来重载 BigDecimal 类的 ++ 运算符的这个例子:
- operator fun BigDecimal.inc() = this + BigDecimal.ONE
- >>> var bd = BigDecimal.ZERO
- >>> println(bd++)
- 0
- >>> println(++bd)
- 2
后缀运算 ++ 首先返回 bd 变量的当前值, 然后执行 ++, 这个和前缀运算相反. 打印多的值与使用 Int 类型的变量所看到的相同, 不需要额外做什么特别的事情就能支持.
重载比较运算符
与算术运算符一样, 在 Kotlin 中, 可以对任何对象使用比较运算符(==,!=,>,<等), 而不仅仅限于基本数据类型. 不用像 Java 那样调用 equals 或 compareTo 函数, 可以直接使用比较运算符.
等号运算符: equals
我们在教程三中就说到, Kotlin 中使用 == 运算符, 它将被转换成 equals 方法的调用.
使用!= 运算符也会被转换成 equals 函数的调用, 明显的差异在于, 它们的结果是相反的, 和所有其他运算符不同的是:== 和!= 可以用于可空运算数, 因为这些运算符事实上会检查运算数是否为 null. 比较 a == b 会检查 a 是否为非空, 如果不是, 就调用 a.equals(b) 否则, 只有两个参数都是空引用, 结果才是 true.
- a == b ->
- a?.equals(b) ?: (b == null)
对于 Point 类, 因为已经被标记为数据类, equals 的实现将会由编译器自动生成. 但如果手动实现, name 代码可以是这样的:
- data class Point(val x: Int, val y: Int) {
- override fun equals(other: Any?): Boolean {
- if (other === this) return true
- if (other !is Point) return false
- return other.x == x && other.y == y
- }
- }
- >>> println(Point(10, 20) == Point(10, 20))
- true
- >>> println(Point(10, 20) != Point(5, 5))
- true
- >>> println(null == Point(10, 20))
- false
这里使用了恒等运算符 (===) 来检查参数与调用 equals 的对象是否相同. 恒等运算符与 Java 中的 == 运算符完全相同: 检查两个参数是否是同一个对象的引用(如果是基本数据类型, 检查他们是否是相同的值). 在实现了 equals 方法之后, 通常会使用这个运算符来优化调用代码. 注意,=== 运算符不能被重载.
equals 函数之所以被标记 override, 那是因为与其他约定不同的是, 这个方法的实现是在 Any 类中定义的, 这也解释了为什么你不需要将它标记为 operator,Any 中的基本方法就已经标记了, 而且函数的 operator 修饰符也适用于所有实现或重写它的方法. 还要注意, equals 不能实现为扩展方法, 因为继承自 Any 类的实现始终优先于扩展函数.
这个例子显示!= 运算符的使用也会转换为 equals 方法的调用, 编译器会自定对返回值取反, 因此, 你不需要再做别的事情, 就可以正常运行.
排序运算符: compareTo
在 java 中, 类可以实现 Comparable 接口, 以便在比较值的算法中使用, 例如在查找最大值或排序的时候. 接口中定义的 compareTo 方法用于确定一个对象是否大于另一个对象. 但在 Java 中, 这个方法的调用没有简明语法, 只有基本数据类型能使用 <和> 来比较, 所有其他类型都需要明确写为
- element1.conpareTo(element2)
- .
Kotlin 支持相同的 Comparable 接口. 但是可口中定义的 compareTo 方法可以按约定调用, 比较运算符 (>,<,<= 和>=) 的使用将被转换为 compareTo,compareTo 的返回类型必须为 Int.p1 <p2 表达式等价于
p1.compareTo(p2) < 0
. 其他比较运算符的运算方式也是完全一样的. 我们假设以 Point 在 y 轴上的位置来确定大小, y 越大则 Point 越大:
- data class Point(val x: Int, val y: Int) : Comparable<Point> {
- override fun compareTo(other: Point): Int {
- return y.compareTo(other.y)
- }
- }
- >>> val p1 = Point(10, 20)
- >>> val p2 = Point(30, 40)
- >>> val p3 = Point(30, 10)
- >>> println(p1 <p2)
- true
- >>> println(p1 <p3)
- false
我们通过实现 Comparable 接口的方式重载 compareTo 方法, 这样做还可以被 Java 函数 (比如用于对集合进行排序的功能) 进行比较, 与 equals 一样, operator 修饰符已经被用在了基类的接口中, 因此在重写该接口时无需在重复.
所有 Java 中实现了 Comparable 接口的类, 都可以在 Kotlin 中使用简洁的运算符语法, 不用再增加扩展函数:
- >>> println("abc"> "bac")
- true
集合与区间的终定
通过下标来访问元素: get 和 set
我们已经知道在 Kotlin 中可以用类似 Java 中数组的方式来访问 map 中的元素:
val value = map[key]
也可以用同样的运算符来改变一个可变 map 的元素:
mutableMap[key] = newValue
来看看它是如何工作的. 在 Kotlin 中, 下标运算符是一个约定. 使用下标运算符读取元素会被转换为 get 运算符方法的调用, 并且写入元素将调用 set.Map 和 MutableMap 的接口已经定义了这些方法. 让我们看看如何给自定义的类添加类似的方法.
可以使用方括号来引用点的坐标, p[0]访问 x 坐标, p[1]访问 y 坐标:
- operator fun Point.get(index: Int): Int {
- return when (index) {
- 0 -> x
- 1 -> y
- else -> throw IndexOutOfBoundsException("Invalid coordinate $index")
- }
- }
- >>> val p = Point(10, 20)
- >>> println(p[1])
- 20
你只需要定义一个名为 get 的函数, 并标记 operator 之后, 像 p[1]这样的表达式, 其中 p 具有类型 Point, 将被转换为 get 方法的调用.
x[a, b] -> x.get(a ,b)
get 的参数可以是任何类型, 而不只是 Int. 例如, 当你对 map 使用下标运算符时, 参数类型是键的类型, 它可以是任意类型. 还可以定义具有多个参数的 get 方法. 例如, 如果要实现一个类来表示二维数组或矩阵, 你可以定义一个方法, 例如
operator fun get(rowIndex: Int, colIndex: Int)
, 然后用 matrix[row, col] 来调用. 如果需要使用不同的键类型访问集合, 也可以使用不同的参数类型定义多个重载的 get 方法.
我们也可以用类似的方法定义一个函数, 这样就可以使用方括号语法更改给定下标处的值. Point 类是不可变的, 所以定义 Point 的这种方法是没有意义的. 作为例子, 我们来定义另一个类来表示一个可变的点:
- data class MutablePoint(var x: Int, var y: Int)
- operator fun MutablePoint.set(index: Int, value: Int) {
- when (index) {
- 0 -> x = value
- 1 -> y = value
- else -> throw IndexOutOfBoundsException("Invalid coordinate $index")
- }
- }
- >>> val p = MutablePoint(10, 20)
- >>> p[1] = 42
- >>> println(p)
- MutablePoint(x=10, y=42)
这个例子也很简单, 只需定义一个名为 set 的函数, 就可以在赋值语句中使用下标运算符. set 的最后一个参数用来接收赋值语句中等号右边的值, 其他参数作为方括号内的下标.
x[a ,b] = c -> x.set(a, b, c)
in 的约定
集合支持的另一个运算符是 in 运算符, 用于检查某个对象是否属于集合. 相应的函数叫做 contains. 我们来实现以下, 使用 in 运算符来检查点是否属于一个矩形:
- operator fun Rectangle.contains(p: Point): Boolean {
- return p.x in upperLeft.x until lowerRight.x
- && p.y in upperLeft.y until lowerRight.y
- }
- >>> val rect = Rectangle(Point(10, 20), Point(50, 50))
- >>> println(Point(20, 30) in rect)
- true
- >>> println(Point(5, 5) in rect)
- false
in 右边的对象将会调用 contains 函数, in 左边的对象将会作为函数入参.
a in c -> c.contains(a)
在 Rectangle.contains 的实现中, 我们用到了的标准库的 until 函数, 来构建一个开区间, 然后使用运算符 in 来检查某个点是否属于这个区间.
开区间是不包含最后一个点的区间. 例如, 如果用 10..20 构建一个普通的区间(闭区间), 该区间则包括 10 到 20 的所有数字, 包括 20. 开区间 10 until 20 包括从 10 到 19 的数字, 但不包括 20. 矩形类通常定义成这样, 它的底部和右侧坐标不是矩形的一部分, 因此在这里使用开区间是合适的.
rangeTo 的约定
要创建一个区间, 请使用.. 语法... 运算符是调用 rangeTo 函数的一个简洁方法.
start..end -> start.rangeTo(end)
rangeTo 函数返回一个区间. 你可以为自己的类定义这个运算符. 但是, 如果该类实现了 Comparable 接口, 那么就不需要了: 你可以通过 Kotlin 标准库创建一个任意可比较元素的区间, 这个库定义了可以用于任何可比较元素的 rangeTo 函数:
operator fun <T: Comparable<T>> T.rangeTo(that: T): ClosedRange<T>
这个函数返回一个区间, 可以用来检测其他一些元素是否属于它.
rangeTo 运算符的优先级低于算术运算符, 但是最好把参数括起来以免混淆:
- >>> val n = 9
- >>> println(0..(n + 1))
- 0..10
还要注意, 表达式 0..n.forEach{}不会被编译, 必须把区间表达式括起来才能调用它的方法:
- >>> (0..n).forEach { print(it) }
- 0123456789
在 for 循环中使用 iterator 的约定
在 Kotlin 中, for 循环中也可以使用 in 运算符, 和做区间检查一样. 但是在这种情况下它的含义是不同的: 它被用来执行迭代. 这意味着一个诸如 for(x in list) {}将被转换成 list.iterator() 的调用, 然后就像在 Java 中一样, 在它上面重复调用 hasNext 和 next 方法.
在 Kotlin 中, 这也是一种约定, 这意味着 iterator 方法可以被定义为扩展函数. 这就解释了为什么可以遍历一个常规的 Java 字符串: 标准库已经为 CharSequence 定义了一个扩展函数 iterator, 而它是 String 的父类:
- public operator fun CharSequence.iterator(): CharIterator = object : CharIterator() {
- private var index = 0
- public override fun nextChar(): Char = get(index++)
- public override fun hasNext(): Boolean = index <length
- }
- >>> for (c in "abc") {}
解构声明和组件函数
解构声明允许你展开单个复合值, 并使用它来初始化多个单独的变量. 来看看它是怎样工作的:
- >>> val p = Point(10, 20)
- >>> val (x, y) = p
- >>> println(x)
- 10
- >>> println(y)
- 20
一个解构声明看起来像一个普通的变量声明, 但它在括号中有多个变量.
事实上, 解构声明再次用到了约定的原理. 要在结构声明中初始化每个变量, 将调用名为 componentN 的函数, 其中 N 是声明中变量的位置. 换句话说, 前面的例子可以被转换成:
- val (a, b) = p ->
- val a = p.component1(); val b = p.component2()
对于数据类, 编译器为每个在主构造方法中声明的属性生成一个 componentN 函数. 下面的例子显示了如何手动为非数据类声明这些功能:
- class Point(val x: Int,val y: Int) {
- operator fun component1() = x
- operator fun component2() = y
- }
解构声明主要使用场景之一, 是从一个函数返回多个值, 这个非常有用. 如果要这样做, 可以定义一个数据类来保存返回所需的值, 并将它作为函数的返回类型. 在调用函数后, 可以用解构声明的方式, 来轻松地展开它, 使用其中的值. 举个例子, 让我们编写一个简单的函数, 来将一个文件名分割成名字和扩展名:
- data class NameComponents(val name: String, val extension: String)
- fun splitFilename(fullName: String): NameComponents {
- val (name, extension) = fullName.split('.', limit = 2)
- return NameComponents(name, extension)
- }
- >>> val (name, ext) = splitFilename("example.kt")
- >>> println(name)
- example
- >>> println(ext)
- kt
当然, 不可能定义无线数量的 componentN 函数, 这样这个语法就可以与任意数量的集合一起工作了, 但这也没用. 标准库只允许使用此语法来访问一个对象的前五个元素.
让一个函数能返回多个值有更简单的方法, 是使用标准库中的 Pair 和 Triple 类, 在语义表达上这种方式会差一点, 因为这些类也不知道它会返回的对象中包含什么, 但因为不需要定义自己的类所以可以少写代码.
解构声明和循环
解构声明不仅可以作用函数中的顶层语句, 还可以用在其他可以声明变量的地方, 例如 in 循环. 一个很好的例子, 是枚举 map 中的条目, 下面是一个小例子:
- fun printEntries(mapL Map<String, String>) {
- for ((key, value) in map){
- println("$key -> $value")
- }
- }
- >>> val map = mapOf("Oracle" to "Java", "JetBrans" to "Kotlin")
- >>> printEntries(map)
- Oracle -> Java
- JetBrans -> Kotlin
这个简单的例子用到了两个 Kotlin 的约定: 一个是迭代一个对象, 另一个是用于解构声明. Kotlin 标准库给 map 增加了一个扩展的 iterator 函数, 用来返回 Entry 条目的迭代器. 因此, 与 Java 不同的是, 可以直接迭代 map. 它还包含 Map.Entry 上的扩展函数 component1 和 component2, 分别返回它的键和值. 实际上, 前面的循环被转换成了这样的代码:
- for (entry in map.entries){
- val key = entry.component1()
- val value = entry.component2()
- //...
- }
重用属性访问的逻辑: 委托属性
委托属性的基本操作
委托属性的基本语法时这样的:
- class Foo {
- var p: Type by Delegate()
- }
属性 p 将它的访问器逻辑委托给了另一个对象: 这里是 Delegate 类的一个新实例. 通过关键字 by 对其后的表达式求值来获取这个对象, 关键字 by 可以用于任何符合属性委托约定规则的对象.
编译器创建一个隐藏的辅助属性, 并使用委托对象的实例进行初始化, 初始属性 p 会委托给该实例. 为了简单起见, 我们把它称为 delegate:
- class Foo {
- private val delegate = Delegate() // 编译器自动生成
- var p: Type //p 的访问交给 delegate
- set(value: Type) = delegate.setValue(..., value)
- get() = delegate.getValue(...)
- }
按照约定, Delegate 类必须具有 getValue 和 setValue 方法(后者仅适用于可变属性). 它们可以是成员函数, 也可以是扩展函数. 为了让例子看起来更简洁, 这里我们省略掉参数. 准确的函数签名将在之后接招. Delegate 类的简单实现差不多应该是这样的:
- class Delegate{
- operator fun getValue(...) {...} // 实现 getter 逻辑
- operator fun setValue(..., value: Type) {...} // 实现 setter 逻辑
- }
- class Foo{
- var p: Type by Delegate() // 属性关联委托对象
- }
- >>> val foo = Foo()
- >>> val oldValue = foo.p
- >>> foo.p = newValue
可以把 foo.p 作为普通的属性使用, 事实上, 它将调用 Delegate 类型的辅助属性的方法. 为了研究这种机制如何在实践中使用, 我们首先看一个委托属性展示威力的例子: 库对惰性初始化的支持.
使用委托属性: 惰性初始化和 by lazy()
惰性初始化是一种常见的模式, 知道在第一次访问该属性的时候, 才根据需要创建对象的一部分. 当初始化过程消耗大量资源并且在使用对象时并不总是需要数据时, 这个非常有用.
举个例子, 一个 Person 类, 可以用来访问一个人写的邮件列表. 邮件存储在数据库中, 访问比较耗时. 你希望只有在首次访问时才加载邮件, 并只执行一次. 假设你已经有函数 loadEmails, 用来从数据库中检索电子邮件:
- class Email {/*...*/}
- fun loadEmail(person: Person): List<Email> {
- println("Load emails for ${person.name}")
- return listOf(/*...*/)
- }
下面展示如何使用额外的_emails 属性来实现惰性加载, 在没有加载之前为 null, 然后加载为邮件列表:
- class Person(val name: String) {
- private var _emails: List<Email>? = null
- val emails: List<Email>
- get() {
- if(_emails == null) {
- _emails = loadEmails(this)
- }
- return _emials!!
- }
- }
- >>> val p = Person("Alice")
- >>> p.emails // 第一次加载会访问邮件
- Load emails for Alice
- >>> p.emails
这里使用了所谓的属性支持. 你有一个属性_emails 来存储这个值, 而另一个 emails, 用来提供对属性的读取访问. 你需要使用两个属性, 因为属性具有不同类型:_emails 可空, 而 emails 为非空. 这种技术经常会使用到, 值得熟练掌握.
但这个代码有点啰嗦: 要是有几个惰性属性那得有多长. 而且, 它并不总是正常运行: 这个实现不是线程安全的. Kotlin 提供了更好的解决方案.
使用委托属性会让代码变得简单得多, 可以封装用于存储值得支持属性和确保该值只被初始化一次的逻辑. 在这里可以使用标准库函数 lazy 放回的委托.
- class Person(val name: String) {
- val emails by lazy { loadEmails(this) }
- }
lazy 函数返回一个对象, 该对象具有一个名为 getValue 且签名正确的方法, 因此可以把它与 by 关键字一起使用来创建一个委托属性. lazy 的参数是一个 lambda, 可以调用它来初始化这个值. 默认情况下, lazy 函数是线程安全的, 如果需要, 可以设置其他选项来告诉它要使用哪个锁, 或者完全避开同步, 如果该类永远不会再多线程中使用.
实现委托属性
要了解委托属性的实现方式, 让我们来看另一个例子: 当一个对象的属性更改时通知监听器. 这在许多不同的情况下都很有用: 例如, 当对象显示在 UI 时, 你希望在对象变化时 UI 能自动刷新. Java 具有用于此类通知的标准机制: PropertyChangeSupport 和 PropertyChangeEvent 类. 让我们看看在 Kotlin 中不使用委托属性的情况下, 该如何使用它们, 然后我们再将代码重构为用委托属性的方式.
PropertyChangeSupport 类维护了一个监听器列表, 并向它们发送 PropertyChangeEvent 事件. 要使用它, 你通常需要把这个类的一个实例存储为 bean 类的一个字段, 并将属性更改的处理委托给它.
为了避免要在每个类中添加这个字段, 你需要创建一个小的工具类, 用来存储 PropertyChangeSupport 的实例并监听属性更改. 之后, 你的类会继承这个工具类, 以访问 changeSupport.
- open class PropertyChangeAware {
- protected val changeSupport = PropertyChangeSupport(this)
- fun addPropertyChangeListener(listener: PropertyChangeListener) {
- changeSupport.addPropertyChangeListener(listener)
- }
- fun removePropertyChangeListener(listener: PropertyChangeListener) {
- changeSupport.removePropertyChangeListener(listener)
- }
- }
现在我们来写一个 Person 类, 定义一个只读属性 (作为一个人的名字, 一般不会随时更改) 和两个可写属性: 年龄和工资. 当这个人的年龄或工资发生变化时, 这个类将通知它的监听器.
- class Person(val name: String, age: Int, salary: Int) : PropertyChangeAware() {
- var age: Int = age
- set(newValue) {
- val oldValue = field //field 标识符访问支持字段
- field = newValue
- changeSupport.firePropertyChange("age", oldValue, newValue) // 属性变化时通知监听器
- }
- var salary: Int = salary
- set(newValue) {
- val oldValue = field
- field = newValue
- changeSupport.firePropertyChange("salary", oldValue, newValue)
- }
- }
- fun main(args: Array<String>) {
- val p = Person("Dmitry", 34, 2000)
- // 添加监听器
- p.addPropertyChangeListener(PropertyChangeListener { event ->
- println("Property ${event.propertyName} changed from ${event.oldValue} to ${event.newValue}")
- })
- p.age = 35
- p.salary = 2100
- }
- // 输出
- Property age changed from 34 to 35
- Property salary changed from 2000 to 2100
setter 中有很多重复的代码, 我们来尝试提取一个类, 用来存储这个属性的值并发起通知.
- class ObservableProperty(
- val propName: String, var propValue: Int, val changeSupport: PropertyChangeSupport
- ) {
- fun getValue(): Int = propValue
- fun setValue(newValue: Int) {
- val oldValue = propValue
- propValue = newValue
- changeSupport.firePropertyChange(propName, oldValue, newValue)
- }
- }
- class Person(val name: String, age: Int, salary: Int) : PropertyChangeAware() {
- val _age = ObservableProperty("age", age, changeSupport)
- var age: Int
- get() = _age.getValue()
- set(value) = _age.setValue(value)
- val _salary = ObservableProperty("salary", age, changeSupport)
- var salary: Int
- get() = _salary.getValue()
- set(value) = _salary.setValue(value)
- }
现在, 你应该已经差不多理解了在 Kotlin 中, 委托属性是如何工作的. 你创建了一个保存属性值的类, 并在修改属性时自动触发更改通知. 你删除了重复的逻辑代码, 但是需要相当多的样板代码来为每个属性创建 ObservableProperty 实例, 并把 getter 和 setter 委托给它. Kotlin 的委托属性功能可以让你摆脱这些样板代码. 但是在此之前, 你需要更改 ObservableProperty 方法的签名, 来匹配 Kotlin 约定所需的方法.
- class ObservableProperty(
- var propValue: Int, val changeSupport: PropertyChangeSupport
- ) {
- operator fun getValue(p: Person, prop: KProperty<*>): Int = propValue
- operator fun setValue(p: Person, prop: KProperty<*>, newValue: Int) {
- val oldValue = propValue
- propValue = newValue
- changeSupport.firePropertyChange(prop.name, oldValue, newValue)
- }
- }
与之前的版本相比, 这次代码做了一些更改:
现在, 按照也回到那个的需要, getValue 和 setValue 函数被标记了 operator
这些函数加了两个参数: 一个用于接收属性的实例, 用来设置或读取属性, 另一个用于表示属性本身. 这个属性类型为 KProperty(之后章节会详细介绍它), 现在你只需要知道可以通过 KProperty.name 的方式来访问该属性的名称.
把 name 属性从主构造方法中删除了, 因为现在已经可以通过 KProperty 访问属性名称.
终于, 你可以见识 Kotlin 委托属性的神奇了, 来看看代码变短了多少?
- class Person(val name: String, age: Int, salary: Int) : PropertyChangeAware() {
- var age: Int by ObservableProperty(age, changeSupport)
- var salary: Int by ObservableProperty(salary, changeSupport)
- }
通过关键字 by,Kotlin 编译器会自动执行之前版本的代码中手动完成的操作. 如果把这份代码与之前版本的 Person 类进行比较: 使用委托属性时生成的代码非常类似, 右边的对象被称为委托. Kotlin 会自动将委托存储在隐藏的属性中, 并在访问或修改属性时调用委托的 getValue 和 setValue.
你不用手动去实现可观察的属性逻辑, 可以使用 Kotlin 标准库, 它已经包含了类似 ObserverProperty 的类. 标准库和这里使用的 PropertyChangeSupport 类没有耦合, 因此, 你需要传递一个 lambda, 来告诉它如何通知属性值得更改, 可以这样做:
- class Person(val name: String, age: Int, salary: Int) : PropertyChangeAware() {
- private val observer = {
- prop: KProperty<*>, oldValue: Int, newValue: Int ->
- changeSupport.firePropertyChange(prop.name, oldValue, newValue)
- }
- var age: Int by Delegates.observable(age, observer)
- var salary: Int by Delegates.observable(salary, observer)
- }
by 右边的表达式不一定是新创建的实例, 也可以是函数调用, 另一个属性或任何其他表达式, 只要这个表达式的值, 是能够被编译器用正确的参数类型来调用 getValue 和 setValue 的对象. 与其他约定一样, getValue 和 setValue 可以是对象自己生命的方法或扩展函数.
注意, 为了让示例保持简单, 我们只展示了如何使用类型为 Int 的委托属性, 委托属性机制其实是通用的, 适用于任何其他类型.
委托属性的变换规则
让我们来总结一下委托属性是怎样工作的, 假设你已经有了一个具有委托属性的类:
- class C {
- var p: Type by MyDelegate()
- }
- val c = C()
MyDelegate 实例会保存到一个隐藏的属性中, 它被称为 < delegate>. 编译器也将用一个 KProperty 类型的对象来代表这个属性, 它被称为 < property>.
编译器生成的代码如下:
- class C {
- private val <delegate> = MyDelegate()
- var prop: Type
- get() = <delegate>.getValue(this, <property>)
- set(value: Type) = <delegate>.setValue(this, <property>, value)
- }
因此, 在每个属性访问器中, 编译器都会生成对应的 getValue 和 setValue 方法: val x = c.prop ->
- val x = <delegate>.getValue(c, <property>)
- c,prop = x ->
- <delegate>.setValue(c, <property>, x)
这个机制非常简单, 但它可以实现许多有趣的场景. 你可以自定义存储该属性值得位置(map, 数据库表或者用户会话的 Cookie 中), 以及在访问该属性时做点什么(比如添加验证, 更改通知等).
在 map 中保存属性值
委托属性发挥作用的另一种常见用法, 是用在有动态定义的属性集的对象中. 这样的对象有时候被称为自定 (expando) 对象. 例如, 考虑一个联系人管理系统, 可以用来存储有关联系人的任意信息. 系统中的每个人都有一些属性需要特殊处理(例如名字), 以及每个人特有的数量任意的额外属性(例如, 最小的孩子的生日). 实现这种系统的一种方法是将人的所有属性存储在 map 中, 不确定提供属性, 来访问需要特殊处理的信息. 来看个例子:
- class Person {
- private val _attributes = hashMapOf<String, String>()
- fun setAttribute(attrName: String, value: String) {
- _attributes[attrName] = value
- }
- val name: String
- get() = _attributes["name"]!!
- }
- fun main(args: Array<String>) {
- val p = Person()
- val data = mapOf("name" to "Dimtry", "company" to "JetBrans")
- for ((attrName, value) in data) {
- p.setAttribute(attrName, value)
- }
- println(p.name)
- }
- // 输出
- Dimtry
这里使用了一个通用的 API 来吧数据加载到对象中(在实际项目中, 可以是 JSON 反序列化或类似的方法), 然后使用特定的 API 来访问一个属性的值. 把它改为委托属性非常简单, 可以直接将 map 放在 by 关键字后面.
- class Person {
- private val _attributes = hashMapOf<String, String>()
- fun setAttribute(attrName: String, value: String) {
- _attributes[attrName] = value
- }
- val name: String by _attributes
- }
因为标准库已经在标准 Map 和 MutableMap 接口上定义了 getValue 和 setValue 扩展函数, 所以这里可以直接这样用. 属性的名称将自动用作 map 中的键, 属性值作为 map 中的值. 改动前 p.name 隐藏了
_attributes.getValue(p, prop)
的调用, 改动后变为
- _attributes[prop.name]
- .
来源: https://juejin.im/post/5ac1cd1d6fb9a028d9374fe1