- JAVA
Java Stream reduceの使い方 合計・最大とcollectとの違い
reduce とは、ストリームの要素を先頭から順に「これまでの結果」と「次の要素」で1つにまとめていき、最後に1個の値を得る終端操作です。 合計、最大、最小、積、文字列の連結など、「たくさんの値を1つにする」処理の土台になります。引数の数によって3つの形があり、戻り値の型と空のストリームでの結果がそれぞれ違います。
この記事では、3つの形を早見表で整理し、途中の累積値を表示して動きを追います。そのうえで sum や max、joining などの専用メソッドとの選び方、BigDecimal の合計、オーバーフロー、並列実行で結果が変わる例、collect との違いを、すべて実行結果つきで説明します。
確認環境:Ubuntu 24.04/OpenJDK 21.0.10(CPU 2個。java ファイル名.java の単一ファイル実行)。並列実行の出力順や所要時間は、実行のたびに変わります。
reduceの3つの形 早見表
| 形 | 書き方の例 | 戻り値の型 | 空ストリームのとき | 使いどころ |
|---|---|---|---|---|
| 1引数 | reduce(Integer::max) |
Optional<T> |
Optional.empty |
最大・最小など、初期値を決めにくい集約 |
| 2引数 | reduce(0, Integer::sum) |
T(要素と同じ型) |
初期値がそのまま返る | 合計・積・連結など、初期値(単位元)がある集約 |
| 3引数 | reduce(0, (acc, s) -> acc + s.length(), Integer::sum) |
U(要素と別の型でもよい) |
初期値がそのまま返る | 要素の型と結果の型が違う集約 |
引数の呼び名は、順に identity(初期値)、accumulator(累積の関数)、combiner(部分結果どうしをまとめる関数) です。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java Stream reduceの使い方 合計・最大とcollectとの違い」
| やりたいこと | まず検討する書き方 | reduce で書くなら |
|---|---|---|
| int の合計 | mapToInt(...).sum() |
reduce(0, Integer::sum) |
| 最大・最小 | max(Comparator)、min(Comparator) |
reduce(Integer::max) |
| 平均 | mapToInt(...).average() |
合計と件数を別々に求める |
| 件数・合計・最小・平均・最大を一度に | summaryStatistics() |
(専用メソッドが向く) |
| 文字列の連結 | Collectors.joining(", ") |
reduce("", (a, b) -> a + b) |
| BigDecimal の合計 | (専用メソッドなし) | reduce(BigDecimal.ZERO, BigDecimal::add) |
| リストや Map に集める | toList()、collect(...) |
(collect が向く) |
reduceの動きを累積値の表示で追う
ラムダ式の中で println して、accumulator がどう呼ばれるかを見てみます。
import java.util.List;
import java.util.Optional;
public class ThreeForms {
public static void main(String[] args) {
List<Integer> nums = List.of(1, 2, 3, 4);
// 1引数: 初期値なし。最初の要素から始まり、戻り値は Optional
System.out.println("--- 1引数");
Optional<Integer> r1 = nums.stream().reduce((acc, x) -> {
System.out.println(" acc=" + acc + " x=" + x + " -> " + (acc + x));
return acc + x;
});
System.out.println("結果: " + r1);
// 2引数: 初期値あり。戻り値は要素と同じ型
System.out.println("--- 2引数");
Integer r2 = nums.stream().reduce(0, (acc, x) -> {
System.out.println(" acc=" + acc + " x=" + x + " -> " + (acc + x));
return acc + x;
});
System.out.println("結果: " + r2);
// 3引数: 要素と違う型にまとめる (String の長さを int に足し込む)
System.out.println("--- 3引数");
List<String> words = List.of("java", "stream", "reduce");
Integer r3 = words.stream().reduce(0,
(acc, w) -> {
System.out.println(" accumulator: acc=" + acc + " w=" + w + " -> " + (acc + w.length()));
return acc + w.length();
},
(a, b) -> {
System.out.println(" combiner: " + a + " + " + b);
return a + b;
});
System.out.println("結果: " + r3);
}
}
実行結果:
--- 1引数
acc=1 x=2 -> 3
acc=3 x=3 -> 6
acc=6 x=4 -> 10
結果: Optional[10]
--- 2引数
acc=0 x=1 -> 1
acc=1 x=2 -> 3
acc=3 x=3 -> 6
acc=6 x=4 -> 10
結果: 10
--- 3引数
accumulator: acc=0 w=java -> 4
accumulator: acc=4 w=stream -> 10
accumulator: acc=10 w=reduce -> 16
結果: 16
- 1引数:最初の要素 1 が最初の acc になり、2番目の要素から計算が始まります。4要素で呼び出しは3回です。戻り値は
Optional[10]です。 - 2引数:acc は初期値 0 から始まり、4要素で4回呼ばれます。戻り値は Optional ではなく値そのもの(10)です。
- 3引数:acc は Integer、要素は String と型が違っています。文字数を足し込んで 16 になりました。逐次のストリームでは combiner は呼ばれていません。
2引数の reduce は、「変数を初期値で用意し、for 文で1要素ずつ足し込む」処理と同じ動きです。
空のストリームと要素1個のとき
import java.util.List;
public class EmptyStream {
public static void main(String[] args) {
List<Integer> empty = List.of();
System.out.println("1引数: " + empty.stream().reduce(Integer::sum));
System.out.println("2引数: " + empty.stream().reduce(0, Integer::sum));
System.out.println("3引数: " + empty.stream().reduce(0, (acc, x) -> acc + x, Integer::sum));
System.out.println("orElse: " + empty.stream().reduce(Integer::sum).orElse(0));
System.out.println("要素1個: " + List.of(7).stream().reduce((a, b) -> {
System.out.println("呼ばれない");
return a + b;
}));
try {
empty.stream().reduce(Integer::sum).get();
} catch (Exception e) {
System.out.println(e);
}
}
}
実行結果:
1引数: Optional.empty
2引数: 0
3引数: 0
orElse: 0
要素1個: Optional[7]
java.util.NoSuchElementException: No value present
1引数の reduce が Optional を返すのは、空のストリームでは返す値が無いためです。 orElse(0) で既定値を決めるか、2引数の形で初期値を渡します。空の Optional に get を呼ぶと、最後の行のとおり NoSuchElementException になります。要素が1個だけのときは accumulator が一度も呼ばれず、その要素がそのまま結果になりました。
3引数の combiner が呼ばれるのは並列のとき
import java.util.List;
public class Combiner {
public static void main(String[] args) {
List<String> words = List.of("java", "stream", "reduce", "collect");
Integer total = words.parallelStream().reduce(0,
(acc, w) -> {
System.out.println(" accumulator: acc=" + acc + " w=" + w + " [" + Thread.currentThread().getName() + "]");
return acc + w.length();
},
(a, b) -> {
System.out.println(" combiner: " + a + " + " + b);
return a + b;
});
System.out.println("結果: " + total);
// 同じことを map + reduce で書く
System.out.println("map+reduce: " + words.stream().map(String::length).reduce(0, Integer::sum));
System.out.println("mapToInt.sum: " + words.stream().mapToInt(String::length).sum());
}
}
実行結果(行の順番は実行ごとに変わります):
accumulator: acc=0 w=stream [ForkJoinPool.commonPool-worker-1]
accumulator: acc=0 w=java [ForkJoinPool.commonPool-worker-1]
accumulator: acc=0 w=reduce [main]
accumulator: acc=0 w=collect [main]
combiner: 6 + 7
combiner: 4 + 6
combiner: 10 + 13
結果: 23
map+reduce: 23
mapToInt.sum: 23
並列ストリームでは、要素がいくつかのかたまりに分けられ、それぞれが初期値 0 から accumulator で計算されたあと、combiner で部分結果どうしが足し合わされます。 上の出力では 4+6、6+7 がまとめられ、最後に 10+13 で 23 になっています。
3引数の形は、多くの場合 map と2引数の reduce に分けて書けます。 map(String::length).reduce(0, Integer::sum) や mapToInt(String::length).sum() も同じ 23 でした。こちらのほうが読みやすいので、まず分けて書けないかを考えるとよいでしょう。
合計・最大・最小・平均・連結:reduce版と専用メソッド版
同じ集計を、reduce と専用メソッドの両方で書いて並べます。
import java.util.Comparator;
import java.util.IntSummaryStatistics;
import java.util.List;
import java.util.stream.Collectors;
public class Compare {
public static void main(String[] args) {
List<Integer> scores = List.of(72, 88, 95, 60, 81);
List<String> names = List.of("sato", "suzuki", "tanaka");
// 合計
System.out.println("合計 reduce : " + scores.stream().reduce(0, Integer::sum));
System.out.println("合計 sum : " + scores.stream().mapToInt(Integer::intValue).sum());
// 最大
System.out.println("最大 reduce : " + scores.stream().reduce(Integer::max));
System.out.println("最大 max : " + scores.stream().max(Comparator.naturalOrder()));
System.out.println("最大 IntStream.max : " + scores.stream().mapToInt(Integer::intValue).max());
// 最小
System.out.println("最小 reduce : " + scores.stream().reduce(Integer::min));
System.out.println("最小 min : " + scores.stream().min(Comparator.naturalOrder()));
// 平均 (reduce では合計と件数を別々に求める)
int sum = scores.stream().reduce(0, Integer::sum);
long count = scores.stream().count();
System.out.println("平均 reduce : " + (double) sum / count);
System.out.println("平均 average: " + scores.stream().mapToInt(Integer::intValue).average());
// まとめて
IntSummaryStatistics st = scores.stream().mapToInt(Integer::intValue).summaryStatistics();
System.out.println("summaryStatistics : " + st);
// 件数
System.out.println("件数 reduce : " + scores.stream().map(x -> 1).reduce(0, Integer::sum));
System.out.println("件数 count : " + scores.stream().count());
// 文字列連結
System.out.println("連結 reduce : " + names.stream().reduce("", (a, b) -> a + b));
System.out.println("連結 reduce(区切り) : " + names.stream().reduce((a, b) -> a + ", " + b).orElse(""));
System.out.println("連結 joining: " + names.stream().collect(Collectors.joining(", ")));
System.out.println("連結 joining(前後): " + names.stream().collect(Collectors.joining(", ", "[", "]")));
System.out.println("連結 String.join : " + String.join(", ", names));
// 初期値つきで区切り文字を入れると先頭に余分が付く
System.out.println("連結 初期値+区切り : " + names.stream().reduce("", (a, b) -> a + ", " + b));
}
}
実行結果:
合計 reduce : 396
合計 sum : 396
最大 reduce : Optional[95]
最大 max : Optional[95]
最大 IntStream.max : OptionalInt[95]
最小 reduce : Optional[60]
最小 min : Optional[60]
平均 reduce : 79.2
平均 average: OptionalDouble[79.2]
summaryStatistics : IntSummaryStatistics{count=5, sum=396, min=60, average=79.200000, max=95}
件数 reduce : 5
件数 count : 5
連結 reduce : satosuzukitanaka
連結 reduce(区切り) : sato, suzuki, tanaka
連結 joining: sato, suzuki, tanaka
連結 joining(前後): [sato, suzuki, tanaka]
連結 String.join : sato, suzuki, tanaka
連結 初期値+区切り : , sato, suzuki, tanaka
| 集計 | reduce 版 | 専用メソッド版 | 選び方 |
|---|---|---|---|
| 合計 | reduce(0, Integer::sum) |
mapToInt(...).sum() |
int・long・double は sum が短く、ボクシングも避けられる |
| 最大 | reduce(Integer::max) |
max(Comparator.naturalOrder()) |
意図が伝わる max。オブジェクトは max(Comparator.comparing(...)) |
| 最小 | reduce(Integer::min) |
min(Comparator.naturalOrder()) |
同上 |
| 平均 | 合計 ÷ 件数を自分で計算 | mapToInt(...).average() |
average。空のときは OptionalDouble.empty |
| まとめて | (複数回たどる) | summaryStatistics() |
1回の走査で5つの値が得られる |
| 件数 | map(x -> 1).reduce(0, Integer::sum) |
count() |
count |
| 連結 | reduce("", (a, b) -> a + b) |
Collectors.joining(", ") |
区切り・前後の文字も指定できる joining |
| BigDecimal の合計 | reduce(BigDecimal.ZERO, BigDecimal::add) |
なし | reduce |
専用メソッドがある集計は専用メソッド、無い型や独自のまとめ方は reduce、が基本の選び方です。
連結の最後の行にも注目してください。初期値つきの reduce で区切り文字を入れると , sato, suzuki, tanaka と先頭に余分な区切りが付きました。初期値の空文字と最初の要素の間にも区切りが入るためです。区切りつきの連結は joining か String.join が確実です。
文字列の連結は joining が速い
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class JoinTime {
public static void main(String[] args) {
List<String> parts = IntStream.range(0, 20_000).mapToObj(i -> "item" + i).toList();
for (int round = 1; round <= 3; round++) {
long t0 = System.nanoTime();
String a = parts.stream().reduce("", (x, y) -> x + y);
long t1 = System.nanoTime();
String b = parts.stream().collect(Collectors.joining());
long t2 = System.nanoTime();
System.out.printf("%d回目 reduce: %d ms / joining: %d ms / 同じ結果: %b / 長さ: %d%n",
round, (t1 - t0) / 1_000_000, (t2 - t1) / 1_000_000, a.equals(b), a.length());
}
}
}
実行結果(時間は実行ごとに変わります):
1回目 reduce: 1855 ms / joining: 25 ms / 同じ結果: true / 長さ: 168890
2回目 reduce: 917 ms / joining: 1 ms / 同じ結果: true / 長さ: 168890
3回目 reduce: 587 ms / joining: 0 ms / 同じ結果: true / 長さ: 168890
2万個の文字列の連結で、reduce は 587〜1855 ミリ秒、joining は 0〜25 ミリ秒でした。reduce は1回ごとに新しい文字列を作ってコピーするのに対し、joining は内部の入れ物に追記していくためです。Oracle の java.util.stream パッケージの説明にも、reduce での文字列連結は結果は得られるものの、大量の文字列コピーが発生し、実行時間が文字数に対して O(n^2) になる、と書かれています。
BigDecimal の合計と大きな数の積
BigDecimal には sum に当たる専用のストリームが無いので、reduce の出番です。
import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode;
import java.util.List;
import java.util.stream.IntStream;
public class BigDec {
record Line(String name, BigDecimal price, int qty) {}
public static void main(String[] args) {
List<BigDecimal> prices = List.of(new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"));
// BigDecimal には sum() が無いので reduce を使う
BigDecimal total = prices.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
System.out.println("BigDecimal 合計 : " + total);
// double で足すと誤差が出る
System.out.println("double 合計 : " + prices.stream().mapToDouble(BigDecimal::doubleValue).sum());
System.out.println("0.1+0.2+0.3 : " + (0.1 + 0.2 + 0.3));
// 単価 × 数量 の合計
List<Line> lines = List.of(
new Line("pen", new BigDecimal("120.50"), 3),
new Line("note", new BigDecimal("298.00"), 2),
new Line("ink", new BigDecimal("1050.25"), 1));
BigDecimal amount = lines.stream()
.map(l -> l.price().multiply(BigDecimal.valueOf(l.qty())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
System.out.println("金額合計 : " + amount);
System.out.println("平均単価 : " + lines.stream().map(Line::price)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.divide(BigDecimal.valueOf(lines.size()), 2, RoundingMode.HALF_UP));
System.out.println("最大単価 : " + lines.stream().map(Line::price).reduce(BigDecimal::max));
System.out.println("空リストの合計 : " + List.<BigDecimal>of().stream().reduce(BigDecimal.ZERO, BigDecimal::add));
// 階乗 (積) は BigInteger で
BigInteger fact = IntStream.rangeClosed(1, 25).mapToObj(BigInteger::valueOf)
.reduce(BigInteger.ONE, BigInteger::multiply);
System.out.println("25! : " + fact);
System.out.println("25! を long で : " + IntStream.rangeClosed(1, 25).asLongStream().reduce(1L, (a, b) -> a * b));
}
}
実行結果:
BigDecimal 合計 : 0.6
double 合計 : 0.6
0.1+0.2+0.3 : 0.6000000000000001
金額合計 : 2007.75
平均単価 : 489.58
最大単価 : Optional[1050.25]
空リストの合計 : 0
25! : 15511210043330985984000000
25! を long で : 7034535277573963776
reduce(BigDecimal.ZERO, BigDecimal::add)が定番の形です。 0.1+0.2+0.3 がきっかり 0.6 になりました。double のまま0.1 + 0.2 + 0.3と足すと 0.6000000000000001 です。- 平均は合計を件数で割ります。割り切れない場合に備えて、小数の桁数と丸め方(ここでは小数2桁・四捨五入)を指定します。
- 積も同じ考え方です。25 の階乗は BigInteger なら正しく求まり、long では桁があふれて別の値になりました。
Integer の合計がオーバーフローする例と直し方
import java.util.List;
public class Overflow {
public static void main(String[] args) {
List<Integer> big = List.of(2_000_000_000, 2_000_000_000);
System.out.println("reduce(0, Integer::sum) : " + big.stream().reduce(0, Integer::sum));
System.out.println("mapToInt.sum() : " + big.stream().mapToInt(Integer::intValue).sum());
System.out.println("Integer.MAX_VALUE : " + Integer.MAX_VALUE);
// 直し方1: long で足す
System.out.println("mapToLong.sum() : " + big.stream().mapToLong(Integer::longValue).sum());
System.out.println("reduce(0L, ...) : " + big.stream().reduce(0L, (acc, x) -> acc + x, Long::sum));
// 直し方2: あふれたら例外にして気づけるようにする
try {
big.stream().reduce(0, Math::addExact);
} catch (ArithmeticException e) {
System.out.println("Math::addExact : " + e);
}
}
}
実行結果:
reduce(0, Integer::sum) : -294967296
mapToInt.sum() : -294967296
Integer.MAX_VALUE : 2147483647
mapToLong.sum() : 4000000000
reduce(0L, ...) : 4000000000
Math::addExact : java.lang.ArithmeticException: integer overflow
20億+20億は int の最大値 2147483647 を超えるため、結果が -294967296 になりました。reduce でも sum でも、int のままなら同じ結果で、例外は出ません。
直し方は、mapToLong(...).sum() か3引数の reduce で long にする方法と、Math::addExact であふれたら ArithmeticException にする方法の2つです。
合計が大きくなりうるときは long で集計し、範囲を超えたら気づけるようにしたいときは Math::addExact を使います。
初期値を0以外にして並列実行すると結果が変わる
2引数・3引数の reduce の初期値には決まりがあります。それを外すとどうなるかを見てみます。
import java.util.List;
import java.util.stream.IntStream;
public class Identity {
public static void main(String[] args) {
List<Integer> nums = IntStream.rangeClosed(1, 8).boxed().toList(); // 合計 36
System.out.println("CPU数 = " + Runtime.getRuntime().availableProcessors());
System.out.println("初期値 0 逐次 : " + nums.stream().reduce(0, Integer::sum));
System.out.println("初期値 0 並列 : " + nums.parallelStream().reduce(0, Integer::sum));
System.out.println("初期値 10 逐次 : " + nums.stream().reduce(10, Integer::sum));
System.out.println("初期値 10 並列 : " + nums.parallelStream().reduce(10, Integer::sum));
// 初期値が何回使われたかを見る
int r = nums.parallelStream().reduce(10, (acc, x) -> {
if (acc == 10) {
System.out.println(" 初期値 10 から開始: x=" + x);
}
return acc + x;
}, Integer::sum);
System.out.println("結果 = " + r);
// 直し方: 初期値は単位元(足し算なら 0)にして、上乗せ分は後から足す
System.out.println("後から足す 並列 : " + (10 + nums.parallelStream().reduce(0, Integer::sum)));
// 引き算は結合法則を満たさないので、並列で結果が変わる
System.out.println("引き算 逐次 : " + nums.stream().reduce(0, (a, b) -> a - b));
System.out.println("引き算 並列 : " + nums.parallelStream().reduce(0, (a, b) -> a - b));
}
}
実行結果:
CPU数 = 2
初期値 0 逐次 : 36
初期値 0 並列 : 36
初期値 10 逐次 : 46
初期値 10 並列 : 76
初期値 10 から開始: x=5
初期値 10 から開始: x=7
初期値 10 から開始: x=3
初期値 10 から開始: x=1
結果 = 76
後から足す 並列 : 46
引き算 逐次 : -36
引き算 並列 : 0
1〜8 の合計は 36 です。初期値を 10 にすると、逐次では 46、並列では 76 になりました。並列ではかたまりごとに初期値から計算が始まるため、10 が4回足された(36+10×4=76) ことが、途中の表示からわかります。かたまりの数は CPU の数やデータ量で変わるので、環境によって別の値になります。
引き算も同様に、逐次では -36、並列では 0 と結果が分かれました。Oracle の java.util.stream パッケージの説明では、reduce に渡す値と関数に次の条件が示されています。
| 条件 | 内容 | 満たす例 | 満たさない例 |
|---|---|---|---|
| 初期値は単位元 | どの値とまとめても、その値のままになる | 足し算の 0、掛け算の 1、連結の "" | 足し算の 10 |
| 結合法則 | (a op b) op c と a op (b op c) が同じ | 足し算、掛け算、max、min | 引き算、割り算 |
| combiner と accumulator の整合 | 分けて計算してまとめても同じ結果になる | 文字数を足す+ Integer::sum | まとめ方が別の計算 |
上乗せしたい値があるときは、初期値には単位元を渡し、reduce の外で足します。 10 + nums.parallelStream().reduce(0, Integer::sum) は並列でも 46 でした。
ArrayListをreduceで組み立てると起きることと collect での直し方
「初期値に空の ArrayList を渡し、accumulator で add する」という書き方は、逐次では動いて見えます。並列にすると次のようになります。
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class ListReduce {
public static void main(String[] args) {
List<Integer> nums = IntStream.rangeClosed(1, 1000).boxed().toList();
// 逐次では動いて見える
List<Integer> seq = nums.stream().reduce(new ArrayList<>(),
(list, x) -> { list.add(x * 2); return list; },
(a, b) -> { a.addAll(b); return a; });
System.out.println("逐次 reduce : size=" + seq.size());
// 並列にすると、全スレッドが同じ 1 個の ArrayList を書き換える
for (int i = 1; i <= 3; i++) {
try {
List<Integer> par = nums.parallelStream().reduce(new ArrayList<>(),
(list, x) -> { list.add(x * 2); return list; },
(a, b) -> { a.addAll(b); return a; });
System.out.println("並列 reduce " + i + ": size=" + par.size());
} catch (Exception e) {
System.out.println("並列 reduce " + i + ": " + e);
}
}
// 直し方1: collect (スレッドごとに new ArrayList される)
List<Integer> c1 = nums.parallelStream()
.collect(ArrayList::new, (list, x) -> list.add(x * 2), ArrayList::addAll);
System.out.println("collect 3引数 : size=" + c1.size() + " 先頭=" + c1.subList(0, 3));
// 直し方2: map してから toList / Collectors.toList
List<Integer> c2 = nums.parallelStream().map(x -> x * 2).toList();
List<Integer> c3 = nums.parallelStream().map(x -> x * 2).collect(Collectors.toList());
System.out.println("map + toList : size=" + c2.size() + " 先頭=" + c2.subList(0, 3));
System.out.println("Collectors : size=" + c3.size() + " 先頭=" + c3.subList(0, 3));
// reduce のまま正しく書くなら、毎回新しいリストを作る (動くがコピーが増える)
List<Integer> immut = nums.parallelStream().reduce(List.of(),
(list, x) -> { List<Integer> n = new ArrayList<>(list); n.add(x * 2); return n; },
(a, b) -> { List<Integer> n = new ArrayList<>(a); n.addAll(b); return n; });
System.out.println("毎回コピー : size=" + immut.size() + " 先頭=" + immut.subList(0, 3));
}
}
実行結果:
逐次 reduce : size=1000
並列 reduce 1: size=6000
並列 reduce 2: size=6000
並列 reduce 3: size=6000
collect 3引数 : size=1000 先頭=[2, 4, 6]
map + toList : size=1000 先頭=[2, 4, 6]
Collectors : size=1000 先頭=[2, 4, 6]
毎回コピー : size=1000 先頭=[2, 4, 6]
1000 個のはずが 6000 個になりました。別の回では 5916 や 4736 になったこともあります。reduce の初期値は1個のオブジェクトなので、すべてのスレッドが同じ1個の ArrayList に add し、さらに combiner が同じリストどうしを addAll するため、要素が重複します。 ArrayList は複数スレッドからの同時の変更を想定していないため、件数も回によって変わります。
直し方は collect です。 collect(ArrayList::new, (list, x) -> list.add(x * 2), ArrayList::addAll) は、最初の引数が「入れ物を作る関数」なので、かたまりごとに新しい ArrayList が作られます。結果は 1000 個で、順番も保たれました。ふだんは map(...).toList() や Collectors.toList() を使えば十分です。
reduce のまま正しく書くなら、最後の例のように毎回新しいリストを作って返します。結果は正しくなりますが、要素のたびにリスト全体をコピーするので、件数が多いと時間がかかります。
逐次でも初期値のリストは使い回される
import java.util.ArrayList;
import java.util.List;
public class SeqTrap {
public static void main(String[] args) {
// 逐次でも、初期値のリストを使い回すと前回の結果が残る
List<String> identity = new ArrayList<>();
List<String> first = List.of("a", "b").stream().reduce(identity,
(list, x) -> { list.add(x); return list; }, (a, b) -> a);
System.out.println("1回目: " + first);
List<String> second = List.of("c").stream().reduce(identity,
(list, x) -> { list.add(x); return list; }, (a, b) -> a);
System.out.println("2回目: " + second);
System.out.println("first == second : " + (first == second));
}
}
実行結果:
1回目: [a, b]
2回目: [a, b, c]
first == second : true
同じ初期値のリストで2回 reduce すると、2回目の結果に1回目の a、b が残りました。first と second は同じ1個のリストです。reduce は「値を書き換えずに、新しい値を返す」まとめ方のための操作、と考えると整理しやすくなります。
reduce と collect の違い
| 観点 | reduce | collect |
|---|---|---|
| まとめ方 | 2つの値から新しい値を作る | 入れ物に要素を追加していく |
| 初期値 | 値を1個渡す(全体で共有) | 入れ物を作る関数を渡す(必要な数だけ作られる) |
| 向いている結果 | Integer、BigDecimal、String など変更できない値 | List、Set、Map、StringBuilder など変更できる入れ物 |
| 代表的な書き方 | reduce(BigDecimal.ZERO, BigDecimal::add) |
collect(Collectors.toList())、Collectors.joining() |
「1個の値に畳み込むなら reduce、入れ物に集めるなら collect」 と覚えておけば、ほとんどの場面で迷いません。
数値以外の reduce:オブジェクトの最大・グループごとの集計・関数の合成
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.function.BinaryOperator;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;
public class Others {
record Product(String name, String category, int price) {}
public static void main(String[] args) {
List<Product> products = List.of(
new Product("pen", "文具", 120),
new Product("note", "文具", 300),
new Product("mouse", "機器", 2500),
new Product("cable", "機器", 800));
// 一番高い商品 (オブジェクトの最大)
Optional<Product> top = products.stream()
.reduce(BinaryOperator.maxBy(Comparator.comparingInt(Product::price)));
System.out.println("最高値 reduce : " + top.map(Product::name));
System.out.println("最高値 max : " + products.stream().max(Comparator.comparingInt(Product::price)).map(Product::name));
// 一番長い名前 (同じ長さなら先に出たほう)
System.out.println("最長の名前 : " + products.stream().map(Product::name)
.reduce((a, b) -> a.length() >= b.length() ? a : b));
// 価格の合計 (3引数 / mapToInt)
System.out.println("合計 3引数 : " + products.stream().reduce(0, (acc, p) -> acc + p.price(), Integer::sum));
System.out.println("合計 mapToInt : " + products.stream().mapToInt(Product::price).sum());
// カテゴリごとの合計 (groupingBy の中で使う Collectors.reducing)
Map<String, Integer> byCat = products.stream().collect(Collectors.groupingBy(
Product::category, TreeMap::new, Collectors.reducing(0, Product::price, Integer::sum)));
System.out.println("reducing : " + byCat);
Map<String, Integer> byCat2 = products.stream().collect(Collectors.groupingBy(
Product::category, TreeMap::new, Collectors.summingInt(Product::price)));
System.out.println("summingInt : " + byCat2);
// 積
System.out.println("積 : " + IntStream.rangeClosed(1, 5).reduce(1, (a, b) -> a * b));
// 関数を順番につなぐ
Function<String, String> pipeline = Stream.<Function<String, String>>of(
String::trim, String::toUpperCase, s -> s.replace(" ", "_"))
.reduce(Function.identity(), Function::andThen);
System.out.println("関数の合成 : " + pipeline.apply(" java stream reduce "));
// すべて true か (allMatch のほうが途中で打ち切れる)
System.out.println("論理積 reduce : " + Stream.of(true, true, false).reduce(true, Boolean::logicalAnd));
System.out.println("allMatch : " + Stream.of(true, true, false).allMatch(b -> b));
// プリミティブ用ストリームの reduce
System.out.println("IntStream 1引数: " + IntStream.of(3, 9, 4).reduce(Integer::max));
System.out.println("IntStream 2引数: " + IntStream.of(3, 9, 4).reduce(0, Integer::sum));
}
}
実行結果:
最高値 reduce : Optional[mouse]
最高値 max : Optional[mouse]
最長の名前 : Optional[mouse]
合計 3引数 : 3720
合計 mapToInt : 3720
reducing : {文具=420, 機器=3300}
summingInt : {文具=420, 機器=3300}
積 : 120
関数の合成 : JAVA_STREAM_REDUCE
論理積 reduce : false
allMatch : false
IntStream 1引数: OptionalInt[9]
IntStream 2引数: 16
- オブジェクトの最大:
BinaryOperator.maxBy(Comparator)を渡すと reduce で最大の要素を選べます。max(Comparator)でも同じ結果です。 - 独自の選び方:「名前が一番長いもの、同じなら先に出たほう」のような規則は、2つを比べて残すほうを返すラムダ式で書けます。
- グループごとの集計:groupingBy の中では
Collectors.reducing(初期値, 取り出す関数, まとめる関数)が使えます。合計なら summingInt が同じ結果を短く書けます。 - 関数の合成:関数のストリームを
Function::andThenで畳み込むと、trim、大文字化、置換を順に行う1つの関数になります。 - 論理積:
reduce(true, Boolean::logicalAnd)でも求まりますが、allMatch は結果が決まった時点で打ち切れます。 - IntStream の reduce:1引数は OptionalInt、2引数は int を返します。
よくある質問
Q. reduce と sum はどちらを使えばよいですか?
A. int、long、double の合計なら mapToInt(...).sum() などの専用メソッドが短く、意図も伝わります。BigDecimal のように専用メソッドが無い型や、独自のまとめ方をしたいときに reduce を使います。
Q. 1引数の reduce が Optional を返すのはなぜですか?
A. 空のストリームでは返す値が無いためです。実行結果でも Optional.empty になりました。orElse で既定値を決めるか、2引数の形を使います。
Q. reduce でリストを作ってもよいですか? A. 初期値の ArrayList に add する書き方は、並列で件数が変わり、逐次でも初期値が使い回されました。リストや Map に集めるときは toList や collect を使うのがおすすめです。
まとめ
- reduce は要素を1個の値に畳み込む終端操作で、1引数・2引数・3引数の3つの形がある。
- 1引数は Optional を返し、空なら Optional.empty。2引数・3引数は空なら初期値が返る。
- 合計・最大・平均・連結は sum、max、average、joining などの専用メソッドが第一候補。 BigDecimal の合計は
reduce(BigDecimal.ZERO, BigDecimal::add)です。 - int の合計はオーバーフローしても例外にならない。 long で集計するか Math::addExact を使います。
- 初期値は単位元(足し算の 0、掛け算の 1)、関数は結合法則を満たすものにする。 外すと並列実行で結果が変わります。
- リストや Map に集めるなら collect。 reduce の初期値は全体で共有される1個の値です。
- 3引数の形は、map と2引数の reduce に分けると読みやすくなることが多い。
参考・出典
- Oracle「Java SE 21 API 仕様 java.util.stream パッケージ」(Reduction operations、Mutable reduction、Associativity の節。https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/package-summary.html)
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java Stream reduceの使い方 合計・最大とcollectとの違い」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。