第072篇 中缀表达式与运算符重载:可读性的双刃剑

简介: Kotlin运算符重载比Java更彻底:不仅支持`+ - * /`等映射为`plus`/`minus`等约定函数,还允许任意单参函数通过`infix`声明实现中缀调用(如`a to b`)。核心原则是——符号语义必须与原始含义一致(`+`即相加,`-`即取反或相减),滥用将损害可读性。

运算符重载在 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-类

下一篇预告:字符串模板与原生字符串:多行文本的正确姿势

有任何问题欢迎在评论区留言交流。

相关文章
|
2天前
|
弹性计算 人工智能 运维
省钱攻略|阿里云轻量与 ECS 服务器:新购续费优惠解析、活动规则及新老用户选型教程
对于个人开发者、学生、小微企业而言,云服务器的新购价格与长期续费成本,是决定上云方案是否可行的核心因素。很多用户在初次选购服务器时,只关注新购的低价,却忽略续费相关政策,等到实例即将到期,才发现续费价格大幅上涨,业务成本超出预期。 阿里云服务器 的优惠体系分为新购优惠与续费优惠两大板块,轻量应用服务器、ECS弹性云服务器拥有独立的活动规则,低价套餐存在严格的资格、配置、数量限制,一旦操作不当,就会直接失去低价续费资格。
35 1
|
1天前
|
SQL 安全 Java
第073篇 字符串模板与原生字符串:多行文本的正确姿势
Kotlin字符串模板核心三点:①编译后转为`StringBuilder.append`链(全常量时优化为字面量);②原生字符串`&quot;&quot;&quot;...&quot;&quot;&quot;`保留缩进,必须用`trimIndent()`或`trimMargin()`清理;③Android中禁在循环内用模板拼接,否则触发O(n²)性能坑。安全上,模板不用于SQL/URL等需解析的场景。
23 0
|
1天前
|
并行计算 Java 测试技术
第042篇 CountDownLatch 与 CyclicBarrier:等待与协作
本文深入解析`CountDownLatch`、`CyclicBarrier`与`Semaphore`的核心差异与实战陷阱:前者为一次性事件门闩,后者为可复用集合点,`Semaphore`则基于独占模式限流。重点剖析“计数未归零阻塞”“屏障损坏”“许可泄漏”等生产级坑点,并给出`finally`防护、超时兜底、参与方一致性等落地解法,助你面试直击采分关键。
18 0
第042篇 CountDownLatch 与 CyclicBarrier:等待与协作
|
1天前
|
监控 IDE Java
第023篇 自定义异常与异常链:生产代码怎么设计错误
本文深入剖析自定义异常与异常链的实战要点,提炼出“动机—上下文—链路—反例”四步公式,直击面试高频追问(如“为何不用RuntimeException?”)。通过典型踩坑案例(丢cause、泛化捕获、序列化失败)和可运行代码,讲清如何科学分类错误、精准携带订单号等上下文、完整保留根因链。强调:异常是可观测性的第一现场,设计不当反增维护成本。
19 0
|
1天前
|
安全 Java 编译器
第013篇 final 的四种用法:变量、方法、类与参数
本文深度解析 Java 中 `final` 的四大语义:变量(引用/值仅赋值一次)、参数(方法内不可重赋值)、方法(禁止重写)、类(禁止继承)。厘清“引用不可变≠内容不可变”“final 不保证线程安全”“this 逸出破坏可见性”等高频误区,结合源码、面试话术与工程实践,助你透彻掌握 final 的原理、坑点与最佳用法。
17 0
|
1天前
|
设计模式 安全 算法
第057篇 设计模式入门:单例、工厂、观察者的 Android 落地
设计模式本质是应对“变化”的解法:封装可变点,降低耦合。Android中,它源于真实痛点——如创建分散、行为需替换、状态需通知等。关键不在背23种名称,而在识别“哪处会因需求变更而反复修改”。单例防多实例、工厂解耦创建、策略隔离算法、观察者实现松耦合通信。用错的根源往往是“为模式而模式”,而非解决真实变化。
25 0
|
1天前
|
缓存 网络协议 测试技术
第052篇 Socket 与 HTTP:网络编程的两层视角
本文深入解析Android网络底层:Socket(传输层字节流)与HTTP(应用层语义协议)的本质区别及协作关系;聚焦高频面试题——TCP连接池设计、HTTP队头阻塞、TIME_WAIT端口耗尽、TLS握手时机、四层超时分级等;结合OkHttp源码与实战案例,讲清弱网优化、连接复用、缓冲背压与避坑要点。
26 0
|
1天前
|
缓存 Java 编译器
第067篇 data class:一行顶 Java 一百行
Kotlin `data class` 高频却易出事故:编译器自动生成 `equals`/`hashCode`/`copy` 等方法,但行为隐式、易踩坑——数组字段引用比较致去重失效、`equals`/`hashCode` 不配套致 `HashMap` 查不到、`copy` 浅拷贝引发数据污染、解构依赖参数顺序、序列化需 `@JvmField`。核心原则:字段须不可变、集合用只读类型、契约必须守恒。
23 0
|
1天前
|
缓存 算法 Java
第047篇 垃圾回收基础:可达性分析与 GC Roots
本文深入剖析JVM垃圾回收核心——可达性分析机制,厘清GC Roots四类(栈变量、静态字段、JNI引用、活跃线程)作为泄漏排查入口;对比引用计数缺陷,详解强/软/弱/虚四级引用的本质差异在于“回收时机”;直击Android开发中静态缓存、Context泄漏等高频坑点,并给出支配树定位、显式限容、弱引用替代等工程化解决方案。
14 0
|
1天前
|
缓存 安全 API
第033篇 TreeMap 与排序:Comparable 与 Comparator
Android高阶面试常以TreeMap排序为切入点,层层追问原理、应用与坑点。它基于红黑树实现按键有序,支持O(log n)增删查及首/尾/范围查询;排序依赖Comparable或Comparator,须避减法溢出、key不可比、compare与equals不一致等典型陷阱。真题需结合多字段排序实战讲透。
16 0

热门文章

最新文章