Blog

Java 的 lambda 与流:惰性求值、收集器以及何时用循环

Java 的 lambda 表达式把一小段行为变成值,流再把它们串成惰性的流水线。看看流水线到底怎样运行、该用哪些收集器,以及什么时候普通循环更好读。

lambda 表达式是一小段代码,你可以像传值一样把它传来传去。流(stream)是一条流水线,让一串值依次经过由这些 lambda 组成的步骤。两者合起来,能取代老式 Java 代码里满眼的循环。

本文讲 lambda 表达式和它背后的函数式接口、方法引用、lambda 能捕获什么、流水线、惰性求值、收集器、基本类型流、reduce、gatherer,以及循环依然更合适的场景。下面每个程序都在 Java 25 上跑过,输出直接从运行结果粘贴而来。想自己运行,就把代码存成 Main.java,再执行 java Main.java

从匿名类到 lambda

lambda 是一种更短的写法,用来写出一个实现了“只有一个抽象方法的接口”的对象。按长度给单词排序,就能看清这条演变路线。下面把同一个 Comparator 写了三遍:

List<String> sortedBy(List<String> words, Comparator<String> order) {
    var copy = new ArrayList<>(words);
    copy.sort(order);
    return copy;
}

void main() {
    var words = List.of("banana", "fig", "cherry", "kiwi");

    var anonymous = new Comparator<String>() {
        @Override
        public int compare(String a, String b) {
            return Integer.compare(a.length(), b.length());
        }
    };
    Comparator<String> lambda = (a, b) -> Integer.compare(a.length(), b.length());
    Comparator<String> byKey = Comparator.comparing(String::length);

    IO.println(sortedBy(words, anonymous));
    IO.println(sortedBy(words, lambda));
    IO.println(sortedBy(words, byKey));
}

输出:

[fig, kiwi, banana, cherry]
[fig, kiwi, banana, cherry]
[fig, kiwi, banana, cherry]

匿名类要把接口、方法名和参数类型全写出来。lambda 只留下参数和方法体,因为编译器已经知道目标类型是 Comparator<String>,而 Comparator 只有一个方法要填。第三行干脆不写比较逻辑:Comparator.comparing 根据一个键(这里是长度)替你造出比较器。

每一行里 banana 都排在 cherry 前面,虽然两者都是六个字母。List.sort 是稳定排序,相等的元素保持原来的顺序。

函数式接口

函数式接口是恰好只有一个抽象方法的接口,任何一个这样的接口都可以用 lambda 来实现。JDK 在 java.util.function 里提供了一组通用的函数式接口,所以你很少需要自己定义:

@FunctionalInterface
interface PriceRule {
    int apply(int cents);
}

void main() {
    Function<String, Integer> length = s -> s.length();
    Predicate<String> isLong = s -> s.length() > 4;
    Supplier<List<String>> fresh = () -> new ArrayList<>();
    Consumer<String> shout = s -> IO.println(s.toUpperCase() + "!");
    BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
    UnaryOperator<String> tidy = s -> s.strip();
    PriceRule discount = cents -> cents * 90 / 100;

    IO.println(length.apply("coffee"));
    IO.println(isLong.test("tea"));
    var list = fresh.get();
    list.add("new");
    IO.println(list);
    shout.accept("hello");
    IO.println(add.apply(2, 3));
    IO.println("[" + tidy.apply("  milk ") + "]");
    IO.println(discount.apply(500));
}

输出:

6
false
[new]
HELLO!
5
[milk]
450

每个接口都按它的形状命名:

  • Function<T, R> 接收一个 T,返回一个 R。调用它用 apply
  • Predicate<T> 接收一个 T,返回 boolean,用 test 调用。
  • Supplier<T> 不接收参数,返回一个 T,用 get 调用。
  • Consumer<T> 接收一个 T,什么都不返回,用 accept 调用。
  • BiFunction<T, U, R> 接收两个参数,返回一个 R
  • UnaryOperator<T> 就是 Function<T, T>:输入和输出类型相同。

