reduce で集約する

生田 陸人
LuaGate エンジニア / 現役エンジニア
編集 LuaGate編集部

reduce で集約する とは

Stream API の reduce を使って配列の要素を 1 つの値に畳み込む方法を学び、整数配列の積を求めるコードを書いてみよう。

Stream の終端操作 reduce とは

ここまでの Stream API のレッスンでは、filter で要素を絞り込んだり map で形を変えたり、count で個数を数えたりしてきました。今回扱う reduce は、Stream の「最後の仕上げ」を担う終端操作の中でも一番表現力が高いものです。reduce は「複数の要素を 1 つの値にまとめる」という畳み込みの考え方をそのままメソッドにしたもので、合計・積・最大値・最小値・文字列連結など、ありとあらゆる集約処理を 1 行で書けます。

関数型プログラミングの世界では、この操作を fold (フォールド) と呼びます。HaskellfoldlScalafoldLeftPythonfunctools.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 が返ってくることに注目してください。これは identity1 を指定したおかげで、要素が 1 つもなくても「初期値そのもの」が結果として返るためです。reduceidentity には「単位元」つまり「その値を演算に入れても結果が変わらない値」を選ぶ、というのが鉄則です。足し算なら 0、掛け算なら 1String 連結なら空文字列、これは数学的にも自然なルールです。

単位元の意味で identity という名前が付いています。0 + x = x1 * 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 の大きな利点です。

diagram (will load when visible)

図のように、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) が常に成り立つ、つまり「どこから計算しても結果が変わらない」性質のことです。加算 (+)、乗算 (*)、最大値、最小値、文字列連結はすべて結合的なので並列化しても安全ですが、減算 (-) や除算 (/) は順序で結果が変わるので並列化と相性が悪く、避けるのが無難です。

diagram (will load when visible)

図のように、並列の 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 は「ab を受け取って新しい値を返すだけの純粋関数」に保つ、というのが鉄則です
  • 結合則を破る演算を使うreduce(0, (a, b) -> a - b) のように引き算で書くと、順次実行では 0 - 1 - 2 - 3 = -6 になりますが、並列で (0 - 1) と (2 - 3) を計算して合成すると結果が変わってしまいます。減算や除算は結合的ではないので、reduceaccumulator には使わないのが原則です。どうしても引き算したいときは、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 を使う、といった工夫を後の章で学んでいきます。

IntStreamStream<Integer> の違いは Stream API の最初のつまずきポイントです。int[] の積を求める今回の課題では IntStream のまま reduce するのが一番素直で、本文で紹介した書き方そのままで動きます。

やってみよう

それでは今回の課題に取り組みましょう。やることは次のとおりです。

  1. Solution.product(int[] arr) の本体で Arrays.stream(arr) (もしくは IntStream.of(arr)) で IntStream に変換する
  2. .reduce(1, (a, b) -> a * b) を呼び出して全要素の積を計算する
  3. 結果をそのまま return する

ポイントは identity1 を渡すところで、これにより空配列のときに 1 が返るようになります。int product = arr.length == 0 ? 1 : ...; のように自分で分岐を書く必要はなく、reduceidentity がその役目を引き受けてくれます。

慣れてきたら、reduce(0, Integer::sum) に書き換えて合計を出してみたり、reduce(Integer.MIN_VALUE, Math::max) で最大値を出してみたり、自分で別の集約処理を書いてみてください。reduce のアイデアが手に馴染むほど、Stream API の表現の幅がぐっと広がっていきます。次のレッスンでは、reduce の親戚にあたる collect を扱って、ListMap に集める方法を学んでいきます。

よくある質問

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 つができることを確認しましょう。

  1. reduce の要点を自分の言葉で説明できる
  2. このレッスンの最小コード (または操作手順) を見ずに書ける
  3. 練習問題やクイズで間違えた箇所を読み直して理解した

理解度チェック (30 秒)

Q. reduce とは何か、1 文で説明してください。

この章のポイント

A. 本文の「このレッスンで分かること」または冒頭の説明文を見直し、自分の言葉で要約できれば OK。詰まったら本レッスンの最初の H2 セクションを読み返してみましょう。

関連レッスン

要件

  1. Arrays.stream(arr)IntStream.of(arr)int[]IntStream に変換すること
  2. reduce(1, (a, b) -> a * b) のように identity1 を渡して畳み込むこと (空配列でも 1 が返ることを保証する)
  3. 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

ヒント

main.java
main.java
学習モード

メモ

reduce で集約する

⌘S で保存