Kotlin核心语法(五):运算符重载以及其它的约定

小兵兵同学

博客主页

java在标准库中有一些与特定的类相关联的语言特性,如实现了java.lang.Iterable接口的对象可以在for循环中使用,实现了java.lang.AutoCloseable接口的对象可以在try-with-resources语句中使用。

但在kotlin中,一些功能是与特定的函数名相关,而不是与特定的类型绑定。kotlin使用约定的原则,不像java依赖类型。kotlin可以通过扩展函数机制来为现有的类增添新的方法,可以把任意约定方法定义为扩展函数。

一. 重载算术运算符

java中,算术运算符只能用于基本数据类型,+运算符可以与String值一起使用。如果给集合添加元素时,想要能够用 += 运算符就完美。在kotlin中,是可以这样做的。

1. 重载二元算术运算

先来看一个例子:定义Point类(代表一个点),把点的(X, Y)坐标分别加到一起。

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

    // 定义一个名为 "plus" 的方法
    operator fun plus(other: Point): Point {
        return Point(x + other.x, y + other.y)
    }
}

val p1 = Point(1, 2)
val p2 = Point(3, 4)
println(p1 + p2) // 通过使用 + 号 来调用 "plus" 方法
//输出结果>>> Point(x=4, y=6)

operator关键字声明plus函数。所有的重载运算符函数都需要使用该关键字标记,表示这个函数作为约定实现。

使用operator修饰符声明plus函数后,可以直接使用 + 号来求和。其实就是调用plus函数。

除了可以把运算符声明为一个成员函数外,还可以把它定义为一个扩展函数

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

在定义运算符时,两个运算数可以是不同的类型

operator fun Point.times(scale: Double): Point {
    return Point((x * scale).toInt(), (y * scale).toInt())
}

val p1 = Point(10, 20)
println(p1 * 1.5) // 不会自定支持交换性,不能 1.5 * p1
//输出结果>>> Point(x=15, y=30)

kotlin运算符不会自定支持交换性,不能 1.5 * p1。如果希望可以,需要单独定义一个运算符

operator fun Double.times(p: Point): Point {...}

运算符函数的返回类型也可以是任意一个运算数类型。
这个运算符,接收一个Char作为左值,Int作为右值,然后返回一个String类型。

operator fun Char.times(count: Int) : String {
    return toString().repeat(count)
}

println('b' * 3)
//输出结果>>> bbb

2. 重载复合赋值运算符

+= 、-=等这些运算符称为复合赋值运算符。

var p = Point(1, 2)
p += Point(3, 4) // 等同于 p = p + Point(3, 4)写法
println(p)

//输出结果>>> Point(x=4, y=6)

+=运算符可以修改变量所引用的对象,但不会重新分配引用,如:将一个元素添加到可变集合中

val numbers = ArrayList<Int>()
numbers += 12
println(numbers[0])

//输出结果>>> 12

如果定义了一个返回值为Unit,名为plusAssign函数,kotlin会在用到 += 运算符的地方调用它。二元运算符对应函数,如:minusAssign、timesAssign

kotlin标准库为可变集合定义了plusAssign函数:

operator fun <T> MutableCollection<T>.plusAssign(element: T) {
    this.add(element)
}

在代码中使用 += 时,理论上 plus 和 plusAssign都有可能被调用,所以尽量不要同时给一个类添加 plus 和 plusAssign 运算。

如:例子中的Point类,是一个不可变的,那么应该只提供返回一个新值plus运算,如果一个类是可变的,那么只需要提供plusAssign和类似的运算。

kotlin标准库支持集合的两种方法,+ 和 - 运算符总是返回一个新的集合,+= 和 -= 运算符用于可变集合时,始终在一个地方修改它们。而用于只读集合时,返回一个修改过的副本,意味着只有当引用只读集合的变量声明为var时,才能使用+=和-=。

val list = arrayListOf(1, 2)
list += 3 // += 修改list
val newList = list + listOf(4, 5)  // 返回一个包含所有元素的新列表

println(list)
//输出结果>>> [1, 2, 3]

println(newList)
//输出结果>>> [1, 2, 3, 4, 5]

3. 重载一元运算符

预先定义一个名称来声明函数(成员函数或者扩展函数),并用修饰符operator标记。

// 一元运算符无参数
operator fun Point.unaryMinus(): Point {
    return Point(-x, -y) // 坐标取反,然后返回
}

可重载的一元算法的运算符:

表达式 函数名
+a unaryPlus
-a unaryMinus
!a not
++a,a++ inc
--a, a-- dec

自增运算符案例:

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

var bd = BigDecimal.ZERO
println(bd++) //后缀运算:在执行后增加(先返回bd变量当前值,然后执行++)
//输出结果>>> 0

println(++bd) //前缀运算:在执行前增加(与后缀运算相反)
//输出结果>>> 2

二. 重载比较运算符

在kotlin中,可以对于任何对象使用比较运算符(==、!=、>、< 等),不仅仅限于基本数据类型,可以直接使用比较运算符。不像java需要调用equals或者compareTo函数。

1. 等号运算符:"equals"

如果在kotlin中使用 == 运算符,会将被转换成equals方法的调用。

== 和 != 可以用于可空运算符,因为这些运算符事实上会检查运算数是否为null。比较 a == b 会检查a是否为非空,如果不是,就调用a.equals(b),否则,只有两个参数都是空引用,结果才是true

案例中Point类,被标记为数据类(data),equals的实现会由编译器自动生成。如果需要手动实现,如下:

class Point(val x: Int, val y: Int) {
    override fun equals(other: Any?): Boolean {
        // 优化:检查参数是否与this是同一个对象
        if (this === other) return true
        // 检查参数类型
        if (other !is Point) return false
        // other智能转换为Point来访问x,y属性
        return other.x == x && other.y == y
    }
}

println(Point(1, 2) == Point(1, 2)) //输出结果>>> true
println(Point(2, 3) != Point(3, 4)) //输出结果>>> true
println(null == Point(1, 2)) //输出结果>>> false

恒等运算符(===)来检查两个参数是否是同一个对象的引用(如果是基本数据类型,检查是否是相同的值)。在实现了equals函数后,通常使用这个(===)运算符来优化调用代码,但是===运算符不能被重载。

equals方法是在Any类中定义的,所以equals方法不需要标记为operator,因为Any类中基本方法已经标记了。但是equals不能实现为扩展方法,因为继承自Any类的实现始终优先于扩展函数。

public open class Any { 
    // ...
    public open operator fun equals(other: Any?): Boolean
}

!=运算符也会转换为equals方法调用,编译器会自动对返回值取反。

2. 排序运算符:compareTo

在java中,类可以实现Comparable接口,接口中定义的compareTo方法用于确定一个对象是否大于另一个对象。但是在java中,只有基本数据类型可以使用< 和 > 来比较,其它类型都需要element1.compareTo(element2)。

而在kotlin中,可以使用比较运算符(< 、> 、<=、>=),会被转换为compareTo,compareTo的返回类型必须为Int。

定义Person类实现compareTo方法:先比较firstName,如果相同,再比较lastName

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

val p1 = Person("a", "b");
val p2 = Person("a", "c");
println(p1 < p2)
//输出结果>>> true

可以使用kotlin标准库中的compareValuesBy函数来简洁地实现compareTo方法。所有java中实现了Comparable接口的类,都可以在kotlin使用简洁的运算符语法,不用再增加扩展函数。如:

println("abc" < "cba")
//输出结果>>> true

三. 集合与区间的约定

集合的操作通常都是通过下标。kotlin中所有这些操作都支持运算符语法:通过下标获取或者设置元素,可以使用语法a[b](称为下标运算符);可以使用in运算符来检查元素是否在集合区间内,也可以迭代集合。

1. 通过下标来访问元素:“get”和“set”

kotlin中,访问map中元素,可以通过方括号的方式:

val value = map[key]

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

mutable[key] = newValue

如何工作的呢?
在kotlin中,下标运算符是一个约定。使用下标运算符读取元素会被转换为get运算符方法的调用,写入元素调用set。Map和MutableMap的接口都已经定义了这些方法。

如何给自定义的类添加类似的方法呢?

实现get约定:还是以自定义Point类为例,使用方括号来引用点的坐标,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]就会被转换为get方法的调用。

注意:get的参数可以是任意类型,而不只是Int。还可以定义具有多个参数的get方法。如果需要使用不同的健类型访问集合,也可以使用不同的参数类型定义多个重载的get方法。

实现set约定:上例中Point类是不可变的(变量是val修改),所以实现set约定没有意义。
接下来定义一个可变的点MutablePoint

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, 23)
p[1] = 24
println(p)
//输出结果>>> MutablePoint(x=10, y=24)

只需要定义一个set函数,并标记operator后,p[1]=24就会被转换为set方法的调用。

