Kotlin 的运算符重载及其他约定

重载算术运算符

接下来的示例,我们都将用 UI 框架中常见的类 Point 来演示,来看下定义:

data class Point(val x: Int,val y : Int)

重载二元算术运算

第一个支持的运算是加号运算,这个运算需要把点的 x , y 值分别加到一起,可以这样实现:

data class Point(val x: Int, val y: Int) {
    // 重载加号运算
    operator fun plus(other: Point) = Point(x + other.x, y + other.y)
}

val p1 = Point(10, 50)
val p2 = Point(100, 3)
// Point(x=110, y=53)
println(p1 + p2)

用于重载运算符的所有函数都需要用 operator 关键字来标记,用来表示你打算把这个函数作为相应的约定的实现,并不是碰巧定义了一个同名函数。使用 operator 声明了 plus 函数之后,就可以直接使用 + 号来求和了,实际上调用的就是 plus 函数,比如 p1+p2 ——》 p1.plus(p2) 。

注意:除了声明成为一个成员函数外,也可以定义成一个扩展函数,同样有效。通常会用扩展函数的方式来给第三方库的类定义约定,非常方便。

上述的加号运算重载,用扩展函数定义如下:

// 要加上 operator
operator fun Point.plus(other: Point) = Point(x + other.x, y + other.y)

可重载的二元算术运算符如下:

表达式        函数名

a*b            times
a/b            div
a%b            mod
a+b            plus
a-b            minus

自定义类型的运算符与标准数字类型的运算符有着相同的优先级。例如 a + b * c ,乘法将在加号之前执行。运算符 * 、 / 和 % 具有相同的优先级,高于 + 和 - 运算符的优先级。

从 Java 调用 kotlin 运算符非常容易,因为每个重载的运算符都被定义为一个函数,可以像普通函数那样调用它们,如下:

Point p1 = new Point(100, 50);
Point p2 = new Point(10, 10);
System.out.println(OverridePraticeKt.plus(p1,p2));

当从 Kotlin 调用 Java 的时候,只要 Java 代码中存在函数名和参数数量都匹配的函数,就可以在 Kotlin 中使用。如果 Java 已经存在类似的方法,但是方法名不同,可以通过扩展函数来修正这个函数名,用来代替现有的 Java 方法。

比如,在 Java 中有如下类:

public class Circle {
    private int x;
    private int y;

    public Circle(int x, int y) {
        this.x = x;
        this.y = y;
    }

    // 函数名和参数数量都匹配
    public Circle plus(Circle other) {
        return new Circle(x + other.x, y + other.y);
    }

    // 函数名不匹配
    public Circle reduce(Circle other) {
        return new Circle(x - other.x, y - other.y);
    }

    @Override
    public String toString() {
        return "Circle{" +
                "x=" + x +
                ", y=" + y +
                '}';
    }
}

在 kotlin 中能正常地使用 + 号来进行求和,但是不能直接用 - 号来做减法操作,因为方法名不匹配。因此,我们需要用扩展函数的方式来修正,如下:

operator fun Circle.minus(other: Circle) = reduce(other)

接下来就能正常使用 - 号操作符了:

val c1 = Circle(15, 15)
val c2 = Circle(5, 5)

println(c1 + c2)    //Circle{x=20, y=20}
println(c1 - c2)    //Circle{x=10, y=10}

运算符函数并没要求两个运算数要相同的类型,比如我们可以用一个数字来缩放一个点:

operator fun Point.times(scale:Double) = Point((x*scale).toInt(), (y*scale).toInt())

val p = Point(10, 50)
// Point(x=30, y=150)
println(p * 3.0)

注意: Kotlin 运算符不会自动支持交换性(交换运算符的左右两边)。如果希望用户能够使用 p * 3.0 以外,还能使用 3.0 * p ,你需要为它定义一个单独的运算符:

operator fun Double.times(p: Point) = Point((this * p.x).toInt(), (this * p.y).toInt())

运算符函数也没要求返回类型是相同的类型,比如我们可以定义一个运算符,通过多次重复单个字符来创建字符串:

operator fun Char.times(count: Int) = toString().repeat(count)

// aaa
println('a' * 3)

这个运算符接收一个 Char 作为左值,Int 作为右值,然后返回一个 String 类型。

注意: 和普通函数一样,可以重载 operator 函数,可以定义多个同名的但参数类型不同的方法,不过参数个数只能是一个。

