Reading 16: 映射、过滤与归约(Map, Filter, Reduce)

目录 · ← l15 · l17 →

Reading 16: 映射、过滤与归约(Map, Filter, Reduce)

说明:本讲 sp22 原版使用 TypeScript,本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版)原文保持一致。

概述

本讲要解决的核心问题是:如何对「元素的序列」编写函数而完全不写显式控制流。课程给出的答案是三个操作——mapfilterreduce,它们把整个序列当作一个整体来处理,于是原来代码里的 forifreturn 以及 filenamesfresult 这些临时变量统统消失,程序员只需要专注于计算本身的含义。支撑这一模式的关键语言特性是一等函数(first-class functions):函数可以像整数、字符串一样被存入变量、作为参数传递、动态创建;在此基础上才能写出高阶函数(higher-order function)来把控制流抽象掉。

这一讲与三大目标的关系非常直接。Safe from bugsmap/filter/reduce 只使用纯函数(pure function)不可变数据类型,天然避免了循环下标越界、临时变量被意外修改、累加器初始化写错这类 bug,而且纯函数 + 不可变数据的组合可以自动并行化而不改变结果。Easy to understand:一行 cameras.filter(c -> c.brand().equals("Nikon")).map(Camera::pixels).reduce(Math::max) 把「筛选—投影—聚合」的意图直接写在代码表面,比三十行循环加分支更容易读懂。Ready for change:把控制流交给库实现之后,改变遍历方式(顺序流改并行流)、改变数据结构(ListSet)、改变元素类型(IntegerDouble)都只需要改链条中的一个环节,而不用重写循环体。

核心概念与设计原则详解

一等函数(First-class Functions)

  • 定义与目的:若一种语言中的函数可以像其他值一样被传递、返回、存储和使用,就称函数在该语言中是一等(first-class)的。它解决的是「把行为本身参数化」的问题,让复用从数据层上升到行为层,直接服务于可修改性与易理解性。
  • 直观解释(”它是什么?”):把函数想成一张写着操作步骤的卡片。传统语言里你只能照着卡片做事,却没法把卡片本身递给别人;一等函数允许你把卡片放进抽屉(变量)、交给同事(参数)、甚至现场写一张新卡片(动态创建)。很多语言构造并不是一等的:public/private 这类访问控制不能作为参数传递,while 循环和 if 语句也不能被单独引用或操纵——它们只是语法,不是值。
  • 关键规则与最佳实践
    • 在 Java 中函数本身不是严格意义上的一等值,但函数式对象(functional object)达到了同样的效果:Function<T,R> 表示一元函数,其核心操作是 applyMath::sqrt 就是这样一个对象。
    • 需要「返回一个函数」时,让方法返回 Function/Predicate/Comparator 等函数式接口,而不是返回某个已经算好的结果。
    • 一旦某段逻辑需要按场景替换(比较规则、过滤条件、映射规则),就应把它抽成参数而不是写死在方法体里。
    • 记住函数式接口的签名必须与使用点匹配:filter 需要 Predicate<T>(返回 boolean),map 需要 Function<T,R>reduce 需要 BinaryOperator<T> 或累加器/组合器。

函数式编程与纯函数(Functional Programming & Pure Functions)

  • 定义与目的函数式编程(functional programming)指用不可变数据和实现纯函数的操作来建模问题、实现系统,与「可变数据 + 有副作用(side effect)的操作」相对。它通过消除状态变化来提升安全性与可并行性。
  • 直观解释(”它是什么?”):纯函数就像自动售货机:投币、按键、掉出商品,除此之外世界没有任何变化,同样的输入永远得到同样的输出。有副作用的操作则像在公共白板上写字:谁先写、写了几次都会互相干扰。
  • 关键规则与最佳实践
    • 传给 map/forEach 的函数必须是无状态(stateless)的:其行为不能依赖在 map/forEach 执行过程中会变化的状态,因为库不保证元素上的函数调用顺序,并且可能在并行流中分不同线程执行。
    • 不要在 map/forEach 里修改共享的可变对象;需要累加时用 reduce,需要收集时用 collect
    • 需要「对每个元素做同样的动作但不收集结果」时才用 forEach(如 sockets.forEach(Socket::close));forEach 的返回值被丢弃,因此它天然是一种有副作用的操作。
    • 不可变 + 纯函数 = 立刻可并行:把顺序流换成 parallel()parallelStream(),结果不变。

