Elytra
BlogLang

理解 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 最重要的主线。

Loading diagram…

1Kotlin 想解决什么问题?

Kotlin 并不是一门追求某种极端语言哲学的语言。

它更像是在不断问:

程序员每天重复解决的这些问题,真的还应该由程序员自己解决吗?

例如,Java 中一个简单的数据对象过去经常需要:

Constructor
Getter / Setter
equals
hashCode
toString

Kotlin 可以直接:

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
↓
取出 name

it 只是单参数 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
Loading diagram…

所以 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 Function

Library 再在上面构造:

launch
async
withContext
Dispatchers
Flow
...

这是一种很漂亮的分层。

例如:

launch(Dispatchers.Main) {
    val data = withContext(Dispatchers.IO) {
        loadFile()
    }

    updateUI(data)
}

可以粗略理解成:

Loading diagram…

这里:

  • launch 启动 Coroutine;
  • withContext 临时在另一个 Context 中执行,并等待结果;
  • Dispatcher 决定实际执行环境。13

suspend ≠ 新线程,Coroutine ≠ Thread,异步也 ≠ 并行。把这三个概念分开,是理解 Kotlin Coroutine 的第一步。

至于 Continuation 状态机、Structured Concurrency、Cancellation、Flow 等,更适合放进专门的 Coroutine 文章。


9Kotlin 如何扩展语言,而不真的修改语言?

Kotlin 还有一个很有意思的设计倾向:

它通常不允许你随意发明语法,而是提供一些固定的“语言接缝”。

例如:

写法背后的 Convention
a + ba.plus(b)
a[i]a.get(i)
a in bb.contains(a)
val (x, y) = pcomponent1() / component2()
val x by dgetValue(...)

这意味着 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

有限状态
→ sealed

10.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 / Flow

11代价:隐式越多,理解成本也越高

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 不断重复使用同一组设计积木的不同结果。

注释

  1. Kotlin Documentation,Null safety。Kotlin 在类型系统中区分 Nullable 与 Non-nullable Type,同时 !!、Java Interop 等边界仍可能导致空指针异常。 ↩

  2. Kotlin Documentation,Type checks and casts。Smart Cast 建立在控制流分析之上,只有编译器能够证明值在检查后没有发生不安全变化时才会应用。 ↩

  3. Kotlin Documentation,Exceptions — The Nothing type;Kotlin Language Specification,Built-in types。Nothing 没有实例,是 Kotlin 类型体系中的 Bottom Type。 ↩

  4. Kotlin Documentation,Sealed classes and interfaces。Sealed Hierarchy 使编译器能够利用已知子类型检查 when 的 Exhaustiveness。 ↩

  5. Kotlin Documentation,Higher-order functions and lambdas。单参数 Lambda 可以使用隐式参数名 it,最后一个表达式可作为 Lambda 的结果。 ↩

  6. Kotlin Documentation,Extensions。Extension 不会真正修改被扩展类型,并根据 Receiver 的编译期类型静态解析。 ↩

  7. Kotlin Documentation,Type-safe builders。@DslMarker 用于控制嵌套 DSL 中多个隐式 Receiver 的作用域。 ↩

  8. Kotlin Documentation,Delegated properties。Delegated Property 使用 getValue() / setValue() 等 Operator Convention 将 Property 访问委托给其他对象。 ↩

  9. Android Developers,State and Jetpack Compose。remember 可以在 Composition 中保留对象,mutableStateOf 创建可观察 State,其变化可以触发相关 UI 的 Recomposition。 ↩

  10. Kotlin Documentation,Compiler plugins。Compose 使用 Compiler Plugin 参与 @Composable 代码的编译过程。 ↩

  11. Kotlin Language Specification,Asynchronous programming with coroutines。suspend 表示函数能够在 Suspension Point 暂停并恢复,并不意味着自动创建线程。 ↩

  12. Kotlin Documentation,Coroutines guide。launch、async 等高层 Coroutine API 来自 kotlinx.coroutines,而不是 Kotlin 关键字。 ↩

  13. Kotlin Documentation,Coroutine context and dispatchers。Dispatcher 决定 Coroutine 使用的执行线程或线程池,Coroutine 本身并不等同于 Thread。 ↩

  14. Kotlin Documentation,Operator overloading;Kotlin Language Specification,Operator overloading。Kotlin 将一组固定语法映射到预定义的函数 Convention,而不是允许任意创建新运算符。 ↩

  15. Kotlin Documentation,Kotlin Symbol Processing API。KSP 提供 Symbol Processing 与代码生成能力,但并不是通用 Token Macro,也不能直接修改原始 Kotlin 源码。 ↩

CONTINUE EXPLORING

沿着这篇文章继续

在地图中查看

On this page