不喜欢循环?试试 Java Streams

现在是 2020 年,是时候学习 Java Streams 了。
97 位读者喜欢这篇文章。

在本文中,我将解释如何不再编写循环。

什么?你的意思是,不再有循环了?

是的,这就是我 2020 年的决心——在 Java 中不再使用循环。请理解,这并不是说循环让我失望了,或者它们把我引入歧途(好吧,至少,我可以争辩这一点)。实际上,是因为我,一位自 1997 年左右以来能力普通的 Java 程序员,必须最终学习所有这些新的 Streams 的东西,表达我想要“做什么”而不是我想要“如何做”,也许能够并行化我的一些计算,以及所有其他好东西。

我猜想,还有其他 Java 程序员也在 Java 中编程了相当长的时间,并且和我处境相同。因此,我将提供我的经验,作为“如何在 Java 中不再编写循环”的指南。

找到一个值得解决的问题

如果你像我一样,那么你遇到的第一个难题就是“好吧,这东西很酷,但我要解决什么问题,以及我该如何应用它?” 我意识到我可以发现伪装成我以前做过的事情的绝佳机会。

在我的例子中,它是在特定区域内对土地覆盖进行采样,并得出整个区域土地覆盖的估计值和置信区间。具体问题涉及确定一个区域是否“有森林”,给定一个特定的法律定义:如果至少 10% 的土壤被树冠覆盖,则该区域被认为是森林;否则,就是其他东西。

Image of land cover in an area

这是一个相当深奥的经常性问题的例子;我承认。但事实就是如此。对于习惯于凉爽温带或热带森林的生态学家和林务员来说,10% 可能听起来有点低,但在干旱地区,灌木和树木生长矮小的情况下,这是一个合理的数字。

因此,基本思路是:使用图像对区域进行分层(即,完全没有树木的区域、以稀疏的小树为主的区域、以较密集的小树为主的区域、以稍大的树木为主的区域),在这些地层中定位一些样本,派遣工作人员去测量样本,分析结果,并计算整个区域树冠覆盖土壤的比例。很简单,对吧?

Survey team assessing land cover

野外数据看起来像什么

在当前项目中,样本是矩形区域,宽 20 米,长 25 米,因此每个样本为 500 平方米。在每个样地中,野外工作人员测量了每棵树:树种、树高、最大和最小冠幅以及树干高度(名义上为地面以上 30 厘米)处的树干直径。收集此信息,输入到电子表格中,并导出为条分隔值 (BSV) 文件供我分析。它看起来像这样

地层编号 样本编号 树木编号 树种 树干直径(厘米) 冠幅 1(米) 冠幅 2(米) 高度(米)
1 1 1 Ac 6 3.6 4.6 2.4
1 1 2 Ac 6 2.2 2.3 2.5
1 1 3 Ac 16 2.5 1.7 2.4
1 1 4 Ac 6 1.5 2.1 1.8
1 1 5 Ac 5 0.9 1.7 1.7
1 1 6 Ac 6 1.7 1.3 1.6
1 1 7 Ac 5 1.82 1.32 1.8
1 1 1 Ac 1 0.3 0.25 0.9
1 1 2 Ac 2 1.2 1.2 1.7

第一列是地层编号(其中 1 是“以稀疏的小树为主”,2 是“以较密集的小树为主”,3 是“以稍大的树木为主”;我们没有对“完全没有树木”的区域进行采样)。第二列是样本编号(总共有 73 个样本,按每个地层的面积比例分布在三个地层中)。第三列是样本内的树木编号。第四列是两个字母的树种代码,第五列是树干直径(在本例中,为地面以上或裸露树根以上 10 厘米处),第六列是冠幅的最小距离,第七列是最大距离,第八列是树高。

就本练习而言,我只关心树冠覆盖的总地面面积——而不是树种、树高或树干直径。

除了上面的测量信息外,我还有三个地层的面积,也在一个 BSV 中

地层 公顷
1 114.89
2 207.72
3 29.77

我想做什么(而不是我想如何做)