高阶函数(Higher-order Function)

  • 定义与目的:接受函数作为参数,或把函数作为返回值返回的函数称为高阶函数。它是「对函数这种数据类型做运算」的手段,是抽象掉控制流的杠杆。
  • 直观解释(”它是什么?”):普通函数处理数字和字符串,高阶函数处理「动作」。工厂是典型类比:endsWith(".java") 不是一次判断,而是一台「以后缀为配方」的过滤器制造机,你给它一个后缀,它交给你一个可以反复使用的判断函数。
  • 关键规则与最佳实践
    • 高阶函数的签名要读得出来:sp21 中 static Predicate<File> endsWith(String suffix) 的签名是 String → (File → boolean)
    • 优先用高阶函数把「变化的部分」参数化,把「不变的部分」留在库里:这正是 map/filter/reduce 存在的意义。
    • 高阶函数之间可以组合成链(chaining),链条每一步的输入输出类型必须首尾相接。
    • 编写高阶函数时不要在内部提前调用参数函数——把函数原样传出去,否则退化成普通调用。

Lambda 表达式与方法引用(Lambda Expressions & Method References)

  • 定义与目的:Lambda 表达式是「匿名函数」的字面写法,方法引用是「直接指名一个已有方法」的简写。二者都用于在需要函数式对象的位置上提供实现,让代码聚焦于「做什么」。
  • 直观解释(”它是什么?”)x -> Math.sqrt(x) 是现场写一张新卡片;Math::sqrt 则是直接指着墙上已有的那张卡片说「就用它」。后者少了一层无意义的中间环节——既然 lambda 只是把参数转交给 sqrt 再把结果返回,那它和 sqrt 本身在语义上完全等价。
  • 关键规则与最佳实践
    • 方法引用的写法是 类名::方法名(如 Math::sqrtString::toLowerCaseFile::toPath),注意中间是 :: 而不是 .Math.sqrt 是字段访问,Math.sqrt(25) 是方法调用,Math::sqrt 才是对函数对象的引用。
    • 方法引用既支持静态方法,也支持实例方法(含未绑定接收者的形式,如 String::toLowerCase)。
    • 当 lambda 体只是一次直接转发调用时,改写为方法引用更短、更清晰;当需要额外计算、需要多参数重排或需要捕获局部变量时,仍用 lambda。
    • lambda 只能捕获事实上不可变(effectively final)的局部变量,这也从语言层面鼓励无状态风格。

Map(映射)

  • 定义与目的map 把一个一元函数应用到序列的每个元素上,并按原顺序返回由结果组成的新序列,用于「对每个元素做同样的变换」。
  • 直观解释(”它是什么?”):流水线上的一排工人,每人拿到一个零件、做同一道加工,然后按原有顺序把成品放回传送带。
  • 关键规则与最佳实践
    • 类型签名是 map : Stream<E> × (E → F) → Stream<F>;输入是 Stream<Integer>、函数是 Integer → Double 时,结果是 Stream<Double>,元素类型可以改变,但元素个数不变
    • sp21 的例子:List.of(1, 4, 9, 16).stream().map(Math::sqrt) 得到 1.0, 2.0, 3.0, 4.0List.of("A", "b", "C").stream().map(s -> s.toLowerCase()) 得到 "a", "b", "c"
    • 想「映射一个有副作用的操作」时不要用 map:因为 mutator 通常返回 void,Java 要求改用 forEach
    • map 不修改输入序列,它返回一个新的流/集合。

Filter(过滤)

  • 定义与目的filter 用一个一元谓词(predicate)测试每个元素,保留满足者、丢弃不满足者,返回新的序列;它用于「按条件挑选」。
  • 直观解释(”它是什么?”):一道安检门,每个人经过时被问一个是非题,答「是」的进,答「否」的留下。
  • 关键规则与最佳实践
    • 类型签名是 filter : Stream<E> × (E → boolean) → Stream<E>;元素类型不变,元素个数可能减少(甚至变成空序列)。
    • sp21 的例子:List.of('x', 'y', '2', '3', 'a').stream().filter(Character::isLetter) 得到 ['x', 'y', 'a']List.of(1, 2, 3, 4).stream().filter(x -> x % 2 == 1) 得到 [1, 3]
    • filter 的参数是 Predicate<T>T → boolean),因此 Character::isLetters -> !s.isEmpty() 都可以直接使用。
    • filter 同样不修改输入;空结果不是错误,而是合法的抽象值。