重载复合赋值运算符

通常情况下,当你在定义像 plus 这样的运算符函数时,Kotlin 不止支持 + 号运算,也支持 += 。像 += ,-= 等这些运算符被称为复合赋值运算符。看这个例子:

// 可变属性
data class MutablePoint(var x: Int, var y: Int) 

operator fun MutablePoint.plusAssign(other: MutablePoint) {
    x += other.x
    y += other.y
}

val p1 = MutablePoint(10, 50)
p1 += MutablePoint(100, 3)
//MutablePoint(x=110, y=53)
println(p1)

这等同于 p1 = p1 + MutablePoint(10, 50) 的写法。当然,这个只对于可变变量有效。

在一些情况下,定义 += 运算符可以修改使用它的变量所引用的对象,但不会重新分配引用,将一个元素添加到可变集合,就是一个很好的例子:

val numbers = ArrayList<Int>()
numbers += 42
// [42]
println(numbers)

如果你定义了一个返回值为 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   // 添加单个元素
println(list)

var list2 = listOf(100, 200)   // 这里要用 var 声明可变的
list2+= 5   // 因为作用于只读变量,会返回一个修改过的副本,即引用被改变了
println(list2)

val newList = list + listOf(4, 5)   // 添加类型一致的其他集合
println(newList)

重载一元运算符

可重载的一元算术运算符如下:

表达式            函数名

+a                unaryPlus
-a                unaryMinus
!a                not
++a,a++            inc
--a,a--            dec

重载一元跟重载二元的方式是一样的,只不过一元的约定函数没有任何参数,比如:

// 没有参数
operator fun Point.unaryMinus() = Point(-x, -y)

val p1 = Point(10, 50)
//Point(x=-10, y=-50)
println(-p1)

当你定义 inc 和 dec 函数来重载自增和自减的运算符时,编译器自动支持与普通数字类型的前缀和后缀自增运算符相同的语义。考虑一下用来重载 BigDecimal 类的 ++ 运算符的这个例子:

operator fun BigDecimal.inc() = this + BigDecimal.ONE

var bd = BigDecimal.ZERO
// 0
println(bd++)
// 2
println(++bd)

前一个输出是 0 ,是因为先返回递增前的值再做递增操作,后一个输出是 2 ,是先递增操作了,再返回值。打印的值与使用 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 的实现将会由编译器自动生成。但如果手动实现,那么代码可以是这样的:

override fun equals(other: Any?): Boolean {
    // 用了恒等运算符 ===
    if (other === this) return true
    if (other !is Point) return false
    return other.x == x && other.y == y

}

这里使用了恒等运算符( === )来检查参数与调用 equals 的对象是否相同。恒等运算符与 Java 中的 == 运算符完全相同:检查两个参数是否是同一个对象的引用(如果是基本数据类型,检查他们是否是相同的值)。

equals 函数之所以被标记 override ,那是因为与其他约定不同的是,这个方法的实现是在 Any 类中定义的、这也解释了为什么你不需要将它标记为 operator ,Any 中的基本方法就已经标记了,而且函数的 operator 修饰符也适用于所有实现或重写它的方法。

!= 运算符的使用也会转换为 equals 方法的调用,编译器会自动对返回值取反,因此,你不需要再做别的事情,就可以正常运行。

注意: === 运算符不能被重载,equals 不能实现为扩展方法,因为继承自 Any 类的实现始终优先于扩展函数。

排序运算符:compareTo

在 java 中,类可以实现 Comparable 接口,以便在比较值的算法中使用。接口中定义的 compareTo 方法用于确定一个对象是否大于另一个对象。但在 Java 中,这个方法的调用没有简明语法,只有基本数据类型能使用 < 和 > 来比较,所有其他类型都需要明确写为 element1.conpareTo(element2) 。

Kotlin 的接口中定义的 compareTo 方法可以按约定调用,比较运算符( > , < , <= 和 >= )的使用将被转换为compareTo ,compareTo 的返回类型必须为 Int 。 p1 < p2 表达式等价于 p1.compareTo(p2) < 0 。其他比较运算符的运算方式也是完全一样的。

// 两个对象的比较被转换为 compareTo 的函数调用,然后结果与零比较
a >= b    ——》    a.compareTo(b) >= 0

