理解 Kotlin:一门现代工程语言是如何设计的
从类型安全、Lambda、Receiver、Delegation 到 DSL 与 Coroutine,理解 Kotlin 如何用少数可组合的语言机制,构造简洁而富有表达力的工程 API。
第一次看到 Kotlin,很容易把它理解成:
一个写起来更舒服的 Java。
这种印象并没有错。
Java:
User user = findUser();
if (user != null) {
System.out.println(user.getName());
}Kotlin:
val user = findUser()
user?.let {
println(it.name)
}但如果只把 Kotlin 理解成“少写一些 Getter、Setter 和 Null Check”,其实低估了它。
Kotlin 更值得研究的地方在于:它很擅长把一些反复出现的工程问题,变成类型系统、语言机制或可复用的 API 模式。
例如:
“这个值可能为空”
↓
T?
“这个函数不会正常返回”
↓
Nothing
“这个属性第一次访问才初始化”
↓
by lazy
“这段代码应该在某个对象的上下文中执行”
↓
Lambda with Receiver
“暂停一段异步逻辑,之后再继续”
↓
suspend单独看,每一个 Feature 都未必惊天动地。
Kotlin 真正有意思的是:
这些机制能够彼此组合,而且组合之后依然尽量保持在正常的类型系统、函数调用和 IDE 工具链中。
这也是理解 Kotlin 最重要的主线。
1Kotlin 想解决什么问题?
Kotlin 并不是一门追求某种极端语言哲学的语言。
它更像是在不断问:
程序员每天重复解决的这些问题,真的还应该由程序员自己解决吗?
例如,Java 中一个简单的数据对象过去经常需要:
Constructor
Getter / Setter
equals
hashCode
toStringKotlin 可以直接:
data class User(
val name: String,
val age: Int,
)这当然减少了代码量。
但更重要的是:
编译器已经知道这是一个“数据对象”,那么这些机械工作就没有必要继续让人手写。
这种思路贯穿 Kotlin:
- 能由类型系统表达的约束,尽量进入类型系统;
- 能由编译器推导的事实,尽量不重复书写;
- 反复出现的工程模式,尽量提供统一机制;
- 高层功能尽量由 Library 建立在少数基础语言能力之上。
所以 Kotlin 给人的感觉往往不是“某一个 Feature 特别革命性”,而是:
很多地方都恰好少了一点麻烦。
2类型系统:把更多错误提前到编译期
2.1Null 不再只是运行时问题
Kotlin 最经典的设计之一,就是:
String和:
String?是不同的类型。
var name: String = "Alice"
// name = null // 编译错误只有:
var name: String? = null才能表示“这个值可能为空”。1
于是:
name.length不再被允许直接执行。
你需要明确告诉编译器如何处理:
name?.length或者:
val length = name?.length ?: 0这背后的设计思想比 ?. 本身更重要:
把原本依赖文档和程序员记忆的约定,变成类型信息。
2.2Smart Cast:让编译器理解控制流
Kotlin 还会根据程序已经做出的判断自动缩小类型。
fun printLength(value: Any) {
if (value is String) {
println(value.length)
}
}进入 if 以后,编译器已经知道:
value 一定是 String所以不需要再写一次强制转换。2
同样:
if (user != null) {
println(user.name)
}这里 user 会从:
User?收窄成:
User这种设计很能体现 Kotlin 的风格:
程序员已经证明过一次的事情,就尽量不要要求再证明第二次。
2.3Nothing:连“不会返回”也可以成为类型
考虑:
fun fail(message: String): Nothing {
error(message)
}Nothing 很容易被误解成 void。
实际上恰好相反。
Unit
= 函数正常结束,只是没有有意义的数据返回
Nothing
= 函数根本不会正常结束例如:
val user = findUser() ?: fail("User not found")如果找到用户:
findUser()
↓
User
↓
user如果没有:
null
↓
fail(...)
↓
后面的代码不会继续执行因此这条语句执行完以后,user 可以直接被认为是:
User而不是 User?。
Nothing 是 Kotlin 的 Bottom Type,没有任何实例。3
Nothing 不是“没有返回内容”,而是“正常返回这件事根本不会发生”。Unit 才更接近“正常结束,但不返回有意义的数据”。
2.4Sealed Type:让类型表达有限状态
Kotlin 还很适合表达:
一个状态只有有限几种可能。
sealed interface Result {
data class Success(val value: String) : Result
data class Error(val message: String) : Result
data object Loading : Result
}于是:
when (result) {
is Result.Success -> show(result.value)
is Result.Error -> showError(result.message)
Result.Loading -> showLoading()
}编译器知道有哪些可能状态,因此可以帮助检查是否遗漏分支。4
从 Nullable、Smart Cast、Nothing 到 Sealed Type,其实都指向同一个方向:
类型不仅描述“数据长什么样”,也可以描述程序允许发生什么。
3Lambda:Kotlin API 为什么经常像一门小语言?
Kotlin 代码里经常出现:
users.filter { it.age >= 18 }remember {
...
}launch {
...
}button {
...
}它们看起来像很多不同的特殊语法。
其实绝大多数首先都来自同一个东西:
Lambda。
3.1函数本身也是值
例如:
val square: (Int) -> Int = { x ->
x * x
}这里:
(Int) -> Int表示:
接受一个
Int,返回一个Int的函数。
而:
{ x ->
x * x
}就是一个 Lambda。
所以函数可以像普通值一样:
- 存进变量;
- 作为参数传递;
- 从函数返回。
这就是 Higher-order Function 的基础。
3.2it 其实一点也不神秘
假设:
users.filter { user ->
user.age >= 18
}当 Lambda 只有一个参数时,Kotlin 允许把这个参数省略,并自动叫它:
it于是:
users.filter {
it.age >= 18
}所以:
users
.filter { it.age >= 18 }
.map { it.name }可以读成:
对每一个 User
↓
留下 age >= 18 的
再对每一个剩下的 User
↓
取出 nameit 只是单参数 Lambda 的默认参数名。5
3.3为什么 Lambda 经常写在括号外?
假设:
fun execute(block: () -> Unit)正常可以写:
execute({
println("Hello")
})因为最后一个参数是 Lambda,Kotlin 允许写成:
execute {
println("Hello")
}这叫 Trailing Lambda。
所以以后看到:
foo {
...
}可以先在脑中还原成:
foo({
...
})很多 Kotlin 的“魔法感”到这里就已经消失了一半。
4Receiver 与 Extension:Kotlin DSL 的关键
仅仅有 Lambda,还不足以写出 Kotlin 那些很像配置语言的 API。
真正重要的另一块是:
Receiver。
4.1Extension Function:让普通函数拥有成员式写法
Kotlin 可以定义:
fun String.isEmail(): Boolean {
return '@' in this
}然后:
"user@example.com".isEmail()看起来像给 String 增加了方法。
但实际上并没有修改 String 类。
Extension Function 本质仍然是一个静态解析的函数,只是允许使用成员调用风格。6
它的价值主要在 API 设计:
工具函数
↓
不再全部堆在 Utils 类里
对某个类型的操作
↓
可以自然地写在那个值后面4.2Lambda with Receiver:让 Lambda 进入一个上下文
普通 Lambda:
(Button) -> Unit通常写成:
{ button ->
button.text = "Play"
}Kotlin 还可以写:
Button.() -> Unit这意味着:
Lambda 执行时,
Button会成为隐式this。
于是:
{
text = "Play"
}其实相当于:
{
this.text = "Play"
}这正是 Kotlin DSL 最关键的能力之一。
5DSL:看起来像新语言,其实还是 Kotlin
假设我们希望写:
window {
title = "My Game"
button {
text = "Play"
}
}它看起来很像某种专门描述 UI 的语言。
但可以只靠普通 Kotlin 实现:
class Button {
var text = ""
}
class Window {
var title = ""
fun button(block: Button.() -> Unit) {
val button = Button()
button.block()
}
}
fun window(block: Window.() -> Unit): Window {
val window = Window()
window.block()
return window
}于是:
window {
title = "My Game"
}实际上:
window(...)
↓
普通函数
{ ... }
↓
Lambda
Window.() -> Unit
↓
Lambda 中 this = Window里面再调用:
button {
text = "Play"
}则:
Button.() -> Unit
↓
Lambda 中 this = Button所以 Kotlin DSL 很有意思的一点在于:
它通常没有创造一套新的语法,而是让普通 Kotlin 足够容易组合,看起来像一门领域语言。
这意味着 IDE 通常仍然能够理解:
- 类型;
- Symbol;
- 自动补全;
- 跳转定义;
- Rename;
- Refactor。
这也是 Kotlin DSL 相比“字符串配置语言”的一个重要工程优势。
Kotlin 也提供 @DslMarker 来限制复杂 DSL 中多个隐式 Receiver 的作用域,避免内外层上下文混在一起。理解 DSL 时知道它的存在即可,具体规则可以单独展开。7
6Property 与 by:把状态访问也变成可复用机制
Kotlin 中:
var name: String = ""应该理解为一个 Property,而不仅仅是字段。
它可以有 Getter / Setter:
var name: String = ""
get() = field.uppercase()
set(value) {
field = value.trim()
}因此访问:
user.name本来就已经是一个抽象操作。
这给 Delegation 留出了空间。
6.1= 和 by 的区别
val name = delegate意味着:
name就是delegate本身。
而:
val name by delegate意味着:
name的读取行为由delegate负责。
对于:
var写入行为也可以一起委托。
概念上:
name
↓
delegate.getValue(...)
name = value
↓
delegate.setValue(...)Kotlin 官方把这套机制称为 Delegated Property。8
6.2by lazy
最常见例子:
val config by lazy {
loadConfig()
}含义是:
第一次读取 config
↓
执行 loadConfig()
↓
缓存结果
↓
之后重复使用这里其实组合了两个东西:
lazy { ... }
↓
提供“延迟计算一次”的对象
by
↓
把 Property 的访问委托给它所以 by lazy 并不是某个整体关键字。
7Compose:为什么 Kotlin 特别适合声明式 UI?
Jetpack Compose 很能体现 Kotlin 这些机制如何组合。
例如:
@Composable
fun Counter() {
var count by remember {
mutableStateOf(0)
}
Button(
onClick = { count++ },
) {
Text("Count: $count")
}
}表面上信息很多,但可以拆开:
remember { ... }
↓
普通函数 + Trailing Lambda
mutableStateOf(0)
↓
创建可观察状态
by
↓
把 State 暴露成普通 Property
Button { ... }
↓
函数 + Lambda其中:
remember {
mutableStateOf(0)
}大意是:
在 Composition 中记住这个 State,不要每次 Recomposition 都重新创建它。
当:
count++改变被 UI 读取的状态时,Compose 会安排相关内容重新组合。9
这里不需要深入 Compose Runtime。
真正值得观察的是:
Kotlin 的语言设计让框架作者可以把复杂机制包装成非常自然的 API。
当然,Compose 并不“只是 DSL”。
@Composable 还依赖 Compose Compiler Plugin 和 Runtime 才能实现完整的 Composition / Recomposition 模型。10
8Coroutine:暂停计算,而不是“开一个线程”
Coroutine 是 Kotlin 另一个非常容易产生误解的地方。
例如:
suspend fun loadUser(): User这里:
suspend并不表示:
开一个后台线程。
它只表示:
这个函数允许在某些位置暂停,并在之后恢复。11
8.1Coroutine 和 Thread 不是一回事
可以把它们简单区分成:
Thread
↓
实际执行代码的资源
Coroutine
↓
可以暂停和恢复的一段计算Coroutine 可以:
执行
↓
遇到 Suspension Point
↓
暂停
↓
线程去做别的事情
↓
稍后恢复 Coroutine因此:
delay(1000)和:
Thread.sleep(1000)在语义上并不相同。
前者可以暂停 Coroutine,而不必让线程一直阻塞在那里。
8.2launch 和 withContext 不是关键字
这些:
launch
async
withContext
Dispatchers.IO来自:
kotlinx.coroutines而不是 Kotlin Grammar 本身。12
Kotlin Language 更底层的核心主要是:
suspend
Continuation
Suspending FunctionLibrary 再在上面构造:
launch
async
withContext
Dispatchers
Flow
...这是一种很漂亮的分层。
例如:
launch(Dispatchers.Main) {
val data = withContext(Dispatchers.IO) {
loadFile()
}
updateUI(data)
}可以粗略理解成:
这里:
launch启动 Coroutine;withContext临时在另一个 Context 中执行,并等待结果;Dispatcher决定实际执行环境。13
suspend ≠ 新线程,Coroutine ≠ Thread,异步也 ≠ 并行。把这三个概念分开,是理解 Kotlin Coroutine 的第一步。
至于 Continuation 状态机、Structured Concurrency、Cancellation、Flow 等,更适合放进专门的 Coroutine 文章。
9Kotlin 如何扩展语言,而不真的修改语言?
Kotlin 还有一个很有意思的设计倾向:
它通常不允许你随意发明语法,而是提供一些固定的“语言接缝”。
例如:
| 写法 | 背后的 Convention |
|---|---|
a + b | a.plus(b) |
a[i] | a.get(i) |
a in b | b.contains(a) |
val (x, y) = p | component1() / component2() |
val x by d | getValue(...) |
这意味着 Library 可以和语言表面结合得非常紧密,却不需要自己修改 Parser。14
这和拥有通用 Token Macro 的语言是不同路线。
例如强宏系统可以:
Token
↓
宏自己解析
↓
生成新的程序Kotlin 更常见的是:
正常 Kotlin Grammar
↓
函数 / Receiver / Convention
↓
构造 DSL它牺牲了一部分任意元编程能力,却换来了:
- 更统一的语法;
- 更容易理解的 Tooling;
- 更稳定的 IDE 支持;
- 普通类型系统仍然能够工作。
需要真正代码生成时,Kotlin 还有 KSP;需要更深入改变编译过程时,则可以使用 Compiler Plugin。15
但这已经属于另外一个层级,不需要为了日常 Kotlin DSL 而使用。
10Kotlin 的强大不在单个 Feature,而在组合
如果把整篇文章重新压缩,可以看到 Kotlin 经常做四件事情。
10.1让类型表达更多事实
可能为空
→ T?
不会正常返回
→ Nothing
有限状态
→ sealed10.2让编译器承担机械工作
Smart Cast
Data Class
类型推导10.3给 Library 留出受控的语言扩展点
Lambda
Receiver
Extension
Property
Delegation
Operator Convention于是可以建立:
DSL
Compose
Gradle Kotlin DSL
Ktor
...10.4把底层能力和高层 API 分开
Coroutine 是最典型的例子:
Language
↓
suspend
Library
↓
launch / async / Dispatcher / Flow11代价:隐式越多,理解成本也越高
Kotlin 的这些设计当然不是免费的。
例如:
foo {
bar()
}有时需要进一步判断:
foo 是什么函数?
这个 Lambda 有没有 Receiver?
当前 this 是谁?
bar 是成员函数?
Extension?
还是来自外层 Receiver?同样:
val x by something也隐藏了:
getValue
setValue这样的 Convention。
因此 Kotlin 有一个很有意思的 Trade-off:
它减少了表面的样板代码,却增加了一部分语言机制和隐式规则。
写得好的 Kotlin 可以非常自然。
写得过度 DSL 化的 Kotlin,也可能像在阅读一门没人告诉你规则的小语言。
所以 Kotlin 的优秀之处并不是:
“越短越好。”
而是:
在简洁、类型安全、表达力、Tooling 和可理解性之间寻找一个工程上的平衡。
12结语
Kotlin 很难用某一个 Feature 来概括。
它不像 Rust 那样可以很快让人想到 Ownership,也不像 C 那样可以用“接近机器”概括。
它的特点反而更分散,但也更工程化:
Null Safety
Nothing
Smart Cast
Lambda
Receiver
Extension
Property
Delegation
Coroutine这些能力最后指向的是同一件事情:
让程序员能够用更接近问题本身的方式表达程序,同时尽量让编译器仍然理解这些表达。
因此 Kotlin 最值得学习的并不只是:
某个关键字怎么写而是观察:
一门现代语言如何通过少量可组合的基础机制,让普通 Library 获得接近语言本身的表达力。
理解这一点以后,再看到:
users.filter { it.active }
val config by lazy { loadConfig() }
window {
button {
text = "Play"
}
}
launch {
...
}它们就不再是一堆彼此无关的“神奇语法”。
而只是 Kotlin 不断重复使用同一组设计积木的不同结果。
注释
-
Kotlin Documentation,Null safety。Kotlin 在类型系统中区分 Nullable 与 Non-nullable Type,同时
!!、Java Interop 等边界仍可能导致空指针异常。 ↩ -
Kotlin Documentation,Type checks and casts。Smart Cast 建立在控制流分析之上,只有编译器能够证明值在检查后没有发生不安全变化时才会应用。 ↩
-
Kotlin Documentation,Exceptions — The Nothing type;Kotlin Language Specification,Built-in types。
Nothing没有实例,是 Kotlin 类型体系中的 Bottom Type。 ↩ -
Kotlin Documentation,Sealed classes and interfaces。Sealed Hierarchy 使编译器能够利用已知子类型检查
when的 Exhaustiveness。 ↩ -
Kotlin Documentation,Higher-order functions and lambdas。单参数 Lambda 可以使用隐式参数名
it,最后一个表达式可作为 Lambda 的结果。 ↩ -
Kotlin Documentation,Extensions。Extension 不会真正修改被扩展类型,并根据 Receiver 的编译期类型静态解析。 ↩
-
Kotlin Documentation,Type-safe builders。
@DslMarker用于控制嵌套 DSL 中多个隐式 Receiver 的作用域。 ↩ -
Kotlin Documentation,Delegated properties。Delegated Property 使用
getValue()/setValue()等 Operator Convention 将 Property 访问委托给其他对象。 ↩ -
Android Developers,State and Jetpack Compose。
remember可以在 Composition 中保留对象,mutableStateOf创建可观察 State,其变化可以触发相关 UI 的 Recomposition。 ↩ -
Kotlin Documentation,Compiler plugins。Compose 使用 Compiler Plugin 参与
@Composable代码的编译过程。 ↩ -
Kotlin Language Specification,Asynchronous programming with coroutines。
suspend表示函数能够在 Suspension Point 暂停并恢复,并不意味着自动创建线程。 ↩ -
Kotlin Documentation,Coroutines guide。
launch、async等高层 Coroutine API 来自kotlinx.coroutines,而不是 Kotlin 关键字。 ↩ -
Kotlin Documentation,Coroutine context and dispatchers。Dispatcher 决定 Coroutine 使用的执行线程或线程池,Coroutine 本身并不等同于 Thread。 ↩
-
Kotlin Documentation,Operator overloading;Kotlin Language Specification,Operator overloading。Kotlin 将一组固定语法映射到预定义的函数 Convention,而不是允许任意创建新运算符。 ↩
-
Kotlin Documentation,Kotlin Symbol Processing API。KSP 提供 Symbol Processing 与代码生成能力,但并不是通用 Token Macro,也不能直接修改原始 Kotlin 源码。 ↩
CONTINUE EXPLORING