Kotlin 操作符重载及中缀调用

操作符重载其实很有意思!但这个概念却很少有人知道,使用操作符重载在某种程度上会给代码的阅读带来一定的麻烦。因此,慎用操作符被认为是一个好习惯。的确,操作符重载是一把双刃剑,既能削铁如泥,也能“引火烧身”,这篇文章将从实用的角度来讲解操作符重载的基本用法。

支持重载的操作符类型

Kotlin语言支持重载的操作符类型比较多。以最新版本1.2.21为准,目前支持重载的操作符可以归纳为以下几类:

一元操作符

一元前缀操作符

操作符 对应方法
+a a.unaryPlus()
-a a.unaryMinus()
!a a.not()

以上三个操作符在日常使用中频率很高,第一个操作符在基本运算中很少使用,第二个操作符就是常见的取反操作,第三个操作符是逻辑取反操作。接下来,我们使用扩展的方式重载这三个操作符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* 一元操作符
*
* @author Scott Smith 2018-02-03 14:11
*/
data class Number(var value: Int)

/**
* 重载一元操作符+,使其对Number中实际数据取绝对值
*/
operator fun Number.unaryPlus(): Number {
this.value = Math.abs(value)
return this
}

/**
* 重载一元操作符-,使其对Number中实际数据取反
*/
operator fun Number.unaryMinus(): Number {
this.value = -value
return this
}

/**
* 这个操作符通常是用于逻辑取反,这里用一个没有意义的操作,来模拟重载这个操作符
* 结果:始终返回Number中实际数据的负值
*/
operator fun Number.not(): Number {
this.value = -Math.abs(value)
return this
}

fun main(args: Array<String>) {
val number = Number(-3)
println("Number value = ${number.value}")
println("After unaryPlus: Number value = ${(+number).value}")
println("After unaryMinus: Number value = ${(-number).value}")

number.value = Math.abs(number.value)
println("After unaryNot: Number value = ${(!number).value}")
}

运行上述代码,将得到如下结果:

1
2
3
4
Number value = -3
After unaryPlus: Number value = 3
After unaryMinus: Number value = -3
After unaryNot: Number value = -3

自增和自减操作符

操作符 对应方法
a++/++a a.inc()
a–/–a a.dec()

重载这个操作符相对比较难理解,官方文档有一段简短的文字解释,翻译成代码可以这样表示:

1
2
3
4
5
6
7
8
9
10
11
12
// a++
fun increment(a: Int): Int {
val a0 = a
a = a + 1
return a0
}

// ++a
fun increment(a: Int): Int {
a = a + 1
return a
}

看懂上面的代码后,我们换成需要重载的Number类,Kotlin最终会这样处理:

1
2
3
4
5
6
7
8
9
10
11
// Number++
fun increment(number: Number): Number {
val temp = number
val result = number.inc()
return result
}

// Number++
fun increment(number: Number): Number {
return number.inc()
}

因此,重载Number类自加操作符,我们可以这样做:

1
2
3
operator fun Number.inc(): Number {
return Number(this.value + 1)
}

重载自减操作符同理,完整代码请参考我的Git版本库:kotlin-samples

二元操作符

算术运算符

操作符 对应方法
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)
a..b a.rangeTo(b)

前5个操作符相对比较好理解,我们以a + b为例,举个一个简单的例子:

1
2
3
4
5
6
7
8
9
10
// 重载Number类的加法运算符
operator fun Number.plus(value: Int): Number {
return Number(this.value + value)
}

fun main(args: Array<String>) {
println((Number(1) + 2))
}
// 输出结果:
Number value = 3

相对比较难理解的是第六个范围运算符,这个操作符主要用于生成一段数据范围。我们认为Number本身就代表一个整型数字,因此,重载Number是一件有意义的事情。直接看例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
operator fun Number.rangeTo(to: Number): IntRange {
return this.value..to.value
}

fun main(args: Array<String>) {
val startNumber = Number(3)
val endNumber = Number(9)

(startNumber..endNumber).forEach {
println("value = $it")
}
}

// 运行结果:
value = 3
value = 4
value = 5
value = 6
value = 7
value = 8
value = 9

“In”运算符

操作符 对应方法
a in b b.contains(a)
a !in b !b.contains(a)

这个操作符相对比较好理解,重载这个操作符可以用于判断某个数据是否在另外一个对象中。我们用一个非常简单的自定义类来模拟集合操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class IntCollection { 
val intList = ArrayList<Int>()
}

// 重载"in"操作符
operator fun IntCollection.contains(value: Int): Boolean {
return this.intList.contains(value)
}

fun main(args: Array<String>) {
val intCollection = IntCollection()
intCollection.add(1, 2, 3)
println(3 in intCollection)
}

// 输出结果:
true

索引访问运算符

操作符 对应方法
a[i] a.get(i)
a[i, j] a.get(i, j)
a[i_1, …, i_n] a.get(i_1, …, i_n)
a[i] = b a.set(i, b)
a[i, j] = b a.set(i, j, b)
a[i_1, …, i_n] = b a.set(i_1, …, i_n, b)

