第11章 运算符重载与约定
我们在《第2章 Kotlin 语法基础》中已经学习过关于运算符的相关内容,本章将继续深入探讨Kotlin中的运算符的重载与约定。
通常一门编程语言中都会内置预定义的运算符(例如: + , - , * , / , == , != 等),这些运算符的操作对象只能是基本数据类型。而在实际的编程场景中有很多自定义类型,其实也有类似的运算操作。这就是我们通常说的运算符重载(overload)。
Java中是不支持运算符重载的。而 Kotlin 允许我们为自己的类型实现一套自己的操作符运算逻辑的实现(重载函数)。这些操作符在Kotlin中是约定好的固定的符号 (如:加法 + 、乘法 *)和固定的优先级。而实现这样的操作符,我们也必须使用映射的固定名字的成员函数或扩展函数(加法 plus 、 乘法times)。 重载操作符的函数需要用 operator 修饰符来标记。
11.1 什么是运算符重载
运算符重载是对已有的运算符赋予新的含义,使同一个运算符作用于不同类型的数据,会有对应这个数据类型的行为。
运算符重载的实质是函数重载,本质上是对运算符函数的调用,从运算符到对应函数的映射的这个过程由编译器完成。由于一般数据类型间的运算符没有重载的必要,所以运算符重载主要是面向对象之间的。
Kotlin中的运算符重载约定定义在 org.jetbrains.kotlin.util.OperatorNameConventions中
package org.jetbrains.kotlin.util
import org.jetbrains.kotlin.name.Name
object OperatorNameConventions {
@JvmField val GET_VALUE = Name.identifier("getValue")
@JvmField val SET_VALUE = Name.identifier("setValue")
@JvmField val PROVIDE_DELEGATE = Name.identifier("provideDelegate")
@JvmField val EQUALS = Name.identifier("equals")
@JvmField val COMPARE_TO = Name.identifier("compareTo")
@JvmField val CONTAINS = Name.identifier("contains")
@JvmField val INVOKE = Name.identifier("invoke")
@JvmField val ITERATOR = Name.identifier("iterator")
@JvmField val GET = Name.identifier("get")
@JvmField val SET = Name.identifier("set")
@JvmField val NEXT = Name.identifier("next")
@JvmField val HAS_NEXT = Name.identifier("hasNext")
@JvmField val COMPONENT_REGEX = Regex("component\\d+")
@JvmField val AND = Name.identifier("and")
@JvmField val OR = Name.identifier("or")
@JvmField val INC = Name.identifier("inc")
@JvmField val DEC = Name.identifier("dec")
@JvmField val PLUS = Name.identifier("plus")
@JvmField val MINUS = Name.identifier("minus")
@JvmField val NOT = Name.identifier("not")
@JvmField val UNARY_MINUS = Name.identifier("unaryMinus")
@JvmField val UNARY_PLUS = Name.identifier("unaryPlus")
@JvmField val TIMES = Name.identifier("times")
@JvmField val DIV = Name.identifier("div")
@JvmField val MOD = Name.identifier("mod")
@JvmField val REM = Name.identifier("rem")
@JvmField val RANGE_TO = Name.identifier("rangeTo")
@JvmField val TIMES_ASSIGN = Name.identifier("timesAssign")
@JvmField val DIV_ASSIGN = Name.identifier("divAssign")
@JvmField val MOD_ASSIGN = Name.identifier("modAssign")
@JvmField val REM_ASSIGN = Name.identifier("remAssign")
@JvmField val PLUS_ASSIGN = Name.identifier("plusAssign")
@JvmField val MINUS_ASSIGN = Name.identifier("minusAssign")
// If you add new unary, binary or assignment operators, add it to OperatorConventions as well
@JvmField
internal val UNARY_OPERATION_NAMES = setOf(INC, DEC, UNARY_PLUS, UNARY_MINUS, NOT)
@JvmField
internal val SIMPLE_UNARY_OPERATION_NAMES = setOf(UNARY_PLUS, UNARY_MINUS, NOT)
@JvmField
val BINARY_OPERATION_NAMES = setOf(TIMES, PLUS, MINUS, DIV, MOD, REM, RANGE_TO)
@JvmField
internal val ASSIGNMENT_OPERATIONS = setOf(TIMES_ASSIGN, DIV_ASSIGN, MOD_ASSIGN, REM_ASSIGN, PLUS_ASSIGN, MINUS_ASSIGN)
}
运算符跟操作符函数的映射关系的定义在
org.jetbrains.kotlin.types.expressions.OperatorConventions.java中
public static final ImmutableBiMap UNARY_OPERATION_NAMES = ImmutableBiMap.builder()
.put(KtTokens.PLUSPLUS, INC)
.put(KtTokens.MINUSMINUS, DEC)
.put(KtTokens.PLUS, UNARY_PLUS)
.put(KtTokens.MINUS, UNARY_MINUS)
.put(KtTokens.EXCL, NOT)
.build();
public static final ImmutableBiMap BINARY_OPERATION_NAMES = ImmutableBiMap.builder()
.put(KtTokens.MUL, TIMES)
.put(KtTokens.PLUS, PLUS)
.put(KtTokens.MINUS, MINUS)
.put(KtTokens.DIV, DIV)
.put(KtTokens.PERC, REM)
.put(KtTokens.RANGE, RANGE_TO)
.build();
public static final ImmutableBiMap REM_TO_MOD_OPERATION_NAMES = ImmutableBiMap.builder()
.put(REM, MOD)
.put(REM_ASSIGN, MOD_ASSIGN)
.build();
public static final ImmutableBiMap ASSIGNMENT_OPERATIONS = ImmutableBiMap.builder()
.put(KtTokens.MULTEQ, TIMES_ASSIGN)
.put(KtTokens.DIVEQ, DIV_ASSIGN)
.put(KtTokens.PERCEQ, REM_ASSIGN)
.put(KtTokens.PLUSEQ, PLUS_ASSIGN)
.put(KtTokens.MINUSEQ, MINUS_ASSIGN)
.build();
其中,KtTokens.kt中定义了+, -, *, / , == , ! , ++, -- , *= , /= 等等运算符的符号。从源码中的这一句
public static final ImmutableSet NOT_OVERLOADABLE =
ImmutableSet.of(KtTokens.ANDAND, KtTokens.OROR, KtTokens.ELVIS, KtTokens.EQEQEQ, KtTokens.EXCLEQEQEQ);
我们可以知道,Kotlin中的 && 、 || 、 ?: 、 === 、 !== 是不能被重载的。
有了操作符重载我们可以将两个对象加起来变成另外一个对象。例如,我们自定义一个BoxInt类型,然后重载 times (乘法 * )函数, plus ( 加法 + )函数。
class BoxInt(var i: Int) {
operator fun times(x: BoxInt) = BoxInt(i * x.i) // 使用类成员函数重载
override fun toString(): String {
return i.toString()
}
}
operator fun BoxInt.plus(x: BoxInt) = BoxInt(this.i + x.i) // 使用扩展函数的方式重载
然后,我们的测试代码如下
fun main(arg: Array) {
val a = BoxInt(3)
val b = BoxInt(7)
println(a + b) //10
println(a * b) //21
}
运算符重载其实是Kotlin的一个语法糖。我们可以把上述代码反编译成Java 字节码,可以看到 a+b 其实是等价于Java中的:
public static final BoxInt plus(@NotNull BoxInt $receiver, @NotNull BoxInt x) {
...
return new BoxInt($receiver.getI() + x.getI());
}
下面是a+b被编译成class代码之后的样子。第三句的 INVOKESTATIC 验证了上面的说明:
ALOAD 1
ALOAD 2
INVOKESTATIC com/easy/kotlin/OperatorOverloadDemoKt.plus (Lcom/easy/kotlin/BoxInt;Lcom/easy/kotlin/BoxInt;)Lcom/easy/kotlin/BoxInt;
POP
而 a * b 等价于 Java 中的:
public final class BoxInt {
public final BoxInt times(@NotNull BoxInt x) {
...
return new BoxInt(this.i * x.i);
}
...
}
对应的字节码如下。同样的,第3行 INVOKEVIRTUAL 表明运算符重载确实是Kotlin的在编译器层面实现的一个语法糖。
ALOAD 1
ALOAD 2
INVOKEVIRTUAL com/easy/kotlin/BoxInt.times (Lcom/easy/kotlin/BoxInt;)Lcom/easy/kotlin/BoxInt;
POP
从上面的例子的分析,我们可以看出 Kotlin 通过在编译器层面做了大量工作,就是为了让 Kotlin 程序员们的代码尽可能的简洁,而让编译器处理更多的事情。毋庸置疑的是Kotlin的简洁优雅而且强大实用的语法和各种各样的语法糖可以大大地提升程序员们的生产力。这是都是直接使用 Java 享受不到的特性。
11.2 重载二元算术运算符
通过阅读上面的源码,我们可以总结出Kotlin中的二元运算符以及对于的运算符重载函数名称之间的映射关系如下表
二元运算符 | 重载函数名称 | 备注 |
---|---|---|
a + b | a.plus(b) | 加法操作 |
a - b | a.minus(b) | 减法操作 |
a * b | a.times(b) | 乘法操作 |
a / b | a.div(b) | 除法操作 |
a % b | a.rem(b) | 取余操作,早期版本中叫mod |
a..b | a.rangeTo(b) | 范围操作符 |
例如,一个简单的 1+1 = 2 的运算的实例代码
>>> 1+1
2
其实,本质上执行的是
>>> 1.plus(1)
2
Kotlin中使用 operator fun 声明重载运算符函数。例如上面的Int类型的加法运算符函数的声明如下
operator fun plus(other: Byte): Int
自定义类型的运算符重载函数的作用与内置赋值运算符的作用是同样的声明方式,但是具体的运算逻辑的实现则是“自定义”的。
编程实例题:
设计一个类Complex,实现复数的基本操作:
成员变量:实部 real,虚部 image,均为整数变量;
构造方法:无参构造函数、有参构造函数(参数2个)
成员方法:两个复数的加、减、乘操作。例如:
相加: (1+2i) + (3+4i) = 4 + 6i
相减: (1+2i) - (3+4i) = -2 - 2i
相乘: (1+2i) * (3+4i) = -5 + 10i
1.首先,我们来声明一个类Complex,里面声明两个Int成员变量 real, image。代码如下
package com.easy.kotlin
class Complex {
var real: Int = 0
var image: Int = 0
}
使用IDEA进行Kotlin编程的过程是非常享受的过程。直接在当前源码文件Complex类内右击鼠标,我们会看到 Generate (Mac 上的快捷键是 Command N) 如下图
点击 Generate ,进入 Generate 生成对话框
点击 Secondary Constructor, 进入初始化构造函数的属性选择对话框
在这个Choose Properties to Initialize by Constructor对话框中不选参数将会生成无参构造函数,如下图
选中2个参数将会生成这2个参数的构造函数,如下图
最终自动生成的无参构造函数、2个参数的构造函数代码如下
package com.easy.kotlin
class Complex {
var real: Int = 0
var image: Int = 0
constructor()
constructor(real: Int, image: Int) {
this.real = real
this.image = image
}
}
我们看到,Generate对话框中还可以自动生成equals() 和 hashCode()函数,toString()函数,可以选择 Override Methods,Implement Methods,自动生成 Copyright等功能。
2.实现加法、减法、乘法运算符重载函数
复数加法的运算规则是:实部加上实部,虚部加上虚部
(a+bi) + (c+di) = ( a + c )+ ( b + d )i
对应的算法的函数实现是
operator fun plus(c: Complex): Complex {
return Complex(this.real + c.real, this.image + c.image)
}
复数减法的运算规则是:实部减去实部,虚部减去虚部
(a+bi) - (c+di) = (a - c)+ (b - d)i
对应的算法的函数实现是
operator fun minus(c: Complex): Complex {
return Complex(this.real - c.real, this.image - c.image)
}
复数乘法的运算规则是按照乘法分配律展开:
(a+bi)(c+di) = ac-db + (bc+ad)i
对应的算法的函数实现是
operator fun times(c: Complex): Complex {
return Complex(this.real * c.real - this.image * c.image, this.real * c.image + this.image * c.real)
}
然后,为了可读性,我们重写 toString() 函数如下
override fun toString(): String {
val img = if (image >= 0) "+ ${image}i" else "${image}i"
return "$real ${img} "
}
测试代码:
fun main(args: Array) {
val c1 = Complex(1, 1)
val c2 = Complex(2, 2)
val p = c1 + c2
val m = c1 - c2
val t = c1 * c2
println(p)
println(m)
println(t)
}
输出:
3 + 3i
-1 -1i
0 + 4i
11.3 重载自增自减一元运算符
我们已经知道Kotlin中可以重载的一元运算符有
运算符函数 | 运算符 |
---|---|
a.unaryPlus() | +a |
a.unaryMinus() | -a |
a.not() | !a |
a.inc() | a++,++a |
a.dec() | a--,--a |
现在我们就用实例来深入说明怎样重载这些运算符。
我们定义一个Point类,然后实现unaryMinus运算符函数的重载。
class Point(val x: Int, val y: Int) {
operator fun unaryMinus() = Point(-x, -y)
override fun toString(): String {
return "Point(x=$x, y=$y)"
}
}
测试代码
val p1 = Point(1, 1)
println(-p1) // Point(x=-1, y=-1)
我们现在给Java中的BigDecimal 类型添加一个自增运算符 inc() 函数,给已有的类添加运算符重载函数,我们采用扩展函数来实现。定义 operator fun BigDecimal.inc() ,实现代码如下
operator fun BigDecimal.inc() = this + BigDecimal.ONE
然后,我们就可以直接对一个 BigDecimal 类型进行自增的操作了。测试代码如下
var bigDecimal1 = BigDecimal(100)
var bigDecimal2 = BigDecimal(100)
val tmp1 = bigDecimal1++
val tmp2 = ++bigDecimal2
println(tmp1)// 100
println(tmp2)// 101
println(bigDecimal1) // 101
println(bigDecimal2) // 101
因为自增后缀表达式是先返回表达式的值,然后进行加1操作,所以tmp1的值是100;而自增后缀表达式是加1的后值作为表达式的值,所以tmp2的值是101 。而在下一行打印变量bigDecimal1,bigDecimal2的值都是101 。
类似的自减运算符重载函数实现如下
operator fun BigDecimal.dec() = this - BigDecimal.ONE
测试代码:
var bigDecimal3 = BigDecimal(100)
var bigDecimal4 = BigDecimal(100)
val tmp3 = bigDecimal3--
val tmp4 = --bigDecimal4
println(tmp3)// 100
println(tmp4)// 99
println(bigDecimal3) // 99
println(bigDecimal4) // 99
而我们之所以能在实现函数中直接调用 this + BigDecimal.ONE 和 this - BigDecimal.ONE 是因为Kotlin 语言本身已经对 BigDecimal 进行了加法、减法、乘法、取余、取负等运算符的重载。这些重载运算符函数定义在BigNumbers.kt中
public inline operator fun BigDecimal.plus(other: BigDecimal) : BigDecimal = this.add(other)
public inline operator fun BigDecimal.minus(other: BigDecimal) : BigDecimal = this.subtract(other)
public inline operator fun BigDecimal.times(other: BigDecimal) : BigDecimal = this.multiply(other)
public inline operator fun BigDecimal.div(other: BigDecimal) : BigDecimal = this.divide(other, RoundingMode.HALF_EVEN)
public inline operator fun BigDecimal.mod(other: BigDecimal) : BigDecimal = this.remainder(other)
public inline operator fun BigDecimal.rem(other: BigDecimal) : BigDecimal = this.remainder(other)
public inline operator fun BigDecimal.unaryMinus() : BigDecimal = this.negate()
而这些运算符重载函数实现的背后其实就是调用的BigDecimal 的add 、 subtract、 multiply 、divide、remainder 、 negate 等方法。
我们可以看出,Kotlin 通过更高层次的封装,大大简化了BigDecimal 数据类型的算术运算的代码,使得BigDecimal 算术运算的代码更加简单易读。而在Java中,我们不得不实用冗长的方法名进行调用。虽然Kotlin背后的调用的仍然是Java的方法,但是对于Kotlin程序员来说,无疑是更加简洁明了了。
11.4 重载比较运算符
我们知道,在Java中,< 、> 、 >= 、 <= 、 ==、 != 运算符,只能作用于基本数据类型的比较
public static void main(String[] args) {
int x = 1;
int y = 1;
boolean b1 = x > y;
boolean b2 = x < y;
boolean b3 = x >= y;
boolean b4 = x <= y;
boolean b5 = x == y;
boolean b6 = x != y;
System.out.println(b1);
System.out.println(b2);
System.out.println(b3);
System.out.println(b4);
System.out.println(b5);
System.out.println(b6);
}
而在对象类型上是不允许使用这些比较运算符进行比较的
而实际上,只要给定一个比较标准,原则上对象之间也是可以比较大小的,而不是仅仅限于基本数据类型。因为Kotlin中一切类型都是引用类型。所以,对象之间的比较将是“自然而然”的。本节我们介绍比较运算符的重载。
上面的BigDecimal 比较的Java代码,在Kotlin中是允许的
val bd1 = BigDecimal.ONE
val bd2 = BigDecimal.ONE
val bdbd = bd1 > bd2
val bdeq = bd1 == bd2
val bdeqeq = bd1 === bd2
println(bdbd) // false
println(bdeq) // true
println(bdeqeq) // true
其中的大于号 > 会映射成调用 compareTo(BigDecimal val) > 0 的值。
Kotlin中的比较运算符与重载函数名之间的映射关系如下表所示
表达式 | 翻译成函数调用 |
---|---|
a > b |
a.compareTo(b) > 0 |
a < b |
a.compareTo(b) < 0 |
a >= b |
a.compareTo(b) >= 0 |
a <= b |
a.compareTo(b) <= 0 |
两个等于号 == 会映射成调用 equals(Object x) 方法。需要注意的是,a == b表达式中就算 a、 b 是null,也可以安全调用。因为 a==b 会被Kotlin编译器翻译成带可空性判断的 equals() 方法的调用: a?.equals(b) ?: (b === null) 。
而3个等于号 === 是Kotlin中自己实现的运算符,这个运算符不能被重载,它不仅比较值是否相等,还会去比较对象的引用是否相等。因为BigDecimal.ONE 是常量,在JVM内存模型中是存在常量区的,所以 bd1 === bd2 返回的也是 true 。
例如,我们对下面的Point类
class Point(val x:Int, val y:Int)
如果我们不去重载实现其equals() 函数,编译器会去自动生成一个equals()函数跟hashCode()函数
class Point(val x:Int, val y:Int){
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Point
if (x != other.x) return false
if (y != other.y) return false
return true
}
override fun hashCode(): Int {
var result = x
result = 31 * result + y
return result
}
}
而如果我们想自定义相等的判断方法,可以进行重写实现其 equals() 函数。
现在,我们来比较两个Point对象的大小。如果我们定义Point对象之间大小的比较标准是其范数(用来度量某个向量空间(或矩阵)中的每个向量的长度)的大小,那么比较运算符的重载函数实现如下
operator fun compareTo(other: Point): Int {
val thisNorm = Math.sqrt((this.x * this.x + this.y * this.y).toDouble())
val otherNorm = Math.sqrt((other.x * other.x + other.y * other.y).toDouble())
return thisNorm.compareTo(otherNorm)
}
测试代码
val p1 = Point(1, 1)
val p2 = Point(1, 1)
val p3 = Point(1, 3)
println(p1 >= p2) // true
println(p3 > p1) // true
11.5 重载计算赋值运算符
同样的计算赋值运算符
表达式 | 翻译成运算符重载函数的调用 |
---|---|
a += b |
a.plusAssign(b) |
a -= b |
a.minusAssign(b) |
a *= b |
a.timesAssign(b) |
a /= b |
a.divAssign(b) |
a %= b |
a.remAssign(b) |
如果我们想要重载某个类型的这些赋值运算符,只需要实现其对应的运算符重载函数即可。
本章小结
在进行对象之间的运算时,编译器解析的时候会去调用对应运算符重载函数。为了代码简单易懂,在实现运算符重载函数的时候一定要考虑其实际问题场景的意义,并且在运算符重载函数上写清楚对象之间的比较规则,注释写清楚。否则,如果滥用运算符重载,会导致代码易读性大大下降。
Kotlin 开发者社区
国内第一Kotlin 开发者社区公众号,主要分享、交流 Kotlin 编程语言、Spring Boot、Android、React.js/Node.js、函数式编程、编程思想等相关主题。