reduce で集約する
reduce で集約する とは
Stream API の
reduceを使って配列の要素を 1 つの値に畳み込む方法を学び、整数配列の積を求めるコードを書いてみよう。
Stream の終端操作 reduce とは
ここまでの Stream API のレッスンでは、filter で要素を絞り込んだり map で形を変えたり、count で個数を数えたりしてきました。今回扱う reduce は、Stream の「最後の仕上げ」を担う終端操作の中でも一番表現力が高いものです。reduce は「複数の要素を 1 つの値にまとめる」という畳み込みの考え方をそのままメソッドにしたもので、合計・積・最大値・最小値・文字列連結など、ありとあらゆる集約処理を 1 行で書けます。
関数型プログラミングの世界では、この操作を
fold(フォールド) と呼びます。HaskellのfoldlやScalaのfoldLeft、Pythonのfunctools.reduceも同じ考え方です。Java のreduceもこの流れを汲んだメソッドで、入門者にとっては「forループの結果を 1 つの変数に貯めていく動き」を宣言的に書き直したもの、と捉えると分かりやすいです。
たとえば配列 [1, 2, 3, 4] の合計を求めるとき、これまでなら int sum = 0; から始めて for ループで sum += arr[i] を繰り返したはずです。reduce を使うと「初期値 0 と二項演算 (a, b) -> a + b を渡せばあとは Stream が回してくれる」という形で同じ処理が書けます。今回のレッスンでは、この reduce を使って配列の全要素の 積 を計算する Solution.product(int[] arr) を完成させていきます。
reduce(identity, BinaryOperator) の文法
一番よく使うのは引数 2 つの reduce で、シグネチャは次のようになっています。
Java
T reduce(T identity, BinaryOperator<T> accumulator);identity(アイデンティティ) は畳み込みの初期値です。合計なら0、積なら1、文字列連結なら""を渡しますaccumulatorは 2 つの値を受け取って 1 つの値にまとめる関数 (BinaryOperator) です。たとえば積なら(a, b) -> a * b
実際に積を求めるコードはこう書けます。int[] を Stream に変換するときは IntStream.of(arr) か Arrays.stream(arr) を使うのが定番です。
Java
import java.util.Arrays;
public class Demo {
public static int product(int[] arr) {
return Arrays.stream(arr)
.reduce(1, (a, b) -> a * b);
}
public static void main(String[] args) {
System.out.println(product(new int[]{1, 2, 3, 4})); // 24
System.out.println(product(new int[]{5})); // 5
System.out.println(product(new int[]{})); // 1
}
}空配列を渡したときに 1 が返ってくることに注目してください。これは identity に 1 を指定したおかげで、要素が 1 つもなくても「初期値そのもの」が結果として返るためです。reduce の identity には「単位元」つまり「その値を演算に入れても結果が変わらない値」を選ぶ、というのが鉄則です。足し算なら 0、掛け算なら 1、String 連結なら空文字列、これは数学的にも自然なルールです。
単位元の意味で
identityという名前が付いています。0 + x = x、1 * x = x、"" + s = s、と「自分自身が返ってくる」ような値ですね。空 Stream に対しても安全に動かすために、この単位元を渡す形が一番安心です。
for ループとの対応
reduce の動きを掴むのに一番手っ取り早いのは、対応する for ループを並べてみることです。下の 2 つは、まったく同じ計算をしています。
Java
// 命令型: 自分でループを回して累積
int acc = 1;
for (int x : arr) {
acc = acc * x;
}
return acc;
// 宣言型: reduce に「初期値」と「2 つを 1 つにする関数」を渡すだけ
return Arrays.stream(arr).reduce(1, (a, b) -> a * b);命令型は「変数を用意してループで上書きする」という手順を自分で書きますが、宣言型は「初期値は 1、隣り合う 2 つは掛け算でまとめる」と材料を渡すだけで、ループの回し方は Stream に任せます。コード量が短いだけでなく、「何をしたいか」が読み取りやすいのが reduce の大きな利点です。
図のように、reduce は左から順に「これまでの累積結果 (a) と次の要素 (b)」を accumulator に渡し、結果を次の a に持ち越していきます。最後の要素まで処理し終わったら、その時点の累積値がそのまま戻り値になります。
sum / max / min / count との関係
Stream API には sum() max() min() count() のような専用メソッドもあって、用途が決まっているときはそちらの方が短く書けます。実はこれらは内部的にはすべて reduce の特殊ケースとして実装されています。次の表を眺めてみてください。
| やりたいこと | 専用メソッド | reduce で書くと |
|---|---|---|
| 合計 | IntStream.sum() | reduce(0, (a, b) -> a + b) |
| 最大値 | IntStream.max() | reduce(Integer.MIN_VALUE, Math::max) |
| 最小値 | IntStream.min() | reduce(Integer.MAX_VALUE, Math::min) |
| 個数 | Stream.count() | reduce(0L, (a, b) -> a + 1, Long::sum) |
| 積 | (専用なし) | reduce(1, (a, b) -> a * b) |
面白いのは、「積」だけは標準 API に専用メソッドが用意されていない、という点です。だからこそ reduce を覚えておくと、sum だけでは表現できない自分専用の集約処理 (積・XOR・文字列連結・最大公約数など) を自由に書けるようになります。reduce は Stream API の中でも一番つぶしが効くメソッド、と言っても言いすぎではありません。
Math::maxのような書き方はメソッド参照と呼ばれる記法で、(a, b) -> Math.max(a, b)のラムダ式と等価です。意味が読み取れる場面では、メソッド参照の方が短くて気持ち良いことが多いです。
並列化を意識した「結合的」な演算
reduce のもう 1 つの強みが、parallelStream() に切り替えるだけで並列処理に乗せられるところです。Arrays.stream(arr).parallel().reduce(1, (a, b) -> a * b) と書けば、CPU の複数コアを使って分散して計算できます。
ただしこれが正しく動くためには、accumulator が 結合的 (associative) な演算であることが条件になります。結合的とは、(a OP b) OP c == a OP (b OP c) が常に成り立つ、つまり「どこから計算しても結果が変わらない」性質のことです。加算 (+)、乗算 (*)、最大値、最小値、文字列連結はすべて結合的なので並列化しても安全ですが、減算 (-) や除算 (/) は順序で結果が変わるので並列化と相性が悪く、避けるのが無難です。
図のように、並列の reduce は配列を分割して並行に畳み込み、最後にお互いの結果を combiner (3 引数版で指定) でまとめます。結合的な演算なら、どんな分割の仕方でも結果は同じになります。reduce を書くときは「自分の演算は結合的か?」を一瞬考える癖をつけておくと、後で並列化したくなったときにもそのまま使い回せます。
よくある間違い
ここで、reduce を初めて触るときに多くの人が踏む落とし穴を 3 つ紹介しておきます。
identityを省略してOptionalを扱い忘れる —reduce(BinaryOperator)のように引数 1 つ版もありますが、こちらは空 Stream の可能性があるため戻り値がOptional<T>になります。int product = stream.reduce((a, b) -> a * b);と書いてもコンパイルすら通らず、Optional<Integer>をintに代入できないと怒られます。今回のように空配列でも1を返したい場合は、identityを渡す 2 引数版を使うのが圧倒的に楽ですaccumulatorに副作用を入れる —reduceの関数の中でSystem.out.printlnを呼んだり、外側のフィールドを書き換えたりするのは禁じ手です。並列化したときに表示順がぐちゃぐちゃになったり、ConcurrentModificationExceptionが出たりして、デバッグ困難なバグの温床になります。accumulatorは「aとbを受け取って新しい値を返すだけの純粋関数」に保つ、というのが鉄則です- 結合則を破る演算を使う —
reduce(0, (a, b) -> a - b)のように引き算で書くと、順次実行では0 - 1 - 2 - 3 = -6になりますが、並列で(0 - 1) と (2 - 3)を計算して合成すると結果が変わってしまいます。減算や除算は結合的ではないので、reduceのaccumulatorには使わないのが原則です。どうしても引き算したいときは、map(x -> -x).reduce(0, Integer::sum)のように加算に変換するか、forループで書くかを検討します
もう 1 つ細かい注意として、int[] を Arrays.stream(arr) で流すと得られるのは IntStream で、reduce(int identity, IntBinaryOperator op) というプリミティブ用のオーバーロードが呼ばれます。これは int をボクシングせず高速に動きますが、戻り値も int なので桁あふれ (オーバーフロー) に注意が必要です。本当に大きな数を扱いたいなら、mapToLong(x -> x) してから long で畳み込むか、BigInteger を使う、といった工夫を後の章で学んでいきます。
IntStreamとStream<Integer>の違いは Stream API の最初のつまずきポイントです。int[]の積を求める今回の課題ではIntStreamのままreduceするのが一番素直で、本文で紹介した書き方そのままで動きます。
やってみよう
それでは今回の課題に取り組みましょう。やることは次のとおりです。
Solution.product(int[] arr)の本体でArrays.stream(arr)(もしくはIntStream.of(arr)) でIntStreamに変換する.reduce(1, (a, b) -> a * b)を呼び出して全要素の積を計算する- 結果をそのまま
returnする
ポイントは identity に 1 を渡すところで、これにより空配列のときに 1 が返るようになります。int product = arr.length == 0 ? 1 : ...; のように自分で分岐を書く必要はなく、reduce の identity がその役目を引き受けてくれます。
慣れてきたら、reduce(0, Integer::sum) に書き換えて合計を出してみたり、reduce(Integer.MIN_VALUE, Math::max) で最大値を出してみたり、自分で別の集約処理を書いてみてください。reduce のアイデアが手に馴染むほど、Stream API の表現の幅がぐっと広がっていきます。次のレッスンでは、reduce の親戚にあたる collect を扱って、List や Map に集める方法を学んでいきます。
よくある質問
Q. reduce はどんなときに使うべきですか?
A. 合計・最大値・グルーピングなど、配列を 1 つの値や別の構造にまとめたいときに有効です。単純な合計なら sum / Array.prototype.reduce((a,b)=>a+b,0) で OK ですが、複雑なロジックでは for ループの方が読みやすいこともあります。
Q. 初期値は省略しても良いですか?
A. 省略すると配列の先頭要素が初期値になり、空配列のときに TypeError になります。明示的に 0 や [] を渡しておくと安全で、型推論にも優しくなります。可読性と安全性の両面から初期値は付ける派が多数です。
Q. reduce でオブジェクトを作るには?
A. items.reduce((acc, x) => ({ ...acc, [x.id]: x }), {}) のように初期値に {} を渡し、各反復で展開構文を使います。要素数が多い場合は scatter で性能劣化するため、acc[x.id] = x; return acc; のように mutate する形も検討してください。
次のレッスン
次は collect で集める で、Stream API の reduce を使って配列の要素を 1 つの値に畳み込む方法を学び、整数配列の積を求めるコードを書いてみよう を学びます。
事前確認 — 進む前に次の 3 つができることを確認しましょう。
- reduce の要点を自分の言葉で説明できる
- このレッスンの最小コード (または操作手順) を見ずに書ける
- 練習問題やクイズで間違えた箇所を読み直して理解した
理解度チェック (30 秒)
Q. reduce とは何か、1 文で説明してください。
A. 本文の「このレッスンで分かること」または冒頭の説明文を見直し、自分の言葉で要約できれば OK。詰まったら本レッスンの最初の H2 セクションを読み返してみましょう。
関連レッスン
要件
Arrays.stream(arr)かIntStream.of(arr)でint[]をIntStreamに変換することreduce(1, (a, b) -> a * b)のようにidentityに1を渡して畳み込むこと (空配列でも1が返ることを保証する)forループや手動の累積変数を使わず、Stream API のreduceを使って書くこと
入出力例
test-cases.txt
product([1,2,3,4]) → 24
product([5]) → 5
product([]) → 1
product([2,3]) → 6
product([1,1,1,1,1]) → 1
product([2,5,10]) → 100