我使用 Java 已经有很长的时间了,工作中的使用有15年。如果算上在学校的时间的话,那就更长了。Java 的一个很大的优势是平台的开放性。这得益于 Java 字节代码和虚拟机的存在。由于 Java 语言自身的发展速度比较慢,就催生了很多运行在 JVM 上的语言。Scala、Groovy、Kotlin、Clojure 是其中比较流行的,还有一些小众的语言。总有一款语言适合你,而且不断有新的语言出现。
当 Kotlin 最早出现的时候,我只是了解到了它在 Android 开发中的作用,并没有对它有过多的关注。直到后来了解到了协程(Coroutines),才开始深入认识到了 Kotlin 的优势,并逐渐在日常的开发中使用 Kotlin 来替代 Java。这个过程所带来的不仅是自己开发效率的提高,以及对代码信心的增强,更多的是对代码的那种掌控感和简洁的代码所带来的内心的愉悦。
本文的目标是让那些没有听过,或是没有深入了解 Kotlin 的人,对 Kotlin 产生兴趣,并开始尝试在日常的开发中使用 Kotlin 来替代 Java。虽然大部分人在熟悉了一种语言之后,很难再有动力去学习和掌握新的编程语言。但从 Java 到 Kotlin 这样的转变所带来的收益是非常巨大的,是值得投入的。
下面就来说一说 Kotlin 的优势吧。
简化的语法,少而精的代码
Java 的语法是比较严格而繁琐的,导致代码不必要的冗长。Kotlin 的语法在很多方面都相对于Java进行了简化,可以用更少的代码来实现同样的功能。下面是几个突出的变化:
- Kotlin的分号的可选的。
- Kotlin中使用 val 和 var 来区分不可变和可变的变量。使用 val 声明变量只能被赋值一次,而使用 var 声明的变量可以被多次赋值。
- Kotlin的变量类型声明是可选的,编译器可以尽可能地进行类型推断。如 val name="Alex" 中的 name 的类型被自动推断为 String,并不需要显式的进行声明。
- Kotlin中创建对象不需要 new。
- Kotlin提供了对范围(range)的支持,如 1..5 表示从1到5的范围。
- Kotlin中的字符串字面量提供了对模板的原生支持,不再需要 Java 中的 String.format。比如,val str="name is $name" 中的 name 是引用的变量。
- Kotlin中对包、类和源文件之间采用了更加松散的组织结构。一个Kotlin文件中可以包含多个 public 的类。类的 package 声明不需要与其源文件在文件系统中的层次结构完全匹配(不过不建议这么做)。
- Kotlin中对 getter 和 setter 方法的使用进行了简化。如果一个类中有 getName 方法,可以直接以 name 属性的形式来访问;同样的,如果有 setName 方法,可以使用赋值操作来调用,如 user.name = "Alex" 就相当于调用 user.setName("Alex")。
- Kotlin中的变量和方法可以出现在顶层中,并不需要作为类的成员。
- Kotlin 直接提供了对文本块的支持,使用三个双引号可以声明多行文本。
Kotlin的语法真正体现了少即是多(Less is More)的精髓,可以去掉Java中很多不必要的boilerplate代码。
空指针安全,告别NPE
空指针问题应该是 Java 程序中最常出现也最让人烦恼的问题。空引用,这个 Tony Hoare 眼中的“数十亿美元的错误”,直到现在还在困扰着不同编程语言的程序员。在Java中,我们一般应对的策略称为防御式编程(defensive programming)。也就是说,对于可能出现值为 null 的地方,应该首先检查值是否为 null ,再使用该引用。如果是很长的对象引用链,如 a.b.c.d 这样的情况, 进行检查的代码就很冗长。需要先检查 a ,再检查 a.b ,以此类推。因为太繁琐,并且没有有效的方式来强制进行,在开发人员有意无意的情况下,这样的防御式检查在很多时候就被省略了,其结果就是运行时的 NullPointerException 。
Java 中出现这样问题的根源在于,单纯从一个对象引用,是无法判断其是否可能为 null 的。Kotlin采取了一种釜底抽薪的做法,把可为 null 和不可为 null 的对象划分成两种不同的类型。比如, String 类型的值是不可能为 null 的,而与之对应的 String? 类型的值是可能为 null 的。如果一个方法的参数是 String 类型,就意味着在方法体中,不需要检查该参数的值是否为 null ,可以放心地直接使用;而如果类型是 String? ,则不能直接使用该参数,需要首先进行 null 检查。直接使用可为 null 类型的变量会产生编译错误。
可为 null 类型的重要意义在于,它强制开发人员去思考一个引用是否应该可能为 null。在 Java 中,一个引用是否可能为 null 的契约是隐式的。这种契约通常以 Java 文档或是团队内部规约的形式出现。Java 编译器并不会帮助你在编译时刻阻止错误的发生。而 Kotlin 中的可为 null 类型让这种契约变为显式的,并由编译器来保证。当你在设计一个类或方法时,就需要仔细思考类中的属性或方法的参数是否可能为 null ,并使用合适的类型来表示。
与可为 null 类型相匹配的,Kotlin 提供了 null 安全的调用操作符 ?. 。使用 ?. 之后,只有前面的引用不为 null 的时候,才会调用后面的操作。当对象引用很长时,使用 ?. 可以写出简洁安全的代码。比如类 User 的属性 address 的类型是表示地址的 Address ,类 Address 中有个类型为 String 的属性 streetName 表示所在街道名称。类 User 中 address 的类型是 Address? 。对于一个类型为 User? 的变量 user ,可以通过 user?.address?.streetName 来安全地获取到 streetName 。除了 ?. 之外,还可以使用 ?: 来提供默认值,如 user?.address?.streetName ?: "<未知>" 的类型就变为了 String,因为有默认值的存在。 ?. 和 ?: 使用起来的效果类似 Java 中的 Optional.map 和 Optional.orElse ,但是书写起来要简洁很多。
实用语言结构,满足常用需求
Kotlin中有不少实用的功能,可以提升很多开发效率。这里仅列举几个重要的。
数据类
数据类(data class)可以看成是Java Bean的增强版。数据类在定义时只需要声明其中的属性名称和类型,Kotlin会自动生成相应的 equals、hashCode 和 toString 等方法。如果需要创建的类只是作为数据的容器,数据类是最好的选择。Java 直到 Java 14 中才引入了类似的记录类型(详解 Java 17 中的记录类型(Record))。
数据类还提供了 copy 方法可以得到一个只改变了部分属性值的新的对象。如果数据类的属性都声明为 val,与 copy 结合可以实现不可变对象。
下面代码中的 User 是一个数据类。所有属性都声明为 val,因此 User 类的对象是不可变的。
data class User(val id: String, val username: String, val name: String, val email: String)
在下面的代码中,newUser 是在 user 的基础上修改了 username 得到的新对象。
val user = User("001", "alexcheng", "Alex Cheng", "alexcheng@example.org")
println(user)
val newUser = user.copy(username = "alexcheng_new")
println(newUser)
if / when / try 表达式
Kotlin中的 if / when / try 都可以是表达式。可以用这些表达式的值进行赋值。
在下面的代码中,变量 goodOrBad 直接由 if/else 表达式进行赋值。 result 的结果来自于 when 表达式。 successOrError 的值由 try/catch 来得到。
import java.lang.RuntimeException
import kotlin.random.Random
fun main(args: Array<String>) {
val test = Random.nextBoolean()
val goodOrBad = if (test) "Good" else "Bad"
println(goodOrBad)
val number = Random.nextInt(100)
val result = when {
number > 20 -> "> 20"
number > 10 -> "> 10 && <= 20"
else -> "<= 10"
}
println(result)
val successOrError = try {
error()
} catch (e: Exception) {
e.message
}
println(successOrError)
}
fun error(): String {
return if (Random.nextBoolean()) "success" else throw RuntimeException("boom!")
}
use
Java 7中新增了 try-with-resources 来简化对资源的管理。只需要实现了 AutoCloseable 的对象都可以用在 try 中。Kotlin中类似的结构是 use ,使用起来比 try-with-resources 更自然。下面的代码使用了一个 OutputStream 对象并确保其被关闭。
Files.newOutputStream(Paths.get("./test.txt")).use {
it.write("Hello".toByteArray())
it.flush()
}
let 和 run
在Java中,如果需要重复使用一个很长的对象引用,一般的做法是把首先把该引用赋值给一个局部变量,再使用该局部变量。Kotlin中的 let 和 run 可以简化这样的场景。
以前面的 User 和 Address 为例,下面代码中的 getUser() 返回的是 User? 类型, getUser()?.address?.streetName 的类型是 String? 。通过 let 和 run 可以直接对 streetName 进行操作,并不需要创建额外的对象引用。两者的区别在于, run 中的 this 指向的是当前的接收对象,也就是 String 类型;而在 let 中,当前的对象引用是作为参数传入的。
getUser()?.address?.streetName?.let {
println(it)
}
getUser()?.address?.streetName?.run {
println(this)
}
丰富的标准库,免去查找第三方库的困扰
Java 本身所提供的标准库的功能比较有限,包括在字符串处理和集合类上。很多 Java 程序都会依赖 Guava 或 Apache Commons Lang 这样的第三方库来提供常用的功能。Kotlin 提供了丰富的标准库,免去了查找和使用第三方库的困扰。
集合类
Kotlin标准库在集合类 Iterable 、 List 、 Set 和 Map 上都添加了新的扩展方法。相对于Java 8 中的 Stream,Kotlin标准库中的集合类方法更加简单实用。由于集合类相关的方法很多,下面仅选择 Iterable 中有代表性的方法进行说明。
- all 和 any :分别检查是否全部或任意元素满足给定 Predicate,如 listOf(1, 2, 3).all { it > 2 } 。
- associate 、 associateBy 和 associateWith :根据 Iterable 中的元素生成 Map 。如 listOf("a", "bc", "def").associateWith { it.length } 得到的 Map 的键是 Iterable 中的 String 元素,而对应的值是该 String 的长度。
- distinct 和 distinctBy :删除 Iterable 中的重复元素。
- find 和 findLast :根据Predicate来查找。
- fold :通过累积器来累加值。
- first 、 firstOrNull 、 last 和 lastOrNull :返回第一个或最后一个元素。
- minus 和 plus :分别删除已有元素或添加新的元素,可以使用操作符重载形式 + 和 - 。如 (listOf(1, 2, 3) - 1 + 2) 的结果是包含元素 2、3 和 2 的列表。
- intersect 和 union :分别得到集合的交集和并集。
- joinToString :连接成字符串,可以对元素进行转换。
字符串
字符串可以看成是字符的序列,因此对 Iterable 可以用的很多方法都可以应用在字符串上。除此之外,还有一些字符串独有的实用方法。
- commonPrefixWith 和 commonSuffixWith :分别返回与另外一个字符串相同的最长前缀和后缀。
- lines :把字符串按照 CRLF 、 LF 或 CR 来分隔成多行。
- padStart 和 padEnd :分别在字符串的起始和结束位置上添加空白。
- removePrefix 和 removeSuffix :分别删除指定的前缀或后缀。
- repeat :重复一个字符串多次。
I/O
Kotlin在 Java 中与 I/O 相关的类上添加了一些实用方法。以 File 为例,就有以下这些实用的方法。
- copyRecursively :递归复制当前文件及其子文件到指定路径。
- copyTo :把当前文件的内容复制到目标文件。
- deleteRecursively :递归删除当前文件及其子文件。
- readText :把文件内容读取为 String 。
扩展方法,简单地扩展已有API
在 Java 程序中,如果不控制所使用的第三方库的源代码,是很难对这些已有的API进行扩展的。比如 Java 中的 String 类是声明为 final 的,并没有办法扩展 String 类来添加额外的功能。在另外的一些情况下,即便是自己开发的项目的一部分,如果一个类是共有的,为了满足某一个使用者的需要,而在共有的类中随意添加方法,并不是一个很好的实践。举例来说,程序中有一个共享的类 User ,包含了用户的基本信息。在使用了 User 类的一个模块中,代码需要根据 User 的某些属性生成一个唯一的标识符。那么生成该标识符的方法应该被添加到 User 类中,才能被使用。而实际上,这个方法仅对该模块有意义。一旦添加到 User 类中,其他模块也能看到这个方法。另外一种做法是该模块继承 User 并创建自己的类,而这样的继承的目的只是为了添加一个辅助的方法,并没有真正产生价值。
Kotlin 允许在不继承类或使用装饰器这样的设计模式的情况下,为已有类扩展功能。实际上,这也是 Kotlin 标准库增强已有 Java 标准库的方式。上面提到的这些方法,是作为已有 Java 类的扩展而出现的。在下面的代码中, User.greetings 的作用是在 User 类上添加了一个新的扩展方法 greetings 。在方法中的 this 指向的是当前的 User 对象。可以直接在 User 类的对象上使用新添加的扩展方法。
fun User.greetings(greeting: String) {
println("$greeting, ${this.name}")
}
fun main(args: Array<String>) {
User("001", "alexcheng", "Alex Cheng", "alexcheng@example.org")
.greetings("Hi")
}
回到上面的例子,我们可以保持共用的类 User 中仅包含真正共通的内容。每个模块可以根据需要添加模块代码所需的扩展方法。
函数式编程支持,拥抱函数式编程实践
Java 中的 java.util.function 包和 Lambda 表达式的引入,使得 Java 语言对函数式编程有了更好的支持。但是这样的支持还是不太够的,很多时候我们还需要使用 Vavr 这样的第三方库。Kotlin中的函数提供了更加灵活的功能。
Kotlin 中的函数使用 fun 来声明。函数的参数可以有默认值。当调用函数时,可以根据参数名称指定为某些参数传入值。在下面的代码中,函数 sayHi 的两个参数 greetings 和 name 都有默认值。如果在调用时不指定任何参数,则两个参数都使用默认值; sayHi(name = "Bob") 为参数 name 指定了值,而参数 greetings 使用默认值。
fun main(args: Array<String>) {
sayHi()
sayHi(name = "Bob")
}
fun sayHi(greetings: String = "Hi", name: String = "Alex") = "$greetings, $name"
Kotlin 中的函数是一等公民,可以作为其他函数的参数和返回值。在 Kotlin 中创建高阶函数是非常容易的。Kotlin 中有函数类型,如 (String) -> String 表示的是一个参数和返回值都为 String 的函数类型。下面代码中的函数 process 的参数 transform 的类型是函数类型 (String) -> String 。函数类型的实例可以使用 invoke 或者 () 来调用。
fun process(input: String, transform: (String) -> String) = transform.invoke(input)
下面的代码使用方法引用 String::toUpperCase 来创建函数类型的实例。
process("Hello", String::toUpperCase)
除此之外,还可以使用Lambda表达式来创建函数类型的实例。
process("Hello") {
it.substring(1)
}
如果Lambda表达式只有一个参数,那么它有一个隐式的名称 it。在Lambda表达式中可以直接使用该名称。
Java互操作性,与已有系统良好交互
Kotlin 在设计之初就充分考虑到了与 Java 的互操作性。不管是在 Kotlin 代码中调用 Java 代码,或是在 Java 代码中调用 Kotlin 代码,都非常的自然。这点在集合类的使用上尤为突出。这是因为 Kotlin 并没有替换 Java 已有的集合框架,而是在 Java 已有的集合框架的基础上,通过扩展方法的机制,让对集合的操作变得更加自然。这点与 Scala 是不同的。Scala 有自己的集合框架。所以当涉及到与 Java 互操作时,与 Scala 交互的代码会显得很奇怪。从某种角度来说,可以认为 Kotlin 是一个语法增强版的 Java。
Kotlin 的这种与 Java 的良好互操作性,使得在已有项目中引入 Kotlin 的侵入性很小。已有的 Java 代码可以保持不变,新的功能可以使用 Kotlin 来开发,然后根据情况来逐步迁移。使用 Kotlin 开发的新功能,也同样可以在已有 Java 代码中直接使用。当然,在互操作中也会有一些需要注意的细节。
Kotlin 提供了对主流的构建工具 Gradle 和 Maven 的支持。对于一个已有的基于 Gradle 或 Maven 的 Java 项目,在上面添加对 Kotlin 的构建支持,是很简单的。
多平台支持,一个代码库支持多个平台
多平台项目(Kotlin Multiplatform)是 Kotlin 的一个强大的功能。虽然在目前的版本中,该功能仍然是试验性的,但未来的发展前景巨大。通过多平台支持,同样的 Kotlin 代码可以运行在 JVM、Android、JavaScript、iOS、Linux、Windows、Mac和嵌入式系统上。这就意味着跨平台的代码复用。这无疑可以极大地提高跨平台支持时的开发效率。
Kotlin 多平台
在一个Kotlin多平台项目中,共通的代码使用 Kotlin 编写,只能使用Kotlin标准公共库的内容。在公共代码中可以通过 expect 来声明对实际底层运行平台的期望,而具体的运行平台则以 actual 的方式来提供相应的实现。比如下面的代码声明了一个公共的函数 uuid,返回全局唯一的ID。
expect fun uuid(): String
在Java平台上,我们可以使用 UUID 来实现这个函数。
actual fun uuid() = UUID.randomUUID().toString()
而在JavaScript平台上,我们可以使用当前日期加上随机数的方式来实现这个函数。
actual fun uuid() = "${Date().getTime()}-${Random.nextInt(1000)}"
expect 和 actual 使得在公共代码中也同样可以使用平台相关的代码。其他公共代码可以直接使用这样的函数。
强大的工具支持,令人舒适的代码编写体验
Kotlin 和 IntelliJ IDEA 的开发者都是 JetBrains。这样保证了 Kotlin 可以在 IntelliJ IDEA 上得到很好的编程体验。虽然 Kotlin 也支持 Eclipse,不过为了最佳的编程体验,推荐使用 IntelliJ IDEA。
IntelliJ IDEA 提供了从 Java 转换到 Kotlin 的支持。当复制一段 Java 代码,并试图粘贴到 Kotlin 代码中时,IntelliJ IDEA 会自动把 Java 代码转换为 Kotlin 代码,这样就省去了手动转换的麻烦,使得代码迁移变得更顺畅。
IntelliJ IDEA 中还提供了 Kotlin REPL 的支持。可以输入一段 Kotlin 代码之后,马上执行并看到运行结果。REPL 方便对 Kotlin 的功能进行探索和尝试。REPL 会使用当前项目的 CLASSPATH,可以直接访问当前项目中的类和方法。当不确定某些类或方法的实际运行行为时,REPL可以帮助你快速查看结果。
IntelliJ IDEA还可以查看Kotlin编译器生成的字节代码。
创新而又实用的高级功能,优雅解决复杂问题
除了 Kotlin 语言和标准库之外,Kotlin 生态圈中还有其他一些创新而又实用的功能。
协程(Coroutines)
Coroutines是Kotlin异步编程的解决方案,可以在服务器端、浏览器端和移动端同时使用。Coroutines 目前已经稳定可用,不再是试验性功能。
Kotlin 协程的内容很多,其具体的使用细节超出了本文的内容。下面的代码展示了协程的使用效果。方法 calculateSize 下载一个网页的内容并返回其长度。使用 async 启动新的协程来执行 calculateSize ,其执行结果可以通过 await 来得到。
import java.net.URL
import kotlin.system.measureTimeMillis
import kotlinx.coroutines.*
fun main() = runBlocking {
val time = measureTimeMillis {
val size1 = async { calculateSize("http://www.baidu.com") }
val size2 = async { calculateSize("https://www.qq.com") }
val results = mapOf(size1.await(), size2.await())
println("Web page size is $results")
}
println("Task completed in $time ms")
}
fun calculateSize(url: String) = url to URL(url).readText().length
序列化(Serialization)
kotlinx.serialization 是 Kotlin 的对象序列化解决方案。 kotlinx.serialization 与Java中常用的 Jackson 的不同之处在于,它不是基于 Java 反射来实现的,而是由编译器直接生成序列化所需的代码。 kotlinx.serialization 目前提供了对JSON、Protobuf、CBOR、Properties 和 HOCON 的支持。
在需要支持序列化的类上添加
kotlinx.serialization.Serializable 注解,Kotlin编译器会负责生成相关的代码。以之前的 User 类为例,添加 @Serializable 进行声明,然后就可以直接进行序列化和反序列化。
@Serializable
data class User(val id: String, val username: String, val name: String, val email: String)
下面的代码展示了 JSON 序列化的用法。
val json = Json.encodeToString(User("001", "alexcheng", "Alex Cheng", "alexcheng@example.org"))
val obj = Json.decodeFromString<User>(jsonString)
Ktor
Ktor(ktor.io)是Kotlin提供的构建Web应用、HTTP服务和移动应用的框架。下面的代码是一个使用 Ktor 的简单服务器的实现。Ktor 还可以作为 Sockets、HTTP 和 WebSockets 的客户端。Ktor 的实现大量使用了协程。
fun main() {
embeddedServer(Netty, port = 8000) {
routing {
get ("/") {
call.respondText("Hello, world!")
}
}
}.start(wait = true)
}
Kotlin/Native
Kotlin/Native 是 Kotlin 全平台支持中的重要一环。Kotlin/Native 可以把 Kotlin 编译成原生代码,直接在机器上运行。Kotlin/Native 包含了一个 Kotlin 编译器使用的LLVM实现,以及 Kotlin 标准库的原生实现。Kotlin/Native 可以生成针对不同平台的可执行文件和静态或动态链接库。
总结
Kotlin 作为 Java 平台上的编程语言,它很好地在实用性和复杂性之间找到了平衡。这一方面来源于 JetBrains 在 Java 领域多年的积累,更加了解 Java 开发人员的需求。从某种程度来说,Kotlin 正在引领 Java 的发展。很多新特性,都是在 Kotlin 中实现了之后,再被加到 Java 中的,包括 switch 语句、记录类型、var 变量等。