操作符重载
在计算机编程的上下文中,重载 意味着“为已经存在的东西添加额外的含义”。
操作符重载 允许你为类似 +
这样的操作符赋予你的新类型一定的意义,或者为现有类型赋予额外的含义。
操作符重载经历了一个风雨飘摇的历史。它在C++中得到了推广,但由于C++没有垃圾回收机制,编写重载的操作符变得困难。因此,早期的Java设计者认为操作符重载是“不好的”,并且没有在Java中允许操作符重载,尽管Java的垃圾回收机制本应使其相对容易实现。在垃圾回收支持下,操作符重载的简单性在Python语言中得以展示,该语言限制了你可以使用的一组有限(熟悉的)操作符,类似于C++。随后,Scala尝试允许你发明自己的操作符,导致一些程序员滥用此功能并编写了难以理解的代码。Kotlin从这些语言中汲取了经验教训,简化了操作符重载的过程,但限制了你的选择,将其限制在一个合理且熟悉的操作符集合中。此外,操作符优先级的规则是不能改变的。
我们将创建一个小型类 Num
,并通过重载的扩展函数添加一个 +
操作符。要重载一个操作符,你需要在 fun
前使用 operator
关键字,后跟该操作符的特殊预定义函数名。例如,+
操作符的特殊函数名是 plus()
:
// OperatorOverloading/Num.kt
package operatoroverloading
import atomictest.eq
data class Num(val n: Int)
operator fun Num.plus(rval: Num) =
Num(n + rval.n)
fun main() {
Num(4) + Num(5) eq Num(9)
Num(4).plus(Num(5)) eq Num(9)
}
如果你正在为在两个操作数之间使用的普通(非操作符)函数进行定义,你可以使用 infix
关键字,但操作符本身已经是 infix
了。因为 plus()
是一个普通的函数,你也可以以常规方式调用它。
当你将操作符定义为成员函数时,你可以访问类中的 private
元素,而扩展函数则不能:
// OperatorOverloading/MemberOperator.kt
package operatoroverloading
import atomictest.eq
data class Num2(private val n: Int) {
operator fun plus(rval: Num2) =
Num2(n + rval.n)
}
// 无法访问 'n':它在 'Num2' 中是私有的:
// operator fun Num2.minus(rval: Num2) =
// Num2(n - rval.n)
fun main() {
Num2(4) + Num2(5) eq Num2(9)
}
在某些情况下,为操作符创建特殊含义是有帮助的。在这里,我们模拟了一个 Molecule
,使用 +
将其附加到另一个 Molecule
上。attached
属性是连接两个 Molecule
的链接:
// OperatorOverloading/Molecule.kt
package operatoroverloading
import atomictest.eq
data class Molecule(
val id: Int = idCount++,
var attached: Molecule? = null
) {
companion object {
private var idCount = 0
}
operator fun plus(other: Molecule) {
attached = other
}
}
fun main() {
val m1 = Molecule()
val m2 = Molecule()
m1 + m2 // [1]
m1 eq "Molecule(id=0, attached=" +
"Molecule(id=1, attached=null))"
}
- [1] 读起来像一个熟悉的数学表达式,但对于使用该模型的人来说,这可能是一个特别有意义的语法。
这个示例还不完整;如果你添加了一行 m2 + m1
,然后尝试显示 m2
,你将会遇到堆栈溢出问题(你能解决这个问题吗?)。
相等性
调用 ==
(相等性)或 !=
(不相等性)会调用 equals()
成员函数。data
类会自动重新定义 equals()
来比较存储的数据,但是如果你不为非 data
类重新定义 equals()
,默认版本将比较引用而不是内容:
// OperatorOverloading/DefaultEquality.kt
package operatoroverloading
import atomictest.eq
class A(val i: Int)
data class D(val i: Int)
fun main() {
// 普通类:
val a = A(1)
val b = A(1)
val c = a
(a == b) eq false
(a == c) eq true
// data 类:
val d = D(1)
val e = D(1)
(d == e) eq true
}
a
和 b
引用了内存中的不同对象,所以引用是不同的,a == b
是 false
,即使这两个对象存储了相同的数据。a
和 c
引用了内存中的相同对象,所以比较它们会产生 true
。因为 data class D
自动生成了一个比较 D
内容的 equals()
,所以 d == e
会产生 true
。
equals()
是唯一一个不能作为扩展函数的操作符;它必须作为一个成员函数进行覆盖。在
覆盖自己的 equals()
时,你会重写默认的 equals(other: Any?)
函数。注意 other
的类型是 Any?
,而不是你的类的具体类型。这允许你将你的类型与其他类型进行比较,这意味着你必须选择允许比较的类型:
// OperatorOverloading/DefiningEquality.kt
package operatoroverloading
import atomictest.eq
class E(var v: Int) {
override fun equals(other: Any?) = when {
this === other -> true // [1]
other !is E -> false // [2]
else -> v == other.v // [3]
}
override fun hashCode(): Int = v
override fun toString() = "E($v)"
}
fun main() {
val a = E(1)
val b = E(2)
(a == b) eq false // a.equals(b)
(a != b) eq true // !a.equals(b)
// 引用相等性:
(E(1) === E(1)) eq false
}
- [1] 这是一个优化:如果
other
引用了内存中的同一个对象,结果自动为true
。三重等号符号===
用于测试引用相等性。 - [2] 这确定
other
的类型必须与当前类型相同。为了将E
与其他类型进行比较,你可以添加更多的匹配表达式。 - [3] 这比较了存储的数据。此时编译器已经知道
other
的类型是E
,因此我们可以在没有强制转换的情况下访问other.v
。
在覆盖 equals()
时,你还应该覆盖 hashCode()
。这是一个复杂的话题,但基本规则是,如果两个对象相等,它们必须产生相同的 hashCode()
值。标准的数据结构如 Map
和 Set
在没有这个规则的情况下将无法正常工作。对于一个 open
类来说,情况会变得更加复杂,因为你必须将一个实例与所有可能的子类进行比较。你可以在Wikipedia上了解更多有关哈希的概念。
定义适当的 equals()
和 hashCode()
超出了本书的范围,我们在这里所做的只是演示了概念,并适用于我们简单示例的情况,但不适用于更复杂的情况。这种复杂性是为什么 data
类会创建自己的 equals()
和 hashCode()
的原因。如果你必须定义自己的 equals()
和 hashCode()
,我们建议使用IntelliJ IDEA或Android Studio自动生成这些方法,方法是使用操作 Generate -> equals and hashCode
。
当你使用 ==
比较可为空的对象时,Kotlin 强制执行 null
检查。你可以使用 if
或 Elvis 操作符来实现这一点:
// OperatorOverloading/EqualsForNullable.kt
package operatoroverloading
import atomictest.eq
fun equalsWithIf(a: E?, b: E?) =
if (a === null)
b === null
else
a == b
fun equalsWithElvis(a: E?, b: E?) =
a?.equals(b) ?: (b === null)
fun main() {
val x: E? = null
val y = E(0)
val z: E? = null
(x == y) eq false
(x == z) eq true
equalsWithIf(x, y) eq false
equalsWithIf(x, z) eq true
equalsWithElvis(x, y) eq false
equalsWithElvis(x, z) eq true
}
equalsWithIf()
首先检查引用 a
是否为 null
,在这种情况下,只有在引用 b
也是 null
时它们才会相等。如果 a
不是 null
引用,就使用成员 equals()
来比较两者。equalsWithElvis()
则使用 ?.
和 ?:
来实现相同的效果,更加简洁。
在Kotlin中,你可以通过扩展函数来定义基本的算术操作符,让它们适用于类 E
:
// OperatorOverloading/ArithmeticOperators.kt
package operatoroverloading
import atomictest.eq
// 一元操作符:
operator fun E.unaryPlus() = E(v)
operator fun E.unaryMinus() = E(-v)
operator fun E.not() = this
// 增加/减少:
operator fun E.inc() = E(v + 1)
operator fun E.dec() = E(v - 1)
fun unary(a: E) {
+a // unaryPlus()
-a // unaryMinus()
!a // not()
var b = a
b++ // inc()(必须是 var)
b-- // dec()(必须是 var)
}
// 二元操作符:
operator fun E.plus(e: E) = E(v + e.v)
operator fun E.minus(e: E) = E(v - e.v)
operator fun E.times(e: E) = E(v * e.v)
operator fun E.div(e: E) = E(v % e.v)
operator fun E.rem(e: E) = E(v / e.v)
fun binary(a: E, b: E) {
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)
}
// 增强赋值:
operator fun E.plusAssign(e: E) { v += e.v }
operator fun E.minusAssign(e: E) { v - e.v }
operator fun E.timesAssign(e: E) { v *= e.v }
operator fun E.divAssign(e: E) { v /= e.v }
operator fun E.remAssign(e: E) { v %= e.v }
fun assignment(a: E, b: E) {
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)
}
fun main() {
val a = E(2)
val b = E(3)
a + b eq E(5)
a * b eq E(6)
val x = E(1)
x += b * b
x eq E(10)
}
在编写扩展时,记住扩展类型的属性和函数是隐式可用的。例如,在 unaryPlus()
的定义中,E(v)
中的 v
是正在被扩展的 E
的属性。
请注意,x += e
可以解析为 x = x.plus(e)
,如果 x
是一个 var
,或者解析为 x.plusAssign(e)
,如果 x
是 val
,并且相应的 plusAssign()
成员可用。如果两个选项都可以工作,编译器将发出错误,指示它无法选择。
参数可以与扩展操作符所扩展的类型不同。在这里,E
的 +
操作符扩展接受一个 Int
参数:
// OperatorOverloading/DifferentTypes.kt
package operatoroverloading
import atomictest.eq
operator fun E.plus(i: Int) = E(v + i)
fun main() {
E(1) + 10 eq E(11)
}
操作符的优先级是固定的,对于内置类型和自定义类型来说是相同的。例如,乘法的优先级高于加法,二者的优先级都高于相等性。因此,1 + 2 * 3 == 7
是 true
。你可以在文档中找到操作符优先级表。
有时在混合使用算术和编程操作符时,结果并不明显。在下面的示例中,我们结合了 +
操作符和 Elvis 操作符:
// OperatorOverloading/ConfusingPrecedence.kt
package operatoroverloading
import atomictest.eq
fun main() {
val x: Int? = 1
val y: Int = 2
val sum = x ?: 0 + y
sum eq 1
(x ?: 0) + y eq 3 // [1]
x ?: (0 + y) eq 1 // [2]
}
在 sum
中,+
操作符的优先级高于 Elvis 操作符 ?:
,所以结果是 1 ?: (0 + 2) == 1
。这可能不是程序员想要的结果。当混合使用优先级不明显的不同操作时,建议添加括号,如 [1] 和 [2] 行所示。
比较操作
当你定义 compareTo()
时,所有的比较操作符 <
、>
、<=
、>=
会自动可用:
// OperatorOverloading/Comparison.kt
package operatoroverloading
import atomictest.eq
operator fun E.compareTo(e: E): Int =
v.compareTo(e.v)
fun main() {
val a = E(2)
val b = E(3)
(a < b) eq true // a.compareTo(b) < 0
(a > b) eq false // a.compareTo(b) > 0
(a <= b) eq true // a.compareTo(b) <= 0
(a >= b) eq false // a.compareTo(b) >= 0
}
compareTo()
必须返回一个 Int
值,表示:
- 如果两个元素相等,返回
0
。 - 如果第一个元素(接收者)大于第二个元素(参数),返回正值。
- 如果第一个元素小于第二个元素,返回负值。
区间和容器
rangeTo()
可以为 ..
操作符创建范围,而 contains()
可以指示一个值是否在范围内:
// OperatorOverloading/Ranges.kt
package operatoroverloading
import atomictest.eq
data class R(val r: IntRange) { // 范围
override fun toString() = "R($r)"
}
operator fun E.range
To(e: E) = R(v..e.v)
operator fun R.contains(e: E): Boolean =
e.v in r
fun main() {
val a = E(2)
val b = E(3)
val r = a..b // a.rangeTo(b)
(a in r) eq true // r.contains(a)
(a !in r) eq false // !r.contains(a)
r eq R(2..3)
}
容器访问
通过重载 contains()
,你可以检查一个值是否在容器中,而 get()
和 set()
支持使用方括号读取和分配容器中的元素:
// OperatorOverloading/ContainerAccess.kt
package operatoroverloading
import atomictest.eq
data class C(val c: MutableList<Int>) {
override fun toString() = "C($c)"
}
operator fun C.contains(e: E) = e.v in c
operator fun C.get(i: Int): E = E(c[i])
operator fun C.set(i: Int, e: E) {
c[i] = e.v
}
fun main() {
val c = C(mutableListOf(2, 3))
(E(2) in c) eq true // c.contains(E(2))
(E(4) in c) eq false // c.contains(E(4))
c[1] eq E(3) // c.get(1)
c[1] = E(4) // c.set(2, E(4))
c eq C(mutableListOf(2, 4))
}
在 IntelliJ IDEA 或 Android Studio 中,你可以从使用处导航到函数或类的声明中。这也适用于操作符:你可以将光标放在 ..
上,然后导航到其定义,以查看调用了哪个操作符函数。
调用操作
在对象后面加上括号会生成对 invoke()
的调用,因此 invoke()
操作符使对象看起来像一个函数。你可以定义带有任意数量参数的 invoke()
:
// OperatorOverloading/Invoke.kt
package operatoroverloading
import atomictest.eq
class Func {
operator fun invoke() = "invoke()"
operator fun invoke(i: Int) = "invoke($i)"
operator fun invoke(i: Int, j: String) =
"invoke($i, $j)"
operator fun invoke(
i: Int, j: String, k: Double
) = "invoke($i, $j, $k)"
}
fun main() {
val f = Func()
f() eq "invoke()"
f(22) eq "invoke(22)"
f(22, "Hi") eq "invoke(22, Hi)"
f(22, "Three", 3.1416) eq
"invoke(22, Three, 3.1416)"
}
你还可以将带有 vararg
的 invoke()
定义为与相同类型的任意数量的参数一起使用(详见可变参数列表)。
invoke()
也可以定义为扩展函数。在这里,它是一个对 String
的扩展,接受一个函数作为参数,并在该函数上调用 String
:
// OperatorOverloading/StringInvoke.kt
package operatoroverloading
import atomictest.eq
operator fun String.invoke(
f: (s: String) -> String
) = f(this)
fun main() {
"mumbling" { it.toUpperCase() } eq
"MUMBLING"
}
由于 lambda 是最终的 invoke()
参数,可以在不使用括号的情况下直接调用它。
如果你有一个函数引用,可以使用它通过括号直接调用函数,或通过 invoke()
调用:
// OperatorOverloading/InvokeFunctionType.kt
package operatoroverloading
import atomictest.eq
fun main() {
val func: (String) -> Int = { it.length }
func("abc") eq 3
func.invoke("abc") eq 3
val nullableFunc: ((String) -> Int)? = null
if (nullableFunc != null) {
nullableFunc("abc")
}
nullableFunc?.invoke("abc") // [1]
}
- [1] 如果函数引用可为空,可以将
invoke()
和安全访问组合使用。
自定义 invoke()
最常见的用途是在创建 DSL 时。
用反引号括起来的函数名
Kotlin 允许在函数名前后使用反引号来包含空格、特定的非标准字符和保留字:
// OperatorOverloading/Backticks.kt
package operatoroverloading
fun `A long name with spaces`() = Unit
fun `*how* is this working?`() = Unit
fun `'when' is a keyword`() = Unit
// fun `Illegal characters :<>`() = Unit
fun main() {
`A long name with spaces`()
`*how* is this working?`()
`'when' is a keyword`()
}
这在单元测试中特别有用,因为你可以创建包含关于测试细节的可读性良好的测试名称。它还简化了与 Java 代码的交互。
你可以轻松地创建难以理解的代码:
// OperatorOverloading/Swearing.kt
package operatoroverloading
import atomictest.eq
infix fun String.`#!%`(s: String) =
"$this Rowzafrazaca $s"
fun main() {
"howdy" `#!%` "Ma'am!" eq
"howdy Rowzafrazaca Ma'am!"
}
Kotlin 接受这种代码,但是对于读者来说这意味着什么呢?因为代码被阅读的次数远远多于编写的次数,所以你应该使你的程序尽可能易于理解。
- -
操作符重载不是一个必要的特性,但它是一个很好的例子,说明了语言不仅仅是操作底层计算机的方式。挑战在于通过精心设计语言来提供更好的方式来表达抽象概念,从而使人们更容易理解代码,而不会被不必要的细节所
困扰。虽然可以以使意义变得晦涩难懂的方式定义操作符,但请谨慎行事。
一切都是语法糖。卫生纸也是语法糖,但我仍然需要它。 —— 巴里·霍金斯
练习和解答可在 www.AtomicKotlin.com 找到。