为了与 Java Streams 的主要设计目标之一保持一致,这里是我想要“做什么”

  1. 读取地层面积 BSV 并将数据保存为查找表。
  2. 从测量 BSV 文件中读取测量值。
  3. 累加每个测量值(树木)以计算样本中树冠覆盖的总面积。
  4. 累加样本树冠面积值并计算样本数量,以估计每个地层的平均树冠面积覆盖率和平均值的标准误差。
  5. 汇总地层数据。
  6. 按地层面积(从步骤 1 中创建的表中查找)权衡地层平均值和标准误差,并将它们累加以估计总面积的平均树冠面积覆盖率和平均值的标准误差。
  7. 汇总结算后的数据。

一般来说,使用 Java Streams 定义“做什么”的方法是创建数据流处理管道,其中包含在数据上运行的函数调用。所以,是的,实际上最终会渗入一些“如何做”……事实上,相当多的“如何做”。但是,它需要与良好的、老式的循环非常不同的知识库。

我将详细介绍这些步骤中的每一个。

构建地层面积表

第一项工作是将地层面积 BSV 文件转换为查找表

String fileName = "stratum_areas.bsv";
Stream<String> inputLineStream = Files.lines(Paths.get(fileName));  // (1)

final Map<Integer,Double> stratumAreas =   // (2)
    inputLineStream  	// (3)
        .skip(1)                   // (4)
        .map(l -> l.split("\\|"))  // (5)
        .collect(                  // (6)
            Collectors.toMap(      // (7)
                a -> Integer.parseInt(a[0]),  // (8)
                a -> Double.parseDouble(a[1]) // (9)
            )
        );
inputLineStream.close();   // (10)

System.out.println("stratumAreas = " + stratumAreas);  // (11)

我将一次处理一两行,其中上面行后面的注释中的数字——例如,// (3)——对应于下面的数字

  1. java.nio.Files.lines() 提供与文件中的行对应的字符串流。
  2. 目标是创建查找表 stratumAreas,它是一个 Map<Integer,Double>。因此,我可以将地层 2 的 double 值面积获取为 stratumAreas.get(2)
  3. 这是流“管道”的开始。
  4. 跳过管道中的第一行,因为它是包含列名的标题行。
  5. 使用 map()String 输入行拆分为 String 字段数组,其中第一个字段是地层编号,第二个字段是地层面积。
  6. 使用 collect() 物化结果
  7. 物化结果将生成为 Map 条目的序列。
  8. 每个 map 条目的键是管道中数组的第一个元素——int 地层编号。顺便说一下,这是一个 Java lambda 表达式——一个匿名函数,它接受一个参数并返回转换为 int 的参数。
  9. 每个 map 条目的值是管道中数组的第二个元素——double 地层面积。
  10. 不要忘记关闭流(文件)。
  11. 打印出结果,它看起来像
    stratumAreas = {1=114.89, 2=207.72, 3=29.77}

构建测量值表并将测量值累加到样本总数中

现在我有了地层面积,我可以开始处理数据的主体——测量值。我将构建测量值表和将测量值累加到样本总数这两个任务结合起来,因为我对测量数据本身没有任何兴趣。

fileName = "sample_data_for_testing.bsv";
inputLineStream = Files.lines(Paths.get(fileName));

 final Map<Integer,Map<Integer,Double>> sampleValues =
    inputLineStream
        .skip(1)
        .map(l -> l.split("\\|"))
        .collect(                  // (1)
            Collectors.groupingBy(a -> Integer.parseInt(a[0]),     // (2)
                Collectors.groupingBy(b -> Integer.parseInt(b[1]), // (3)
                    Collectors.summingDouble(                      // (4)
                        c -> {                                     // (5)
                            double rm = (Double.parseDouble(c[5]) +
                                Double.parseDouble(c[6]))/4d;      // (6)
                            return rm*rm * Math.PI / 500d;         // (7)
                        })
                )
            )
        );
inputLineStream.close();

System.out.println("sampleValues = " + sampleValues);  // (8)

