过去几年的项目主要是基于
Java
的应用程序。今年,我有机会在项目中重新开始使用 Kotlin。在这篇文章中,我想分享我作为前 Java 开发人员在 Kotlin 中最欣赏的一些关键方面。
命名参数
Java
不允许我们在函数调用中显式命名参数,而是仅依靠位置参数。而且 Java
没有处理默认参数值的概念,甚至没有处理 null
。因此,您总是被迫以正确的顺序提供所有参数,从而使重构变得麻烦。
虽然现在大多数 IDE 都通过突出显示底层参数的名称来支持我们,但是当从我们的代码中调用函数时,这并不能完全弥补这个缺点。
在 Kotlin 中,参数可以通过位置或名称传递,但不能同时使用。此外,Kotlin 允许默认值声明(包括 null),这使得调用者可以在必要时省略这些参数。
fun hello(x: Int, str: String = "Hello Kotlin", y: Int) {
...
}
fun main(args: Array<String>) {
hello(1, 2) // 编译失败: str 参数必须设置
hello(x = 1, y =2) // 编译成功: str 是可选参数
}
与其他语言相比,Kotlin 在函数声明中参数定义时没有任何限制。但是,当调用具有交替的必需参数和可选参数的函数时,使用位置参数调用它时会强制我们提供所有参数,直到最后一个必需参数,即使那些具有默认值的参数。
严格的可变性检测
虽然在(函数式)编程中不可变性至关重要,但 Java
仅提供有限的支持来确保编译时的不可变性。将所有字段标记为 final 是确保对象在构造后无法修改的唯一方法。不幸的是,基于 Java Bean 风格的框架,例如 JPA,通常期望对象属性通过 setter 是可变的。
此外,处理对象的多个相同类型的属性在对象构造或复制方面既麻烦又容易出错,因为 Java 只知道构造函数中的位置参数。
最后,Java 并没有强制执行一致的对象相等概念,而是让开发人员实现 equals()
和 hashCode()
,这就是 Lombok 等框架普遍使用的原因。
val demo = demo("Demo1")
demo = Demo("Demo2") // val 不可变
demo.name = "Demo3" // 如果 name 是 val, 则编译不通过
demo.lastUpdate = LocalDate.now() // 如果lastUpdate是 var,则编译通过
标记为只读的值(包括引用)在分配后不能更改,这就是为什么它们应该始终优先用于支持或可变状态。
在组合多个属性时,Kotlin 提供了数据类,它支持类级别的不变性概念。虽然这些类也允许可变性,但它们的内置相等函数和复制构造函数使它们可以以面向对象的方式表示不可变状态。
data class Car(val name: String,
val type: String) {
var lastDate = LocalDate.now()
}
fun main(args: Array<String>) {
val car1 = Car("Car1", "Type1")
val car2 = car1.copy(name = "Car2")
println(car1 == car2) // name不同 -> false
val car3 = car1.copy()
println(car3 == car1) // 'equal' -> true
println(car3 === car1) // 不同的引用 -> false
}
空安全
“I call it my billion-dollar mistake. It was the invention of the null reference in 1965.” (Tony Hoare)
NullPointerException
(NPE) 是每个程序员都必须处理的事情,多数程序员更愿意在编译时预防此类问题。但事实上,Java 语言本身并没有提供任何方法来告诉编译器,无论引用是否为空安全,都没有对代码中的初始变量赋值进行简单检查。
其他框架(例如 Java Bean 验证)通常用于对此进行补充,例如使用 @NotNull
注解。尽管如此,如果忘记了这些附加验证,则没有严格的警告甚至错误。
随着 Java 8 中 java.lang.Optional
的引入,只要将潜在的空值正确包装为 Optional
,情况就会略有改善。这在可能出现空值和非空值的情况下很有帮助,例如当从数据库查询返回结果时,可能存在也可能不存在。但是,在必须避免 null
的情况下,它没有多大帮助,例如当一个方法根本无法处理空参数时。顾名思义,该值被简单地标记为可选,仍然需要由调用者处理“空情况”。
Kotlin 在语言级别提供 null
安全性,即通过明确说明是否可以将值分配为 null
。因此,只要涉及纯 Kotlin 代码,编译器就会强制执行空安全,从而降低运行时 NPE
的风险。
data class Car(val name: String,
val wheels: Int?,
val date: LocalDate? = null)
fun main(args: Array<String>) {
Car(
name ="Car Name",
age = null // 需要赋值, 但是可null
// 'date' 可忽略赋值,默认是null
)
}
这意味着,Kotlin 程序员,必须定义哪些对象可能为空或不为空,包括类属性、函数参数和返回值。这在定义接口和对象结构时需要额外的工作,但在实际实现时会带来很高的回报。
编译器自动将额外的空检查添加到访问这些函数或对象的字节码中。此外,java.lang.Optional
在 Kotlin 中几乎没有好处,因为任何属性都具有内置的空值感知,除非您明确想表达值的不可用。
null 安全的唯一警告是在与 Java 平台交互时。由于没有关于从 Java 方法返回的值的 null 安全性的可用信息,因此 Kotlin 的 null 检查是宽松的,由程序员决定是否应该允许 null。
个人而言,这是我努力避免过多地混合 Java 和 Kotlin 代码的主要原因。 一旦您熟悉了 Kotlin,您的应用程序的业务代码应该主要用 Kotlin 编写,如果需要,将空安全检查留给与第三方 API 的交互。
字符串插值 / 字符串模板
在 Java 中处理字符串一直都比较繁琐:从加号运算符开始,它会带来一些严重的性能损失,到使用 StringBuffer
,最后是 String.format()
函数。 所以在 Java 中处理字符串时,会看到如此多的 StringUtil(str)
类。
Kotlin 通过引入插值极大地简化了字符串处理。正如许多其他语言那样,表达式可以使用 ${expr}
直接嵌入到字符串中,如果引用简单变量,则大括号是可选的。
当涉及到迭代时,插值甚至更强大。 由于 Kotlin 中的 for
和 while
循环不是表达式,因此它们不能在字符串中使用。
然而,通常迭代集合就足够了,为每个元素生成部分字符串,然后将它们连接成一个完整的字符串,像使用 StringBuffer
所做的那样。 可以使用 Kotlin 扩展函数 Iterable<T>.joinToString()
来实现,它默认使用逗号分隔符自动分隔元素。
data class location(val lat: Double, val lng: Double)
fun main(args: Array<String>) {
val locations = listOf(
location(66.6, 22.2),
location(55.5, 8.8)
)
val jsonStr = """
{
"id": "${UUID.randomUUID()}",
"locations": [
${locations.joinToString {
"""
[
"lat": ${it.lat},
"lng": ${it.lng}
]
"""
}}
]
}
"""
println(jsonStr)
}
可以看出,在 Kotlin 中处理字符串比在标准 Java 中简单得多,而且通常不需要使用显式的
StringBuffer
或其他常见的 Util 类。三重引用的字符串(又名原始字符串)可用于避免不必要的转义,甚至可以在使用
joinToString()
时嵌套使用。
作用域函数
Kotlin 的标准库包含几个函数,其唯一目的是在对象的上下文中执行代码块。 当您在提供
lambda
表达式的对象上调用此类函数时,它会形成一个临时作用域。
Function | Object reference | Return value | Is extension function |
---|---|---|---|
let | it | Lambda result | Yes |
run | this | Lambda result | Yes |
run | - | Lambda result | No: called without the context object |
with | this | Lambda result | No: takes the context object as an argument. |
apply | this | Context object | Yes |
also | it | Context object | Yes |
流和集合
Kotlin 为每种类型的集合提供函数转换支持。 因此,Kotlin 中的标准转换是立即执行的,但如果需要,使用 asSequence()
可以选择延迟处理。
Kotlin 内置了处理各种集合的支持,下表列出了其中的一些函数:
map()
,mapNotNull()
,mapKeys()
,mapValues()
从一种表示到另一种表示的标准转换,分别用于列表和映射
- first(), last()
提取与给定函数匹配的第一个或最后一个元素
- single()
提取满足条件的唯一元素,如果有更多元素匹配则失败。如果满足条件的元素数量0,抛出 NoSuchElementException
,等于1,则返回该元素。反之,则抛出 IllegalArgumentException
- associate(), associateBy()
将一个集合映射成为map
- mapIndexed(), filterIndexed(), forEachIndexed()
用来提取(过滤)列表里面符合条件的元素形成新的列表
- plus(), drop(), dropLast()
添加或删除元素
枚举和密封类
Kotlin 支持枚举,也提供了一种更好的枚举方法。
枚举类型允许我们定义属性,也是受限的,但每个枚举常量只存在一个实例,而密封类 的一个子类可以有可包含状态的多个实例。
理解: 密封类是可用于构建具有灵活子类的枚举
sealed interface Error
sealed class IOError(): Error
class FileReadError(val f: File): IOError()
class DatabaseError(val source: DataSource): IOError()
object RuntimeError : Error
fun log(e: Error) = when(e) {
is FileReadError -> { println("Error while reading file ${e.file}") }
is DatabaseError -> { println("Error while reading from database ${e.source}") }
// is 关键字对于单例是可选的,因为只有一个实例存在。
RuntimeError -> { println("Runtime error") }
// 如果覆盖了所有情况,就不需要再添加一个 `else` 子句了。
}