Reduce(归约)

  • 定义与目的reduce 用二元函数把序列的元素合并成一个结果,用于「聚合」。它是三者中设计空间最大的一个,有三个关键设计选择,直接关系到正确性。
  • 直观解释(”它是什么?”):把一叠账单一张张并入一个累计总额,最后手里只剩一个数字。累计的起点叫初始值(identity / init),合并的规则叫累加器(accumulator)
  • 关键规则与最佳实践
    • 设计选择一——是否要求初始值:Java 允许省略,此时以第一个元素作为初始值;但空序列没有值可返回,所以省略初始值的 reduce 返回 Optional<E>(sp21 原文:List.of(5, 8, 3, 1).stream().reduce(Math::max) 返回含 8Optional<Integer>)。sp22 的 TypeScript 版本则在空数组时抛 TypeError——这也是 max/min 这类没有天然幺元(identity element)的归约必须小心处理的原因。
    • 设计选择二——结合方向:Java 要求归约运算符满足结合律(associative),如 +max。满足结合律时组合顺序无关紧要,实现因此可以把 ((0+1)+2)+3 计算成 (0+1)+(2+3) 等任意等价形式,并自动并行化。Python 的 fold-left 从左侧开始,对应的 fold-right 从右侧开始;对非结合运算符(如减法)两个方向结果不同:fold-left([1,2,3], 0, –) = ((0-1)-2)-3 = -6,而 fold-right2
    • 设计选择三——归约到另一种类型:结果类型 F 不必等于元素类型 E。Java 最一般的形式是 reduce : Stream<E> × F × (F × E → F) × (F × F → F) → F,即还要提供一个组合器(combiner) ⊗ : F × F → F 来合并两个部分结果。累加器与组合器都必须满足结合律,且彼此一致:(("" ⊙ 1) ⊙ 2) ⊙ 3("" ⊙ 1) ⊗ (("" ⊙ 2) ⊙ 3)("" ⊙ 1) ⊗ (("" ⊙ 2) ⊗ ("" ⊙ 3)) 都得 "123"
    • 初始值必须是该运算的幺元:求和的初始值是 0,求积的初始值是 1,拼接字符串的初始值是 "";把求积的初始值写成 0 会让结果恒为 0
    • 对没有天然幺元的运算(min/max),要么用 Optionalreduce(Math::min) 配合 orElse/get),要么选一个「极端值」当初始值(Integer.MAX_VALUEMath::min),要么显式处理空序列——三者的语义差别必须在规格里说清。

Stream:惰性求值与一次性消费(Streams, Laziness, Single Consumption)

  • 定义与目的Stream<E> 是 Java 中表示元素序列的抽象数据类型,来自「抽象掉控制流」这一设计目标:List/Set 等集合提供 stream()Arrays.stream 由数组建流,Stream 自身还提供 ofconcatIntStream.range(...).boxed() 等工厂。
  • 直观解释(”它是什么?”):把流想成一条「只能走一次的传送带」,而不是装满零件的箱子。链条上的 map/filter 只是挂上加工工位,真正推动零件的动作发生在终端操作(reducecollect)被调用时。
  • 关键规则与最佳实践
    • 流只能被消费一次,不可重用。用 lines 流造出 words 流后,就不能再用同一个 lines 流去找含注释的行;必须重新调用构建方法(如再次调用 allFilesIn())获得新流。在这一点上 StreamIteratorInputStreamOutputStream 同类。
    • 方法调用链(method call chaining)是流的惯用写法:List.of(1,4,9,16).stream().map(Math::sqrt),每一步的返回值直接用于调用下一步。
    • 传给 map/filter/reduce 的函数不能抛出受检异常(checked exception);需要调用 Files.readAllLines 这类会抛 IOException 的方法时,必须用 lambda 把受检异常包装成非受检异常(如 UncheckedIOException)。
    • 想并行时用 parallel()parallelStream();这正是「纯函数 + 不可变数据」带来的红利。

何时用函数式、何时用命令式(Functional vs Imperative)

  • 定义与目的map/filter/reduce 让代码更短更简单,使程序员专注于计算的核心而非循环、分支与控制流的细节;但并非所有代码都适合函数式改写,判断标准是「抽象后的代码是否更清楚地表达了意图」。
  • 直观解释(”它是什么?”):这就像用电动工具还是手动工具:批量、同构、可组合的加工用电动工具(map/filter/reduce)又快又整齐;需要频繁试探、提前退出、复杂状态机的加工,手动工具(显式循环)反而更直白。
  • 关键规则与最佳实践
    • 只要循环体是「对每个元素做同一件事」或「按条件挑选」或「合并成一个值」,就优先考虑 map/filter/reduce
    • 数据库查询的经典类比:SQL 的 select max(pixels) from cameras where brand = "Nikon" 中,cameras 是序列,wherefilterpixelsmapmaxreduce;关系数据库把这套范式称为 project/select/aggregate。
    • TypeScript/Python 中惯用列表推导式(list comprehension)的地方,Java 中就用 filter + map 的组合;同样,能避免下标计数器就避免。
    • 若改写后需要嵌套三层 lambda、需要临时状态、或可读性明显下降,就保留命令式写法并把它封装在一个有规格的方法里——可读性优先于时髦

代码示例与对比分析

场景 1:求列表中奇数的乘积——reduce 的初始值必须是幺元

❌ 错误代码

import java.util.List;