PriceRule 是我们自己定义的。@FunctionalInterface 可写可不写,但写上后编译器会检查“恰好一个”这条规则。我们加了第二个抽象方法 int undo(int cents),构建就失败了,报 Unexpected @FunctionalInterface annotation,后面跟着 multiple non-overriding abstract methods found in interface PriceRule

方法引用的四种形式

方法引用用 :: 来写,它是一个方法体只有一次方法调用的 lambda。方法引用有四种,区别在于对象从哪里来:

record Point(int x, int y) {}

void main() {
    // static method: s -> Integer.parseInt(s)
    Function<String, Integer> parse = Integer::parseInt;
    // method on one object you already have: s -> prefix.concat(s)
    String prefix = "id-";
    Function<String, String> tag = prefix::concat;
    // method on whatever object arrives: s -> s.toUpperCase()
    Function<String, String> upper = String::toUpperCase;
    // constructor: (x, y) -> new Point(x, y)
    BiFunction<Integer, Integer, Point> make = Point::new;

    IO.println(parse.apply("42") + 1);
    IO.println(tag.apply("7"));
    IO.println(upper.apply("tea"));
    IO.println(make.apply(3, 4));
}

输出:

43
id-7
TEA
Point[x=3, y=4]

每行上方的注释就是它所替代的 lambda。最容易让人犯迷糊的是 String::toUpperCase。它看起来像静态调用,但 toUpperCase 是实例方法,所以第一个参数会成为调用这个方法的对象。

lambda 能捕获什么

lambda 可以读取外层方法里的局部变量,但前提是这些变量是 final 或 effectively final 的,也就是赋值之后再也没有被赋过值。用 lambda 计数就违反了这条规则:

void main() {
    int count = 0;
    List.of("a", "b", "c").forEach(s -> count++);
    IO.println(count);
}

构建失败,报错:

Main.java:3: error: local variables referenced from a lambda expression must be final or effectively final
    List.of("a", "b", "c").forEach(s -> count++);
                                        ^

lambda 的寿命可能比创建它的方法更长,比如被存进字段,或者在另一个线程上运行。所以 Java 会把值复制进 lambda,而不是共享这个变量。如果变量之后还能改,副本和原变量就会对不上,Java 在编译期就把这种情况排除了。字段不受这个限制,因为 lambda 是通过对象访问字段的。

和匿名类还有一点不同:在 lambda 里,this 的含义和外层代码里一样。

void main() {
    Runnable lambda = () -> IO.println("lambda this: " + this.getClass().getName());
    Runnable anonymous = new Runnable() {
        @Override
        public void run() {
            IO.println("anonymous this: " + this.getClass().getName());
        }
    };
    lambda.run();
    anonymous.run();
}

输出:

lambda this: Main
anonymous this: Main$1

lambda 里的 this 就是运行 main 的那个 Main 对象。匿名类是一个新类 Main$1,所以它的 this 是它自己。

流水线:源、步骤、结果

一条流水线分三部分:一个源、任意多个中间操作,以及一个产生结果的终止操作。下面一次就把一份订单列表整理好:

void main() {
    var orders = List.of("tea", "coffee", "tea", "juice", "water", "coffee", "milk");

    var result = orders.stream()
        .filter(o -> !o.equals("water"))
        .distinct()
        .map(String::toUpperCase)
        .sorted()
        .limit(3)
        .toList();

    IO.println(result);
    IO.println(orders);
}

输出:

[COFFEE, JUICE, MILK]
[tea, coffee, tea, juice, water, coffee, milk]
  • 源: orders.stream()。集合、数组、Stream.of(...) 和文件都可以做源。
  • 中间操作: filter 保留通过 Predicate 的元素,distinct 去掉重复,map 把每个元素变成别的东西,sorted 排序,limit 保留前几个。每个中间操作都返回一个新的流。
  • 终止操作: toList() 把剩下的元素收集起来。