再次,一次一两行左右

  1. 前七行在这个任务和上一个任务中是相同的,除了这个查找表的名称是 sampleValues;它是一个 MapMap
  2. 测量数据按样本(按样本编号)分组,样本又按地层(按地层编号)分组,因此我在顶层使用 Collectors.groupingBy() 将数据分离到地层中,这里的 a[0] 是地层编号。
  3. 我再次使用 Collectors.groupingBy() 将数据分离到样本中,这里的 b[1] 是样本编号。
  4. 我使用方便的 Collectors.summingDouble() 累加数据,用于地层内样本中每个测量值。
  5. 再次,一个 Java lambda 或匿名函数,其参数 c 是字段数组,其中此 lambda 具有多行代码,这些代码被 {} 包围,并在 } 之前有一个 return 语句。
  6. 计算测量值的平均冠幅半径。
  7. 计算测量值的冠幅面积,作为总样本面积的比例,并将该值作为 lambda 的结果返回。
  8. 再次,与之前的任务类似。结果看起来像(省略了一些数字)
    sampleValues = {1={1=0.09083231861452731, 66=0.06088002082602869, ... 28=0.0837823490804228}, 2={65=0.14738326403381743, 2=0.16961183847374103, ... 63=0.25083064794883453}, 3={64=0.3306323635177101, 32=0.25911911184680053, ... 30=0.2642668470291564}}

此输出清楚地显示了 MapMap 结构——顶层有三个条目,对应于地层 1、2 和 3,并且每个地层都有子条目,对应于样本中树冠覆盖的比例面积。

将样本总数累加到地层平均值和标准误差中

此时,任务变得更加复杂;我需要计算样本数量,将样本值相加以为计算样本平均值做准备,并将样本值的平方和相加以为计算平均值的标准误差做准备。我不妨也将地层面积纳入此数据分组,因为我很快就需要它来权衡地层结果。

因此,首先要做的是创建一个类 StratumAccumulator,以处理累加并提供有趣结果的计算。此类实现 java.util.function.DoubleConsumer,可以将其传递给 collect() 以处理累加

class StratumAccumulator implements DoubleConsumer {
    private double ha;
    private int n;
    private double sum;
    private double ssq;
    public StratumAccumulator(double ha) { // (1)
        this.ha = ha;
        this.n = 0;
        this.sum = 0d;
        this.ssq = 0d;
    }
    public void accept(double d) { // (2)
        this.sum += d;
        this.ssq += d*d;
        this.n++;
    }
    public void combine(StratumAccumulator other) { // (3)
        this.sum += other.sum;
        this.ssq += other.ssq;
        this.n += other.n;
    }
    public double getHa() {  // (4)
        return this.ha;
    }
    public int getN() {  // (5)
        return this.n;
    }
    public double getMean() {  // (6)
        return this.n > 0 ? this.sum / this.n : 0d;
    }
    public double getStandardError() {  // (7)
        double mean = this.getMean();
        double variance = this.n > 1 ? (this.ssq - mean*mean*n)/(this.n - 1) : 0d;
        return this.n > 0 ? Math.sqrt(variance/this.n) : 0d;
    }
}

逐行解释

  1. 构造函数 StratumAccumulator(double ha) 接受一个参数,即地层的面积(以公顷为单位),这使我可以将地层面积查找表合并到此类的实例中。
  2. accept(double d) 方法用于累加双精度值流,我使用它来

    a. 计算值的数量。

    b. 将值相加,为计算样本平均值做准备。

    c. 将值的平方和相加,为计算平均值的标准误差做准备。
  3. combine() 方法用于合并 StratumAccumulator 的子流(如果我想并行处理)。
  4. 地层面积的 getter
  5. 地层中样本数量的 getter
  6. 地层中平均样本值的 getter
  7. 地层中平均值的标准误差的 getter

一旦我有了这个累加器,我就可以使用它来累加与每个地层相关的样本值

final Map<Integer,StratumAccumulator> stratumValues =   // (1)
    sampleValues.entrySet().stream()   // (2)
        .collect(                      // (3)
            Collectors.toMap(          // (4)
                e -> e.getKey(),       // (5)
                e -> e.getValue().entrySet().stream()   // (6)
                    .map(Map.Entry::getValue)           // (7)
                    .collect(          // (8)
                        () -> new StratumAccumulator(stratumAreas.get(e.getKey())),   // (9)
                        StratumAccumulator::accept,     // (10)
                        StratumAccumulator::combine)    // (11)
            )
        );

