第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-类

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

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

相关文章
|
5天前
|
弹性计算 网络协议 应用服务中间件
SSL 证书 90 天到期太频繁:三种自动续期方案横向对比
证书 90 天到期,忘了续站点就报警告。这篇把三条续期自动化路线摆在一起对比:自建服务器用 acme.sh 全自动续签、云产品上开阿里云证书托管、以及用 HTTPS 加速网关把证书交给平台。含可直接复制的 acme.sh 命令、托管的触发条件与限制、选型决策表和 8 条避坑点,读完能给自己站点定一套不会再漏的续期机制。
|
1月前
|
算法 数据挖掘 Shell
经验自进化:自动挖掘经验资产,消融实验验证真实收益丨AgentLoop 数据飞轮实践(五)
本文介绍AgentLoop经验自进化实践:从运行轨迹自动挖掘成功与失败模式,经Skill召回并注入上下文。文章详解接入验证流程,并通过消融实验优化召回策略,降低耗时、成本、Token消耗和工具调用,让Agent持续迭代。
273 13
|
3天前
|
弹性计算 人工智能 运维
省钱攻略|阿里云轻量与 ECS 服务器:新购续费优惠解析、活动规则及新老用户选型教程
对于个人开发者、学生、小微企业而言,云服务器的新购价格与长期续费成本,是决定上云方案是否可行的核心因素。很多用户在初次选购服务器时,只关注新购的低价,却忽略续费相关政策,等到实例即将到期,才发现续费价格大幅上涨,业务成本超出预期。 阿里云服务器 的优惠体系分为新购优惠与续费优惠两大板块,轻量应用服务器、ECS弹性云服务器拥有独立的活动规则,低价套餐存在严格的资格、配置、数量限制,一旦操作不当,就会直接失去低价续费资格。
40 1
|
1天前
|
调度 Android开发 容器
第126篇Fragment 事务与回退栈:add、replace、show、hide
Fragment事务异步队列执行,非调用即生效;回退栈存操作记录而非实例。`show/hide` 快但常驻内存、不可回退;`replace`+栈可回退但重建视图。选型关键:是否需回退?是否敏感内存?——是判断题,非知识点。
20 1
|
4天前
|
云安全 人工智能 安全
本周云安全公告速览与影响解读:哪些漏洞该今晚就打补丁
过去一周漏洞公告里真正需要当天动手的只有几条。本篇用公网可达+无需认证+已有利用三把尺子给公告定级,逐条解读 AI 应用框架代码执行(camel-ai/bisheng/DB-GPT)、MCP 抓取工具 SS元数据、Trivy 与 Langchain-Cha应链风险,并给出 P0 到 P3 的可执行处置清单与避坑要点,帮你从看公告变成能落地。
|
1天前
|
SQL 安全 Java
第073篇 字符串模板与原生字符串:多行文本的正确姿势
Kotlin字符串模板核心三点:①编译后转为`StringBuilder.append`链(全常量时优化为字面量);②原生字符串`&quot;&quot;&quot;...&quot;&quot;&quot;`保留缩进,必须用`trimIndent()`或`trimMargin()`清理;③Android中禁在循环内用模板拼接,否则触发O(n²)性能坑。安全上,模板不用于SQL/URL等需解析的场景。
24 0
|
18天前
|
人工智能 安全 测试技术
Copilot会自动关闭自己提过的问题:AI代码评审最该测的,变成了“它为什么消失”
GitHub Copilot代码评审升级为“状态机”:自动关闭评论需可验证证据。新功能区分Open/Resolved/Missing等状态,强调问题追踪而非单纯数量统计,要求高危缺陷召回率≥95%、误关闭率为0。
|
1天前
|
编译器 测试技术 调度
第110篇 Flow 与 RxJava 对比:响应式迁移指南
本文深度对比 Flow 与 RxJava 的设计哲学、背压机制、Subject/SharedFlow 语义差异及迁移陷阱,直击面试高频考点。指出二者“问题重叠、取向相反”:Flow 借协程挂起实现天然背压,Rx 依赖显式策略;强调 `PublishSubject ≠ SharedFlow(replay=0)` 等关键误区,附对照表与实战代码。
18 0
|
1天前
|
安全 Java 编译器
第103篇 DSL 构建原理:type-safe builder 如何工作
Kotlin DSL 并非魔法,本质是三大特性协同:带接收者 Lambda(`T.() -&gt; Unit`)提供隐式 `this`、扩展函数封装配置逻辑、尾随语法提升可读性。编译后即普通方法调用,零运行时开销;配合 `@DslMarker` 可实现严格作用域隔离,保障类型安全与工程健壮性。
15 0
|
1天前
|
存储 Java 数据库连接
第044篇 ThreadLocal 原理:主线程到底能不能用
ThreadLocal核心在于“线程独享+弱引用Key+强引用Value”:每个线程持独立ThreadLocalMap,Key为ThreadLocal弱引用,Value强引用用户对象;若不显式remove,线程池复用时易致内存泄漏与数据串号。务必在finally中清理!
22 0

热门文章

最新文章