在最近的一篇文章中,我提到了我 2020 年的新年决心:在 Java 中不再使用循环。在那篇文章中,我选择了一个常见的(且简化的)森林管理计算——根据法律定义,通过计算树冠遮蔽地面的比例来确定一个区域是否是森林。
从数据收集的角度来看,这需要对该区域进行采样,然后根据样本估计树冠覆盖的比例。 传统上,首先通过查看航空照片或卫星图像来进行采样,并将该区域划分为具有大致均匀植被特征的单元。 这些单元称为层(stratum 的复数)。 然后,在每个层中生成一组随机定位的点。 在每个点上定位一个样本,通常是特定尺寸的圆形或矩形,并测量每个样本中的所有树木。 然后,回到办公室,将样本值合计,计算层平均值,并将这些平均值加权成该区域的总平均值。
在传统的命令式 Java 编程风格中,这将需要几个循环:一个用于读取层定义,另一个用于读取现场样本数据,另一个用于对样本中树冠覆盖的面积求和,另一个用于计算这些样本的层平均值,最后一个用于计算该区域的层加权平均值。
在我之前的文章中,我解释了如何使用 Java Streams 将每个循环替换为 map 和 reduce 函数调用序列。 Java 接口 java.util.stream 定义了两种不同类型的 reduce 函数(在我的样本计算中,它们采用累加器的形式)
- reduce(),在消费流中的每个项目时产生一个不可变的局部累积
- collect(),在消费流中的每个项目时产生一个可变的局部累积
使用 collect() 的优点是开销更少:在累积的每个步骤中,不会生成新的不可变局部结果,然后丢弃; 而是将新的数据元素累积到现有的局部结果中。
当我在处理我的样本计算时,我发现自己以一种在我看来很常见且不令人满意的方式学习 collect():我能找到的所有示例和教程都基于一次累积一个数据项的玩具问题; 此外,所有示例和教程都构建为小食谱,它们使用现有的预定义功能,这些功能似乎仅在这种有限的“一次累积一个数据项”的情况下才有用。 当我继续进行编程时,我不断地陷入更深更深的水中,直到我不确定我是否充分理解了整个 Java Streams 框架,以至于真正能够使用它。
所以我决定重新审视我的代码,试图详细了解“底层”发生的事情,并以更一致和连贯的方式暴露更多涉及的机制。 请继续阅读我所做修改的摘要。
收集复杂事物的映射
之前,我使用 collect() 调用将包含第一列中的层号和第二列中的层区域的输入行转换为 Map<Integer,Double>
final Map<Integer,Double> stratumDefinitionTable = inputLineStream
.skip(1) // skip the column headings
.map(l -> l.split("\\|")) // split the line into String[] fields
.collect(
Collectors.toMap(
a -> Integer.parseInt(a[0]), // (1)
a -> Double.parseDouble(a[1]) // (2)
)
);
上面的代码注释 (1) 标记了键的定义(整数层号),注释 (2) 标记了值的定义(双精度层区域)。
更详细地说,(静态)便捷方法 java.util.stream.Collectors.toMap() 创建一个 Collector,该收集器初始化映射并在处理输入数据时使用映射条目填充它。 严格来说,这不算累积……但无论如何。
但是,如果要收集的信息不仅仅是层区域怎么办? 例如,如果我想在输出中包含一个文本标签以及区域,该怎么办?
为了解决这个问题,我可以首先定义一个像这样的类,它将保存有关该层的所有信息
class StratumDefinition {
private int number;
private double ha;
private String label;
public StratumDefinition(int number, double ha, String label) {
this.number = number;
this.ha = ha;
this.label = label;
}
public int getNumber() { return this.number; }
public double getHa() { return this.ha; }
public String getLabel() { return this.label; }
}
然后,一旦声明了 StratumDefinition,我可以使用类似于以下代码的代码来执行“累积”(以绿色文本突出显示更改)
final Map<Integer,StratumDefinition> stratumDefinitionTable = inputLineStream
.skip(1) // skip the column headings
.map(l -> l.split("\\|")) // split the line into String[] fields
.collect(
Collectors.toMap(
a-> Integer.parseInt(a[0]),
a-> new StratumDefinition(Integer.parseInt(a[0]),Double.parseDouble(a[1]), a[2])
)
);
现在,代码的通用性大大提高,因为我可以更改层定义文件中的列以及 StratumDefinition 类中的字段和方法以进行匹配,而无需更改 Streams 处理逻辑。
请注意,我可能不需要同时将层号保留为键和存储在每个映射条目中的值; 但是,这样,如果我以后决定将映射条目值作为流进行处理,我可以免费获得层号,而无需任何花招来获取键。
按组和子组收集多个数据项的小计
之前,我使用 collect() 调用将每个单独的树冠区域累积到每个层中的每个样本的总覆盖率中,即映射的映射 Map<Integer, Map<Integer,Double>>
final Map<Integer,Map<Integer,Double>> sampleValues = inputLineStream
.skip(1)
.map(l -> l.split("\\|"))
.collect(
Collectors.groupingBy(a -> Integer.parseInt(a[0]), // (1)
Collectors.groupingBy(b -> Integer.parseInt(b[1]), // (2)
Collectors.summingDouble( // (3)
c -> {
double rm = (Double.parseDouble(c[5]) + Double.parseDouble(c[6]))/4d;
return rm*rm * Math.PI / 500d; // (4)
})
)
)
);
上面的代码注释 (1) 标记了顶级键的定义位置——层号。 注释 (2) 标记了二级键的定义——样本号,注释 (3) 累积在 (4) 中计算的双精度值流。
更详细地说,(静态)便捷方法 java.util.stream.Collectors.groupingBy() 创建一个 Collector,该收集器根据第一个参数返回的值对流进行子集化,并应用作为第二个参数给出的 Collector。 在上面的示例中,有两层分组,首先按层分组,然后按样本(在层内)分组。 内部 groupingBy() 使用 java.util.stream.Collectors.summingDouble() 创建一个 Collector,该收集器初始化总和并累积每棵树对样本内总覆盖率的比例贡献。
请注意,如果在上面您只想对一个数字求和,则 summingDouble() 是一个方便的快捷方式。 但是,请记住我已经记录了每棵测量的树木的物种、树干直径、树冠直径和高度,如果我想累积与所有这些测量值相关的数字怎么办?
为了解决这个问题,我需要定义一对类,一个用于包装测量信息,它可能看起来像这样
class Measurement {
private int stratum, sample, tree;
private String species;
private double ha, basalDiameter, crownArea, height;
public Measurement(int stratum, int sample, double ha, int tree, String species,
double basalDiameter, double crownDiameter1, double crownDiameter2, double height) {
...
}
public int getStratum() { return this.stratum; }
public int getSample() { return this.sample; }
public double getHa() { return this.ha; }
public int getTree() { return this.tree; }
public String getSpecies() { return this.species; }
public double getBasalDiameter() { return this.basalDiameter; }
public double getCrownArea() { return this.crownArea; }
public double getHeight() { return this.height; }
}
一个用于将信息累积到样本总计中,它可能看起来像这样
class SampleAccumulator implements Consumer<Measurement> {
private double ...;
public SampleAccumulator() {
...
}
public void accept(Measurement m) {
...
}
public void combine(SampleAccumulator other) {
...
}
...
}
请注意,SampleAccumulator 实现了接口 java.util.function.Consumer<T>。 这不是绝对必要的; 只要我最终提供类似于构建我的 Collector 所需的功能,我就可以“徒手”设计这个类,我将在下面展示。
然后,我可以使用类似于原始代码的代码来执行到 SampleAccumulator 实例中的累积(以绿色文本突出显示更改)
final Map<Integer,Map<Integer,SampleAccumulator>> sampleAccumulatorTable = inputLineStream
.skip(1)
.map(l -> l.split("\\|"))
.map(a -> new Measurement(Integer.parseInt(a[0]), Integer.parseInt(a[1]),
Double.parseDouble(a[2]), Integer.parseInt(a[3]), a[4], Double.parseDouble(a[5]),
Double.parseDouble(a[6]), Double.parseDouble(a[7]), Double.parseDouble(a[8])))
.collect(
Collectors.groupingBy(Measurement::getStratum,
Collectors.groupingBy(Measurement::getSample,
Collector.of(
SampleAccumulator::new,
(smpAcc, msrmt) -> smpAcc.accept(msrmt),
(smpAcc1, smpAcc2) -> {
smpAcc1.combine(smpAcc2);
return smpAcc1;
},
Collector.Characteristics.UNORDERED
)
)
)
);
请注意使用上面两个新类创建的两个重大更改
- 它插入对 java.util.stream.map() 的第二次调用,使用 lambda 创建 Measurement 的新实例,其中包含从数据字段的 String 数组中解析出的值。
- 它将 java.util.stream.Collectors.summingDouble() 的使用替换为创建“doubles 的收集器”,该收集器一次仅累积一个数字,使用 java.util.stream.Collector.of() 创建“SampleAccumulators 的收集器”,该收集器一次累积任意数量的数字。
同样,生成的代码具有更通用的用途:我可以更改样本数据文件以及 Measurement 和 SampleAccumulator 类中的字段来管理不同的输入数据项,而无需修改流处理代码。
也许我很慢,但是我花了一段时间才弄清楚 of() 方法的参数类型与实际 lambda 参数之间的对应关系。 例如,of() 的第三个参数定义了“组合器”函数,其类型为 BinaryOperator<A>。 尽管该类型的名称具有暗示性,但实际上查找其定义以了解它接受两个类型为 A 的参数并返回一个类型为 A 的值(即参数的组合)非常重要。 顺便说一句,我应该强调,这与 java.util.function.Consumer<T> 的“combine”方法不同,后者接受一个类型为 T 的参数并将其与实例组合。
一旦我弄清楚了这一点,我就意识到我已经定义了一个 Collector.of() 的版本,该版本将 Consumer 作为参数…… 遗憾的是,这没有内置到 java.util.stream.Collector 接口中; (现在)在我看来,这似乎是一个明显的遗漏。
其余代码
先前示例中的其余代码使用 collect() 的版本,该版本采用三个参数:供应商、累加器和组合器。 StratumAccumulator 和 TotalAccumulator 类都实现了接口 java.util.function.Consumer<T>,因此,它们提供了这三个函数。
对于 StratumAccumulator,我看到
.collect(
() -> new StratumAccumulator(stratumDefinitionTable.get(e.getKey()).getHa()),
StratumAccumulator::accept,
StratumAccumulator::combine)
对于 TotalAccumulator,我看到
.collect(
TotalAccumulator::new,
TotalAccumulator::accept,
TotalAccumulator::combine)
对于这两个,唯一需要做的工作是进一步详细说明 StratumAccumulator 和 TotalAccumulator 类,以合并其他字段和累积步骤。
但是,为了对称起见,也可以重写这些代码,以使用 Collector.of() 作为 collect() 调用的参数(对于那些喜欢在可能的情况下应用通用方法的人)。
然后,对于 StratumAccumulator,我看到
.collect(
Collector.of(
() -> new StratumAccumulator(stratumDefinitionTable.get(e.getKey()).getHa()),
(strAcc, smpAcc) -> strAcc.accept(smpAcc),
(strAcc1, strAcc2) -> {
strAcc1.combine(strAcc2);
return strAcc1;
},
Collector.Characteristics.UNORDERED
)
)
对于 TotalAccumulator
.collect(
Collector.of(
TotalAccumulator::new,
(totAcc, strAcc) -> totAcc.accept(strAcc),
(totAcc1, totAcc2) -> {
totAcc1.combine(totAcc2);
return totAcc1;
},
Collector.Characteristics.UNORDERED
)
)
这样更好吗? 好吧,也许,因为它对每个 collect() 调用都使用相同的模式,但是它也更冗长。 您自己判断。 也许我应该咬紧牙关并实现 java.util.stream.Collector 而不是 java.util.function.Consumer。
结论
当我将我的单用途应用程序转换为可以处理所有可用数据的更通用的程序时,我发现自己对 collect() 和 Collectors 了解得更多了。特别是,当我处理输入流时,需要累积多个值,这意味着我不得不抛弃 java.util.stream.Collectors 中定义的那些方便且诱人的专用 Collectors,并学习如何构建自己的 Collectors。最后,我想这并不难,但从(例如)使用 java.util.stream.Collectors.summingDouble() 来累积 double 值流,到使用 Collector.of() 来滚动我自己的 Collector 以累积元组流,这确实是一个飞跃,至少对我来说是这样。
我认为至少有两件事可以使 Java Streams 用户的生活更轻松:
- java.util.stream.Collectors.groupingBy() 的一个版本,它接受一个“分类器”和三个参数,这些参数对应于 java.util.function.Consumer<T> 定义的“supplier”、“consumer”和“combiner”(与 collect() 相同)
- java.util.stream.Collector.of() 的一个版本,它接受三个参数,这些参数对应于 java.util.function.Consumer<T> 定义的“supplier”、“consumer”和“combiner”(与 collect() 相同),虽然也许使用与 of() 不同的名称会更好。
也许有一天,当我对这一切有更深入的了解时,我会清楚地知道为什么我们真正需要一个 Consumer 和一个 Collector 来实现如此相似的目的。
也许我的下一个学习努力将是用成熟的 Collector<T,A,R> 替换我对 Consumer<T> 的使用。
无论如何,我希望通过详细描述我的学习路径,我可以帮助其他人走向同一个目标。
您在使用 Java Streams 方面有什么经验?您是否发现自己难以从玩具示例过渡到更复杂的实际应用程序?
评论已关闭。