逐行解释

  1. 这次,我使用管道来构建 stratumValues,它是一个 Map<Integer,StratumAccumulator>,因此 stratumValues.get(3) 将返回地层 3 的 StratumAccumulator 实例。
  2. 在这里,我使用 Map 提供的 entrySet().stream() 方法来获取(键,值)对的流;回想一下,这些是按地层划分的样本值的 Map
  3. 再次,我使用 collect() 来收集按地层划分的管道结果…
  4. 使用 Collectors.toMap() 生成 Map 条目的流…
  5. 其键是传入流的键(即,地层编号)…
  6. 其值是样本值的 Map,我再次使用 entrySet().stream() 转换为 Map 条目的流,每个样本一个条目。
  7. 使用 map() 获取样本 Map 条目的值;此时我对键不感兴趣。
  8. 再次,使用 collect() 将样本结果累加到 StratumAccumulator 实例中。
  9. 告诉 collect() 如何创建新的 StratumAccumulator——我需要在此处将地层面积传递到构造函数中,因此我不能只使用 StratumAccumulator::new
  10. 告诉 collect() 使用 StratumAccumulatoraccept() 方法来累加样本值流。
  11. 告诉 collect() 使用 StratumAccumulatorcombine() 方法来合并 StratumAccumulator 实例。

汇总结算地层数据

呼!完成所有这些之后,打印出地层数据就非常简单了

stratumValues.entrySet().stream()
    .forEach(e -> {
        StratumAccumulator sa = e.getValue();
        int n = sa.getN();
        double se66 = sa.getStandardError();
        double t = new TDistribution(n - 1).inverseCumulativeProbability(0.975d);
        System.out.printf("stratum %d n %d mean %g se66 %g t %g se95 %g ha %g\n",
            e.getKey(), n, sa.getMean(), se66, t, se66 * t, sa.getHa());
    });

在上面,我再次使用 entrySet().stream()stratumValues Map 转换为流,然后将 forEach() 方法应用于该流。ForEach() 几乎就像它听起来的那样——一个循环!但是查找流的头部、查找下一个元素以及检查是否到达末尾的所有业务都由 Java Streams 处理。因此,我只需说明我想对每个记录执行什么操作,这基本上就是将其打印出来。

我的代码看起来有点复杂,因为我声明了一些局部变量来保存我多次使用的一些中间结果——n,样本数量,以及 se66,平均值的标准误差。我还计算了逆 T 值,以将我的平均值标准误差转换为 95% 置信区间

结果看起来像这样

stratum 1 n 24 mean 0.0903355 se66 0.0107786 t 2.06866 se95 0.0222973 ha 114.890
stratum 2 n 38 mean 0.154612 se66 0.00880498 t 2.02619 se95 0.0178406 ha 207.720
stratum 3 n 11 mean 0.223634 se66 0.0261662 t 2.22814 se95 0.0583020 ha 29.7700

将地层平均值和标准误差累加到总数中

再次,任务变得更加复杂,所以我创建了一个类,TotalAccumulator,来处理累积并提供有趣结果的计算。此类实现了 java.util.function.Consumer<T> 接口,可以将其传递给 collect() 方法来处理累积。

class TotalAccumulator implements Consumer<StratumAccumulator> {
    private double ha;
    private int n;
    private double sumWtdMeans;
    private double ssqWtdStandardErrors;
    public TotalAccumulator() {
        this.ha = 0d;
        this.n = 0;
        this.sumWtdMeans = 0d;
        this.ssqWtdStandardErrors = 0d;
    }
    public void accept(StratumAccumulator sa) {
        double saha = sa.getHa();
        double sase = sa.getStandardError();
        this.ha += saha;
        this.n += sa.getN();
        this.sumWtdMeans += saha * sa.getMean();
        this.ssqWtdStandardErrors += saha * saha * sase * sase;
    }
    public void combine(TotalAccumulator other) {
        this.ha += other.ha;
        this.n += other.n;
        this.sumWtdMeans += other.sumWtdMeans;
        this.ssqWtdStandardErrors += other.ssqWtdStandardErrors;
    }
    public double getHa() {
        return this.ha;
    }
    public int getN() {
        return this.n;
    }
    public double getMean() {
        return this.ha > 0 ? this.sumWtdMeans / this.ha : 0d;
    }
    public double getStandardError() {
        return this.ha > 0 ? Math.sqrt(this.ssqWtdStandardErrors) / this.ha : 0;
    }
}

