Kotlin 知识梳理(8) - 运算符重载及其他约定

Kotlin 知识梳理系列文章

Kotlin 知识梳理(1) - Kotlin 基础
Kotlin 知识梳理(2) - 函数的定义与调用
Kotlin 知识梳理(3) - 类、对象和接口
Kotlin 知识梳理(4) - 数据类、类委托 及 object 关键字
Kotlin 知识梳理(5) - lambda 表达式和成员引用
Kotlin 知识梳理(6) - Kotlin 的可空性
Kotlin 知识梳理(7) - Kotlin 的类型系统
Kotlin 知识梳理(8) - 运算符重载及其他约定
Kotlin 知识梳理(9) - 委托属性
Kotlin 知识梳理(10) - 高阶函数:Lambda 作为形参或返回值
Kotlin 知识梳理(11) - 内联函数
Kotlin 知识梳理(12) - 泛型类型参数


一、本文概要

本文是对<<Kotlin in Action>>的学习笔记,如果需要运行相应的代码可以访问在线环境 try.kotlinlang.org,这部分的思维导图为:


Kotlin中,我们可以通过 调用自己代码中定义的函数,来实现 特定语言结构。这些功能与 特定的函数命名 相关,而不是与特定的类型绑定。例如,如果在你的类中定义了一个名为plus的特殊方法,那么按照约定,就可以在该类的实例上使用+运算符,这种技术称为 约定

因为由类实现的接口集是固定的,而Kotlin不能为了实现其他接口而修改现有的类,因此一般 通过扩展函数的机制 来为现有的类增添新的 约定方法,从而适应任何现有的Java类。

二、重载算术运算符

Kotlin中,使用约定的最直接的例子就是 算术运算符,在Java中,全套的算术运算符只能用于基本数据类型,+运算符可以与String一起使用。下面,我们看一下在Kotlin中,如何使用算术运算符来完成一些其它的事情。

2.1 重载二元运算符

假设已经有一个数据类Point,它包含两个成员变量,分别是x,y点的坐标值,我们希望通过算术运算符+对两个Point对象相加之后,能够得到一个新的Point对象,它的成员变量x,y为原有两个Point对象的x,y之和。


运行结果为:

在上面的代码中,我们为Point类定义了一个扩展函数plus,这样当我们调用first + second,实际上执行的是first.plus(second)方法来得到一个新的Point对象。这里需要注意的是:用于重载运算符的所有函数都需要 用 operator 关键字来标记,用来表示你打算 把这个函数作为相应的约定的实现

所有可重载的二元算术运算符如下,自定义类型的运算符,基本上和标准数字类型的运算符有着相同的优先级。

  • a * btimes
  • a / bdiv
  • a % bmod
  • a + bplus
  • a - bminus

运算符函数和 Java

  • 当从Java调用Kotlin运算符非常容易,只需要像普通函数一样调用即可,例如上面的plus方法。
  • 当从Kotlin调用Java的时候,对于与Kotlin约定匹配的函数(不要求使用operator修饰符,但是参数需要匹配名称和数量)都可以使用运算符语言来调用。如果Java类定义了一个满足需求的函数,但是起了一个不同的名称,可以通过定义一个扩展函数来修正这个函数名用来替代现有的Java方法。

没有用于位运算的特殊运算符

Kotlin没有为标准数字类型IntLong等定义任何位运算符,因此也不允许你为自定类型定义它们。相反,它使用中缀调用语法的函数,可以为自定义类型定义相似的函数,下面我们为Point添加一个and,用于执行位运算。


运行结果为:

这里我们不再使用operator关键字来声明,而是用infix来定义一个中缀调用语法的函数,其它执行位运算的函数包括:shlshrushrandorxorinv

2.2 重载复合赋值运算符

当在定义像plus这样的函数,Kotlin不止支持+号运算,也支持像+=这样的 复合赋值运算符


需要注意,这个只对于可变变量有效,也就是first要声明为var。在一些情况下,定义+=运算符可以 修改使用它的变量所引用的对象,但不会重新分配引用,将一个元素添加到可变集合,就是一个很好的例子:


如果你定义了一个返回值为Unit,名为plusAssign的函数,Kotlin将会在用到+=运算符的地方使用它,其它二元运算符也有命名相似的对应函数:minusAssigntimesAssign等。

当在代码中用到+=的时候,理论上plusplusAssign都可能会被调用,如果两个函数都有定义并且适用,那么编译器就会报错,例如下面这样的定义:


编译时的错误为:

解决方法有两种:

  • 使用 不可变 val 代替可变 var 来修饰first,这样plus运算符就不再适用。
  • 不要同时为一个类添加plusplusAssign运算。如果一个类是 不可变的,那就应该只提供返回一个新值的运算;如果一个类是 可变的,例如构建器,那么只需要提供plusAssign和类似的运算符就够了。

Kotlin的标准库支持集合的这两种方法:

  • +-运算符总是返回一个新的集合
  • +=-=运算符用于可变集合时,始终在一个地方修改它们;而它们用于只读集合时,会返回一个修改过的副本。

作为它们的运算数,可以使用单个元素,也可以使用元素类型一致的其它集合:



运行结果为:


2.3 重载一元运算符

重载一元运算的过程和前面看到的方式相同:用预先定义的一个名称来声明函数,并用修饰符operator标记。下面的例子中重载了-a运算符:


运行结果为:

所有可重载的一元算法运算符包括:

  • +aunaryPlus
  • -aunaryMinus
  • !anot
  • ++a/a++inc
  • --a/a--dec