2. "in"的约定

集合支持的另一个运算符是in运算符:用来检查某个对象是否属于集合,对于的函数是contains。

实现in的约定:检查点是否属于一个矩形

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

operator fun Rectangle.contains(p: Point): Boolean {
    // 使用until函数来构建一个区间
    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

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

3. rangeTo的约定

创建一个区间,使用 .. 语法。如:1..10表示从1到10的数字。 ..运算符是调用rangeTo函数的一个简洁方法。

rangeTo函数返回一个区间。可以为自定义的类定义这个运算符,但是如果该类实现了Comparable接口,就不需要了。可以通过kotlin标准库创建一个任意可比较元素的区间:

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

例如:

val now = LocalDate.now();
val vacation = now..now.plusDays(10) // 创建一个从今天开始的10天的区间
println(now.plusWeeks(1) in vacation) // 检测一个特定的日期是否属于这个区间
//输出结果>>> true

now..now.plusDays(10)会被编译器转换为now.rangeTo(now.plusDays(10))。其中rangeTo并不是LocalDate的成员函数,而是Comparable的一个扩展函数。

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

val n = 9
println(1..(n + 1)) // 可以写成1..n + 1,但括起来更清晰一点
//输出结果>>> 1..10

表达式1..n.forEach { print(it) }不会被编译,必须把区间表达式括起来才能调用forEach方法

val n = 9
(1..n).forEach { print(it) }
//输出结果>>> 123456789

4. 在"for"循环中使用"iterator"的约定

在kotlin中,for循环中也可以使用in运算符,和做区间检查一样。但是在这种情况下它的含义是不同的:它被用来执行迭代。如:for(x in list) {...} 将被转换成list.iterator()的调用。

在kotlin中,iterator方法可以被定义为扩展函数,所以可以遍历一个常规的java字符串,标准库已经为CharSequence定义了一个扩展函数iterator

operator fun CharSequence.iterator(): CharIterator

for(c in "abc"){}

可以为自定义的类定义iterator方法:实现日期区间的迭代器

operator fun ClosedRange<LocalDate>.iterator(): Iterator<LocalDate> =
    // 这个对象实现了遍历LocalDate元素的Iterator
    object : Iterator<LocalDate> {
        var current = start

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

        // 在修改前返回当前日期作为结果
        override fun next() = current.apply {
            // 把当前日期增加一天
            current = plusDays(1)
        }
    }

val newYear = LocalDate.ofYearDay(2017, 1)
val daysOff = newYear.minusDays(1)..newYear

for (dayOff in daysOff) { println(dayOff) }
//输出结果>>> 2016-12-31
//          2017-01-01

四. 解构声明和组件函数

相信大家对数据类已经很熟悉了。

接下来解构声明,它是怎么工作的?

// 数据类
data class Point(val x: Int, val y: Int) 

val p = Point(10, 20)
val (x, y) = p  // 声明变量x,y,然后用p的组件来初始化
println(x)
//输出结果>>> 10

println(y)
//输出结果>>> 20

解构声明就像普通的变量声明,但它在括号中有多个变量。

解构声明也用到了约定原理。要在解构声明中初始化每个变量,将调用名为componentN的函数,其中N是声明中变量的位置。

对于数据类,编译器为每个在主构造方法中声明的属性生成一个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 result = fullName.split(".", limit = 2)
    // 返回一个数据类型的实例
    return NameComponents(result[0], result[1])
}

val (name, ext) = splitFilename("example.kt")
println(name)
//输出结果>>> example

println(ext)
//输出结果>>> kt

componentN函数在数组和集合中也有定义。当已知大小的集合时,可以使用解构声明来处理集合。
改造一下splitFilename函数:

fun splitFilename(fullName: String): NameComponents {
    val (name, ext) = fullName.split(".", limit = 2)
    return NameComponents(name, ext)
}

componentN在标准库只允许使用此语法来访问一个对象的前五个元素。

接收一个函数返回多个值,可以使用标准库中的 PairTriple 类。

1. 解构声明和循环

解构声明不仅可以用作函数中的顶层语句,还可以在其它可以声明变量的地方,如:in 循环

fun printEntries(map: Map<String, String>) {
    // 在in 循环中用解构声明
    for ((key, value) in map) {
        println("$key -> $value")
    }
}

val map = mapOf("Oracle" to "Java", "JetBrains" to "Kotlin")
printEntries(map)
//输出结果>>> Oracle -> Java
//          JetBrains -> Kotlin