public class Products {
    /**
     * @param list list of integers
     * @return product of the odd integers in list
     */
    public static int productOfOdds(List<Integer> list) {
        // 错误:求积却把初始值写成 0,任何数乘以 0 都是 0
        return list.stream()
                   .filter(x -> x % 2 == 1)
                   .reduce(0, (x, y) -> x * y);
    }
}

【错误代码的问题】

  1. 只要 list 非空,结果恒为 0(课程练习明确问过 List.of(1,2,3).stream().reduce(0, (a,b) -> a*b) 的结果就是 0),函数对所有正常输入都返回错误答案。
  2. 这是一个动态错误:类型检查完全通过,编译器无法发现,只有运行测试时才暴露;而且空列表返回 0「看起来恰好对」,更容易骗过随意写的测试。
  3. 初始值的语义被误解成「累加的计数起点」而非「运算符的幺元」,一旦有人照抄这段代码去写求和、求最大值,错误会继续扩散。

✅ 正确代码

import java.util.List;

public class Products {
    /**
     * @param list list of integers
     * @return product of the odd integers in list
     */
    public static int productOfOdds(List<Integer> list) {
        // 正确:乘法的幺元是 1,空列表返回 1(空乘积)
        return list.stream()
                   .filter(x -> x % 2 == 1)
                   .reduce(1, (x, y) -> x * y);
    }
}

【为什么这样更好】 初始值取乘法的幺元 1,保证「把初始值并入任何部分结果都不改变结果」这一性质成立,于是无论流被顺序处理还是并行地分成若干段再合并,答案都一致;空序列也自然得到数学上正确的空乘积 1,而不需要额外的特判分支。这正是 Java 要求归约运算符满足结合律、并要求初始值与其一致的直接体现。

【代码对比解说】 两种写法的控制流完全相同(都是 filter 后 reduce),差别只在一个字面量,但后果是「全错」与「全对」。这揭示了一个重要事实:函数式代码把 bug 压缩到了更少的自由度上——你不再有循环边界、局部变量、return 位置可以出错,但剩下的每一个参数(初始值、谓词、累加器)都必须与算子的代数性质严格吻合。经验法则是:写 reduce 前先问自己「这个二元运算的幺元是什么,它在空序列上代表什么含义」。

【设计原则透视】 从规格(Reading 06、Reading 07)的角度看,reduce 的初始值属于调用契约的一部分:它决定了空序列时的返回值,因此应当在方法规格中写明「@return 空列表时返回 1」。从不可变性(Reading 08)的角度看,这段代码没有修改 listfilterreduce 都只读输入,所以它是纯函数;而纯函数是实现表示独立(Reading 11)与后续并行化(Reading 21)的前提。


场景 2:按后缀挑选文件——用高阶函数替代复制粘贴

❌ 错误代码

import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class Files1 {
    /** @return only the .java files in files */
    public static List<File> javaFiles(List<File> files) {
        List<File> result = new ArrayList<>();
        for (File f : files) {
            if (f.getName().endsWith(".java")) {
                result.add(f);
            }
        }
        return result;
    }

    /** @return only the .class files in files */
    public static List<File> classFiles(List<File> files) {
        List<File> result = new ArrayList<>();
        for (File f : files) {
            // 错误:从上一个方法复制粘贴而来,忘记把后缀从 ".java" 改成 ".class"
            if (f.getName().endsWith(".java")) {
                result.add(f);
            }
        }
        return result;
    }
}

【错误代码的问题】

  1. 复制粘贴的第二个方法返回了错误的文件集合——这是最典型的「维护困难直接变成 bug」:逻辑改动需要在三处同步修改,漏改一处就静默出错。
  2. 每个新后缀都要新增一个几乎相同的方法,类的体积随需求线性膨胀,调用者还要记住众多同义方法名,Ready for change 被彻底破坏。
  3. 循环里重复出现了 for/if/result.add 的样板代码,真正的意图(「按后缀过滤」)被淹没在控制流噪声里。

✅ 正确代码

import java.io.File;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class Files1 {
    /**
     * @param suffix filename suffix to match, e.g. ".java"
     * @return a predicate that tests whether a file's name ends with suffix
     */
    public static Predicate<File> endsWith(String suffix) {
        // 高阶函数:签名是 String -> (File -> boolean)
        return f -> f.getName().endsWith(suffix);
    }

    /** @return only the .java files in files */
    public static List<File> javaFiles(List<File> files) {
        return files.stream().filter(endsWith(".java")).collect(Collectors.toList());
    }

    /** @return only the .class files in files */
    public static List<File> classFiles(List<File> files) {
        return files.stream().filter(endsWith(".class")).collect(Collectors.toList());
    }
}

