Skip to content
小记
Go back

从 Java 到 Kotlin:不可变状态、Reducer 与扩展函数学习笔记

Edit page

上一篇笔记解决了 Kotlin 基础语法“看起来很奇怪”的问题。今天继续往前走,我开始尝试把零散语法组织成一套完整的状态处理模型。

真正困难的部分不是 map、copy() 或 when 中的任何一个,而是这些概念同时出现时,代码突然从熟悉的 Java Service 风格变成了:

旧状态 + Action -> 新状态

这篇笔记记录我如何从空安全和计算属性出发,逐步理解不可变状态、密封 Action、Reducer、结构共享和扩展函数,也记录测试如何把“能够编译”和“符合业务契约”区分开来。

Table of contents

Open Table of contents

先建立学习验收标准

这次学习采用了一个很实用的三项验收规则:

  1. Code:业务行为已经实现。
  2. Tests:正确层级的测试覆盖了正常值和边界值。
  3. Retell:能够用自己的话解释代码为什么工作。

只看到 BUILD SUCCESSFUL 并不代表真正完成。现有测试可能漏掉了需求,代码也可能只是碰巧通过某一组数据。复述则可以暴露一种更隐蔽的问题:代码是照着提示写出来的,但心智模型还没有建立。

今天的几个练习都遵循同样的循环:

实现功能
-> 编写并运行测试
-> 根据失败结果修正
-> 用自己的话解释
-> 记录进度

val 固定的是绑定,不是对象内部状态

Java 开发者很容易把 Kotlin 的 val 直接理解成“对象不可变”。更准确的说法是:val 声明的变量不能重新绑定到另一个值。

val names = mutableListOf("Java")

names.add("Kotlin") // 合法
// names = mutableListOf("Android") // 不合法

这与 Java 的 final 引用比较接近。真正构造不可变状态,还需要同时满足:

这也是后面使用 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,所以:

"" ?: "默认值"

结果仍然是空字符串。

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 主要额外提供基于数据的常用操作:

例如不可变任务:

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>

清除已完成任务时,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 却声明非空类型。这说明类型契约不一致,代码甚至无法进入运行阶段。

测试可以运行,但断言失败

例如:

这说明程序结构合法,但实际业务结果错误。

测试通过,但覆盖不足

最值得警惕的一次情况是: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 惯用写法训练:

今天最大的收获是,Kotlin 的简洁并不是省略业务步骤,而是把很多步骤变成表达式、类型约束和不可变转换。只有先看清隐藏的接收者、返回值和新旧对象关系,这种简洁才会真正变得可读。


Edit page
Share this post:

Previous Post
从 Java 到 Kotlin:原生 Android 学习第一天笔记