最后一行说明源列表没被动过。流只读取它的源,不会修改它。

toList() 还是 collect(Collectors.toList())

Stream.toList() 是 Java 16 才加入的。我们验证过:用 javac --release 15 编译,这个调用会报 cannot find symbol。在那之前,你得写 collect(Collectors.toList()),很多代码至今还这么写。两者并不完全一样:

void main() {
    var viaCollect = Stream.of("b", "a").collect(Collectors.toList());
    viaCollect.add("c");
    IO.println(viaCollect);

    var viaToList = Stream.of("b", "a").toList();
    IO.println(viaToList);
    viaToList.add("c");
}

输出后停止:

[b, a, c]
[b, a]
Exception in thread "main" java.lang.UnsupportedOperationException

toList() 返回的是不可修改的列表,所以 add 会抛异常。Collectors.toList() 对此不做任何保证,只是目前它碰巧返回 ArrayList。除非你之后真的要改这个列表,否则就用 toList()。如果确实要改,就用 Collectors.toCollection(ArrayList::new) 把意图写明。

惰性:你不要,就什么都不运行

调用中间操作时,它们并不干活,只是描述要干的活。等终止操作要结果时,活才开始干:

void main() {
    var pipeline = Stream.of(1, 2, 3).map(n -> {
        IO.println("map saw " + n);
        return n * 10;
    });
    IO.println("pipeline built");

    var result = pipeline.toList();
    IO.println(result);
}

输出:

pipeline built
map saw 1
map saw 2
map saw 3
[10, 20, 30]

pipeline built 最先出现。执行 map 那一行时,lambda 一次都没被调用。它返回的是一个记住了“乘以十”的流。直到 toList() 把元素拉过流水线,lambda 才真正运行。

一次一个元素

流不会先对整个列表跑一遍 filter,再对结果跑一遍 map。每个元素都会走完整条流水线,下一个元素才开始:

void main() {
    var result = Stream.of("apple", "fig", "cherry", "kiwi")
        .filter(w -> {
            IO.println("filter " + w);
            return w.length() > 3;
        })
        .map(w -> {
            IO.println("map    " + w);
            return w.toUpperCase();
        })
        .toList();
    IO.println(result);
}

输出:

filter apple
map    apple
filter fig
filter cherry
map    cherry
filter kiwi
map    kiwi
[APPLE, CHERRY, KIWI]

从上往下读。apple 通过过滤,直接进入 mapfig 没通过过滤,所以 map 根本见不到它。接着 cherry 走完全程,然后是 kiwi。在循环里套一个 if 做的也正是这件事,而这正是关键:流就是一个你描述出来、而不是亲手写出来的循环。

sorted 是例外。它必须看过所有元素才能往下传,所以会先把元素全部收齐:

void main() {
    var result = Stream.of("kiwi", "apple", "fig")
        .peek(w -> IO.println("before sorted " + w))
        .sorted()
        .peek(w -> IO.println("after sorted  " + w))
        .toList();
    IO.println(result);
}

输出:

before sorted kiwi
before sorted apple
before sorted fig
after sorted  apple
after sorted  fig
after sorted  kiwi
[apple, fig, kiwi]

peek 会在每个元素经过时对它执行一个 Consumer,用来观察数据流动很方便。sorteddistinct 叫作有状态操作,因为它们需要记住元素。filtermap 是无状态的。

短路:提前停下,无限流也不怕

有些操作一拿到答案就让流水线停下。findFirst 在第一个到达它的元素处停止,limit(n)n 个元素之后停止。正因如此,无限的源才是安全的:

void main() {
    var firstBig = Stream.iterate(1, n -> n * 2)
        .peek(n -> IO.println("looked at " + n))
        .filter(n -> n > 20)
        .findFirst();
    IO.println(firstBig);

    var firstFive = Stream.iterate(1, n -> n * 2).limit(5).toList();
    IO.println(firstFive);
}