其中Map.Entry上扩展函数component1和component2,分别返回它们的健和值

for (entry in map.entries) {
    val key = entry.component1()
    val value = entry.component2()
    // ...
}

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

1. 委托属性的基本操作

委托属性的基本语法:

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

属性p将它的访问器逻辑委托给了另一个对象,这里是Delegate类的一个新的实例。通过关键字by对其后的表达式求值来获取这个对象。

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

class Foo {
   // 编译器会自动生成一个辅助属性
   private val delegate = Detegate()
   // p的访问都会调用对应的delegate的getValue和setValue
   var p: Type
      set(value: Type) = delegate.setValue(...,value)
      get() = delegate.getValue(...)
}

Detegate类必须具有setValue和getValue方法,可以是成员函数,也可以是扩展函数。

class Detegate {
   // getValue包含了实现getter的逻辑
   operator fun getValue(...) {...}
   // setValue包含了实现setter的逻辑
   operator fun setValue(..., value: Type) {...}
}

class Foo {
  // 关键字by把属性关联上委托对象
  var p: Type by Delegate()
}

val foo = Foo()
val oldValue = foo.p // 通过调用delegate.getValue(...)来实现属性的修改
foo.p = newValue // 通过调用delegate.setValue(..., newValue)来实现属性的修改

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

惰性初始化:当第一次访问该属性的时候,才根据需要创建对象的一部分。

例如:一个Person类,用来访问一个人写的邮件列表。邮件存储在数据库中,访问耗时。但是只希望在首次访问时才加载邮件,并只执行一次

class Person {
    // _emails属性用来保存数据,关联委托
    private var _emails: List<String>? = null

    val emails: List<String>
        get() {
            if (_emails == null) {
                // 访问时加载邮件
                _emails = loadEmails();
            }
            // 如果已经加载,直接返回
            return _emails!!
        }

    private fun loadEmails(): List<String>? {
        // 耗时
        return listOf("1", "2");
    }
}

val p = Person()
println(p.emails)
//输出结果>>> [1, 2]

如果有几个属性怎么办呢?且这个实现也不是线程安全的。kotlin提供了更好的解决方案:
使用委托属性会让代码变得简单,可以封装用于存储值的支持属性和确保该值只被初始化一次的逻辑。
可以使用标准库函数lazy返回委托

使用委托属性来实现惰性初始化:

class Person {
    val emails by lazy { loadEmails() }
}

lazy函数返回一个对象,该对象具有一个名为getValue且签名正确的方法,因此可以把它与by关键字一起使用来创建一个委托属性。默认情况下,lazy函数是线程安全的。

3. 实现委托属性

在java中当一个对象的属性发生更改时通知监听器,具有用于此类通知的标准机制:PropertyChangeSupport和PropertyChangeEvent类。但是在kotlin不使用属性委托,怎么实现的呢?

PropertyChangeSupport类维护了一个监听器列表,并向它们发送PropertyChangeEvent事件。要使用它,通常需要把这个类的一个实例存储为bean类的一个字段,并将属性更改的处理委托给它。

为了避免在每个类中都创建这个字段,创建一个工具类,然后bean类继承这个工具类。

open class PropertyChangeAware {
    protected val changeSupport = PropertyChangeSupport(this);

    fun addPropertyChangeListener(listener: PropertyChangeListener) {
        changeSupport.addPropertyChangeListener(listener)
    }

    fun removePropertyChangeListener(listener: PropertyChangeListener) {
        changeSupport.removePropertyChangeListener(listener)
    }
}

写一个Person类,定一个只读属性name和一个可写属性age,当age发生改变时,通知它的监听器

class Person(
    val name: String,
    age: Int
) : PropertyChangeAware() {

    var age: Int = age
        set(newValue) {
            // field标识符允许访问属性背后支持字段
            val oldValue = field
            field = newValue
            // 当属性变化时,通知监听器
            changeSupport.firePropertyChange("age", oldValue, newValue)
        }
}

val p = Person("kerwin", 30)
p.addPropertyChangeListener(PropertyChangeListener { event ->
    println("Property ${event.propertyName} change from ${event.oldValue} to ${event.newValue}")
})

p.age = 31;
//输出结果>>> Property age change from 30 to 31

接下来通过辅助类实现属性变化的通知