假设有一个 Person 类,这个实现将对地址簿排序(先比较名字中的姓,如果姓相同,再比较名字):

class Person(val firstName: String, val lastName: String) : Comparable<Person> {
    override fun compareTo(other: Person): Int {
        // 按顺序调用给定的方法,并比较它们的值
        return compareValuesBy(this,other,Person::lastName,Person::firstName)
    }
}

fun testPerson() {
    val p1 = Person("fanda", "zeng")
    val p2 = Person("hang", "liu")

    // false
    println(p1 <p2)
}

我们通过实现 Comparable 接口的方式重载 compareTo 方法,这样做还可以被 Java 函数(比如用于对集合进行排序的功能)进行比较,与 equals 一样,operator 修饰符已经被用在了基类的接口中,因此在重写该接口时无需再重复。 compareValuesBy 方法非常有用,会按顺序依次调用回调方法,两两一组做比较,并返回结果。如果值不同,就返回结果,如果相同,则进行下一个比较,如果没有更多比较,则返回 0 .

注意:所有 Java 中实现了 Comparable 接口的类,都可以在 Kotlin 中使用简洁的运算符语法,不用再增加扩展函数。

集合与区间的约定

通过下标来访问元素: get 和 set

我们已经知道在 Kotlin 中可以用类似 Java 中数组的方式来访问 map 中的元素:

val value = map[key]

也可以用同样的运算符来改变一个可变 map 的元素:

mutableMap[key] = newValue

在 kotlin 中,下标运算符是一个约定,使用下标运算符读取元素会被转换为 get 运算符方法的调用,并且写入元素将调用 set 。 Map 和 MutableMap 的接口已经定义了这些方法。让我们看看如何给自定义的类添加类似的方法。

我们给之前的 MutablePoint 类添加下标的方式来访问对应的 x 和 y 坐标:

operator fun MutablePoint.get(index: Int) = when (index) {
    0 -> x
    1 -> y
    else -> throw IndexOutOfBoundsException("Invalid coordinate $index")
}

val p = MutablePoint(10, 50)
//10
println(p[0])

你只需要定义一个名为 get 的函数,并标记 operator 之后,像 p[0] 这样的表达式,其中 p 具有类型 MutablePoint ,将被转换为 get 方法的调用,方括号里面的值将变成 get 方法的参数。

x[a,b] ——》 x.get(a,b)

get 方法的函数可以是任意类型,可以有多个不同的参数,可以有多个重载方法,看成普通的函数即可,只不过通过方括号的形式来调用,因为这是一种约定。

接下来我们来看一下约定的 set 方法:

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, 50)
p[1] = 100
// MutablePoint(x=10, y=100)
println(p)

只需定义一个名为 set 的函数,就可以在赋值语句中使用下标运算符。set 的最后一个参数用来接收赋值语句中等号右边的值,其他参数作为方括号内的下标。

x[a,b] = c    ——》    x.set(a,b,c)

in 的约定

集合支持的另一个运算符是 in 运算符,用于检查某个对象是否属于集合,相应的函数叫作 contains 。我们使用 in 运算符来检查点是否属性一个矩形:

data class Rectangle(val upperLeft: Point, val lowerRight: Point)

operator fun Rectangle.contains(p: Point): Boolean {
    return p.x in upperLeft.x until lowerRight.x &&
            p.y in upperLeft.y until lowerRight.y
}

fun testRectangle() {
    val rect = Rectangle(Point(10, 20), Point(50, 50))
    println(Point(20,20) in rect)   //true
    println(Point(5,5) in rect)     //false
}

in 右边的对象将会调用 contains 函数,in 左边的对象将会作为函数入参。

a in c ——》  c.contains(a)

在 Rectangle.contains 的实现中,我们用到了的标准库的 until 函数,来构建一个开区间,然后使用运算符 in 来检查某个点是否属于这个区间。开区间 10 until 20 包括从 10 到 19 的数字,但不包括 20 。

rangeTo的约定

要创建一个区间,请使用 .. 语法 ,.. 运算符是调用 rangeTo 函数的一个简洁语法。

start .. end ——》  start.rangeTo(end)

比如,1..10 代表所有从 1 到 10 的数字,1.rangeTo(10) 。这个方法调用返回一个区间,你可以为自己的类定义这个运算符,但是如果该类已经实现了 Comparable 接口,那么就不需要了。你可以通过 kotlin 标准库创建一个任意可比较元素的区间,这个库定义了可以用于任何可比较元素的 rangeTo 函数。