我不会详细介绍这一点,因为它在结构上与 StratumAccumulator 非常相似。主要关注点在于

  1. 构造函数不接受任何参数,这简化了其使用。
  2. accept() 方法累积的是 StratumAccumulator 的实例,而不是 double 值,因此使用了 Consumer<T> 接口。
  3. 至于计算,它们是组合 StratumAccumulator 实例的加权平均值,因此它们使用了层区域,并且对于任何不习惯分层抽样的人来说,这些公式可能看起来有点奇怪。

至于实际执行工作,这非常容易

final TotalAccumulator totalValues =
    stratumValues.entrySet().stream()
        .map(Map.Entry::getValue)
        .collect(TotalAccumulator::new, TotalAccumulator::accept, TotalAccumulator::combine);

和之前一样的内容

  1. 使用 entrySet().stream()stratumValue Map 条目转换为流。
  2. 使用 map()Map 条目替换为它们的值——StratumAccumulator 的实例。
  3. 使用 collect()TotalAccumulator 应用于 StratumAccumulator 的实例。

汇总总数

TotalAccumulator 实例中获取感兴趣的部分也非常简单

int nT = totalValues.getN();
double se66T = totalValues.getStandardError();
double tT = new TDistribution(nT - stratumValues.size()).inverseCumulativeProbability(0.975d);
System.out.printf("total n %d mean %g se66 %g t %g se95 %g ha %g\n",
    nT, totalValues.getMean(), se66T, tT, se66T * tT, totalValues.getHa());

StratumAccumulator 类似,我只需调用相关的 getter 来挑选出样本数量 nT 和标准误差 se66T。我计算 T 值 tT(这里使用 "n – 3",因为有三个层),然后我打印结果,看起来像这样

total n 73 mean 0.139487 se66 0.00664653 t 1.99444 se95 0.0132561 ha 352.380

结论

哇,这看起来像是一场马拉松。感觉也确实如此。通常情况下,有很多关于如何使用 Java Streams 的信息,所有这些信息都用玩具示例来说明,这在某种程度上有所帮助,但并非真正如此。我发现让它在一个真实世界(尽管非常简单)的示例中工作是很困难的。

因为我最近一直在使用 Groovy,所以我一直发现自己想要累积到“map 的 map 的 map”中,而不是创建累加器类,但我从来没有成功地实现这一点,除了在对样本中的测量值进行求和的情况下。因此,我使用了累加器类而不是 map 的 map,以及 map 的累加器类而不是 map 的 map 的 map。

在这一点上,我不觉得自己是 Java Streams 的任何方面的专家,但我确实觉得我对 collect() 有了相当扎实的理解,这非常重要,以及各种将数据结构重新格式化为流以及重新格式化流元素本身的方法。所以是的,还有更多要学习!

说到 collect(),在上面我展示的示例中,我们可以看到从非常简单地使用这个基本方法 - 使用 Collectors.summingDouble() 累积方法 - 到定义一个扩展预定义接口之一的累加器类 - 在这种情况下是 DoubleConsumer - 再到定义我们自己的完整累加器,用于累积中间层类。我曾被诱惑 - 有点 - 向后工作并为层和样本累加器实现完全自定义的累加器,但这个练习的目的是更多地了解 Java Streams,而不是成为其单个部分的专家。

您使用 Java Streams 的经验如何?做过任何大的和复杂的事情吗?请在评论中分享。

接下来阅读什么
标签
Chris Hermansen portrait Temuco Chile
自从 1978 年毕业于不列颠哥伦比亚大学以来,我几乎没有离开过计算机。自 2005 年以来,我一直是全职 Linux 用户,从 1986 年到 2005 年是全职 Solaris 和 SunOS 用户,在那之前是 UNIX System V 用户。

评论已关闭。

© . All rights reserved.