Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

操作符重载

在计算机编程的上下文中,重载 意味着“为已经存在的东西添加额外的含义”。

操作符重载 允许你为类似 + 这样的操作符赋予你的新类型一定的意义,或者为现有类型赋予额外的含义。

操作符重载经历了一个风雨飘摇的历史。它在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
}

ab 引用了内存中的不同对象,所以引用是不同的,a == bfalse,即使这两个对象存储了相同的数据。ac 引用了内存中的相同对象,所以比较它们会产生 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() 值。标准的数据结构如 MapSet 在没有这个规则的情况下将无法正常工作。对于一个 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),如果 xval,并且相应的 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 == 7true。你可以在文档中找到操作符优先级表。

有时在混合使用算术和编程操作符时,结果并不明显。在下面的示例中,我们结合了 + 操作符和 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)"
}

你还可以将带有 vararginvoke() 定义为与相同类型的任意数量的参数一起使用(详见可变参数列表)。

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 找到。