operator fun <T: Comparable<T>> T.rangeTo(that: T): ClosedRange<T>

这是一个 Comparable 的扩展函数,所以你的类实现了 Comparable 接口,都可以使用。这个函数返回一个区间,可以用来检测其他一些元素是否属于它。

比如,我们构建一个日期的区间,检查某一天是否在区间内:

val now = LocalDate.now()
val vacation = now..now.plusDays(10)
println(now.plusWeeks(1) in vacation)   //true

LocalDate 实现了 Comparable 接口,所以 now..now.plusDays(10) 将会被编译器转换为 now.rangeTo(now.plusDays(10)) 。

注意: rangeTo 运算符的优先级低于算术运算符,最好把参数括起来以免混淆。

val n = 9
println(0..n + 1)   //0..10
println(0..(n + 1))   //最好这样写,用括号括起来    0..10

还要注意,表达式 0..n.forEach{} 不会被编译,必须把区间表达式括起来才能调用它的方法:

// 0123456789
(0..n).forEach { print(it) }

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

// 用 in 来遍历字符串
for (s in "adfsfsdf") {
    print(s)
}

可以为自己的类定义 iterator 方法,让该类能够通过 for 来遍历数据:

// 定义 iterator 约定函数,用于遍历
operator fun ClosedRange<LocalDate>.iterator(): Iterator<LocalDate> =
    // 匿名对象,实现了遍历 LocalDate 元素的 Iterator
    object : Iterator<LocalDate> {
        var current = start

        // 这里用到了 compareTo 约定
        override fun hasNext() = current <= endInclusive

        @SuppressLint("NewApi")
        override fun next(): LocalDate = current.apply { current = plusDays(1) }
    }

fun testRangeTo3() {
    val newYear = LocalDate.ofYearDay(2017, 1)
    val daysOff = newYear.minusDays(1)..newYear
    for (dayOff in daysOff) {
        println(dayOff)
    }
}

解构声明和组件函数

解构声明允许你展开单个复合值,并使用它来初始化多个单独的变量,比如:

val p = Point(50, 30)
// 解构声明
val (x, y) = p
println(x)    //50
println(y)    //30

一个解构声明看起来像一个普通的变量声明,但它在括号中有多个变量。事实上,解构声明再次用到了约定的原理。要在结构声明中初始化每个变量,将调用名为 componentN 的函数,其中 N 是声明中变量的位置。换句话说,前面的例子可以被转换成:

val (a, b) = p -> val a = p.component1(); val b = p.component2()

对于数据类,编译器为每个在主构造方法中声明的属性生成一个 componentN 函数。下面的例子显示了如何手动为非数据类声明这些功能:

class OtherPoint(val x: Int,val  y: Int){
    // 定义了约定的方法,就可以使用解构声明了
    operator fun component1() = x
    operator fun component2() = y
}

解构声明主要使用场景之一,是从一个函数返回多个值,这个非常有用。如果要这样做,可以定义一个数据类来保存返回所需的值,并将它作为函数的返回类型。在调用函数后,可以用解构声明的方式,来轻松地展开它,使用其中的值。举个例子,让我们编写一个简单的函数,来将一个文件名分割成名字和扩展名:

data class NameComponents(val name: String, val ext: String)

fun splitFileName(fullName: String): NameComponents {
    // 集合上也有定义约定的 componentN 函数,所以这里可以用解构声明
    val (name ,ext) = fullName.split(".",limit = 2)
    return NameComponents(name,ext)
}

fun testNameComponents() {
    val (name, ext) = splitFileName("fanda.exe")
    println(name)   //fanda
    println(ext)    //exe
}

让一个函数能返回多个值有更简单的方法,是使用标准库中的 Pair 和 Triple 类,在语义表达上这种方式会差一点,因为这些类也不知道它会返回的对象中包含什么,但因为不需要定义自己的类所以可以少写代码。

fun splitFilename(fullName: String): Pair<String, String> {
    // 集合上也有定义约定的 componentN 函数,所以这里可以用解构声明
    val (name, ext) = fullName.split(".", limit = 2)
    return Pair(name, ext)
}

注意:不可以定义无限数量的 componentN 函数,标准库只允许使用此语法来访问一个对象的前五个元素。