输出:

looked at 1
looked at 2
looked at 4
looked at 8
looked at 16
looked at 32
Optional[32]
[1, 2, 4, 8, 16]

Stream.iterate(1, n -> n * 2) 描述的是 1、2、4、8,一直到无穷。流水线看了六个值,找到 32,就停了。findFirst 返回 Optional,因为流可能根本没有第一个元素。讲 Optional 的那一篇会细说。把 findFirst() 换成 toList(),程序就永远不会结束。

惰性可能跳过你的 lambda

惰性还有一个我们没料到的意外。终止操作可能判断自己根本不需要运行流水线:

void main() {
    long quick = Stream.of(1, 2, 3)
        .peek(n -> IO.println("peek " + n))
        .count();
    IO.println("count " + quick);

    long slow = Stream.of(1, 2, 3)
        .filter(n -> n > 1)
        .peek(n -> IO.println("peek " + n))
        .count();
    IO.println("count " + slow);
}

输出:

count 3
peek 2
peek 3
count 2

第一个 peek 从没运行过。Stream.of(1, 2, 3) 知道自己有三个元素,peek 改变不了这一点,所以 count 不拉任何元素就答出了 3。一旦中间隔着一个 filter,元素个数就不确定了,每个元素都得流过去。JDK 从 Java 9 起就这么做,Stream 的文档也允许这样。所以别把你依赖的工作放进 peekmap。流里的 lambda 应该计算一个值,而不是制造副作用。

用十岁孩子能懂的话说

流就像玩具工厂里的一条流水线。流水线旁站着几个工人:一个把坏玩具扔掉,一个给好玩具上色,一个把玩具装进盒子。流水线的最末端站着一位顾客。

把流水线搭好,并不会造出任何玩具。直到末端的顾客说“我要一个做好的玩具”,东西才开始动。

然后第一个玩具走完整条流水线:检查、上色、装盒、交给顾客。这之后第二个玩具才出发。如果顾客只要一个玩具,流水线就停了,其他玩具永远不会离开那一堆。

准确的说法

流水线是由一串阶段对象组成的链。每个中间操作,比如 filtermap,都会添加一个阶段并立即返回。终止操作启动求值。它向源逐个索取元素,每个元素依次经过每个阶段的 lambda,然后才向源索取下一个。

filtermap 这样的无状态阶段处理完一个元素就马上往下传。sorted 这样的有状态阶段会把元素扣下,直到源被取空。findFirstanyMatchlimit 这类短路操作会发出“已完成”的信号,源就不再产生元素。因为终止操作在开始之前就能看到整条流水线,它还可以跳过结果用不上的阶段,就像 count 那样。

这个比喻的局限:真实的流水线如果有个慢吞吞的上色工,工人之间就会堆起排队的玩具。顺序流永远不会这样。它没有队列,也不会有第二个玩具同时在途,唯一的例外是 sorted 这样的有状态阶段把流水线停下来收集全部元素。另外,用了 parallel() 之后,好几条流水线会同时运行,元素也不再按顺序到达。

findFirst 怎样让流停下

下面的动画演示 Stream.of(1, 2, 3, 4, 5).filter(n -> n % 2 == 1).map(n -> n * 10).findFirst(),它返回 Optional[10]

Stream.of(1, 2, 3, 4, 5) 1 2 3 4 5 2 到 5:从未被读取 filter(n % 2 == 1) map(n * 10) findFirst() 1 10 1 是奇数:通过 1 变成 10 拿到了:停止 结果:Optional[10] 流水线已经搭好,但还没有东西在动 findFirst() 索取一个元素 1 进入 filter:是奇数,所以通过 1 进入 map,出来变成 10 10 到达 findFirst(),它拿到了答案 流停止:2 到 5 从未被读取