class ObservableProperty(
    val propertyName: String,
    var propertyValue: Int,
    val changeSupport: PropertyChangeSupport
) {
    fun getValue() = propertyValue
    fun setValue(newValue: Int) {
        val oldValue = propertyValue
        propertyValue = newValue
        changeSupport.firePropertyChange(propertyName, oldValue, newValue)
    }
}

class Person(
    val name: String,
    age: Int
) : PropertyChangeAware() {

    val _age = ObservableProperty("age", age, changeSupport)
    var age: Int
        get() = _age.getValue()
        set(newValue) = _age.setValue(newValue)
}

这样我们还是需要为每个属性创建ObservableProperty实例,并把setter和getter委托给它。kotlin中的委托功能不用这样写,但是需要更改下ObservableProperty方法的签名,匹配kotlin约定所需的方法

class ObservableProperty(
    var propertyValue: Int,
    val changeSupport: PropertyChangeSupport
) {
    operator fun getValue(p: Person, prop: KProperty<*>) = propertyValue
    operator fun setValue(p: Person, prop: KProperty<*>, newValue: Int) {
        val oldValue = propertyValue
        propertyValue = newValue
        changeSupport.firePropertyChange(prop.name, oldValue, newValue)
    }
}

ObservableProperty这个类做了更改的地方:

  • getValue和setValue函数都被标记了operator
  • 这些函数增加了两个参数:一个用于接收属性的实例,用来设置或读取属性;另一个用于表示属性本身,这个属性类型为KProperty
  • 把propertyName属性从主构造中移除

然后使用委托属性来绑定更该通知:

class Person(
    val name: String,
    age: Int
) : PropertyChangeAware() {

    var age: Int by ObservableProperty(age, changeSupport)
}

通过by关键字,kotlin编译器会自动执行之前手动编写的代码。右边的对象被称为委托。kotlin会自动将委托存储在隐藏的属性中,并在访问或修改属性时调用委托的getValue和setValue。

你不用手动去实现可观察的属性逻辑。kotlin标准库中已经包含类似ObservableProperty的类。标准库与PropertyChangeSupport类没有耦合。

使用Delegates.observable来实现属性修改的通知:

class Person(
    val name: String,
    age: Int
) : PropertyChangeAware() {

    private val observer = { property: KProperty<*>, oldValue: Int, newValue: Int ->
        changeSupport.firePropertyChange(property.name, oldValue, newValue)
    }
    var age: Int by Delegates.observable(age, observer)
}

by右边的表达式不一定是新创建的实例。也可以是函数调用,另一个属性或者任何其它表达式。只要这个表达式的值,是能够被编译器用正确的参数类型来调用getValue和setValue的对象。

4. 委托属性的变化规则

接下来总结一下委托属性是怎么工作的?
假设有一个委托属性的类:

class C {
  val prop: Type by MyDelegate()
}

MyDelegate实例会被保存到一个隐藏的属性中,它被称为<delegate>。编译器也将用一个KProperty类型的对象来代表这个属性,它被称为<property>

class C {
  private val <delegate> = MyDelegate()

  val prop: Type
     get() = <delegate>.getValue(this, <property>)
     set(value: Type) = <delegate>.setValue(this, <property>, value)
}

5. 在map中保存属性值

委托属性另一种常见用法,是用在有动态定义的属性集的对象中,这种对象有时被称为自订对象。

举一个例子:定义一个属性,把值存到map中

class Person {
    private val _attributes = hashMapOf<String, String>()

    fun setAttribute(attrName: String, arrtValue: String) {
        _attributes[attrName] = arrtValue
    }

    val name: String
        get() = _attributes["name"]!!  // 从map中手动检索属性
}

那么把它修改为委托属性非常简单,可以直接将map放在by关键字后面

class Person {
    private val _attributes = hashMapOf<String, String>()

    fun setAttribute(attrName: String, arrtValue: String) {
        _attributes[attrName] = arrtValue
    }

    //  将map作为委托属性
    val name: String by _attributes
}

因为标准库已经在标准Map和MutableMap接口上定义了getValue和setValue扩展函数。

如果我的文章对您有帮助,不妨点个赞鼓励一下(^_^)

阅读 4.9k

Android技术专题分享
Android技术分享平台,每个工作日都有优质技术文章分享。从技术角度,分享生活工作的点滴。

Android技术分享平台,每个工作日都有优质技术文章分享。从技术角度,分享生活工作的点滴。

56 声望
23 粉丝
0 条评论

Android技术分享平台,每个工作日都有优质技术文章分享。从技术角度,分享生活工作的点滴。

56 声望
23 粉丝
宣传栏