【为什么这样更好】 「后缀」成了参数而不是硬编码常量,endsWith 每次调用动态生成一个新的谓词函数供 filter 使用,于是「按后缀过滤」这件事只有一份实现、一处可能出错。调用点 files.filter(endsWith(".java")) 直接读作「留下以 .java 结尾的文件」,被过滤的意图一目了然;新增后缀只需写一行新调用,不需要新增方法。

【代码对比解说】 关键区别在于抽象层次:错误版本把「过滤」这一通用模式与「.java 后缀」这一具体参数混在同一个方法体里重复了三遍;正确版本用高阶函数把通用模式交给库的 filter,把具体参数留在调用点。这也是本讲标题中「抽象掉控制流」的字面含义——程序员不再书写循环,而是制造并组合小函数。要注意 Predicate<File>File → boolean,与 filter 的期望完全吻合;如果把它写成 Function<File, Boolean>,类型检查就会失败,这也是 Reading 01(静态检查)所强调的「让编译器替你抓错」。

【设计原则透视】 endsWith 是一个函数工厂:它的返回值是函数式对象,属于「对函数这种数据类型做运算」。从抽象边界(Reading 10、Reading 11)看,endsWith 的规格只承诺「返回一个判断文件名后缀的谓词」,完全不暴露内部是 lambda、方法引用还是匿名类,因此实现可以自由替换(例如改成预编译的正则,见 Reading 18),客户端代码不受影响。这与 ImList 用静态工厂 empty() 隐藏 Empty 构造器是同一个理念(Reading 17):客户端只应依赖抽象操作,不应依赖具体构造方式


场景 3:在流操作里累加共享可变状态——副作用与并行化冲突

❌ 错误代码

import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

public class WordCounter {
    private static final List<String> allWords = new ArrayList<>();

    /**
     * @param files files to read
     * @return every word found in files
     */
    public static List<String> wordsIn(Stream<File> files) {
        // 错误:在 forEach 里向共享的可变 ArrayList 追加,并依赖执行顺序
        files.parallel()
             .map(f -> f.getName().split("\\W+"))
             .forEach(words -> {
                 for (String w : words) {
                     if (w.length() > 0) {
                         allWords.add(w);   // 非线程安全 + 结果依赖调度
                     }
                 }
             });
        return allWords;
    }
}

【错误代码的问题】

  1. ArrayList 不是线程安全的,而 parallel() 会让 forEach 在多个线程中执行,可能丢失元素、抛出 ArrayIndexOutOfBoundsException 或产生损坏的内部数组——bug 具有随机性,难以复现。
  2. allWords 是静态字段,函数的行为依赖并改变跨越多次调用的外部状态,因此它不是纯函数:同样的输入在不同调用次序下得到不同结果,破坏了 Reading 06/07 中「规格—实现」必须一致的前提。
  3. 把结果写进外部集合后,方法的返回值还可能与调用者的预期共享别名,调用者一次 clear() 就会毁掉别人拿到的列表——典型的表示暴露(rep exposure)问题。

✅ 正确代码

import java.io.File;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class WordCounter {
    /**
     * @param files files to read
     * @return every word found in files
     */
    public static List<String> wordsIn(Stream<File> files) {
        // 正确:用 flatMap + filter 表达,用 collect 收集,全程无共享可变状态
        return files.parallel()
                    .flatMap(f -> Stream.of(f.getName().split("\\W+")))
                    .filter(w -> w.length() > 0)
                    .collect(Collectors.toList());
    }
}

【为什么这样更好】 计算被表达成一条纯函数的管道:每段的输入输出都是值,没有共享变量、没有对执行顺序的假设。collect(Collectors.toList()) 由库负责安全地合并各线程的部分结果(其内部依赖与 reduce 相同的结合律要求),因此把 parallel() 去掉或加上都不改变答案,只是性能不同。方法返回值是新建的不可变意义上的结果列表,不存在跨调用的别名污染。

【代码对比解说】 错误版本的思想模型是「我先开一条空集合,然后逐个往里塞」;正确版本的思想模型是「这个结果是若干变换后的值的聚合」。前者把状态当作计算的主线,后者把当作计算的主线。课程原文特别提醒:传给 mapforEach 的函数必须是无状态的,因为这两个方法对函数在元素上的执行顺序不作任何保证,并行流更会在不同线程里执行。凡是需要「合并」的地方,就应该交给 reduce/collect 去表达,而不是自己维护累加器。

【设计原则透视】 这段对比同时触及三条原则:其一,不可变性(Reading 08)——结果对象一旦构造就不再被修改,因此可以安全地共享和传递;其二,纯函数与副作用的分界——forEach 的定位是「执行动作、丢弃返回值」,一旦在它内部修改外部状态,就放弃了函数式风格的全部好处;其三,并发安全(Reading 21、Reading 23)——「纯函数 + 不可变数据」是免锁并行的充分条件,而共享可变状态则需要互斥(Reading 23)或消息传递(Reading 24)来保护,代价与复杂度都高得多。