这个操作符很有意思,例如,如果你要访问Map中某个数据,通常是这样的map.get("key"),使用索引运算符你还可以这样操作:

1
val value = map["key"]

我们继续以IntCollection类为例,尝试重写a[i]a[i] = b两个运算符,其它运算符同理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 重载a[i]操作符
operator fun IntCollection.get(index: Int): Int {
return intList[index]
}

// 重载a[i] = b操作符
operator fun IntCollection.set(index: Int, value: Int) {
intList[index] = value
}

fun main(args: Array<String>) {
val intCollection = IntCollection()
intCollection.add(1, 2, 3)
println(intCollection[0])

intCollection[2] = 4
print(intCollection[2])
}

接下来,我们用索引运算符来做一点更有意思的事情!新建一个普通的KotlinUser

1
2
3
4
class User(var name: String,
var age: Int) {

}

使用下面的方式重载索引运算符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
operator fun User.get(key: String): Any? {
when(key) {
"name" -> {
return this.name
}
"age" -> {
return this.age
}
}

return null
}

operator fun User.set(key: String, value:Any?) {
when(key) {
"name" -> {
name = value as? String
}
"age" -> {
age = value as? Int
}
}
}

接下来,你会神奇地发现,一个普通的Kotlin类居然也可以使用索引运算符对成员变量进行操作了,是不是很神奇?

1
2
3
4
5
6
fun main(args: Array<String>) {
val user = User("Scott Smith", 18)
println(user["name"])
user["age"] = 22
println(user["age"])
}

因此,索引运算符不仅仅可以对集合类数据进行操作,对一个普通的Kotlin类也可以发挥同样的作用。如果你脑洞足够大,你还可以发现更多更神奇的玩法。

调用操作符

操作符 对应方法
a() a.invoke()
a(i) a.invoke(i)
a(i, j) a.invoke(i, j)
a(i_1, ……, i_n) a.invoke(i_1, ……, i_n)

重载这个操作符并不难,理解它的应用场景却有一定的难度。为了理解它的应用场景,我们来举一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
class JsonParser {

}

operator fun JsonParser.invoke(json: String): Map<String, Any> {
val map = Json.parse(json)
...
return map
}

// 可以这样调用
val parser = JsonParser()
val map = parser("{name: \"Scott Smith\"}")

这里的调用有点像省略了一个解析Json数据的方法,难道它仅仅就是这个作用吗?是的,调用操作符其实就这一个作用。如果一个Kotlin类仅仅只有一个方法,直接使用括号调用的确是一个不错的主意。不过,在使用的时候还是要稍微注意一下,避免出现歧义。

广义赋值操作符

操作符 对应方法
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)

这个操作符相对比较好理解,我们以Number类为例,举一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 广义赋值运算符
operator fun Number.plusAssign(value: Int) {
this.value += value
}

fun main(args: Array<String>) {
val number = Number(1)
number += 2
println(number)
}

// 输出结果:
Number value = 3

相等与不等操作符

操作符 对应方法
a == b a?.equals(b) ?: (b === null)
a != b !(a?.equals(b) ?: (b === null))

重载这个操作符与Java重写equals方法是一样的。不过,这里要注意与Java的区别,在Java端==用于判断两个对象是否是同一对象(指针级别)。而在Kotlin语言中,如果我们不做任何处理,==等同于使用Java对象的equals方法判断两个对象是否相等。

另外,这里还有一种特殊情况,如果左值等于null,这个时候a?.equals(b)将返回null值。因此,这里还增加了?:运算符用于进一步判断,在这个情况下,当且仅当b === null的时候,a、b才有可能相等。因此,才有了上面的对应关系,这里以User类为例举一个简单的例子:

1
2
3
4
5
6
7
8
9
10
class User(var name: String?,
var age: Int?) {

operator override fun equals(other: Any?): Boolean {
if(other is User) {
return (this.name == other.name) && (this.age == other.age)
}
return false
}
}

注意:这里有一个特殊的地方,与其它操作符不一样的地方是,如果使用扩展的方式尝试重载该操作符,将会报错。因此,如果要重载该操作符,一定要在类中进行重写。

比较操作符

操作符 对应方法
a > b a.compareTo(b) > 0
a < b a.compareTo(b) < 0
a >= b a.compareTo(b) >= 0
a <= b a.compareTo(b) <= 0

比较操作符是一个在日常使用中频率非常高的操作符,重载这个操作符只需要掌握以上表格中几个规则即可。我们以Number类为例举一个简单的例子:

1
2
3
operator fun Number.compareTo(number: Number): Int {
return this.value - number.value
}

属性委托操作符

属性委托操作符是一种非常特殊的操作符,其主要用在代理属性中。关于Kotlin代理的知识,如果你还不了解的话,请参考这篇文章
Delegation。这篇文章介绍的相对简略,后面会出一篇更详细的文章介绍代理相关的知识。

中缀调用

看到这里,可能有一些追求更高级玩法的同学会问:Kotlin支持自定义操作符吗?

答案当然是:不能!不过,别失望,infix也许适合你,它其实可以看做一种自定义操作符的实现。这里我们对集合List新增一个扩展方法intersection用于获取两个集合的交集:

1
2
3
4
5
6
7
8
9
10
11
// 获取两个集合的交集
fun <E> List<E>.interSection(other: List<E>): List<E> {
val result = ArrayList<E>()
forEach {
if(other.contains(it)) {
result.add(it)
}
}

return result
}

接下来,我们就可以在List及其子类中使用点语法调用了。但,它看起来仍然不像一个操作符。为了让它更像一个操作符,我们继续做点事情:

  • 添加infix关键词
  • 将函数名修改为∩(这是数学上获取交集的标记符号)
    然而,万万没想到,修改完成后居然报错了。Kotlin并不允许直接使用特殊符号作为函数名开头。因此,我们取形近的字母n用于表示函数名:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 获取两个集合的交集
    infix fun <E> List<E>.n(other: List<E>): List<E> {
    val result = ArrayList<E>()
    forEach {
    if(other.contains(it)) {
    result.add(it)
    }
    }

    return result
    }

接下来,我们就可以这样调用了val interSection = list1 n list2,怎么样?是不是很像自定义了一个获取交集的操作符n?如果你希望自定义操作符,可以尝试这么做。

其实infix的应用场景还不止这些,接下来,我们再用它完成一件更有意思的事情。

在实际项目开发中,数据库数据到对象的处理是一件繁琐的过程,最麻烦的地方莫过于思维的转换。那我们是否可以在代码中直接使用SQL语句查询对象数据呢?例如这样:

1
val users = Select * from User where age > 18

纸上学来终觉浅,觉知此事需躬行。有了这个idea,接下来,我们就朝着这个目标努力。
一、先声明一个Sql类,准备如下方法:

1
2
3
4
5
6
7
infix fun select(columnBuilder: ColumnBuilder): Sql {

infix fun from(entityClass: Class<*>): Sql

infix fun where(condition: String): Sql

fun <T> query(): T

二、我们的目的是:最终转换到SQL语句形式。因此,增加如下实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
class ColumnBuilder(var columns: Array<out String>) {

}

class Sql private constructor() {
var columns = emptyList<String>()
var entityClass: Class<*>? = null
var condition: String? = null

companion object {
fun get(): Sql {
return Sql()
}
}

infix fun select(columnBuilder: ColumnBuilder): Sql {
this.columns = columnBuilder.columns.asList()
return this
}

infix fun from(entityClass: Class<*>): Sql {
this.entityClass = entityClass
return this
}

infix fun where(condition: String): Sql {
this.condition = condition
return this
}

fun <T> query(): T {
// 此处省略所有条件判断
val sqlBuilder = StringBuilder("select ")

val columnBuilder = StringBuilder("")
if(columns.size == 1 && columns[0] == "*") {
columnBuilder.append("*")
} else {
columns.forEach {
columnBuilder.append(it).append(",")
}
columnBuilder.delete(columns.size - 1, columns.size)
}

val sql = sqlBuilder.append(columnBuilder.toString())
.append(" from ${entityClass?.simpleName} where ")
.append(condition)
.toString()
println("执行SQL查询:$sql")

return execute(sql)
}

private fun <T> execute(sql: String): T {
// 仅仅用于测试
return Any() as T
}
}

三、为了看起来更形似,再增加如下两个方法:

1
2
3
4
5
6
7
8
9
// 使其看起来像在数据库作用域中执行
fun database(init: Sql.()->Unit) {
init.invoke(Sql.get())
}

// 因为infix限制,参数不能直接使用可变参数。因此,我们增加这个方法使参数组装看起来更自然
fun columns(vararg columns: String): ColumnBuilder {
return ColumnBuilder(columns)
}

接下来,就是见证奇迹的时刻!

1
2
3
4
5
6
7
8
fun main(args: Array<String>) {
database {
(select (columns("*")) from User::class.java where "age > 18").query()
}
}

// 输出结果:
执行SQL查询:select * from User where age > 18

为了方便大家查看,我们提取完整执行代码段与SQL语句对比:

1
2
select          *       from User             where  age > 18
select (columns("*")) from User::class.java where "age > 18"

神奇吗?
至此,我们就可以直接在代码中愉快地使用类似SQL语句的方式进行方法调用了。

总结

本篇文章从操作符重载实用的角度讲解了操作符重载的所有相关知识。如文章开头所说,操作符重载是一把双刃剑。用得好事半功倍,用不好事倍功半。因此,我给大家的建议是:使用的时候一定要保证能够自圆其说,简单来说,就是自然。我认为相对于古老的语言C++来说,Kotlin语言操作符重载的设计是非常棒的。如果你知道自己在做什么,我非常推荐你在生产环境中使用操作符重载来简化操作。

本篇文章例子代码点这里:kotlin-samples


我是欧阳锋,一个热爱Kotlin语言编程的学生。如果你喜欢我的文章,请在文章下方留下你爱的印记。如果你不喜欢我的文章,请先喜欢上我的文章。然后再留下爱的印记!

下次文章再见,拜拜!


欧阳锋工作室 wechat
扫描二维码,关注欧阳锋工作室