运算符重载:让 Kotlin 运算符“听懂”你的类

运算符重载,说白了就是让 Kotlin 的 +-* 这些符号,能操作你自定义的类。我刚开始学 Kotlin 时,觉得这功能挺花哨的。直到有一次写一个矩阵运算库,发现用 matrix1 + matrix2matrix1.add(matrix2) 优雅太多了。嗯,从那以后我就爱上了这个特性。

一、operator 关键字:重载的“开关”

在 Kotlin 里,想重载运算符,必须用 operator 关键字修饰函数。这是语法硬性要求,少写了编译器会报错。

data class Point(val x: Int, val y: Int) {
    operator fun plus(other: Point): Point {
        return Point(x + other.x, y + other.y)
    }
}

fun main() {
    val p1 = Point(1, 2)
    val p2 = Point(3, 4)
    val p3 = p1 + p2  // 等价于 p1.plus(p2)
    println(p3)  // Point(x=4, y=6)
}

我个人习惯把运算符重载函数定义在类内部,这样代码更内聚。但如果类不是你写的(比如第三方库),可以用扩展函数加 operator:

operator fun Point.times(scale: Int): Point {
    return Point(x * scale, y * scale)
}

fun main() {
    val p = Point(2, 3)
    val scaled = p * 5  // Point(x=10, y=15)
}
小提示:operator 函数必须用固定的函数名。比如加法用 plus,乘法用 times。Kotlin 官方文档有完整映射表,建议收藏。

二、一元运算符:单兵作战

一元运算符只操作一个对象。常见的有 +a-a!a++aa--。它们对应的函数名分别是 unaryPlusunaryMinusnotincdec

data class Vector(val x: Int, val y: Int) {
    operator fun unaryMinus() = Vector(-x, -y)
    operator fun inc() = Vector(x + 1, y + 1)
}

fun main() {
    val v = Vector(3, 4)
    val neg = -v          // Vector(-3, -4)
    var v2 = Vector(1, 2)
    v2++                  // Vector(2, 3)
}

这里有个坑:incdec 必须返回一个新对象,不能修改自身。因为 a++ 在 Kotlin 里是先返回旧值再自增,底层会调用 inc() 并重新赋值。我曾经在项目里踩过这个坑,直接修改了 this 的字段,结果并发环境下数据全乱了。

注意:一元运算符的返回类型可以和原类型不同。比如 !a 返回 Boolean 完全合理。但 inc/dec 建议返回相同类型,否则 ++ 语义会变得很奇怪。

三、二元运算符:双剑合璧

二元运算符是重载的重头戏。除了常见的 plusminustimesdivrem(取余),还有 rangeTo..)、containsin)等。

运算符 函数名 示例
+ plus a + b
- minus a - b
* times a * b
/ div a / b
% rem a % b
.. rangeTo a..b
in contains a in b
data class Money(val amount: Long, val currency: String) {
    operator fun plus(other: Money): Money {
        require(currency == other.currency) { "货币类型不一致" }
        return Money(amount + other.amount, currency)
    }
    
    operator fun times(rate: Double): Money {
        return Money((amount * rate).toLong(), currency)
    }
}

fun main() {
    val salary = Money(5000, "CNY")
    val bonus = Money(2000, "CNY")
    val total = salary + bonus  // Money(7000, "CNY")
    val tax = salary * 0.1      // Money(500, "CNY")
}

你想想看,如果不用运算符重载,上面这段代码得写成 salary.plus(bonus)salary.times(0.1),可读性差了一大截。

四、比较运算符:equals 和 compareTo

Kotlin 的 ==!= 对应 equals 函数。注意 ===(引用比较)不能重载。而 <><=>= 对应 compareTo 函数。

data class Person(val name: String, val age: Int) : Comparable<Person> {
    override fun compareTo(other: Person): Int {
        return this.age - other.age
    }
}