场景 4:流操作里遇到受检异常——包装而不是吞掉

❌ 错误代码

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class LineReader {
    /**
     * @param paths files to read
     * @return the lines of those files
     */
    public static List<String> allLines(Stream<Path> paths) {
        return paths.map(path -> {
            try {
                return Files.readAllLines(path);
            } catch (IOException ioe) {
                // 错误:吞掉异常并返回 null,把故障推迟到下游
                return null;
            }
        }).flatMap(List::stream).collect(Collectors.toList());
    }
}

【错误代码的问题】

  1. 读取失败时 flatMap(List::stream) 立刻对 null 解引用,抛出与真实原因(文件不存在、权限不足)毫无关系的 NullPointerException,排错时被严重误导——这不是 fail fast,而是 fail late 且 fail 得含糊。
  2. catch 块没有任何日志或状态传递,调用者无法区分「文件是空的」与「文件读失败了」,规格(Reading 06)中承诺的后置条件被静默违反。
  3. 部分成功、部分失败时返回一个「看起来正常」的列表,错误被伪装成正常结果,属于最难发现的一类 bug。

✅ 正确代码

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class LineReader {
    /**
     * @param paths files to read
     * @return the lines of those files
     * @throws UncheckedIOException if any file cannot be read
     */
    public static List<String> allLines(Stream<Path> paths) {
        return paths.map(path -> {
            try {
                return Files.readAllLines(path);
            } catch (IOException ioe) {
                // 正确:受检异常 -> 非受检异常,故障立即传播且保留原因
                throw new UncheckedIOException(ioe);
            }
        }).flatMap(List::stream).collect(Collectors.toList());
    }
}

【为什么这样更好】 传给 map 的函数不允许抛出受检异常,因此 Files.readAllLines 抛出的 IOException 必须先被捕获;但捕获之后正确的做法是立刻把它转换成非受检异常抛出,让失败以最快的速度、最完整的原因到达调用者——这正是课程原文所说的「用 lambda 把 readAllLines 包一层,把受检异常转成非受检异常」。UncheckedIOException 保留了原始 IOException 作为 cause,栈信息完整。

【代码对比解说】 两个版本都要写 try/catch,差别在于 catch 块里做什么:「返回哨兵值」与「转换后继续抛出」是两种截然不同的错误处理哲学。前者假设调用者会检查 null,可流管道里根本没有检查 null 的位置;后者把异常当作控制流之外的带外信号,流管道本身保持「要么全部成功,要么抛出」的干净语义。这也响应了 Reading 09(避免调试)中「bug 越早暴露越便宜」的原则。

【设计原则透视】 这里体现的是规格的可检查性:把 @throws UncheckedIOException 写进 Javadoc,等于把「读取失败会抛出异常」上升为契约,调用者可以据此设计处理逻辑;而返回 null 的版本没有任何可依赖的契约,因为 null 既可能表示失败也可能表示空文件。从抽象的角度看,方法的抽象值(Reading 11 的 AF)应当是「这些文件的所有行」,当这个抽象值无法构造时,用异常明确宣告构造失败,比返回一个非法或误导性的值更符合「表示不变量必须始终成立」的要求。

与其他设计原则的关联

本讲是函数式风格的集中体现,而它的技术前提在更早的章节已经铺好:Reading 02(Java 基础)介绍的接口与泛型让 Function<T,R>Predicate<T>BinaryOperator<T> 这样的函数类型可以被静态检查;Reading 12(接口、泛型与枚举)进一步说明「用一个接口表达一族行为、用若干实现或 lambda 提供具体行为」正是接口的本义,函数式接口只是它的一个特例。没有静态类型(Reading 01)对函数签名的检查,map/filter 的误用就只能靠运行测试去发现。

本讲与 Reading 08(不可变性)互为因果:map/filter/reduce 之所以能自由地返回值、组合链、并行执行,是因为它们不修改输入;反过来,不可变数据结构一旦建立,用函数式操作消费它就成了最自然的选择。课程原文的总结句正是这个意思:本讲讨论的是用不可变数据与纯函数来建模问题、实现系统,而不是用可变数据与有副作用的操作。

本讲与 Reading 17(递归数据类型)关系紧密:ImList 这类不可变列表的 size()contains()append() 都可以用「每个 variant 一个 case」的递归方式定义,等价于对链表做 fold;理解 reduce 的「幺元 + 结合律」有助于读懂递归定义中 base case(如 size(Empty) = 0)的作用。本讲的 split(/\W+/)filter(s -> s.length() > 0) 则直接依赖 Reading 18(正则表达式与文法)所介绍的字符串规格工具。

