Skip to content

元数据卡

  • 前置知识: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 代码假设"状态是可变的":

java
List<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");
System.out.println(names.size());  // 2

你改 names——把元素加进去、改名字、删除——每一步都在修改原始列表。这在 Java 里天经地义,但函数式的一方反问:你怎么知道别处的代码会不会也在改?

这就是函数式编程的起点——不可变性

你不再修改数据,而是创建新的拷贝:

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 都实现了。


纯函数:无副作用,可预测

java
// 不纯 —— 依赖和修改了外部状态
private int counter = 0;
public int nextId() {
    return counter++;
}

每次调用 nextId() 的结果不同。测试时需要先设置 counter 的值。多线程环境下还需要加锁。

java
// 纯函数 —— 输入决定输出
public int nextId(int previous) {
    return previous + 1;
}

同样的输入,永远得到同样的输出。不修改任何外部状态。测试不需要前置条件。

纯函数的好处不只是好测试。它们的呼叫者可以任意调换顺序、缓存结果(记忆化)。

java
// Fib 的递归纯函数实现
public int fib(int n) {
    if (n <= 1) return n;
    return fib(n - 1) + fib(n - 2);
}

这个函数在重复计算子问题。如果加一个缓存(memoization),第一次调用后对同样的 n 不需要重新计算——因为它是纯的:

java
// 带缓存的 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 之后靠函数式接口:

java
// 函数作为参数
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 天生就支持:

python
def double(x): return x * 2
result = list(map(double, [1, 2, 3]))

或者更直接的列表推导(Python 的 FP 风格在实践中用得更多):

python
result = [x * 2 for x in [1, 2, 3]]

高阶函数写出来的代码更像"声明要什么"而不是"怎么实现":

java
// 命令式:你告诉机器每一步
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 的实战深度

java
// 你有一个用户列表,要求:
// 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 是"可以合并的东西",满足两个条件:

  1. 结合律(a * b) * c == a * (b * c)
  2. 有单位元:存在一个"什么都不变"的 ID
java
// 数字加法是一个 Monoid
// 结合律:(1 + 2) + 3 == 1 + (2 + 3)
// 单位元:0(任何数加 0 不变)

// 字符串拼接是一个 Monoid
// 结合律: ("a" + "b") + "c" == "a" + ("b" + "c")
// 单位元:""(任何字符串加空串不变)

// List 合并是一个 Monoid
// 结合律: [1].concat([2]).concat([3]) 没问题
// 单位元:[](空列表)

为什么这有用? Monoid 的"结合律"让你可以把合并操作拆成多个独立的部分并行执行——分治的基础。

java
// 假设你有 1000 万条数据要合并
// Monoid 保证:分 10 块 -> 每块独立合并 -> 最后合并各块结果 = 全量合并
// 每块可以放在不同线程、不同机器上算——结果相同。

Functor:可以 map 的东西

Functor 是"你可以把函数应用到内部值上"的东西——其实就是实现了 map

java
// 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——ListSetOptionalStreamCompletableFuture。它们都是 Functor。map 是一个非常通用的模式。

Monad:可以 flatMap 的东西

Monad 是有 flatMap 操作的类型——一种"把值从容器里取出来、变换、再放回去"的通用模式。

Optional 是一个 Monad:

java
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()));
// 平的一层 Optional

Stream 是一个 Monad(Java 里叫 flatMap):

java
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.flatMapOptional.flatMap 多年,并不需要知道"Monad"这个词。Monad 的价值在于——当你接触到新的类型(TryEitherIO),你发现它们都支持 flatMap,你已经知道怎么用了。


常见陷阱

  1. "函数式 = 不用变量" —— 不是不能有变量,是不要有可变状态。局部 var 完全可以
  2. "纯函数没有性能损失" —— 不可变数据结构需要额外的拷贝和 GC 压力。在热点路径上,可变 + 锁可能更快
  3. "Java 不适合函数式" —— 语法确实更繁琐,但 Stream API 在实践中效果很好。真正的限制是 Java 没有尾递归优化和不可变集合的内置支持

通关挑战

  • 热身:取你的一个 for 循环,用 Stream API 重写,比较行数和可读性
  • 动手:实现一个自定义的 @FunctionalInterface——例如 Transformer<T, R>——然后在代码中用 Lambda 调用它
  • 观察:在 IntStream.range(0, 1000000) 上分别用串行流和 parallelStream() 执行 sum,比较性能

旅人笔记

函数式编程不是在消灭副作用——你不可能不打日志、不写文件、不发网络请求。它是在把副作用推到一个边界上管理,让核心逻辑保持纯、可预测、可组合。

下一站预告

你知道了类型怎么约束数据、内存怎么存放数据、并发怎么处理数据、函数怎么写数据不变——但你写的代码到底是怎么执行的?这个谜底藏在字节码里。第五扇门:进入 JVM 字节码,看懂 Java 编译器到底做了什么。

Built with VitePress | Software Systems Atlas