解构声明和循环

解构声明不仅可以作用函数中的顶层语句,还可以用在其他可以声明变量的地方,例如 in 循环。一个很好的例子,是枚举 map 中的条目,下面是一个小例子:

// 解构声明在 for 循环的运用
fun testPrintEntries() {
    val map = mapOf("one" to "first", "two" to "second")
    for ((key, value) in map) {
        println("key is $key , value is $value")
    }
}

这个简单的例子用到了两个 Kotlin 的约定:一个是迭代一个对象,另一个是用于解构声明。 Kotlin 标准库给 map 增加了一个扩展的 iterator 函数,用来返回 Entry 条目的迭代器。因此,与 Java 不同的是,可以直接迭代 map 。它还包含 Map.Entry 上的扩展函数 component1 和 component2 ,分别返回它的键和值。实际上,前面的循环被转换成了这样的代码:

fun testPrintEntries2() {
    val map = mapOf("one" to "first", "two" to "second")
    for (entry in map.entries) {
        val key = entry.component1()
        val value = entry.component2()
        println("key is $key , value is $value")
    }
}

重用属性访问的逻辑:委托属性

委托属性的基本操作

委托属性的基本语法是这样的:

class Foo{
    var p: Type by Delegate()
}

属性 p 将它的访问器逻辑委托给了另一个对象:这里是 Delegate 类的一个新实例。通过关键字 by 对其后的表达式求值来获取这个对象,关键字 by 可以用于任何符合属性委托约定规则的对象。

编译器会创建一个隐藏的辅助属性,并使用委托对象的实例进行初始化,初始属性 p 会委托给该辅助属性对应的实例。

class Foo {
    // 隐藏的辅助属性,用委托对象初始化
    private val delegate = Delegate() //编译器自动生成
    var p: Type //p的访问交给delegate
        set(value: Type) = delegate.setValue(..., value)
        get() = delegate.getValue(...)

按照约定,Delegate 类必须具有 getValue 和 setValue 方法(后者仅适用于可变属性)。它们可以是成员函数,也可以是扩展函数。

类似下面代码:

class Delegate{
    operator fun getValue(...) {...}  //实现getter逻辑
    operator fun setValue(..., value: Type) {...} //实现setter逻辑
}

var p: Type by Delegate() 的 by 关键字把属性关联上委托对象,之后调用属性 p ,都会委托给 delegate 对象进行处理。

使用委托属性:惰性初始化和 by lazy()

惰性初始化是一种常见的模式,直到第一次访问该属性的时候,才根据需要创建对象的一部分。当初始化过程消耗大量资源并且在使用对象并不总是需要数据时,这个非常有用。

举个例子,一个 Person 类,可以用来访问一个人写的邮件列表。邮件存储在数据库中,访问比较耗时。你希望只有在首次访问时才加载邮件,并只执行一次。假设你已经有函数 loadEmails ,用来从数据库中检索电子邮件:

class Email{/*...*/}

fun loadEmail(person: Man):List<Email> {
    println("Load emails for ${person.name}")
    return listOf(/*...*/)
}

下面展示如何使用额外的 _emails 属性来实现惰性加载,在没有加载之前为 null ,然后加载为邮件列表:

class Man(val name: String) {
    private var _emails: List<Email>? = null
    val emails: List<Email>
        get() {
            if (_emails == null) {
                _emails = loadEmail(this)
            }
            return _emails!!    // 非空断言
        }
}

fun testMan() {
    val m = Man("fanda")
    m.emails    // 第一次访问会打印 Load emails for fanda
    m.emails    // 这里不再打印,因为属性已经有值
}

这里使用了所谓的支持属性技术。你有一个属性 _emails 来存储这个值,而另一个 emails ,用来提供对属性的读取访问。你需要使用两个属性,因为属性具有不同类型: _emails 可空,而 emails 为非空。这种技术经常会使用到,值得熟练掌握。

缺点: 如果有多个属性都需要惰性初始化,代码量会非常多,有些啰嗦。其次,这个实现不是线程安全的。

那么,我们用委托属性的方式来重写一下,这里用到了 lazy 函数:

class Man(val name: String) {
    val emails by lazy { loadEmail(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)
    }
}

现在来写一个老师类,定义一个只读属性(姓名,一般不需要更改)和两个可写属性:年龄和工资。当这个人的年龄或工资发生变化时,这个类将通知它的监听器。

class Teacher(val name: String, age: Int, salary: Int) : PropertyChangeAware() {
    var age: Int = age
        set(value) {
            val oldValue = field
            field = value
            // 属性变化时通知监听器
            changeSupport.firePropertyChange("age", oldValue, value)
        }