向后看,本讲是 Reading 21(并发)的重要地基:原文明确指出,「纯函数 + 不可变数据类型」上的 map/filter 天然可并行,「Maps and filters using pure functions over immutable datatypes are instantly parallelizable」,这就是 parallel() 敢自动开线程的理由;而一旦引入共享可变状态,就必须回到 Reading 23(互斥)与 Reading 24(消息传递)去解决竞争。Reading 20(回调与 GUI)与 Reading 22(Promise/async)中的事件处理大量使用函数式对象:把回调写成 lambda 正是本讲「一等函数」思想的直接延伸。最后,Reading 26/27(小语言)与 Reading 19(解析器)中「把语法树节点上的操作表达为一族小函数」也能看到 map/reduce 式抽象的影子。

关键要点

  • 函数式接口就是「行为的类型」Function<T,R>apply)、Predicate<T>test)、BinaryOperator<T> 分别对应映射、过滤、归约所需的签名;方法引用的类型就是这些接口,可以存入变量、作为参数传递、作为返回值返回。
  • map 保长、filter 保型、reduce 归一:记住三条签名 Stream<E> × (E→F) → Stream<F>Stream<E> × (E→boolean) → Stream<E>Stream<E> × F × (F×E→F) × (F×F→F) → F,绝大多数误用都能在写代码前被排除。
  • reduce 的三问:初始值是什么(是否要求、是否为其运算的幺元)?运算符满足结合律吗?结果类型是否与元素类型相同(不同则要提供组合器)?三问中任何一问答错都会产生静默错误。
  • 纯函数 + 不可变数据 = 可并行:流操作中任何依赖共享可变状态或依赖元素处理顺序的写法都是错的,需要聚合时用 reduce/collect 而不是自己维护累加器。
  • 流的生命周期只有一次Stream 消费后不可重用,需要再次遍历就重新构造;把「构造流」放进可重复调用的方法里。

常见陷阱与注意事项

  • forEach 当作 map:需要结果却写成 forEach(list::add) → 依赖副作用与执行顺序,在并行流中丢数据或抛异常;正确做法是用 map + collect
  • reduce 的初始值取错或不处理空序列:求积写 reduce(0, (a,b) -> a*b) 会恒得 0;对没有天然幺元的运算(min/max)省略初始值后直接 .get(),在空序列上抛 NoSuchElementException → 类型检查通不出任何警告,错误只在运行时显现;应当让初始值严格等于该运算的幺元,并用 Optional/orElse 或明确的极值哨兵把空序列语义写进规格。
  • 在流管道里做有副作用的事:打印日志、修改外部集合、递增计数器 → 顺序不确定、并行时结果不确定;副作用要么移到终端操作之后,要么改用 forEach 并接受「无返回值、不保证顺序」的语义。
  • 把方法调用与函数对象混为一谈files.map(File::toPath) 是传函数对象,files.map(File.toPath()) 既不是合法 Java 又表达了错误意图;Math::sqrtMath.sqrt(25) 是两回事——前者是函数对象,后者是调用结果。同理,在 forEach 里写 mySet::delete 会丢失接收者 this 而不工作。
  • 方法引用/ lambda 里调用有受检异常的方法却没包装:代码无法通过编译(这是好事),但若为了通过编译而 catch 后返回 null,就把编译期问题换成了运行期灾难 → 应转换为 UncheckedIOException 之类的非受检异常并写进规格。
  • 忘记 Stream 只能用一次:复用已消费的流会抛 IllegalStateException: stream has already been operated upon or closed → 把流的构造封装成方法,每次需要时重新调用。

思考题(带答案)

问题 1:写出下面这段命令式代码的 map/filter/reduce 版本,并说明为什么改写后的版本在并行时结果不变。

static int productOfOdds(List<Integer> list) {
    int result = 1;
    for (int x : list) {
        if (x % 2 == 1) {
            result *= x;
        }
    }
    return result;
}

答案

static int productOfOdds(List<Integer> list) {
    return list.stream()
               .filter(x -> x % 2 == 1)
               .reduce(1, (x, y) -> x * y);
}

filter 只保留奇数,reduce1(乘法的幺元)为初始值把它们连乘。并行时结果不变的理由有两层:其一,filterreduce 都不修改输入 list,也没有共享可变状态,因此「每个元素上做什么」与「谁先谁后」无关;其二,乘法满足结合律,1 是它的幺元,所以把序列切成若干段分别求积再用同一个运算符合并(这正是 Java 允许的实现自由度:((1*a)*b)*c(1*a)*(b*c) 等等)得到的结果相同。空列表返回 1,即「空乘积」,符合数学约定。若把初始值写成 0,恰好会因为 0 不是乘法的幺元而对所有非空输入返回 0——这说明初始值的选择不是风格问题,而是正确性问题

