讨论函数式编程时,很容易从 map、filter 和不可变数据开始。但在实际代码里,更值得先问的是:读懂这个函数,究竟需要知道多少外部条件?
如果一个函数一边读取缓存,一边更新列表,又顺手发出网络请求,那么即使只有几十行,也很难单独判断它是否正确。函数的输入写在参数里,真正的依赖却散落在别处。
下面用消息列表举一个简化的例子。它不涉及具体业务框架,只讨论一件事:收到已读回执之后,如何计算新的列表。
先把问题缩小
假设每条消息只有三个字段:编号、正文、是否已读。收到一组消息编号后,需要将对应消息标记为已读,其他消息保持原样。
data class Message(
val id: String,
val text: String,
val isRead: Boolean = false,
)
fun markAsRead(
messages: List<Message>,
readIds: Set<String>,
): List<Message> = messages.map { message ->
if (message.id in readIds && !message.isRead) {
message.copy(isRead = true)
} else {
message
}
}
这里没有读取当前用户,没有访问数据库,也没有向界面发送通知。相同的消息列表和编号集合,会得到相同的结果;原来的消息对象也没有被修改。
这个拆分最直接的好处是,边界情况变得容易讨论:回执为空会怎样?编号不存在会怎样?同一条回执收到两次会怎样?答案都在这段计算里,不需要先搭好整个应用才能验证。
把执行留在外面
程序仍然要接收回执、保存状态、刷新界面。这些操作没有消失,只是留给外层处理。外层拿到当前列表和回执编号,调用 markAsRead,再决定何时提交结果。
这样排查问题时可以先分清两种情况。如果计算结果本身不对,就检查函数;如果结果正确,界面却没有变化,就检查状态提交和订阅过程。问题的范围会小很多。
但这个函数没有解决事件顺序问题。如果两次更新都基于同一个旧列表计算,随后分别覆盖状态,仍然可能丢失其中一次更新。纯函数让计算更容易推理,状态的原子更新或串行处理仍然需要外层保证。
只读接口不等于不可变对象
Kotlin 的 List 提供只读接口,却不保证底层集合永远不会变化。如果另一个地方持有同一集合的可变引用,它仍然可以修改内容。这一点在 Kotlin 集合文档中有明确区分。
同样,data class 的 copy 也不是深拷贝。这个例子里的字段都是值或不可变字符串,情况比较简单;如果消息对象还包含一个可变附件列表,复制消息之后,两份对象仍可能共享附件数据。
所以不能只把类型换成 List、把字段写成 val,就认定整条状态链已经不可变。还要检查数据从哪里来,以及谁有权限修改它。
值得保留的边界
map 会遍历列表,并创建新的列表。对这里的例子而言,它换来的是清楚的更新规则;对高频更新的大列表,是否需要增量处理,则应该根据测量结果决定。
我更愿意把纯函数看作一个拆分工具:先把可以独立描述的计算抽出来,让它的输入、输出和规则足够清楚。至于存储、并发和性能,继续在各自的位置处理。
代码变好读,往往始于这种很小的边界,而不是一次彻底的编程范式切换。