fun main() {
    val p1 = Person("Alice", 25)
    val p2 = Person("Bob", 30)
    println(p1 < p2)  // true,因为 25 < 30
    println(p1 == Person("Alice", 25))  // true,data class 自动生成 equals
}

这里有个细节:equals 重载时,参数类型必须是 Any?,不能是具体类型。否则 Kotlin 不会把它当作 == 的对应函数。我见过有人写成 equals(other: Person),结果 == 永远走的是默认的引用比较,调试了半天才发现。

核心要点:实现 Comparable 接口后,compareTo 自动支持所有比较运算符。这是 Kotlin 的约定,不需要额外加 operator 关键字。

五、区间运算符:.. 和 in

.. 运算符对应 rangeTo 函数,返回一个 ClosedRange。而 in!in 对应 contains 函数。这两个组合起来,能写出非常自然的区间判断代码。

data class Date(val year: Int, val month: Int, val day: Int) : Comparable<Date> {
    override fun compareTo(other: Date): Int {
        return compareValuesBy(this, other, { it.year }, { it.month }, { it.day })
    }
    
    operator fun rangeTo(other: Date): ClosedRange<Date> {
        return object : ClosedRange<Date> {
            override val start: Date get() = this@Date
            override val endInclusive: Date get() = other
        }
    }
}

fun main() {
    val start = Date(2024, 1, 1)
    val end = Date(2024, 12, 31)
    val holiday = Date(2024, 10, 1)
    
    if (holiday in start..end) {
        println("假期在2024年内")
    }
}

我个人觉得区间运算符最妙的地方在于,它让代码读起来像自然语言。你想想看,holiday in start..end 这句话,不懂编程的人也能猜出大概意思。

实战建议:如果你的类实现了 ComparablerangeTo 可以直接用 start..end 语法,Kotlin 标准库会帮你生成默认的 ClosedRange。但如果你想自定义区间行为(比如半开区间),就需要像上面那样手动实现。

六、知识体系总览

下面这张图总结了运算符重载的核心分类和对应关系,方便你快速查阅。

Kotlin 运算符重载知识体系 operator 关键字 一元运算符 +a → unaryPlus -a → unaryMinus !a → not ++a / a-- → inc / dec 二元运算符 + → plus, - → minus * → times, / → div % → rem, .. → rangeTo in → contains 比较运算符 == / != → equals < > ≤ ≥ → compareTo 实现 Comparable 接口 区间运算符 a..b → rangeTo in / !in → contains 返回 ClosedRange 所有运算符重载必须使用 operator 关键字修饰函数

七、避坑指南与最佳实践

运算符重载虽然好用,但用不好就是灾难。我总结了几条经验:

  • 不要滥用:只有运算符的语义和你的类操作天然匹配时才重载。比如用 + 表示集合合并没问题,但用 + 表示“发送邮件”就太抽象了。
  • 保持对称性:如果定义了 a + b,最好也考虑 b + a 是否合理。比如 Money * 22 * Money 都应该支持。
  • 注意不可变性:运算符重载函数应该返回新对象,不要修改操作数。这是函数式编程的惯例,也是 Kotlin 标准库的做法。
  • 文档要写清楚:团队协作时,重载运算符一定要写文档注释。否则同事看到 user + role 可能会一脸懵逼。
我曾经踩过的坑:在一个电商项目中,我重载了 Order 类的 plus 运算符用来合并订单。结果新同事接手后,以为 + 是金额相加,直接写了个 order1 + order2 去算总价,导致线上数据异常。从那以后,我规定团队里运算符重载必须经过 code review,并且要在函数上方加 @Documented 注解。

好了,运算符重载的核心内容就这些。记住一个原则:让代码读起来像自然语言,而不是让运算符变成炫技的工具。你想想看,好的代码应该是“自解释”的,运算符重载只是帮你达到这个目的的手段之一。


公众号:蓝海资料掘金营,微信deep3321