上一篇笔记解决了 Kotlin 基础语法“看起来很奇怪”的问题。今天继续往前走,我开始尝试把零散语法组织成一套完整的状态处理模型。
真正困难的部分不是 map、copy() 或 when 中的任何一个,而是这些概念同时出现时,代码突然从熟悉的 Java Service 风格变成了:
旧状态 + Action -> 新状态
这篇笔记记录我如何从空安全和计算属性出发,逐步理解不可变状态、密封 Action、Reducer、结构共享和扩展函数,也记录测试如何把“能够编译”和“符合业务契约”区分开来。
Table of contents
Open Table of contents
先建立学习验收标准
这次学习采用了一个很实用的三项验收规则:
- Code:业务行为已经实现。
- Tests:正确层级的测试覆盖了正常值和边界值。
- Retell:能够用自己的话解释代码为什么工作。
只看到 BUILD SUCCESSFUL 并不代表真正完成。现有测试可能漏掉了需求,代码也可能只是碰巧通过某一组数据。复述则可以暴露一种更隐蔽的问题:代码是照着提示写出来的,但心智模型还没有建立。
今天的几个练习都遵循同样的循环:
实现功能
-> 编写并运行测试
-> 根据失败结果修正
-> 用自己的话解释
-> 记录进度
val 固定的是绑定,不是对象内部状态
Java 开发者很容易把 Kotlin 的 val 直接理解成“对象不可变”。更准确的说法是:val 声明的变量不能重新绑定到另一个值。
val names = mutableListOf("Java")
names.add("Kotlin") // 合法
// names = mutableListOf("Android") // 不合法
这与 Java 的 final 引用比较接近。真正构造不可变状态,还需要同时满足:
- 属性使用
val; - 集合不暴露修改操作;
- 更新时创建新对象,而不是修改旧对象。
这也是后面使用 data class.copy() 和只读 List 的基础。
可空类型是 API 契约
下面两个声明不是注释层面的差异,而是两个不同的类型:
val title: String
val nullableTitle: String?
如果业务允许标题为 null,构造函数却声明为 String,测试甚至无法编译:
Null cannot be a value of a non-null type 'String'
这不是普通语法错误,而是测试发现公共 API 的类型契约和需求不一致。
一个常见的空值规范化写法是:
val displayTitle = title
?.trim()
?.ifBlank { "未命名主题" }
?: "未命名主题"
也可以先把 null 转换为空字符串,再统一处理空白:
val displayTitle = title
.orEmpty()
.trim()
.ifBlank { "未命名主题" }
关键不是追求最短,而是明确区分:
?:处理null;ifBlank处理空字符串或全空白字符串。
"" 并不等于 null,所以:
"" ?: "默认值"
结果仍然是空字符串。
takeIf:条件成立才保留自己
今天一个很典型的错误来自 takeIf:
value.takeIf { it.isBlank() } ?: "匿名用户"
我最初以为它会在值为空白时使用默认值,实际逻辑正好相反。
takeIf 可以近似理解为:
if (predicate(value)) value else null
因此:
value 为空白
-> isBlank() 为 true
-> takeIf 保留空白字符串
-> Elvis 不会执行
value 不为空白
-> isBlank() 为 false
-> takeIf 返回 null
-> Elvis 使用默认值
需要保留非空白值时,可以写:
value.takeIf { it.isNotBlank() } ?: "匿名用户"
不过这里只是处理字符串空白,ifBlank 的意图更直接:
value.ifBlank { "匿名用户" }
普通属性与计算属性
把 Java 模型改写成 Kotlin 时,我使用了计算属性:
class StudySession(
val topic: String?,
val targetMinutes: Int?,
val completedMinutes: Int,
) {
val normalizedTargetMinutes: Int
get() = (targetMinutes ?: 30).coerceAtLeast(0)
val normalizedCompletedMinutes: Int
get() = completedMinutes.coerceAtLeast(0)
val remainingMinutes: Int
get() = (normalizedTargetMinutes - normalizedCompletedMinutes)
.coerceAtLeast(0)
}
这里的 remainingMinutes 没有保存一份独立数据。每次访问它时,getter 都会根据其他属性重新计算。
Java 心智模型大致是:
public int getRemainingMinutes() {
return Math.max(
getNormalizedTargetMinutes() - getNormalizedCompletedMinutes(),
0
);
}
计算属性的好处是避免重复状态。例如同时保存目标时间、已完成时间和剩余时间,一旦某个更新路径漏改剩余时间,数据就会互相矛盾。把剩余时间定义为派生值,可以把一致性规则集中到一个地方。
普通 class 和 data class 的区别
构造函数中的 val 属性并不是 data class 独有的:
class StudySession(
val topic: String,
)
普通类同样具有属性访问能力。
data class 主要额外提供基于数据的常用操作:
equals()hashCode()toString()copy()componentN()
例如不可变任务:
data class DomainTask(
val id: Int,
val title: String,
val priority: TaskPriority = TaskPriority.NORMAL,
val isDone: Boolean = false,
)
切换完成状态时不修改旧任务,而是生成新任务:
val updatedTask = task.copy(
isDone = !task.isDone
)
没有写进 copy() 的属性自动保持原值,所以不需要重复传入 id、title 和 priority。
sealed interface 不一定要声明方法
Java 经验让我下意识认为接口应该定义行为,例如:
interface Runnable {
void run();
}
但接口也可以只负责分类。下面的 TaskListAction 表示一组有限的用户意图:
sealed interface TaskListAction {
data class Add(
val title: String,
val priority: TaskPriority = TaskPriority.NORMAL,
) : TaskListAction
data class Toggle(val id: Int) : TaskListAction
data class ChangePriority(
val id: Int,
val priority: TaskPriority,
) : TaskListAction
data object ClearCompleted : TaskListAction
}
这里声明在接口内部的 Add、Toggle 等是嵌套类型,不是接口属性。
data class Toggle(val id: Int) : TaskListAction
冒号表示 Toggle 实现了 TaskListAction。Java 21 可以近似写成:
record Toggle(int id) implements TaskListAction {}
需要携带数据的 Action 使用 data class;不携带数据、全局只需要一个实例的 ClearCompleted 使用 data object。
sealed 让编译器知道所有直接实现类型,因此 when 可以检查分支是否完整:
when (action) {
is TaskListAction.Add -> ...
is TaskListAction.Toggle -> ...
is TaskListAction.ChangePriority -> ...
TaskListAction.ClearCompleted -> ...
}
以后新增 Action 却忘记更新 Reducer 时,编译器会直接提示,而不是等到某个运行时路径悄悄失效。
表达式函数体为什么不写 return
Kotlin 函数有两种常见写法。
普通函数体需要 return:
fun double(number: Int): Int {
return number * 2
}
表达式函数体使用等号,右侧结果自动成为返回值:
fun double(number: Int): Int = number * 2
这与 JavaScript 箭头函数的简洁形式有些相似:
const double = number => number * 2;
但一个非常重要的陷阱是:
fun something() = {
// 这里返回的是 Lambda
}
= { ... } 不是普通函数代码块,而是“函数返回一个 Lambda”。普通代码块写法必须去掉等号:
fun something() {
// 普通函数体
}
Reducer 使用的是表达式函数体:
fun TaskListState.reduce(
action: TaskListAction,
): TaskListState =
when (action) {
// 每个分支都产生一个 TaskListState
}
Kotlin 的 if 和 when 都能产生值。因此一次 Add 的返回过程是:
this 或 copy(...)
-> if 的结果
-> Add 分支的结果
-> when 的结果
-> reduce() 的返回值
when 分支中的箭头也不是等号:
is TaskListAction.Add -> result
它的含义是“左侧匹配时,使用右侧表达式作为这个分支的结果”,和 Java switch expression 的 case ... -> ... 很接近。
Reducer:旧状态和 Action 生成新状态
Reducer 的核心模型是:
旧 TaskListState + TaskListAction = 新 TaskListState
状态定义为不可变快照:
data class TaskListState(
val tasks: List<DomainTask> = emptyList(),
val nextId: Int = 1,
)
Add 分支不会向旧列表执行 add(),而是创建新任务、新列表和新状态:
is TaskListAction.Add -> {
val normalizedTitle = action.title.trim()
if (normalizedTitle.isBlank()) {
this
} else {
val newTask = DomainTask(
id = nextId,
title = normalizedTitle,
priority = action.priority,
)
copy(
tasks = tasks + newTask,
nextId = nextId + 1,
)
}
}
在扩展函数中,this 是旧的 TaskListState。标题为空白时直接返回旧状态;合法时,tasks + newTask 返回新 List,copy() 再返回新 State。
Toggle 分支使用 map:
is TaskListAction.Toggle -> {
val newTasks = tasks.map { task ->
if (task.id == action.id) {
task.copy(isDone = !task.isDone)
} else {
task
}
}
copy(tasks = newTasks)
}
这里的 map 不是修改每一个旧元素,而是在遍历旧列表时,为新列表的每个位置决定应该放哪个对象。
结构共享:新状态不等于复制所有对象
假设旧列表中有两个任务:
Task1(id=1, isDone=false)
Task2(id=2, isDone=false)
执行 Toggle(id=2) 后:
旧 List -> 旧 Task1
-> 旧 Task2(false)
新 List -> 旧 Task1
-> 新 Task2(true)
Task1 没有发生变化,而且属性都是 val,所以新旧列表可以安全共享同一个 Task1。只有 Task2 的完成状态变化,才需要 copy() 创建新对象。
这让我纠正了一个误解:创建不可变新状态,不代表把整个对象图完整复制一遍;未变化的不可变对象可以安全复用,只复制发生变化的路径。
filter、map 与 filterNot
Kotlin 的集合转换不需要先调用 stream():
val activeTitles = tasks
.filter { task -> !task.isDone }
.map { task -> task.title }
类型变化过程是:
List<DomainTask>
-> filter
List<DomainTask>
-> map
List<String>
filter保留条件为true的元素;map把每个元素转换成另一个值;- 两者都返回新 List。
清除已完成任务时,filterNot 的意图更直接:
val activeTasks = tasks.filterNot { task -> task.isDone }
第一次实现时,我写成了:
tasks.filter { task -> task.isDone }
这实际会保留已完成任务。这个错误再次说明:集合函数名很短,但必须明确条件为 true 时元素究竟是保留还是删除。
扩展函数的参数藏在哪里
下面的函数看起来没有接收任务列表:
fun List<DomainTask>.activeTaskTitles(): List<String> =
filter { !it.isDone }
.map { it.title }
真正的输入在函数名前面:
List<DomainTask>.
调用时,点号左侧的对象成为扩展函数内部的 this:
val titles = tasks.activeTaskTitles()
可以把它展开成:
fun List<DomainTask>.activeTaskTitles(): List<String> {
val tasks: List<DomainTask> = this
return tasks
.filter { !it.isDone }
.map { it.title }
}
Java 心智模型则是一个静态工具方法:
static List<String> activeTaskTitles(List<DomainTask> tasks) {
return tasks.stream()
.filter(task -> !task.isDone())
.map(DomainTask::getTitle)
.toList();
}
扩展函数并没有真的修改 List 类,只是让调用方式从:
activeTaskTitles(tasks)
变成更自然的:
tasks.activeTaskTitles()
Kotlin 也有方法和属性引用
Java 中常见:
.map(DomainTask::getTitle)
Kotlin 对属性可以写:
.map(DomainTask::title)
也可以写 Lambda:
.map { it.title }
两者都正确。当前学习阶段,我更倾向使用:
.map { it.title }
它最常见,也不容易在重载和类型推断中产生额外疑问。已有一个含义清楚的命名函数时,再使用函数引用:
.map(::normalizeTitle)
测试如何暴露不同层次的问题
今天遇到的失败大致分成三类。
测试无法编译
例如测试传入 null,生产 API 却声明非空类型。这说明类型契约不一致,代码甚至无法进入运行阶段。
测试可以运行,但断言失败
例如:
trimStart()没有删除尾部空格;takeIf { it.isBlank() }把条件写反;- Toggle 把目标 ID 写死为 2;
- Toggle 错误递增
nextId; - ClearCompleted 使用
filter保留了已完成任务; - 摘要中的中英文冒号或空格不符合契约。
这说明程序结构合法,但实际业务结果错误。
测试通过,但覆盖不足
最值得警惕的一次情况是:Toggle 的第一个测试通过了,但实现实际上修改了任务 ID 和 nextId。原测试只检查 isDone,没有验证稳定字段。
补充断言后,测试才真正保护完整契约:
assertEquals(
listOf(1, 2, 3),
result.tasks.map { it.id },
)
assertEquals(initial.nextId, result.nextId)
所以测试通过只代表“现有断言通过”,不自动等于“所有需求都正确”。企业开发中的代码审查和测试设计同样重要。
今天最重要的结论
第一,val、只读 List 和 copy() 需要一起使用,才能形成真正可预测的不可变状态更新。
第二,sealed interface 可以只负责表示有限分类,不一定要声明方法。Action 描述发生了什么,Reducer 集中决定状态如何变化。
第三,表达式函数体中的 = 已经负责返回右侧结果;if 和 when 都能产生值。= { ... } 则是返回 Lambda,不能和普通代码块混淆。
第四,扩展函数并不是没有参数。点号左侧的对象作为隐藏接收者 this 传入,JVM 层面可以近似理解为静态函数的第一个参数。
第五,不可变更新允许结构共享:只复制变化的对象,未变化的不可变对象可以安全复用。
第六,测试失败的阶段本身也提供信息:编译失败常常指向 API 或类型契约,断言失败指向运行行为,而全部通过仍需要检查覆盖是否完整。
下一步
接下来会继续完成 Kotlin 惯用写法训练:
- 在测试保护下整理 Reducer 代码风格;
- 继续练习集合转换和命名函数引用;
- 对比
let、run、apply、also、with等作用域函数; - 识别“为了简短而简短”的 Kotlin,避免把 Java 风格机械翻译成 Kotlin;
- 再逐步把这些语言能力带入 Compose、ViewModel 和 Android 数据层。
今天最大的收获是,Kotlin 的简洁并不是省略业务步骤,而是把很多步骤变成表达式、类型约束和不可变转换。只有先看清隐藏的接收者、返回值和新旧对象关系,这种简洁才会真正变得可读。