问题 2List.of(5, 8, 3, 1).stream().reduce(Math::max) 的返回类型是什么?如果列表为空,三种常见写法的行为分别是什么?各自适合什么规格?

答案:省略初始值的 reduce 返回 Optional<Integer>,因为空序列没有值可以返回(sp22 的 TypeScript 版本在空数组时直接抛 TypeError,Java 用 Optional 把「可能没有结果」表达在类型里)。

三种写法与空列表行为:

  1. list.stream().reduce(Math::max).get():非空时正确;空列表时 get()NoSuchElementException。适合「空输入是调用者的错误」的规格。
  2. list.stream().reduce(Math::max).orElse(0):空列表返回 0;但如果列表是 List.of(-1,-2,-3),结果仍是 -3(正确),只是「空输入也得 0」这一语义必须写进规格,且当元素可能为负时 0 作为默认值容易误导。
  3. list.stream().reduce(Integer.MAX_VALUE, Math::min) 用于求最小值时,空列表返回 Integer.MAX_VALUE——它确实是一个「哨兵值」,但如果元素集合可能包含比它更大的值(例如用 Long 元素)就会出错;用它求最大值则初始值应为 Integer.MIN_VALUE

核心判据是:min/max 没有天然的幺元,所以要么用 Optional 把「没有结果」显式建模(推荐,可读性最好),要么选一个在该类型的取值范围内绝对不会被误认为合法结果的极值,并在规格中写清楚空序列的语义。

问题 3:为什么课程说「函数式写法让控制语句消失」是一种好处,而不是「把复杂度藏进库里」?请结合一条具体的流管道说明它分别如何改善三大目标。

答案:控制语句消失并不等于复杂度消失,而是把通用的复杂度(如何遍历、如何切分、如何在并行时合并部分结果、如何安全地收集)一次性放进经过充分测试的库实现里,同时让特定的复杂度(这次要映射什么、筛选什么、如何聚合)以最短的形式留在调用点。以

cameras.filter(camera -> camera.brand().equals("Nikon"))
       .map(Camera::pixels)
       .reduce(Math::max);

为例:filtermapreduce 三段的意图与 SQL 的 select max(pixels) from cameras where brand = "Nikon" 一一对应(筛选—投影—聚合),读代码的人不需要在脑中模拟循环与下标。

对三大目标的作用分别是:Safe from bugs——没有下标、没有临时累加器、没有手写合并逻辑,因此没有越界与漏改状态的机会;纯函数 + 不可变数据使得把 .stream() 换成 .parallelStream() 不会改变结果,并行的正确性由库的结合律假设保证。Easy to understand——代码长度从几十行降到三行,意图(品牌过滤、取像素数、取最大值)直接可见,注释可以专注解释业务含义而非控制流。Ready for change——要换数据来源(List 换成 Set,只需改 stream() 的来源)、要加条件(插入一个 filter)、要改聚合方式(maxaverage)都只改一处,且改动局限在链条内,不会波及其他逻辑。唯一的代价是团队必须理解 reduce 的三条设计选择,否则「库帮了忙」会变成「错误被写得更短」。

问题 3 追问:上面的论述说明「纯函数 + 不可变数据 ⇒ 并行安全」,那么下面这段代码为什么可能出错?请给出修正,并说明这与本讲「无状态函数」的要求有何关系。

List<String> words = new ArrayList<>();
lines.stream().parallel().map(line -> line.split("\\W+"))
     .forEach(arr -> { for (String w : arr) if (!w.isEmpty()) words.add(w); });

答案:错误在于 forEach 内部的 lambda 修改了共享的可变 ArrayListArrayList 不是线程安全的,而 .parallel() 会让 forEach 在不同线程上并发执行,可能出现元素丢失、结果顺序混乱,甚至因内部数组扩容竞争而抛出异常;即便改成顺序流,代码也依赖 forEach 的执行顺序这一库不保证的性质,同时把结果通过副作用「输出」到外部变量,使方法不再是纯函数。课程原文的规则很明确:传给 map/forEach 的函数必须是无状态的,其行为不应依赖在 map/forEach 执行过程中变化的状态,因为实现可能并行执行它们。

修正方式是把「收集」表达为管道的终端操作:

List<String> words = lines.stream().parallel()
        .flatMap(line -> Stream.of(line.split("\\W+")))
        .filter(w -> !w.isEmpty())
        .collect(Collectors.toList());

(若要求去重与顺序稳定,可再加 .distinct() 并去掉 parallel(),或使用 Collectors.toCollection 指定集合类型。)改写后每个阶段都是纯函数,collect 负责安全合并部分结果,parallel() 的存废只影响性能而不影响正确性——这正是本讲反复强调的「不可变数据 + 纯函数 ⇒ 安全并发」的实践含义。