    var salary: Int = salary
        set(value) {
            val oldValue = field
            field = value
            // 属性变化时通知监听器
            changeSupport.firePropertyChange("salary", oldValue, value)
        }
}

fun testPropertyChange() {
    val t = Teacher("fanda", 25, 2000)
    // SAM 构造
    t.addPropertyChangeListener(PropertyChangeListener { event ->
        println("Property ${event.propertyName} changed from ${event.oldValue} to ${event.newValue}")
    })

    t.age = 26
    t.salary = 10000
}

// 输出
Property age changed from 25 to 26
Property salary changed from 2000 to 10000

setter 中有很多重复的代码,我们来提取一个委托类,用来存储这个属性的值并发起通知。

class ObservableProperty(val propName: String, var propValue: Int, val changeSupport: PropertyChangeSupport) {
    fun getValue() = propValue
    fun setValue(value: Int) {
        val oldValue = propValue
        propValue = value
        changeSupport.firePropertyChange(propName, oldValue, value)
    }
}

class Teacher(val name: String, age: Int, salary: Int) : PropertyChangeAware() {
    // 委托对象
    var _age = ObservableProperty("age", age, changeSupport)
    var age: Int
        set(value) = _age.setValue(value)
        get() = _age.getValue()

    // 委托对象
    var _salary = ObservableProperty("salary", salary, changeSupport)
    var salary: Int
        set(value) = _salary.setValue(value)
        get() = _salary.getValue()
}

我们创建了一个保存属性值的委托类,并在修改属性时自动触发更改通知。但是有相当多的样板代码,所以我们使用 kotlin 的委托属性功能来优化上述代码,但在此之前,需要更改 ObservableProperty 方法的签名,来匹配 kotlin 约定所需的方法。

class ObservableProperty(var propValue: Int, val changeSupport: PropertyChangeSupport) {
    // operator 标记
    operator fun getValue(t: Teacher, prop: KProperty<*>) = propValue

    // operator 标记
    operator fun setValue(t: Teacher, prop: KProperty<*>, value: Int) {
        val oldValue = propValue
        propValue = value
        changeSupport.firePropertyChange(prop.name, oldValue, value)
    }
}

与之前的版本相比,这次代码做了一些更改:

  • 按照约定的需要,给 getValue 和 setValue 方法标记了 operator 。
  • 方法增加了两个参数,一个用于接收属性的实例,用来设置或读取属性,另一个用于表示属性本身。这个属性类型为 KProperty(之后章节会详细介绍它),现在你只需要知道可以通过 KProperty.name 的方式来访问该属性的名称。
  • 把 name 属性从主构造方法中删除了,因为现在已经可以通过 KProperty 访问属性名称。

最后,我们用委托属性来给老师类重写一下:

class Teacher(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 Teacher(val name: String, age: Int, salary: Int) : PropertyChangeAware() {

    // 声明一个 lambda ,告诉如何通知属性值的更改
    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 的委托属性,委托属性机制其实是通用的,适用于任何其他类型。

小结

  • kotlin 允许使用对应名称来重载一些标准的数学运算,但是不能定义自己的运算符。
  • 比较运算符映射为 equals 和 compareTo 方法的调用。
  • 通过自定义名为 get 、 set 和 contains 的函数,就可以让你自己的类与 kotlin 的集合一样,使用 [] 和 in 运算符。
  • 可以通过约定来创建区间,以及迭代集合和数组。
  • 解构声明可以展开单个对象用来初始化多个变量,这可以方便地用来从函数返回多个值。编译器会自动处理数据类,可以通过给自己的类定义名为 componentN 的函数来支持。
  • 委托属性可以用来重用逻辑,这些逻辑控制如何存储、 初始化、 访问和修改属性值,这是用来构建框架的一个强大的工具。
  • lazy 函数 提供了一种实现惰性初始化属性的简单方法。
  • Delegates.observable 函数可以用来添加属性更改的观察者。
  • 委托属性可以使用任意 map 来作为属性委托,来灵活处理具有可变属性质的对象。