当你定义incdec函数来重载自增和自减的运算符时,编译器自动支持与普通数字类型的前缀、后缀自增运算符相同的语义。例如后缀运算会先返回变量的值,然后才执行++操作。

三、重载比较运算符

与算术运算符一样,在Kotlin中,可以对任何对象使用比较运算符(==!=><),而不仅仅限于基本数据类型。

3.1 等号运算符,equals

如果在Kotlin中使用==/!=运算符,它将被转换成equals方法的调用,和其他运算符不同的是,==!=可以用于可空运算数,比较a == b会检查a是否为飞空,如果不是就调用a.equals(b),完整的调用如下所示:

a?.equals(b) ?: (b == null)

对于data修饰的数据类,equals的实现将会由编译器自动生成,如果需要手动实现,可以参考下面的做法:

  • 比较是否指向同一对象的引用,如果是,那么直接返回true
  • 类型如果不同,直接返回false
  • 比较作为判断依据的字段

equals函数之所以被标记为override,这是因为这个方法的实现是在Any类中定义的,而operator关键字在基本方法中已经标记了。同时,equals不能实现为扩展函数,因为继承自Any类的实现始终优先于扩展函数。

3.2 排序运算符 compareTo

Kotlin中,对于实现了Comparable接口中定义的compareTo方法的类可以按约定调用,比较运算符<、>、<=、>=的使用将被转换为compareTocompareTo的返回类型必须为int,也就是说p1 < p2表达式等价于p1.compareTo(p2) < 0

下面,我们定义一个Person类,让其根据年龄来比较大小:


运行结果为:

在上面的例子中,我们用到了Kotlin标准库函数中的compareValuesBy函数来简洁地实现compareTo方法,这个函数 接收用来计算比较值的一系列回调,按顺序依次调用回调方法,两两一组分别做比较:

  • 如果值不同,则返回比较结果
  • 如果相同,则继续调用下一个
  • 如果没有更多的回调来调用,则返回0

这些回调函数可以像lambda一样传递,或者像这里做的一样,作为属性引用传递。

四、集合与区间的约定

处理集合最常见的操作包含两种:

  • 通过下标来获取和设置元素,使用语法a[b],称为 下标运算符
  • 检查元素是否属于当前集合,使用in运算符。

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

Kotlin中,下标运算符是一种约定,使用下标运算符读取元素会被转换为get运算符方法的调用,并且写入元素将调用set,下面我们为Point类添加类似的方法:


get的参数可以是任何类型,而不止是Int,例如,当你对map使用下标运算符时,参数类型是键的类型,它可以是任意类型。还可以定义具有多个参数的get方法,例如如果要实现一个类来表示二维数组或矩阵,你可以定义一个方法,例如operator fun get(rowIndex : Int, colIndex : Int),然后用matrix[row, col]来调用。

下面,我们再来看一下set的约定方法:


运行结果为:

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

4.2 in 的约定

集合支持的另一个运算符是in运算符,用于检查某个对象是否属于集合,相应的函数叫做contains,下面的例子用于判断某个点是否处于矩形范围之内:


运行结果为:

4.3 rangeTo 的约定

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

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

这个函数返回一个区间ClosedRanged,可以用来检测其它一些元素是否属于它。

作为例子,我们用LocalData来构建一个日期的区间:


运行结果为:

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

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

for循环中使用in运算符表示 执行迭代操作,诸如for(x in list) { }将被转换成list.iterator()的调用,然后在上面重复调用hasNextnext方法。


运行结果为:

上面用到了 Kotlin 知识梳理(4) - 数据类、类委托 及 object 关键字 中介绍的通过object来实现匿名内部类的知识。

五、解构声明和组件函数

解构声明的功能允许你展开单个复合值,并使用它来初始化多个单独的变量。它再次用到了约定的原理,要在解构声明中初始化每个变量,将调用名为componentN的函数,其中N是声明中变量的位置。

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


运行结果为:

解构声明主要使用场景之一,是从一个函数返回多个值,这个非常有用。如果要这样做,可以定义一个数据类来保存返回所需的值,并将它作为函数的返回类型。在调用函数之后,可以用解构声明的方式,来轻松的展开它,使用其中的值。

解构声明不仅可以用作函数中的顶层语句,还可以用在其他可以声明变量的地方,例如使用in循环来枚举map中的条目:


运行结果为:


更多文章,欢迎访问我的 Android 知识梳理系列:

推荐阅读更多精彩内容

  • 前言 人生苦多,快来 Kotlin ,快速学习Kotlin! 什么是Kotlin? Kotlin 是种静态类型编程...
    橘之缘之空阅读 12,266评论 4 96
  • 在前面写了关于集合和范围的内容,里面包括了一点运算符重载的内容,在这里我们来详细了解运算符重载的知识,内容参考《K...
    叫我旺仔阅读 1,535评论 0 5
  • C++运算符重载-上篇 (Boolan) 本章内容:1. 运算符重载的概述2. 重载算术运算符3. 重载按位运算符...
    Haley_2013阅读 537评论 0 3
  • Kotlin 知识梳理系列文章 Kotlin 知识梳理(1) - Kotlin 基础Kotlin 知识梳理(2) ...
    泽毛阅读 620评论 0 3
  • 我总是习惯憧憬习惯把未来想得美好,现实好像并不想成全反过来给了我一记响亮的耳光。在这个算不上大学的地方,我见识了一...
    徐小雪阅读 33评论 1 1