运算符重载在 Kotlin 里比 Java 更彻底:Java 只允许重载 + - * / % ++ -- == 这几个符号,Kotlin 可以给任意函数起 infix 名字作为中缀调用。这带来很大的表达力,也带来很大的坑——a to b 到底该是什么语义、- 被重载成删除时调用方会不会误解,都是真实会踩的。面试里这题考的是边界感:什么时候该重载,什么时候是滥用。
先把结论放在前面:Kotlin 用 operator 修饰符重载运算符,编译后落到一组约定好的方法名(plus、minus、times、div、rem、inc、dec、get、set、contains、compareTo、rangeTo、invoke)。infix fun 是另一回事——它让任意单参数成员函数或扩展函数可以写成 a foo b 的中缀形式,本质仍是普通函数调用,只是换了写法。核心纪律是:重载保留符号的原始语义(+ 是相加、get 是取值),infix 只用于那些"读起来像一句话"的操作。
机制拆解
先看运算符重载的映射表。a + b → a.plus(b);a += b → a.plusAssign(b)(若不存在则回退到 a = a.plus(b));a[0] → a.get(0);a[0] = v → a.set(0, v);a in b → b.contains(a);a..b → a.rangeTo(b);a++ → a.inc()。这套约定是 Kotlin 给自定义类型提供"像内置类型一样体验"的基础,也是它能写出 val s = "a" + "b"(String.plus)、val t = arr[0] 这类代码的原因。
plus 与 plusAssign 的区别值得单独说:plus 返回新对象(不改变原对象),plusAssign 直接修改原对象并返回 this。如果只定义了 plus 而对象是 val,+= 会编译成 a = a.plus(b);如果对象是 var 且只有 plus,+= 同样是 a = a.plus(b);如果两者都定义,+= 优先用 plusAssign。这个规则导致了一个经典坑:同时定义了 plus 和 plusAssign 时,x += y 的语义取决于 x 是 val 还是 var,行为会不一致(有的产生新对象、有的原地修改),容易引发集合被意外修改的 bug。
中缀函数的规则是:必须是成员函数或扩展函数、单个参数、返回值非 Unit、不能用泛型以外的可变参数。调用形式可以加括号也可以不加:a to b 等价于 a.to(b)。中缀的优先级低于算术与函数调用,但高于 &&、||、=——这个优先级关系导致某些写法会有可读性问题(比如 1 to 2 + 3 实际是 1 to (2+3),因为函数调用优先级更高)。
这些坑的正确绕法
最常见的坑是把 minus 重载成删除语义以外的东西,调用方按符号直觉理解出错。 典型是把 val dict = Dict(); dict - "key" 解释成"删除"——这本身还算符合直觉,但更危险的是把 + 用在语义相反的场景(比如用 + 表示"合并并丢弃左侧重复项"),或者用 - 表示"求交集"。根因是符号有强烈的先验语义,一旦违背,代码读起来是误导的。规矩是:符号重载后必须与原始语义一致——+ 做合并/相加,- 做移除/相减,* 做缩放/倍增;不一致的语义请用普通具名函数(merge、intersect)。
其次是同时定义 plus 与 plusAssign,+= 行为随变量是 val 还是 var 而变。 表现是 val a = listOf(1) 加元素后 a 不变(正确),但 var b = listOf(1) 加元素后 b 变成了新列表,而另一个 var c 却原地修改了同一个集合。修法是只定义 plus(返回新对象),不定义 plusAssign——可变集合类尤其要小心,原地修改会让"共享了同一个引用"的其他代码措手不及。
还有一个更隐蔽的坑:get/set 重载了但没有同时重载 getValue/setValue,导致 val/var 委托失效或行为异常。 表现是自定义容器用 val 声明后取值报错,或者取值时意外走的是 get 而不走委托。原因是 val x = obj.a 这种属性语法走的是 getValue(operator 修饰),obj.a 走的是 get。要两者兼得需要同时定义 get 与 getValue。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
// 1) 运算符重载:映射到约定方法名
class Vec(val x: Int, val y: Int) {
operator fun plus(o: Vec) = Vec(x + o.x, y + o.y) // a + b
operator fun minus(o: Vec) = Vec(x - o.x, y - o.y) // a - b
operator fun times(k: Int) = Vec(x * k, y * k) // a * 2
operator fun unaryMinus() = Vec(-x, -y) // -a
operator fun contains(p: Int) = p == x || p == y // in
operator fun get(i: Int) = if (i == 0) x else y // a[0]
}
val v = Vec(1, 2) + Vec(3, 4) * 2 // 运算按常规优先级
// 2) plus vs plusAssign:只定义 plus,避免 += 行为不一致
class Bag(private val items: MutableList<String> = mutableListOf()) {
operator fun plus(item: String) = Bag(items + item) // 返回新对象
// 不定义 plusAssign:+= 统一走 a = a.plus(b),不会原地改
}
// 3) 中缀函数:任意单参函数可以写成 a foo b
infix fun String.times(n: Int) = repeat(n) {
this } // "ab" times 3
val t = "ab" times 3
// 中缀的优先级:低于函数调用,高于 && || =
infix fun Int.plusLabel(s: String) = "$s$this"
val label = 1 plusLabel "号" // 等价于 1.plusLabel("号")
// 注意:1 + 2 plusLabel "号" 会先算 1+2 —— 括号能消除歧义
// 4) get/set 与委托:getValue/setValue 要一起定义
class Config {
private val m = mutableMapOf<String, String>()
operator fun get(k: String) = m[k] ?: "" // config["a"]
operator fun set(k: String, v: String) {
m[k] = v }
operator fun getValue(thisRef: Config?, prop: KProperty<*>): String = m[prop.name] ?: ""
operator fun setValue(thisRef: Config?, prop: KProperty<*>, v: String) {
m[prop.name] = v }
}
class App(private val cfg: Config) {
var apiBase: String by cfg // 走 getValue/setValue
}
这段代码值得盯三处:第一处,plus 与 plusAssign 的取舍——Bag 刻意不定义 plusAssign 并写明原因,这是正确姿势;第二处,中缀函数的两条规则(单参数、非 Unit)以及"优先级低于函数调用"这个容易忽略的点,用注释标出歧义;第三处,get/set 与 getValue/setValue 同时定义的写法,说明了为什么自定义容器能同时支持 cfg["a"] 与属性委托。
这题在面试里怎么问、怎么答
"Kotlin 的运算符重载和 Java 有什么区别?"三点:①Kotlin 可以重载 get/set/invoke/contains/rangeTo/一元负号等,Java 只能重载算术与比较的少数符号;②Java 的重载必须写在类内部且不能有泛型约束差异,Kotlin 允许重载成扩展函数;③Kotlin 的 += 有 plusAssign 这个独立路径,Java 的 += 在对象上就是一次方法调用。
"infix 有什么限制?"答:必须是成员函数或扩展函数、恰好一个参数、返回值非 Unit、不能是 private 之外的某些可见性组合(可 public/internal/private 但不能是 override/open/abstract)。另外 infix 对所有类型都可用,容易造成"到处都是 infix"的风格问题——团队里最好约定只用于 to、until 这类真正读起来像自然语言的场景。
"为什么 Kotlin 的 !in 自动可用?"答:contains 运算符会自动生成 !contains 的反向形式,不需要单独实现 notContains。
"使用中遇到过什么问题?"案例一:自定义集合类同时定义了 plus 与 plusAssign,某处用 val 拿到新对象、某处用 var 原地修改,共享了同一底层集合导致数据被意外改动;修复为删掉 plusAssign。案例二:给一个领域对象重载了 - 表示"求差集",同事按直觉理解成"删除",上线后误删数据;修复为改名为具名函数 difference。
再补一个工程上值得讲清的一点:运算符重载在数学/几何类型上收益最大,在业务类型上要非常克制。 Vec + Vec、Money * rate 这类重载能显著提升可读性,因为符号本身就精确表达了语义。反过来,"用 + 表示添加监控器、用 && 表示两个条件都满足"这类把运算符当 DSL 的写法,短期看有个性,长期看会让不熟悉代码的人完全读不懂——运算符重载的本质是用符号的通用语义换表达力,只有当新语义与原语义高度一致时,这笔交易才划算。 面试里能给出这条判据,比会写十个 operator fun 更有说服力。
给正在准备面试的你
把这题画成一张"符号 → 方法名"的映射图:中间放一排符号(+ - * / [] []= in .. ++ -a a()),每个符号上下各拉一条线连到对应的方法名(plus/minus/times/div/get/set/contains/rangeTo/inc/unaryMinus/invoke)。右侧再画一个小方框放 infix fun,注明"任意单参函数 → a foo b,仍是普通调用",并标一句"优先级低于函数调用"。图下方画一条红色规则:"符号语义必须与原始语义一致;不一致就用具名函数。"这张图既是知识点清单,也是答题时的骨架。
再补工程案例与踩坑——应用落点是审查项目里所有 operator fun,确认符号语义与原始语义一致(把 + 当合并、- 当删除的改为具名函数);排查同时定义了 plus/plusAssign 的可变容器类,统一只保留 plus;给自定义容器的 get/set 补齐 getValue/setValue 以支持属性委托。
复习时别孤立刷题:顶层函数与属性——中缀函数本质是"特殊的顶层/成员函数",infix 只改写法不改语义。
划两句重点:运算符重载落到 plus/minus/get/set/contains 等约定方法名;只定义 plus 不定义 plusAssign,避免 += 行为随 val/var 变化;infix 要求单参数、非 Unit,优先级低于函数调用;符号语义必须与原义一致,不一致就用具名函数。
下一篇聊字符串模板与原生字符串:多行文本的正确姿势——沿着今天这条主线继续往前走。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:顶层函数与属性:为什么不再需要-Utils-类
下一篇预告:字符串模板与原生字符串:多行文本的正确姿势
有任何问题欢迎在评论区留言交流。