元数据卡
- 前置知识:Vol 9 第1章(类型系统理解)、Java Stream API 基本使用经验
- 预计时间:45 分钟
- 核心难度:进阶
- 阅读模式:高度专注
- 可选跳过:Monad 部分属进阶,首次通读可跳过;理解 Monoid 和 Functor 即可
- 完成标志:能写出无副作用的纯函数;能用 Stream API 重写命令式循环;理解 Functor 和 Monad 的直觉含义
你的进度
你前面看到的三种模型都在解决"怎么控制副作用"——锁、消息、事务。但有一群语言设计师说:为什么要控制副作用?去掉它不就行了。于是你推开了遗迹第四扇门——函数式编程的殿堂。
墙上写着一行字:"计算就是求值,不是执行指令。"
你的任务
函数式编程(FP)不是一个语法甜点工具包。它是对"编程是什么"这个问题的不同回答——命令式编程说"程序是一系列指令",函数式说"程序是一系列表达式求值"。本章从不可变性、纯函数开始,逐步走向 Monoid、Functor 和 Monad 的直觉理解。
本章分层
- 必读:不可变性、纯函数、副作用隔离、高阶函数
- 选读:Monoid、Functor 直觉
- 进阶:Monad 的直觉(Maybe、List、IO)
破局 · 溯源
你之前写的 Java 代码假设"状态是可变的":
List<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");
System.out.println(names.size()); // 2你改 names——把元素加进去、改名字、删除——每一步都在修改原始列表。这在 Java 里天经地义,但函数式的一方反问:你怎么知道别处的代码会不会也在改?
这就是函数式编程的起点——不可变性。
你不再修改数据,而是创建新的拷贝:
// 不可变风格
List<String> names = List.of("Alice", "Bob");
// names.add("Charlie"); // UnsupportedOperationException
List<String> newNames = Stream.concat(names.stream(), Stream.of("Charlie"))
.collect(Collectors.toList());names 没变,但 newNames 多了一个元素。这是拷贝,不是原地修改。
低效吗? 直观上是的。但持久化数据结构的存在让不可变操作在时间和空间上都可以接近 O(log n)——通过共享结构来避免全量拷贝。Java 本身没有内置持久化数据结构,但 Scala 的 Vector、Clojure 的 PersistentVector 都实现了。
纯函数:无副作用,可预测
// 不纯 —— 依赖和修改了外部状态
private int counter = 0;
public int nextId() {
return counter++;
}每次调用 nextId() 的结果不同。测试时需要先设置 counter 的值。多线程环境下还需要加锁。
// 纯函数 —— 输入决定输出
public int nextId(int previous) {
return previous + 1;
}同样的输入,永远得到同样的输出。不修改任何外部状态。测试不需要前置条件。
纯函数的好处不只是好测试。它们的呼叫者可以任意调换顺序、缓存结果(记忆化)。
// Fib 的递归纯函数实现
public int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}这个函数在重复计算子问题。如果加一个缓存(memoization),第一次调用后对同样的 n 不需要重新计算——因为它是纯的:
// 带缓存的 fib —— 利用了纯函数的确定性
private Map<Integer, Integer> cache = new HashMap<>();
public int fibMemo(int n) {
if (n <= 1) return n;
return cache.computeIfAbsent(n, k -> fibMemo(k - 1) + fibMemo(k - 2));
}纯函数 + 记忆化 = 自动性能优化。 这是非纯函数做不到的——因为非纯函数的结果依赖外部状态,缓存可能给你过时的答案。
高阶函数
函数可以是参数、可以是返回值。这在目前的你看起来可能不算什么,但在早期主流语言中,这是个重大选择。
Java 8 之前做不到。Java 8 之后靠函数式接口:
// 函数作为参数
public List<Integer> map(List<Integer> list, Function<Integer, Integer> fn) {
List<Integer> result = new ArrayList<>();
for (Integer i : list) {
result.add(fn.apply(i));
}
return result;
}
// 使用
List<Integer> doubled = map(List.of(1, 2, 3), x -> x * 2); // [2, 4, 6]Python 天生就支持:
def double(x): return x * 2
result = list(map(double, [1, 2, 3]))或者更直接的列表推导(Python 的 FP 风格在实践中用得更多):
result = [x * 2 for x in [1, 2, 3]]高阶函数写出来的代码更像"声明要什么"而不是"怎么实现":
// 命令式:你告诉机器每一步
List<Integer> result = new ArrayList<>();
for (int i = 0; i < list.size(); i++) {
int x = list.get(i);
if (x % 2 == 0) { // 偶数
result.add(x * x);
}
}
// 函数式:你声明想要什么
List<Integer> result = list.stream()
.filter(x -> x % 2 == 0)
.map(x -> x * x)
.collect(Collectors.toList());后者可读性更高——filter 说"我要过滤",map 说"我要转换"。
Java Stream API 的实战深度
// 你有一个用户列表,要求:
// 1. 获取活跃用户
// 2. 提取他们的邮箱
// 3. 按邮箱域名分组
// 4. 每组人数降序排序
Map<String, Long> domainCounts = users.stream()
.filter(User::isActive) // 过滤
.map(User::getEmail) // 提取邮箱
.filter(email -> email.contains("@")) // 排除无效
.collect(Collectors.groupingBy(
email -> email.split("@")[1], // 按域名分组
Collectors.counting() // 计数
))
.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed()) // 排序
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue,
(a, b) -> a,
LinkedHashMap::new // 保持顺序
));这段代码没有任何中间变量、没有显式循环、没有手动索引——声明式的流式表达。但注意:Stream API 不是纯粹的函数式,它允许有副作用的 forEach、允许 peek 用于调试。它是在 Java 的面向对象世界里嫁接的函数式风格。
代数直觉:Monoid、Functor、Monad
这三个概念并不是数学考试。它们是函数式编程的基础构建块——模式,不是公式。
Monoid:你每天都在用,只是没命名
Monoid 是"可以合并的东西",满足两个条件:
- 结合律:
(a * b) * c == a * (b * c) - 有单位元:存在一个"什么都不变"的 ID
// 数字加法是一个 Monoid
// 结合律:(1 + 2) + 3 == 1 + (2 + 3)
// 单位元:0(任何数加 0 不变)
// 字符串拼接是一个 Monoid
// 结合律: ("a" + "b") + "c" == "a" + ("b" + "c")
// 单位元:""(任何字符串加空串不变)
// List 合并是一个 Monoid
// 结合律: [1].concat([2]).concat([3]) 没问题
// 单位元:[](空列表)为什么这有用? Monoid 的"结合律"让你可以把合并操作拆成多个独立的部分并行执行——分治的基础。
// 假设你有 1000 万条数据要合并
// Monoid 保证:分 10 块 -> 每块独立合并 -> 最后合并各块结果 = 全量合并
// 每块可以放在不同线程、不同机器上算——结果相同。Functor:可以 map 的东西
Functor 是"你可以把函数应用到内部值上"的东西——其实就是实现了 map:
// List 是一个 Functor
List<Integer> nums = List.of(1, 2, 3);
List<String> strs = nums.stream()
.map(n -> "num: " + n) // 应用到每个元素
.collect(Collectors.toList());
// Optional 也是一个 Functor
Optional<String> name = Optional.of("Alice");
Optional<Integer> length = name.map(s -> s.length()); // 如果存在,则变换你不需要知道"Functor"这个词也能用 map。但知道了之后,你会注意到很多类都实现了 map——List、Set、Optional、Stream、CompletableFuture。它们都是 Functor。map 是一个非常通用的模式。
Monad:可以 flatMap 的东西
Monad 是有 flatMap 操作的类型——一种"把值从容器里取出来、变换、再放回去"的通用模式。
Optional 是一个 Monad:
Optional<String> name = Optional.of("Alice");
// 没有 flatMap:
Optional<Integer> length = name.map(s -> s.length()); // Optional<Integer>
// 如果变换本身返回 Optional,map 会嵌套:
Optional<Optional<Integer>> nested = name.map(s -> Optional.of(s.length()));
// 两层 Optional —— 不好用
// flatMap 解嵌套:
Optional<Integer> flat = name.flatMap(s -> Optional.of(s.length()));
// 平的一层 OptionalStream 是一个 Monad(Java 里叫 flatMap):
List<List<Integer>> nested = List.of(
List.of(1, 2), List.of(3, 4)
);
// flatMap 把嵌套结构展开
List<Integer> flat = nested.stream()
.flatMap(list -> list.stream())
.collect(Collectors.toList()); // [1, 2, 3, 4]Monad 的直觉:你有一个值在上下文里(Maybe:可能有也可能没有;List:有多个值;IO:有副作用的世界)。你想把变换应用到被包裹的值上,但不想纠结嵌套的上下文。flatMap 帮你做了这件事。
不必强求一次理解 Monad。 大多数 Java 开发者在编码中使用
Stream.flatMap和Optional.flatMap多年,并不需要知道"Monad"这个词。Monad 的价值在于——当你接触到新的类型(Try、Either、IO),你发现它们都支持flatMap,你已经知道怎么用了。
常见陷阱
- "函数式 = 不用变量" —— 不是不能有变量,是不要有可变状态。局部
var完全可以 - "纯函数没有性能损失" —— 不可变数据结构需要额外的拷贝和 GC 压力。在热点路径上,可变 + 锁可能更快
- "Java 不适合函数式" —— 语法确实更繁琐,但 Stream API 在实践中效果很好。真正的限制是 Java 没有尾递归优化和不可变集合的内置支持
通关挑战
- 热身:取你的一个 for 循环,用 Stream API 重写,比较行数和可读性
- 动手:实现一个自定义的
@FunctionalInterface——例如Transformer<T, R>——然后在代码中用 Lambda 调用它 - 观察:在
IntStream.range(0, 1000000)上分别用串行流和parallelStream()执行sum,比较性能
旅人笔记
函数式编程不是在消灭副作用——你不可能不打日志、不写文件、不发网络请求。它是在把副作用推到一个边界上管理,让核心逻辑保持纯、可预测、可组合。
→ 下一站预告
你知道了类型怎么约束数据、内存怎么存放数据、并发怎么处理数据、函数怎么写数据不变——但你写的代码到底是怎么执行的?这个谜底藏在字节码里。第五扇门:进入 JVM 字节码,看懂 Java 编译器到底做了什么。