一个五元素的流被拉着经过 filter、map 和 findFirst。findFirst 索取之前,什么都不动。元素 1 通过过滤,变成 10,到达 findFirst,后者返回 Optional[10]。流就停在那里,2 到 5 从未被读取。

如果动画没有播放,下面用文字说明这几步:

  1. Stream.offiltermap 都立即返回。它们搭好了一条流水线,还没有任何元素移动。
  2. findFirst() 是终止操作。它向流水线索取一个元素,这个请求一路传到源。
  3. 源交出 1。filter 检查 1 % 2 == 1,结果为真,于是 1 通过。
  4. 1 进入 map,出来变成 10
  5. 10 到达 findFirst()。它要的就这么多,于是把值包装成 Optional[10]
  6. findFirst() 告诉流水线自己已经完成。源再也不交出 2、3、4、5,也没有 lambda 为它们运行。

流只能用一次

流是对源的一次单程遍历,所以在同一个流对象上执行第二个终止操作会失败:

void main() {
    var words = Stream.of("tea", "coffee", "milk");
    IO.println(words.count());
    IO.println(words.count());
}

输出后停止:

3
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed

解决办法是每次需要新的流水线时,都回到源头调用 list.stream()。如果要让别的代码自己创建新的流,就把集合传给它,而不是传一个流。

收集器:分组、分区、拼接

collect 是一个根据元素构建出某种结果的终止操作,Collectors 里放着现成的构建器。下面把销售记录分组、拆分、拼接:

record Sale(String city, String product, int amount) {}

void main() {
    var sales = List.of(
        new Sale("Lisbon", "tea", 12),
        new Sale("Porto", "coffee", 30),
        new Sale("Lisbon", "coffee", 25),
        new Sale("Braga", "tea", 8),
        new Sale("Porto", "tea", 15));

    Map<String, Long> perCity = sales.stream()
        .collect(Collectors.groupingBy(Sale::city, TreeMap::new, Collectors.counting()));
    IO.println(perCity);

    Map<Boolean, List<Integer>> bigOrSmall = sales.stream()
        .map(Sale::amount)
        .collect(Collectors.partitioningBy(a -> a >= 20));
    IO.println(bigOrSmall);

    String cities = sales.stream()
        .map(Sale::city)
        .distinct()
        .sorted()
        .collect(Collectors.joining(", ", "<", ">"));
    IO.println(cities);
}

输出:

{Braga=1, Lisbon=2, Porto=2}
{false=[12, 8, 15], true=[30, 25]}
<Braga, Lisbon, Porto>
  • groupingBy(key, mapFactory, downstream) 按键把元素分到不同的桶里,再对每个桶运行第二个收集器。counting() 把每个桶变成它的元素个数。去掉 TreeMap::new,你得到的是 HashMap,打印出来的顺序没什么用。TreeMap 让城市保持有序。
  • partitioningBy(predicate) 总是恰好分成两个桶,falsetrue,即使其中一个是空的。
  • joining(separator, prefix, suffix) 把字符串拼到一起。它只能用在字符串流上,所以前面要先 map

toMap 与重复的键

Collectors.toMap 用一个键函数和一个值函数构建 map。当两个元素产生相同的键时,你必须说明怎么处理,否则流会抛异常:

record Sale(String city, int amount) {}

void main() {
    var sales = List.of(new Sale("Lisbon", 12), new Sale("Porto", 30), new Sale("Lisbon", 25));

    Map<String, Integer> totals = sales.stream()
        .collect(Collectors.toMap(Sale::city, Sale::amount, Integer::sum, TreeMap::new));
    IO.println(totals);

    Map<String, Integer> broken = sales.stream()
        .collect(Collectors.toMap(Sale::city, Sale::amount));
    IO.println(broken);
}

输出后停止:

{Lisbon=37, Porto=30}
Exception in thread "main" java.lang.IllegalStateException: Duplicate key Lisbon (attempted merging values 12 and 25)

