4、运算符与表达式:算术、比较、逻辑、位运算符,区间表达式,运算符重载的初步概念

Kotlin 的运算符体系在 JVM 语言中独树一帜:它既保留了传统运算符的直观性,又通过“运算符重载”机制将运算符与函数调用统一起来。理解这一章,不仅是为了写出语法正确的代码,更是为了建立“表达式即函数调用”的思维模型。本章将从算术、比较、逻辑、位运算四大基础运算符出发,深入解析区间表达式的底层实现,最后通过运算符重载的初步概念,揭示 Kotlin 如何让自定义类型获得与原生类型一致的操作体验。

Kotlin 运算符与表达式体系 运算符与表达式 算术运算符 + - * / % 整数除法截断 比较运算符 == != < > <= >= equals() 与 compareTo() 逻辑运算符 && || ! 短路求值 位运算符 shl shr ushr and or xor inv 无符号右移 ushr 区间表达式 1..10 / 1 until 10 IntRange / LongRange 运算符重载 operator fun plus() 约定函数映射 所有运算符本质都是函数调用 → 表达式求值 → 类型推断

4.1 算术运算符与整数除法陷阱

Kotlin 的算术运算符包括 +、-、*、/、%,它们的行为与 Java 基本一致,但有一个关键差异:Kotlin 不支持隐式类型转换,因此 Int 与 Long 混合运算时必须显式转换。这一设计虽然增加了代码冗余,却避免了 Java 中常见的精度丢失问题。

核心原理:运算符即函数

在 Kotlin 中,a + b 实际上会被编译器翻译为 a.plus(b)。对于原生类型,编译器会生成高效的 JVM 字节码指令;对于自定义类型,则会调用对应的成员函数或扩展函数。这种统一模型是理解运算符重载的基础。

整数除法与取模的边界情况

整数除法直接截断小数部分,而非四舍五入。例如 7 / 2 的结果是 3,-7 / 2 的结果是 -3(向零取整)。取模运算 % 的结果符号与被除数一致:-7 % 3 的结果是 -1。这在循环数组下标、哈希计算等场景中需要特别留意。

fun main() {
    val a = 10
    val b = 3
    println("a / b = ${a / b}")   // 输出 3
    println("a % b = ${a % b}")   // 输出 1
    println("-a / b = ${-a / b}") // 输出 -3
    println("-a % b = ${-a % b}") // 输出 -1
    
    // 浮点运算需要显式转换
    val ratio = a.toDouble() / b
    println("ratio = $ratio")     // 输出 3.3333333333333335
}

实战场景:金额计算中的精度控制

在金融类应用中,直接使用 Double 进行金额计算会产生浮点误差。正确做法是使用 BigDecimal,但 Kotlin 没有为 BigDecimal 提供算术运算符重载,因此需要调用 add()、subtract() 等方法。如果希望使用 +、- 运算符,可以自定义扩展函数,这将在 4.5 节中展开。

表达式结果说明
5 / 22整数除法截断
5.0 / 22.5浮点除法
5 % 21取余
-5 % 2-1符号跟随被除数
5 / 0ArithmeticException整数除零异常

风险提示:整数除零会抛出 ArithmeticException,而浮点除零会得到 Infinity 或 NaN。在数据清洗或用户输入校验时,务必先判断除数是否为零。

4.2 比较运算符与相等性判断

Kotlin 的比较运算符包括 ==、!=、<、>、<=、>=。其中 == 与 Java 有本质区别:Kotlin 的 == 调用的是 equals() 方法,而 === 才是引用比较。这一设计避免了 Java 中 Integer 缓存导致的 == 判断陷阱。

核心原理:equals 与 compareTo 的约定

对于自定义类,== 会调用 equals() 方法,< 和 > 会调用 compareTo() 方法。Kotlin 标准库中的 Comparable 接口定义了 compareTo 的约定:返回负数表示小于,零表示相等,正数表示大于。实现 Comparable 接口后,自定义类型即可使用所有比较运算符。

字符串比较的特殊性

字符串的 == 比较的是内容而非引用,这与 Java 的 equals() 行为一致。但需要注意,字符串的 compareTo() 是字典序比较,大小写敏感。如果需要进行忽略大小写的比较,需要调用 equals(ignoreCase = true) 或自定义比较逻辑。

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
    println(p1 === p2) // false,引用不同
}

常见误区:浮点数比较

直接使用 == 比较浮点数可能因精度问题导致错误。例如 0.1 + 0.2 == 0.3 的结果是 false。正确做法是使用误差范围比较:abs(a - b) < 1e-9。Kotlin 提供了 Double.equals() 的扩展,但更推荐显式设置精度阈值。

