各位 Kotlin 吴彦祖,今天周一,我们探讨一下比较简单的话题 —— 相等。
在 Kotlin 中,相等性比较分为两种方式:结构相等和引用相等。掌握这两者的差异能够帮助屏幕前的你更好地理解对象的比较机制。
结构相等 ==
结构相等用于判断两个对象的内容是否等价。
使用 == 运算符时,Kotlin 会在内部调用 .equals() 方法。如果类中重写了 .equals(),则由自定义实现决定比较逻辑。例如:
data class Person(val name: String, val gender: Int)
val person1 = Person("skydoves", "men")
val person2 = Person("skydoves", "men")
println(person1 == person2) // 输出:true
这里 == 返回 true,因为 Person 作为数据类,自动重写了 .equals() 来比较两个对象的属性。
引用相等 ===
这是 Kotlin 特有的,相较于 Java 引入的一个新运算符。
引用相等用于判断两个引用是否指向内存中的同一个对象。使用 === 运算符时,直接比较内存地址。例如:
val person1 = Person("skydoves", "man")
val person2 = Person("skydoves", "man")
val person3 = person1
println(person1 === person2) // 输出:false(不同实例)
println(person1 === person3) // 输出:true(同一实例)
person1 和 person2 内容相同但属于不同实例,所以 === 返回 false。而 person1 和 person3 指向同一实例,因此返回 true。
核心区别
很简单
- 比较目标:结构相等(
==)比较对象内容,引用相等(===)比较内存地址。 - 实现方式:结构相等依赖可重写的
.equals()方法,引用相等直接比较地址,无法自定义。 - 应用场景:需要比较对象逻辑内容时(如字符串、数据类实例)使用结构相等;需要确认两个引用是否指向同一对象时使用引用相等。
一句话总结:结构相等比较内容,引用相等比较地址。
根据实际需求选择合适的比较方式,能确保代码的准确性和高效性。
进阶:== 的内部机制
结构相等的本质是判断两个可能不同的对象在内容和结构上是否等价。使用 == 运算符(或 !=)时,很多人以为 a == b 只是 a.equals(b) 的语法糖。但实际上,Kotlin 编译器会将其转换为更复杂的空安全形式:
a?.equals(b) ?: (b === null)
第一道防线:安全调用运算符(?.)
表达式以 a?.equals(b) 开始。安全调用运算符 ?. 是防止 NullPointerException 的核心机制。逻辑根据左侧操作数 a 是否为空进行分支:
情况一:a 不为 null
安全调用等同于普通方法调用,equals(b) 在对象 a 上执行,返回 true 或 false,表达式到此结束。
情况二:a 为 null
安全调用会短路整个 equals(b) 调用,避免 NullPointerException。a?.equals(b) 的结果为 null,控制权转移到 Elvis 运算符右侧。
这种设计确保 equals 方法只在非空接收器上调用。
空值处理
Elvis 运算符 ?: 充当空值合并桥梁:只有当左侧表达式结果为 null 时,才会评估右侧表达式。由于前面已确定 a 为 null,Elvis 运算符后面的逻辑专门解决一个问题:"如果 a 为 null,在什么条件下它等于 b?"
最终判断:引用空值检查(b === null)
表达式的最后一部分 (b === null) 给出了明确答案。既然已知 a 为 null,那么 a 和 b 结构相等的唯一可能就是 b 也为 null。
- 如果 b 也为 null:
(b === null)评估为true,整个a == b表达式为true,符合"null 等于 null"的逻辑。 - 如果 b 不为 null:
(b === null)评估为false,整个a == b表达式为false,因为null对象不能等于非null对象。
这里使用引用相等检查 === 是有意为之,既高效又避免了潜在的无限递归(如果类实现了不寻常的 equals 方法)。
当然,这个小技巧,希望各位 Kotlin 开发者在个人实际的开发中,也要用到,对于 Kotlin 来讲,if (a === null) 的写法更加地道。
感觉偶尔看看 Kotlin 的语法,就像逛海澜之家一样,每次都有新发现!
这个设计的优点
编译器驱动的 == 转换提供了多重保障:
- 绝对空安全:无论操作数是否为
null,==运算符都不会触发NullPointerException。 - 逻辑完整性:正确处理所有四种可空性组合(
val == val、val == null、null == val、null == null),无需编写冗长的if-else链。 - 遵循契约:强制
==遵守用户定义的equals方法契约,同时提供安全层。正确实现equals至关重要,而 Kotlin 的数据类会自动处理此任务。
理解这一内部机制,开发者能更深刻地体会 Kotlin 语言特性的协同运作——生成的代码不仅简洁,而且从根本上更健壮、更不易出错。它将常见的运行时错误来源转化为安全、可预测、可靠的语言特性。
当然,既然看到了 null 的讨论,我们顺便说下下一个问题!
进阶:null + null
在 Kotlin 中执行 null + null 不会导致编译错误或 NullPointerException。相反,表达式会评估为字符串 "nullnull"。
这种看似意外但定义明确的行为,源于运算符重载以及 Kotlin 标准库对可空类型字符串连接的处理方式。
关键在:这里的 + 运算符并非执行算术加法,而是被编译器解析为特定的 plus 运算符函数调用。最匹配的重载是 Kotlin 标准库中定义在可空 Any? 类型上的扩展函数:
public operator fun String?.plus(other: Any?): String
该函数用于将可空 String 与任何其他可空类型连接。但由于 Kotlin 解析平台类型和字符串模板的机制,当 + 以暗示字符串连接的方式使用时,null 值会被转换为字符串 "null"。
因此,null + null 被编译器解释为对两个 null 对象执行字符串连接:
val result = null + null
println(result) // 输出:nullnull
为什么要这样做呢?
乍眼一看,这种行为出人意料,但实际上,这么做体现了 Kotlin 的设计哲学:一致性与安全性。
它确保涉及可空类型字符串转换的操作不会因 NullPointerException 而崩溃。同时也提醒开发者:操作的上下文会显著改变其含义。看似无效的算术操作,实际上是合法的字符串操作。理解这一点能避免 null 值被无意转换为字符串字面量的隐蔽错误。
总的来说:Kotlin 中 null + null 的结果是 "nullnull",这是因为 null 在字符串连接时被隐式转换为字符串表示形式。理解 Kotlin 在不同上下文中如何处理可空值,能帮助我们避免代码中的意外结果。