第三个参数 Integer::sum 是合并函数。它拿到旧值和新值,返回要保留的值,所以 Lisbon 的销售额加起来是 37。没有它,第二个 Lisbon 就会抛出 IllegalStateException。异常信息写明了键和两个值。如果要做从键到计数或求和的 map,用 groupingBy 搭配 counting()summingInt,表达得更直白。

基本类型流

IntStreamLongStreamDoubleStream 存放的是普通的 intlongdouble 值,并且带有 Stream<Integer> 没有的数值方法:

void main() {
    IO.println(IntStream.range(0, 5).sum());
    IO.println(IntStream.rangeClosed(1, 5).boxed().toList());

    var words = List.of("tea", "coffee", "milk");
    int letters = words.stream().mapToInt(String::length).sum();
    OptionalDouble average = words.stream().mapToInt(String::length).average();
    IO.println(letters);
    IO.println(average);
    IO.println(average.orElse(0));
    IO.println(IntStream.empty().average());
}

输出:

10
[1, 2, 3, 4, 5]
13
OptionalDouble[4.333333333333333]
4.333333333333333
OptionalDouble.empty

range(0, 5) 是 0 到 4,rangeClosed(1, 5) 包含 5。需要 List 时,用 boxed() 回到 Stream<Integer>

average() 返回的是 OptionalDouble,不是 double,因为空流没有平均值。最后一行展示的就是这种情况。

最重要的是 mapToIntwords.stream().map(String::length) 给你的是 Stream<Integer>:每个长度都被装箱成一个 Integer 对象,而且没有 sum() 方法可调。mapToInt 给你的是由普通 int 组成的 IntStream。不会创建包装对象,sumaverageminmax 也都现成可用。

reduce 简介

reduce 把一个函数两两应用到元素上,把所有元素合并成一个值:

void main() {
    int total = Stream.of(3, 4, 5).reduce(0, Integer::sum);
    Optional<Integer> product = Stream.of(3, 4, 5).reduce((a, b) -> a * b);
    Optional<Integer> nothing = Stream.<Integer>empty().reduce((a, b) -> a * b);

    IO.println(total);
    IO.println(product);
    IO.println(nothing);
    IO.println(IntStream.of(3, 4, 5).sum());
}

输出:

12
Optional[60]
Optional.empty
12

给了初始值(这里是 0),你得到的是普通结果。不给初始值,你得到的是 Optional,因为可能没有东西可合并。reduce 很通用,所以读代码的人得自己琢磨它算的是什么。求和、计数、最小值、最大值和拼接都有专门命名的方法(sum()count()max(...)Collectors.joining),直接把意思说出来,就像最后一行那样。

gatherer:自定义中间步骤

gatherer 是一种自定义的中间操作,配合 Stream.gather 使用。gatherer 在 Java 24 中正式定稿。我们验证过:用 javac --release 23 编译,构建会失败,报 Gatherers is a preview API and is disabled by default,而在 Java 25 上不需要任何标志。Gatherers 自带几个现成的 gatherer,比如固定窗口和滑动窗口:

void main() {
    var readings = List.of(3, 5, 4, 8, 9, 2, 7);

    IO.println(readings.stream().gather(Gatherers.windowFixed(3)).toList());
    IO.println(readings.stream().gather(Gatherers.windowSliding(3)).toList());

    var movingAverage = readings.stream()
        .gather(Gatherers.windowSliding(3))
        .map(w -> w.stream().mapToInt(Integer::intValue).average().orElseThrow())
        .toList();
    IO.println(movingAverage);
}

输出:

[[3, 5, 4], [8, 9, 2], [7]]
[[3, 5, 4], [5, 4, 8], [4, 8, 9], [8, 9, 2], [9, 2, 7]]
[4.0, 5.666666666666667, 7.0, 6.333333333333333, 6.0]