经验:在领域模型中,优先使用 data class 并实现 Comparable 接口,这样既能获得结构化的 equals(),又能支持排序和范围判断。

4.3 逻辑运算符与短路求值

逻辑运算符 &&、||、! 用于布尔表达式的组合。Kotlin 与 Java 一样采用短路求值:&& 左侧为 false 时不再计算右侧,|| 左侧为 true 时不再计算右侧。这一特性在空安全判断中尤为重要。

核心原理:短路求值的工程价值

短路求值不仅提升性能,更重要的是可以避免空指针异常。例如 if (list != null && list.isNotEmpty()),当 list 为 null 时,右侧的 isNotEmpty() 不会执行,从而避免了 NPE。Kotlin 的 ?.let 和 ?: 运算符进一步简化了这类判断。

逻辑运算符与位运算符的区分

初学者容易混淆 && 与 &、|| 与 |。前者是逻辑运算,操作数是布尔值;后者是位运算,操作数是整数。Kotlin 中位运算使用 and()、or() 等函数,而非符号,这从语法层面杜绝了混淆。

fun validate(user: User?): Boolean {
    // 短路求值:user 为 null 时不会执行 user.name.isNotBlank()
    return user != null && user.name.isNotBlank() && user.age in 18..60
}

fun main() {
    val flag = true
    val result = flag || throw IllegalStateException("不会执行")
    println(result) // true
}

实战场景:多条件业务规则

在权限校验中,经常需要组合多个条件。短路求值可以按“代价从低到高”排列条件,将最可能失败的判断放在最前面,从而减少不必要的计算。例如先检查用户是否登录,再检查角色权限,最后检查资源状态。

表达式短路行为典型用途
a && ba 为 false 时不计算 b空安全判断
a || ba 为 true 时不计算 b默认值回退
!a取反条件反转

4.4 位运算符与区间表达式

Kotlin 的位运算符以函数形式提供:shl(左移)、shr(右移)、ushr(无符号右移)、and(按位与)、or(按位或)、xor(按位异或)、inv(按位取反)。这些函数是 Int 和 Long 的成员函数,使用时需要以中缀形式调用:a shl 2。

核心原理:位运算的底层语义

左移 shl 相当于乘以 2 的 n 次方,右移 shr 相当于除以 2 的 n 次方(保留符号位),无符号右移 ushr 则在高位补零。位运算在权限管理、状态压缩、哈希计算等场景中效率极高。

区间表达式:语法糖与底层类型

区间表达式 1..10 实际上是 IntRange 对象的构造语法糖。Kotlin 提供了 IntRange、LongRange、CharRange 等类型,支持 in、!in 操作符判断成员关系,也支持 step、downTo、until 等扩展函数。

fun main() {
    // 位运算示例
    val flags = 0b1010
    val mask = 0b1100
    println(flags and mask)  // 0b1000 = 8
    println(flags or mask)   // 0b1110 = 14
    println(flags xor mask)  // 0b0110 = 6
    println(flags shl 2)     // 0b101000 = 40
    println(flags shr 1)     // 0b0101 = 5
    println(flags ushr 1)    // 0b0101 = 5(正数时与 shr 相同)
    
    // 区间表达式
    for (i in 1..5) print("$i ")        // 1 2 3 4 5
    for (i in 5 downTo 1 step 2) print("$i ") // 5 3 1
    for (i in 1 until 5) print("$i ")   // 1 2 3 4
    println(3 in 1..5)                  // true
    println('c' in 'a'..'z')            // true
}

实战场景:权限系统的位掩码设计

假设系统有四种权限:读(1)、写(2)、执行(4)、删除(8)。使用位运算可以高效地组合和判断权限:

const val READ = 1
const val WRITE = 2
const val EXECUTE = 4
const val DELETE = 8

fun hasPermission(permissions: Int, target: Int): Boolean {
    return permissions and target == target
}

fun main() {
    val userPerm = READ or WRITE  // 3
    println(hasPermission(userPerm, READ))   // true
    println(hasPermission(userPerm, DELETE)) // false
}

注意:until 生成的区间不包含结束值,而 .. 包含。在循环遍历数组下标时,推荐使用 0 until array.size 以避免越界。

4.5 运算符重载的初步概念

运算符重载允许自定义类型使用 +、-、*、/、[]、in 等运算符。Kotlin 通过 operator 关键字修饰的函数实现这一机制,每个运算符对应一个固定的函数名。例如 + 对应 plus,[] 对应 get。

核心原理:约定函数映射表

运算符重载的本质是编译器将运算符表达式转换为函数调用。下表列出了常用运算符与函数的对应关系:

运算符函数名示例
+plusa + b → a.plus(b)
-minusa - b → a.minus(b)
*timesa * b → a.times(b)
/diva / b → a.div(b)
%rema % b → a.rem(b)
[]geta[i] → a.get(i)
incontainsi in a → a.contains(i)

案例拆解:复数类的运算符重载

假设我们需要一个复数类,支持加法、乘法和索引访问。通过运算符重载,可以让复数运算的代码与数学表达式几乎一致:

data class Complex(val real: Double, val imag: Double) {
    operator fun plus(other: Complex) = Complex(real + other.real, imag + other.imag)
    operator fun times(other: Complex) = Complex(
        real * other.real - imag * other.imag,
        real * other.imag + imag * other.real
    )
    operator fun get(index: Int): Double = when (index) {
        0 -> real
        1 -> imag
        else -> throw IndexOutOfBoundsException("Complex index out of range")
    }
}

fun main() {
    val c1 = Complex(1.0, 2.0)
    val c2 = Complex(3.0, 4.0)
    val sum = c1 + c2       // Complex(4.0, 6.0)
    val product = c1 * c2   // Complex(-5.0, 10.0)
    println("sum = $sum, product = $product")
    println("c1[0] = ${c1[0]}, c1[1] = ${c1[1]}")
}

重载的约束与最佳实践

运算符重载必须遵循以下规则:参数类型不能全为原生类型(否则与内置运算符冲突);不能改变运算符的元数(如 plus 必须接收一个参数);建议保持运算符的语义一致性,避免让 + 执行减法操作。此外,重载应优先考虑不可变对象,避免在 plus 中修改自身状态。

经验:在领域驱动设计中,运算符重载可以显著提升代码可读性。例如金额类 Money 重载 + 和 - 后,账务计算代码将非常直观。但务必在文档中明确运算符的语义边界,防止滥用。

风险提示:过度使用运算符重载会降低代码可读性。如果运算符的含义不够直观,建议使用具名函数替代。例如 matrix.multiply(other) 比 matrix * other 更清晰,除非乘法是数学上的自然操作。

4.6 综合实战:构建一个向量运算库

为了巩固本章知识,我们设计一个二维向量类 Vec2,综合运用算术运算符、比较运算符、区间表达式和运算符重载。该案例模拟游戏开发中的坐标运算场景。

需求与约束

游戏引擎需要频繁进行向量加法、标量乘法、点积计算,并判断向量是否在某个矩形区域内。初始版本使用 Double 存储坐标,要求代码简洁且性能可接受。

实现方案

data class Vec2(val x: Double, val y: Double) : Comparable<Vec2> {
    operator fun plus(other: Vec2) = Vec2(x + other.x, y + other.y)
    operator fun minus(other: Vec2) = Vec2(x - other.x, y - other.y)
    operator fun times(scalar: Double) = Vec2(x * scalar, y * scalar)
    operator fun div(scalar: Double) = Vec2(x / scalar, y / scalar)
    
    infix fun dot(other: Vec2) = x * other.x + y * other.y
    
    override fun compareTo(other: Vec2): Int {
        val len1 = x * x + y * y
        val len2 = other.x * other.x + other.y * other.y
        return len1.compareTo(len2)
    }
    
    operator fun contains(range: IntRange): Boolean {
        return x in range && y in range
    }
}

fun main() {
    val v1 = Vec2(1.0, 2.0)
    val v2 = Vec2(3.0, 4.0)
    val sum = v1 + v2
    val scaled = v1 * 2.5
    val dotProduct = v1 dot v2
    
    println("sum = $sum, scaled = $scaled, dot = $dotProduct")
    println("v1 < v2 = ${v1 < v2}")  // 比较向量长度
    
    // 使用区间表达式判断坐标范围
    val region = 0..10
    println("v1 in region = ${v1.x in region && v1.y in region}")
}

结果与复盘

通过运算符重载,向量运算的代码与数学公式几乎一致,显著提升了可读性。同时,Comparable 接口让向量可以直接用于排序和范围判断。但需要注意,times 重载的是标量乘法,如果未来需要向量乘法(叉积),应使用具名函数 cross 以避免歧义。

在性能方面,data class 的 equals 和 hashCode 在频繁创建对象时可能产生额外开销。对于游戏引擎这类高性能场景,可以考虑使用 @JvmInline value class 或直接使用原生数组存储坐标。

本章总结:Kotlin 的运算符体系将“表达式”与“函数调用”统一起来,使得自定义类型能够获得与原生类型一致的操作体验。掌握算术、比较、逻辑、位运算的底层语义,理解区间表达式的类型本质,并合理运用运算符重载,是写出优雅、高效 Kotlin 代码的关键。在实际项目中,务必遵循“语义一致、适度重载、文档明确”的原则,避免过度设计。