1. 命令型 vs 関数型スタイル
まずは素朴な問いから始めましょう。そもそも「関数型スタイル」はなぜ必要なのでしょうか。従来のループ中心の書き方と比べて何が良いのでしょうか。そもそも Java における「関数型スタイル」とは何を指すのでしょうか。
命令型スタイル
命令型スタイルとは、コンピューターに どのように 処理するかを手順で指示する書き方です。例えば、文字列のリストから各文字列の長さを取り出し、奇数だけを残して降順に並べたいとします。命令型では次のように書きます:
List<String> words = Arrays.asList("キリン", "ゾウ", "オランウータン", "トラ", "カンガルー");
List<Integer> lengths = new ArrayList<>();
for (String word : words) {
int len = word.length();
if (len % 2 != 0) {
lengths.add(len);
}
}
lengths.sort(Comparator.reverseOrder());
System.out.println(lengths); // [7, 5, 3]
ここでは中間リストを明示的に作成し、手作業で要素を追加し、ソートしています。まさに手順どおりの実装です。
関数型スタイル
関数型スタイルでは、実装手順ではなく 何を 得たいかを記述します。Java ではこれを Stream API で実現します:
List<String> words = Arrays.asList("キリン", "ゾウ", "オランウータン", "トラ", "カンガルー");
List<Integer> result = words.stream()
.map(String::length)
.filter(len -> len % 2 != 0)
.sorted(Comparator.reverseOrder())
.toList();
System.out.println(result); // [7, 5, 3]
ここではデータ処理の「コンベヤ」を組み立てているようなものです。まず単語をその長さに変換し(map)、次に奇数だけをフィルタし(filter)、最後に並べ替えます(sorted)。すべてを1つのチェーンで、明示的な中間コレクションやループなしに表現できます。
比較: 反復(命令型) vs 関数型スタイル
慣れ親しんだ命令型アプローチでは、ループの中で逐一やることを記述します。各要素を走査し、条件を判定し、新しいリストに追加したり出力したりします。動きますが行数が増え、課題が複雑になるほど読み書きが大変になります。
関数型スタイルでは、処理そのものではなく目標を記述できます。長いループの代わりに操作の連鎖で「フィルタして、変換して、結果を集める」と表現します。より短く、見通しが良く、可変コレクションを手で扱う場面が減る分、バグの余地も減ります。
もちろん裏返しの面もあります。初心者には、複数のラムダが連なるチェーンは単純なループより読みにくく映るかもしれません。関数型スタイルは簡潔さと表現力で優れますが、慣れが必要です。
2. Stream API の基本操作
Stream API は単なる「新しい種類のループ」ではなく、関数型スタイルでコレクションを処理するための道具箱です。主要な操作を整理してみましょう。
Stream の取得方法
List<String> list = List.of("a", "bb", "ccc");
Stream<String> stream = list.stream();
中間操作
- map — 要素を変換する
- filter — 条件で要素を絞り込む
- flatMap — 各要素をストリームに変換して平坦化する
- sorted — 並べ替え
- distinct — 重複を除去する
- limit / skip — 先頭から制限する/スキップする
終端操作
- forEach — 各要素に対して処理を実行する
- collect — 結果をコレクションに収集する
- reduce — ストリームを1つの値に畳み込む(例: 合計)
- count — 要素数を数える
- anyMatch, allMatch, noneMatch — 条件の判定
例: 処理のチェーン
List<String> names = List.of("Anna", "Boris", "Vika", "Gleb", "Dasha");
List<String> filtered = names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.sorted()
.toList();
System.out.println(filtered); // [ANNA, BORIS, DASHA, GLEB, VIKA]
「コンベヤ」のビジュアル図:
[Anna, Boris, Vika, Gleb, Dasha]
| filter (length>3)
[Anna, Boris, Vika, Gleb, Dasha]
| map (toUpperCase)
[ANNA, BORIS, VIKA, GLEB, DASHA]
| sorted
[ANNA, BORIS, DASHA, GLEB, VIKA]
| toList
各操作は元のコレクションを変更しません。新しいストリームが生成されます.
3. ストリームの実行と遅延評価
中間操作と終端操作
Stream には2種類の操作があります。最初は中間操作で、map、filter、sorted などです。これらは新しいストリームを返し、何かを「やるつもり」だと約束するだけで、まだ実行しません。次に終端操作があり、例えば forEach、collect、reduce です。これらが実際に処理全体を起動します。重要なのは、終端操作を呼ぶまではストリームは「遅延」しており、計算は始まらないという点です。
例:
Stream<String> stream = List.of("a", "bb", "ccc").stream()
.map(s -> {
System.out.println("map: " + s);
return s.toUpperCase();
});
System.out.println("forEach の前");
stream.forEach(System.out::println);
出力:
forEach の前
map: a
A
map: bb
BB
map: ccc
CCC
map は forEach が始まるまで実行されないことがわかります。
なぜこれは便利か?
このアプローチにより、無駄なくどれだけでも長い変換チェーンを組み立てられます。ストリームは本当に必要になった時にだけ動作します。これにより、巨大なデータや無限列さえ問題なく処理できます。また、遅延評価はメモリの節約や効率的な計算にも役立ちます。
4. 実践: 「文字列 → 長さ → 奇数のみ → 降順」
次の課題を段階的に解いてみましょう。「文字列のリストから長さのリストを得て、奇数だけを残し、降順に並べ替える。」
命令型の解法
List<String> words = Arrays.asList("キリン", "ゾウ", "オランウータン", "トラ", "カンガルー");
List<Integer> lengths = new ArrayList<>();
for (String word : words) {
int len = word.length();
if (len % 2 != 0) {
lengths.add(len);
}
}
lengths.sort(Comparator.reverseOrder());
System.out.println(lengths); // [7, 5, 3]
Stream API を使った関数型の解法
List<String> words = Arrays.asList("キリン", "ゾウ", "オランウータン", "トラ", "カンガルー");
List<Integer> result = words.stream()
.map(String::length) // 文字列を長さに変換
.filter(len -> len % 2 != 0) // 奇数の長さだけ残す
.sorted(Comparator.reverseOrder()) // 降順に並べ替える
.toList(); // List に収集(Java 16+)
System.out.println(result); // [7, 5, 3]
補足:
- map(String::length) — 各文字列の長さを取り出す。
- filter(len -> len % 2 != 0) — 奇数の長さだけ残す。
- sorted(Comparator.reverseOrder()) — 降順に並べ替え。
- toList() — ストリームを新しいリストに収集。
たとえ
工場のベルトコンベヤのようなものです。各工程で部品が別の形に処理され、最後に箱に収められます。
5. さらに例: map, filter, forEach, collect
例1: フィルタして出力
List<String> names = List.of("Anna", "Boris", "Viktor", "Gleb", "Dasha");
names.stream()
.filter(name -> name.contains("a"))
.forEach(System.out::println);
// 出力: Anna, Dasha
例2: 変換して Set に収集
Set<String> upperNames = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toSet());
System.out.println(upperNames); // [ANNA, BORIS, VIKTOR, GLEB, DASHA]
例3: すべての文字列の長さの合計を得る
int totalLength = names.stream()
.mapToInt(String::length)
.sum();
System.out.println("合計の長さ: " + totalLength);
例4: Predicate と Function の使用
Predicate<String> longName = name -> name.length() > 4;
Function<String, String> greet = name -> "こんにちは、" + name + "!";
names.stream()
.filter(longName)
.map(greet)
.forEach(System.out::println);
// こんにちは、Boris!
// こんにちは、Dasha!
6. Stream API による関数型スタイルの特性
可変状態を持たない
Stream API は副作用のない「純粋」な関数を推奨します。つまり、ラムダの中で外部の変数を変更しないほうがよいということです。
悪い例:
List<String> result = new ArrayList<>();
names.stream()
.filter(name -> name.startsWith("A"))
.forEach(result::add); // side-effect!
より良い例:
List<String> result = names.stream()
.filter(name -> name.startsWith("A"))
.toList();
操作の合成
map、filter、sorted などのメソッドを組み合わせて、非常に長いチェーンを構築できます。ただしやりすぎは禁物です。チェーンが画面より長くなったら、適切に分割することを検討しましょう。
遅延評価
Stream API は、終端操作に到達するまで何もしません。これにより、リソースを節約し、効率的なパイプラインを構築できます。
元のコレクションは不変
Stream は元のコレクションを変更しません。すべての変換は新しいストリーム/コレクションを返します。
7. Stream API を使うべきとき
Stream API が特に適しているのは次のような場合です:
- コレクションを手早く処理したい(フィルタ、変換、ソート)。
- 簡潔で読みやすいコードにしたい。
- 中間コレクションを手で作りたくない。
- 簡単に並列化したい(parallelStream())。
命令型スタイル のほうが望ましいこともあります:
- 入れ子のループや条件が多い複雑なロジックが必要。
- 性能クリティカルな箇所で最大限のパフォーマンスが必要(Stream API はわずかに遅い場合がある)。
- 可変状態を扱う必要がある(その場で要素を更新するなど)。
8. Stream API でよくある誤り
誤り1: forEach を使ってコレクションを集める。 初学者がやりがちなのは、forEach で新しいコレクションに要素を追加することです。これは関数型スタイルではありません!代わりに collect や toList() を使いましょう。
誤り2: 早すぎる最適化。 いきなり parallelStream() を使わないでください。並列化は本当に大きなコレクションや CPU 集約的な処理がある場合にだけ検討します。
誤り3: Stream API とミュータブルなコレクションの混用。 Stream API は不変データを前提とするスタイルです。ラムダの中でコレクションの要素を変更するのは避けましょう。
誤り4: 結果の取りこぼし。 終端操作を呼び忘れると、何も起こりません。
誤り5: ラムダが複雑すぎる。 ラムダ式が1〜2行を超えるなら、意味のわかる名前のメソッドに切り出しましょう。
GO TO FULL VERSION