windowFixed(3) 把流切成每组三个,最后一组拿剩下的元素。windowSliding(3) 每次向前移动一个元素,移动平均需要的正是这个。有 gatherer 之前,要把相邻元素分组,就得写一个带索引的循环。你也可以用 Gatherer.of 写自己的 gatherer。

什么时候循环更清楚

流擅长“拿来这些元素,留下一部分,变换它们,收集起来”。超出这个形状,有四种情况会反复出现。

受检异常。 java.util.function 里的函数式接口没有一个声明了受检异常,所以 lambda 不能抛出受检异常:

int parsePort(String s) throws IOException {
    if (s.isBlank()) {
        throw new IOException("blank port");
    }
    return Integer.parseInt(s);
}

void main() {
    var ports = Stream.of("80", "443").map(s -> parsePort(s)).toList();
    IO.println(ports);
}

构建失败,报错:

Main.java:9: error: unreported exception IOException; must be caught or declared to be thrown
    var ports = Stream.of("80", "443").map(s -> parsePort(s)).toList();
                                                     ^

绕过去的办法有两种:在 lambda 里捕获异常,把它包装成非受检异常;或者写一个辅助方法替你做这件事。两种都会把异常从方法签名里藏起来。而 for 循环可以直接让异常往外传。

修改局部状态,以及提前退出。 下面是“按顺序买东西,直到下一件会超出预算为止”,先用流写一遍,再用循环写一遍:

void main() {
    var prices = List.of(40, 25, 30, 50, 10);
    int budget = 100;

    int[] spentBox = {0};
    long boughtByStream = prices.stream()
        .takeWhile(p -> {
            if (spentBox[0] + p > budget) {
                return false;
            }
            spentBox[0] += p;
            return true;
        })
        .count();
    IO.println("stream: bought " + boughtByStream + ", spent " + spentBox[0]);

    int spent = 0;
    int bought = 0;
    for (int price : prices) {
        if (spent + price > budget) {
            break;
        }
        spent += price;
        bought++;
    }
    IO.println("loop:   bought " + bought + ", spent " + spent);
}

输出:

stream: bought 3, spent 95
loop:   bought 3, spent 95

两者答案相同。流的版本需要一个单元素数组,因为 lambda 不能给局部变量赋值,只好改数组里面的内容。它的 takeWhile 谓词带有副作用,而前面 count 的意外恰恰警告过这一点。循环按事情发生的顺序把它们写出来,break 就是提前退出,不需要任何花招。

调试。 在循环里,你可以在某一行设断点,查看每个变量。在流水线里,你写的代码是在流库内部运行的。栈跟踪里塞满了 java.util.stream 的栈帧和 lambda$main$0 这类生成的名字,断点停在 lambda 里时,周围也没有循环变量可看。如果你需要好几个 peek 才能看懂一条流水线,它多半应该改成循环。

一条简短的规则:流水线读起来像一句话时,比如过滤这个、映射那个、收集起来,就用流。需要 break、受检异常、几个一起变化的变量,或者要用调试器时,就用循环。

要点

  • lambda 实现的是只有一个抽象方法的接口。FunctionPredicateSupplierConsumerBiFunctionUnaryOperator 能满足大多数需要。
  • 方法引用,比如 String::lengthPoint::new,就是只调用一个方法的 lambda。
  • lambda 只能捕获 effectively final 的局部变量。
  • 终止操作开口之前,流什么都不运行;之后它让一个元素走完所有步骤,下一个才开始。findFirstlimit 能让它提前停下,即使源是无限的。
  • 别依赖 peekmap 里的副作用。终止操作可能会跳过它们,就像 count 那样。
  • 流只能用一次。toList() 返回的列表不可修改。顺序重要时给 groupingBy 传一个 TreeMap,键可能重复时给 toMap 传一个合并函数。
  • 数值计算用 mapToInt;遇到受检异常、提前退出和变化的状态,就用普通循环。

流描述的是要做的工作,而终止操作决定其中究竟有多少会真正运行。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。