
Kotlin的设计哲学之:函数式编程
Kotlin的设计哲学之:函数式编程
一、你真的理解函数式编程吗?
先问一个问题:你觉得函数式编程是什么?
函数是一等公民?函数可以独立于类存在?函数可以作为参数和返回值?Lambda 闭包语法糖?如果你这么想,那你写出来的代码该出 bug 还是出 bug,该难测试还是难测试——你只学到了皮。
真正的函数式编程,核心只有两条铁律:
- 绝对的无副作用。 一个函数,绝不修改外部变量。结果可预测,它的返回值只由参数决定,不依赖任何外部上下文。
- 不可变性。 数据一旦创建,永不修改。要改?创建新副本。
var和mutableListOf不是不能用——但它们是例外,不是默认。
《Kotlin 核心编程(DIVE INTO KOTLIN)》的作者团队有一句话,读到时直接醍醐灌顶:
如果说面向对象是归纳法,侧重于对事物特征的提取及概括;那么函数式中的组合实现更像是演绎法,近似于数学中的推导。
这句话把 OOP 和 FP 的本质区别说透了。面向对象是自底向上的——你观察现实世界,抽象出类和接口,然后让对象之间互相协作。函数式是自顶向下的——你拿到输入,定义输出,然后把从输入到输出的变换过程拆成一系列纯函数的组合。前者是"提取共性",后者是"推导结果"。
Kotlin 不是纯函数式语言——它不强迫你写 Haskell 风格的代码。但如果你只把 Lambda 当作"少写几行匿名内部类"的工具,那你从来没有真正理解函数式。
下面我会围绕三个核心展开:函数即值、绝对无副作用、一切皆表达式。 它们层层递进,最后汇聚成一件事:用数学推导的方式写代码,而不是用操作步骤的方式写代码。
二、函数即值
Java 里函数是二等公民。你不能把方法直接赋给变量,不能把方法当参数传给另一个方法——你只能通过匿名内部类迂回实现:
// Java — 传递一段行为,必须先包装成对象
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
System.out.println("clicked");
}
});这不仅仅是啰嗦。深层的问题是:当函数不能被自由传递时,行为就是死的。 你只能在定义它的地方调用它,没法把它像一个值一样传来传去、组合拼装。
Kotlin 把这道锁拆了。函数和 Int、String 等价——可以赋值、可以传参、可以作为返回值:
// 赋值给变量
val onClick: (View) -> Unit = { println("clicked") }
// 作为参数 — 高阶函数
fun <T> List<T>.customFilter(predicate: (T) -> Boolean): List<T> {
val result = mutableListOf<T>()
for (item in this) if (predicate(item)) result.add(item)
return result
}
// 作为返回值 — 函数生成函数
fun createMultiplier(factor: Int): (Int) -> Int = { x -> x * factor }
val triple = createMultiplier(3)
triple(5) // 15函数是值。 这是函数式编程的入场券。它让你有了自由传递和组合行为的能力——有了它,后面的无副作用和表达式编程才有意义。
三、绝对无副作用
纯函数 vs 有副作用的函数
先看两个函数:
// 纯函数 — 你给它参数,它给你结果,世界纹丝不动
fun add(a: Int, b: Int): Int = a + b
// 有副作用的函数 — 你猜它有什么问题?
var counter = 0
fun incrementAndGet(): Int {
counter++
return counter
}add 是纯函数。同样的 (1, 2),永远返回 3。不动任何外部变量,任何时刻、任何线程调用它,结果都确定。
incrementAndGet 是典型的指令式写法。从签名上看 incrementAndGet(): Int 好像只是"返回一个整数",但实际上它偷偷修改了外部的 counter,而且每次调用的返回值都不一样。你无法从函数签名推测它的行为,你必须在脑子里维护一个外部状态的模型——这就是 bug 的温床。
纯函数的好处不需要长篇论证:
- 测试不需要 mock。 传参,看返回值。完事。
- 调试不需要还原现场。 同样的输入永远是同样的输出,跑一次就定位了。
- 并发安全更简单。 不写共享状态,天然不存在竞态条件。
指令式代码不是不能写——现实中的程序必然有 IO、有状态变更、有副作用。问题在于,指令式代码中的副作用往往是无意识的、不可追踪的。你声明一个 var,改一下,再改一下,十几次修改散落在几百行代码里——没人能理清这个变量的状态是怎么变的。
函数式编程不要求你消灭一切副作用(那是 Haskell 的路线)。它要求的是:让副作用变成有意识的、集中的、可追踪的。 Kotlin 是怎么做到的?答案不是某一个特性——而是一条设计哲学贯穿了语法层面的所有决策:默认不可变。
一条哲学,贯穿所有语法设计
val 是默认,var 是你主动选的
Java 和 C# 不是不能写不可变——final 和 readonly 在语法上都可以修饰局部变量:
// Java — final 可以修饰局部变量,语法上完全合法
final int x = 1;
final String name = "张三";语法合法,但你在实际项目里见过几次?Java 社区的风格指南不要求局部变量加 final,IDE 不提示,Code review 不查。结果就是:标 final,全靠自觉。
Kotlin 把这件事从"可以做"上升到了"默认做"。关键不是能力差异,是成本。 Java 的 final 能做,但它是一个额外负担——final int x = 1 比 int x = 1 多敲 6 个字符加一个空格。每次声明一个局部变量都要多打字,程序员本能地跳过它。Kotlin 的 val 只有三个字母,和 var 一样短——选择不可变没有额外成本。JetBrains 把 final 浓缩成了 val,把一种"需要你主动加的防御性标注"简化成了"写变量声明时的默认按键"。你不需要刻意记得"这个变量要不要加 final"——你的手指已经替你做了选择。只有当确实需要可变时,你才多想想,改成 var。
val 的覆盖范围不限于字段,而是从类属性一路贯穿到函数参数、局部变量、Lambda 参数——所有位置统一:
// val 无处不在 — 从类属性到局部变量,一致性贯穿到底
class User(val name: String) // 类属性
fun greet(name: String) { // 函数参数 — 默认 val
val greeting = "Hello, $name" // 局部变量
val chars = name.map { c -> // Lambda 参数 c 也是 val
val upper = c.uppercaseChar() // Lambda 内的局部变量
upper
}
}用 val 不需要理由,用 var 需要理由。这不是规范建议——IDEA 会直接给你黄色警告:"var 从未被修改,可以改为 val。" IDEA 在替你的每一行代码做副作用审计:这个变量你真的改了它吗?没改过?那就应该是个 val。
属性强制显式初始化
// Java — 类的属性有隐式默认值
public class Demo {
String name; // 隐式初始化为 null
int count; // 隐式初始化为 0
}Java 对属性有一个"体贴"的设计:你不需要显式初始化,JVM 替你填默认值。看起来很省事,但省掉的不是一个赋值语句,而是你在声明属性的那一刻必须思考"这个属性的初始状态应该是什么"的机会。来看看 Kotlin 怎么做:
// Kotlin — 没有隐式默认值,你必须主动决定初始值
class Demo {
var name: String = ""
var count: Int = 0
// var result: String // ❌ 编译不过
}Java 的隐式默认值本质上是一个隐式副作用——JVM 在你不知情的情况下,给属性塞了一个值。你写 String name; 的语义是"我还没想好",但 JVM 替你翻译成了 String name = null;。从"不确定"到"null",中间发生了一次你不知情的状态变更。Kotlin 的回答是:不确定就是不确定——没有默认值,编译器让你回去想清楚。
更关键的是,显式初始化配合 Kotlin 的类型系统,从源头上消灭了 NPE(NullPointerException)。一个属性从声明的那一刻就有一个确定的值——它不可能为 null,因为 null 不是编译器偷偷塞进去的,而是你主动赋予的。Java 里 name.length() 随时可能炸,因为 name 可能是 JVM 替你填的 null;Kotlin 里 name.length 一定是安全的,因为这个 name 是你亲手初始化的——要么给了字符串,要么你主动声明了 String? 表示它确实可能为空。非空是默认的,可空是你主动选的。 这和 val 是默认、var 是主动选的逻辑完全一致。
for(;;) 被直接删除
JavaScript 的旧版 for(;;) 有一个教科书级的陷阱,堪称副作用循环的完美标本:
// 你以为打印 0, 1, 2 — 实际打印 3, 3, 3
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}var 是函数作用域,整个循环只有一个 i。每次 i++ 修改的都是同一个变量,三个闭包捕获的都是同一个引用。等 100ms 后回调执行时,循环早就跑完了,i 已经是 3。一个循环,同时干了三件副作用的事:声明可变计数器 i → 每次迭代 i++ 修改它 → i < 3 依赖它做判断 → 闭包捕获可变引用,延迟执行后全部错乱。解法无非两种——let 把变量切成块作用域,或者用立即执行函数把值锁死。本质都是同一件事:让每次迭代拿到不可变的副本。
Kotlin 的回应更彻底——直接把 for(;;) 这个语法删了:
// Kotlin — 这个语法根本不存在
// for (i = 0; i < 10; i++) // ❌ 编译不过
for (i in 0 until 10) { // i 是 val,循环体内不能修改
println(i)
}范围 0 until 10 是不可变的声明,i 在每次迭代中是 val——循环体内你改不了它,闭包捕获它也不会被后续迭代污染。副作用从三个降到零个。 别的语言都在给 for(;;) 打补丁(let、const),Kotlin 直接不给这个语法。你写不了,就永远不会用错。
函数参数强制 val
// Java — 参数可以重新赋值
public void process(int value) {
value = value * 2; // 合法,但隐藏了你篡改了输入的事实
}方法签名告诉你"我会处理这个值",但实际上它可能在中途篡改输入,后面的逻辑全受影响。Kotlin 直接堵死这条路:
// Kotlin — 所有函数参数默认都是 val
fun process(value: Int) {
// value = value * 2 // ❌ 编译错误
}你读到 fun process(value: Int) 的那一刻就知道:这个 value 从函数开始到结束,始终是同一个值,不会被中途掉包。
类和方法默认不可变(默认final)
// Java — 类和方法默认可以被继承和重写
public class Base {
public void process() { /* ... */ }
}
public class Derived extends Base {
@Override
public void process() { /* 可以覆盖,除非你主动加了 final */ }
}在 Java 里,继承和方法重写是默认开着的——你可以随意扩展一个类、覆盖一个方法,除非作者主动加了 final。这等于把"是否允许继承"的决定权交给了使用者,而不是设计者。Kotlin 把这个权力收了回来:
// Kotlin — 类和方法的默认修饰符是 final,不可被继承和重写
class Base {
fun process() { /* ... */ }
}
// class Derived : Base() // ❌ 编译错误:Base 是 final 的
// 必须显式声明 open,子类才能继承或重写
open class Base {
open fun process() { /* ... */ }
}
class Derived : Base() {
override fun process() { /* 显式 override */ }
}这个设计背后是两条面向对象的核心原则。
开放封闭原则(OCP)——对扩展开放,对修改封闭。 Java 的做法恰恰相反:类默认对修改开放(任何人都能继承并覆盖你的方法),只有你主动加 final 才能封闭它。Kotlin 把默认值翻了过来:类默认是封闭的,你只有在深思熟虑之后,用 open 显式声明"这个类被设计为可扩展的"。这样一来,一个类被继承就不是意外——而是作者的有意设计。
单一职责原则(SRP)——一个类应该只有一个引起它变化的原因。 如果一段代码既承担自己的业务逻辑,又要背负"被未知子类以未知方式覆盖"的风险,它就已经有了两个职责。Kotlin 的做法是:一个类没有 open,它就是纯做自己那件事,不会被外部扩展所干扰;一个类标注了 open,则意味着作者已经考虑了扩展点,把"可被继承"作为设计的一部分纳入了职责范围。
val 保证值不可变,final 保证行为不可变。默认不可变的链条从变量一路延伸到了类的继承体系,最终指向同一件事:你不主动设计的,就不应该发生。
集合:读写分离
前面讲的 val / var 控制的只是"引用是否可重新赋值"——val 保证变量名不指向别的对象,但它不能保证对象本身不被修改。Java 的 final List 就是一个活生生的例子:你不能换一个 List,但你可以往里加东西、删东西、排序、清空。引用不可变,内容随便改。
Kotlin 在引用层之下又加了一道锁:读写分离。
val list: List<Int> = listOf(1, 2, 3) // 只读 — 默认
val mutableList: MutableList<Int> = mutableListOf(1, 2, 3) // 读写 — 你主动选的
list.add(4) // ❌ 编译错误:List 没有 add 方法
mutableList.add(4) // ✅ 你主动声明了 MutableListList 接口只暴露读方法(get、size、contains……),MutableList 才暴露写方法(add、remove、clear……)。你把集合传给一个函数时,如果函数声明接收 List,调用方就可以放心——这个函数不会修改你的集合。如果函数声明接收 MutableList,你一眼就知道它有写入的意图。
这和前面 final 类的哲学完全一致:修改是例外,只读是默认。 类默认 final 是不可被继承(行为不可变),集合默认 List 是不可被写入(内容不可变)。两件事的核心逻辑是同一条——你不主动打开,就不能做。
数据类:修改即创建
// kotlin推荐你数据类使用 val 定义属性,修改数据类使用 copy 方法
data class User(val name: String, val age: Int)
val user = User("张三", 25)
val older = user.copy(age = 26) // 不修改原对象,生成新副本数据类的字段推荐使用 val 定义。要"改"一个字段?copy() 创建一个新实例,旧实例纹丝不动。这和函数式编程"数据一旦创建永不修改"的铁律直接对应——不是不能产生新数据,而是不能让旧数据在你眼皮底下变了。
小结
从 val 的引用不可变,到集合读写分离,到 final 的行为不可变,再到数据类修改即创建——"不可变"不是某个局部特性, 从语法层到标准库层、从变量到类继承体系,Kotlin 将不可变和无副作用的设计理念贯彻到底。 Kotlin有这么一种工程上的统一的美。你在写代码的时候默认就在做不修改的事。不是不能改,关键是"改"变成了一种需要你主动做的选择,而不是一种你不小心就会发生的副作用。
消灭副作用不是目标——让副作用有意识、可追踪、躲不掉,才是。
四、一切皆表达式
有了函数即值的基础设施,有了无副作用的安全保证,接下来就是思维方式本身的升级。
表达式必须产生值
Java 里你经常看到这种代码:
String result;
if (score >= 60) {
result = "pass";
} else {
result = "fail";
}声明一个空的 result,然后在 if-else 的两个分支里分别赋值。result 在声明时未初始化,在 if 之后才被赋值——从声明到有效值之间,有一个"裸奔"的窗口期。如果你忘了 else 分支,result 可能永远不被赋值,编译器也救不了你。
Kotlin 的答案:一切控制流都是表达式,表达式必须有值。
// if 是表达式 — 每个分支必须产生值
val result = if (score >= 60) "pass" else "fail"
// when 是表达式 — 缺少 else 编译器直接报错
val grade = when {
score >= 90 -> "A"
score >= 80 -> "B"
score >= 60 -> "C"
else -> "F" // 少这一行,编译不过
}
// try 也是表达式
val content = try {
File("data.txt").readText()
} catch (e: IOException) {
"" // 不写直接报错
}变量从诞生的那一刻就被赋予了一个确定的值,不存在"先声明、再赋值、中间可能漏掉"的窗口。when 缺少分支直接编译不过——这不是编译器的苛刻,是编译器在替你把运行时才能发现的 bug 提前消灭掉。
Unit 和 Nothing:把"什么都不做"也当成值
表达式必须产生值——这个规则如果贯彻到底,就必须面对一个问题:有些函数确实没有有意义的返回值,怎么办?
Java 的答案是 void。void 的意思是"这个函数不产生值",它是一类特殊的语法构造——你用不了它,赋不了值,传不了参。它是类型系统里的一个洞。
Kotlin 不挖洞。它设计了 Unit:
// Java void — 不产生值,不能赋值
public void printMessage(String msg) {
System.out.println(msg);
}
// Kotlin Unit — "有值,但这个值没什么可说的"
fun printMessage(msg: String): Unit {
println(msg)
}
val result: Unit = printMessage("hello") // ✅ 可以赋值、可以传递Unit 是一个真实存在的类型,它只有一个实例。从签名看,printMessage 返回了 Unit——"我做了点事,但没有什么有意义的结果需要告诉你"。从语义上看它和 void 没有区别,但从类型系统上看,它堵上了 void 挖出来的洞。 每一个函数都返回一个值,没有例外。表达式编程的地基因此完整——不是"大部分控制流产生值",而是"所有控制流都产生值"。
另一个极端是 Nothing——标记"永远不会返回"的函数:
fun fail(message: String): Nothing = throw IllegalStateException(message)
fun TODO(reason: String): Nothing = throw NotImplementedError(reason)Nothing 是 Kotlin 类型系统的底类型——它是所有类型的子类型。这有什么用?看一个实际场景:
val score = getScore()
val grade = when {
score >= 90 -> "A"
score >= 60 -> "C"
else -> TODO("还没想好不及格的等级怎么划分")
}when 要求所有分支类型一致。"A" 和 "C" 是 String,TODO() 返回 Nothing——而 Nothing 是 String 的子类型,所以整段代码类型检查通过。你在开发阶段先留一个坑,编译器不会因为类型不匹配而阻断你。
Unit 和 Nothing 本质上在做同一件事:确保每种情况都有值,所以类型系统可以安全地推导。 一个堵上了"无返回值"的洞,一个兜住了"不会返回"的底。表达式编程的完整性由此而来。
从语句到表达式
前面讲的是单个值的表达式——if、when、try 必须产生值,Unit 和 Nothing 保证了类型系统的完整性。现在把范围放大:当你要完成一整个计算任务时,语句和表达式的思维差异会彻底暴露出来。
来看一个最日常的场景——计算订单总价:
// 语句式:在外面声明可变状态,函数偷偷修改它
var total = 0.0
fun addToTotal(item: OrderItem) {
total += item.price * item.quantity // 修改外部变量 — 副作用
}
fun applyTax(rate: Double) {
total *= (1 + rate) // 再次修改外部变量 — 副作用
}
// 调用方必须按顺序执行,顺序错了结果就错了
addToTotal(item1)
addToTotal(item2)
applyTax(0.1)
println(total) // total 的值取决于之前每一步是否都执行了、顺序是否对total 是一个裸露在外的可变状态。addToTotal 和 applyTax 的签名返回 Unit——你从签名上完全看不出它们在干什么,它们的工作成果全部通过修改外部变量来体现。调用方必须严格按顺序操作,漏一步、多一步、顺序错一步,total 就不对。这就是语句式思维:每一步都是一个操作,操作的痕迹留在外部变量里。
// 表达式式:扩展函数 + 链式调用,从左到右像读书一样
fun List<OrderItem>.subtotal(): Double =
sumOf { it.price * it.quantity }
fun Double.withTax(rate: Double): Double =
this * (1 + rate)
// 调用方:一条链,从左读到右就是完整的数据流
val total = items.subtotal().withTax(0.1)没有外部变量。subtotal() 和 withTax() 签名的返回值类型就是它们的全部承诺——输入什么、输出什么,一目了然。调用方的代码就是一连串数据变换:items → 小计 → 税后总价。
扩展函数:变量是主体,不是参数
仔细看刚才的表达式版本——为什么它读起来像一句话?
val total = items.subtotal().withTax(0.1)从左到右:items → 算小计 → 加税。每一步,数据都是主语,操作是谓语。这种写法在 Java 里行不通,因为 Java 没有扩展函数——你只能把数据当作参数传给静态工具类:
// Java 静态工具类:数据是参数,嵌套传参越套越深
double total = TaxUtils.applyTax(OrderUtils.calculateSubtotal(items), 0.1);你得从最内层括号往外读:items 传给 calculateSubtotal → 结果作为第一个参数传给 applyTax → 得到 total。阅读顺序和数据流向是反的。
静态工具类和扩展函数在语法上没有任何区别——fun Double.withTax(rate: Double) 和 fun applyTax(subtotal: Double, rate: Double) 都只是声明了一个函数。但语义上天差地别:
- 静态工具类把变量当作函数的参数——数据是被操作的客体,函数是主体。这是一种语句式思维:你对数据执行某个操作,操作的结果塞进下一层括号。
- 扩展函数把变量当作运算的主体——数据是主语,扩展是对数据的计算和推导。链式调用就是连续的数学推导:
items推导出小计,小计推导出税后价——每一步都产生一个新值,交给下一步继续推导,而非每一步都作为一个参数传递给下一步。连续推导,就像一开始所说的演绎法一样。
这不是审美偏好。语句式的 a(b(c(d, e), f), g) 里,括号嵌套的深度就是你在脑子里维护调用栈的复杂度。扩展链式 d.e().f().g() 里,你只需要从左读到右。前者是"连续传参",后者是"连续推导"——和函数式编程"用推导代替操作"的内核完全对应。
怎么做 vs 要什么
回顾整个第四章,我们走过的路是清晰的。
先从最小的粒度开始——if、when、try 不再是语句,而是必须产生值的表达式。Unit 堵上了 void 的洞,Nothing 兜住了"永不返回"的底。至此,类型系统里不存在"不产生值"的角落——每一个语法单元都在向你交付一个确定的结果。
然后放大到整个计算任务。订单总价的例子把两种思维的对立摆在了台面上:一种在外面立一个 var total,函数偷偷修改它,调用方必须小心翼翼按顺序操作;另一种每一步都返回新值,items.subtotal().withTax(0.1) 一条链从左读到右就是完整的数据流。
扩展函数把这种链式思维推到了极致。静态工具类是"连续传参"——数据被塞进括号深处,阅读顺序和计算方向背道而驰。扩展函数是"连续推导"——数据是主语,每一步推导出一个新结果交给下一步,像数学演算一样一层层推下去。
这几件事归根到底指向同一个结论:当每一个语法单元都是表达式、每一个计算步骤都返回新值时,你写出来的代码就不再是一串操作步骤的序列,而是一系列数据变换的推导。
你不再告诉计算机"怎么做",而是告诉它"要什么"。这就是函数式编程的终点。
五、总结
回到开篇那句话——
如果说面向对象是归纳法,侧重于对事物特征的提取及概括;那么函数式中的组合实现更像是演绎法,近似于数学中的推导。
这句话是整篇文章的总纲。回头看一下三个核心,每一个都是"演绎法"的一块拼图。
函数是值,让你能把行为从"只能定义在某个类里的死代码"变成可以自由传递、组合的活的运算单元。归纳法要求你先提取共性、定义接口,演绎法只需要你把函数像数学公式一样拼在一起。
无副作用,让你能信任每一个运算步骤。数学推导中,每一步代换都不应该偷偷改掉前面的结论。同理,纯函数的返回值只由参数决定——你今天调用、明天调用、单线程调用、多线程调用,同一个输入永远同一个输出。归纳法依赖对象之间隐式的状态协作,演绎法要求每一步变换都独立、确定、可复现。
一切皆表达式,补齐了最后一块——if、when、try 不再是散落的语句,必须产生值。Unit 堵上了 void 的洞,Nothing 兜住了永不返回的底。扩展函数把变量从"被动传参的客体"变成"主动推导的主体"。当每一个语法单元都在交付一个确定的结果时,你写出来的代码就不再是一串操作步骤的序列,而是一系列数据变换的推导。
Kotlin 没有把范畴论塞进语法,没有让你先读完《Haskell 入门》才能写代码。它把 函数式编程里最有用的那部分提纯了出来,放在了 JVM 上,放在了 Java 开发者触手可及的地方。你可以从只用 val 定义变量开始,慢慢理解"推导"比"操作"更接近程序的本质。
