Stream APIの学習を進めていると、必ずぶつかる壁がflatMapです。mapとの違いがいまいち腑に落ちず、「結局何が平坦化されているのか」が分からないまま試験本番を迎えてしまう方も多いのではないでしょうか。実際、1Z0-826ではflatMapの戻り値の型や、ネストしたコレクションの扱い方を問う問題が頻出します。この記事では、flatMapの仕組みを基礎から整理し、試験で狙われやすいポイントまで具体的なコードとともに解説します。読み終える頃には、mapとflatMapを自信を持って使い分けられるようになります。

flatMapとは何か

flatMapは、Streamの各要素を別のStreamに変換し、それらを1つのStreamにまとめる(平坦化する)中間操作です。引数にはFunction<T, Stream<R>>を取り、各要素ごとに生成されたStreamを連結して、ネストのない単一のStreamを返します。

イメージとしては、「箱の中に箱が入っている状態」を「箱の中身だけを取り出して1つの箱にまとめる」操作だと考えると分かりやすいです。リストのリストを1つのリストに変換するような場面で活躍します。

import java.util.List;
import java.util.stream.Stream;

public class FlatMapBasic {
    public static void main(String[] args) {
        List<List<Integer>> nested = List.of(
            List.of(1, 2, 3),
            List.of(4, 5),
            List.of(6)
        );

        List<Integer> flat = nested.stream()
            .flatMap(List::stream)
            .toList();

        System.out.println(flat);
        // 出力: [1, 2, 3, 4, 5, 6]
    }
}

nested.stream()はStream<List<Integer>>を返しますが、flatMap(List::stream)によって各リストがStreamに変換され、最終的にStream<Integer>という平坦な形になります。

mapとflatMapの違い

mapとflatMapはどちらも要素を変換する操作ですが、戻り値の構造が異なります。この違いを正確に理解しているかが、試験で繰り返し問われるポイントです。

項目mapflatMap
関数型インタフェースFunction<T, R>Function<T, Stream<R>>
変換後の構造要素ごとに1つの値(ネストされる場合あり)Streamを連結して平坦化する
典型的な戻り値の型Stream<List<X>>のようにネストしうるStream<X>のように常に平坦
用途単純な値の変換ネストした構造の解消、複数要素への展開

具体例で比較すると違いが明確になります。

import java.util.List;
import java.util.stream.Stream;

public class MapVsFlatMap {
    public static void main(String[] args) {
        List<String> words = List.of("Java", "Gold");

        // mapの場合: 各要素がchar配列のStreamになり、ネストしたままになる
        Stream<Stream<Character>> mapped = words.stream()
            .map(w -> w.chars().mapToObj(c -> (char) c));
        // mappedはStream<Stream<Character>>型であり、そのままでは扱いにくい

        // flatMapの場合: 平坦化され、Stream<Character>として扱える
        List<Character> flatMapped = words.stream()
            .flatMap(w -> w.chars().mapToObj(c -> (char) c))
            .toList();

        System.out.println(flatMapped);
        // 出力: [J, a, v, a, G, o, l, d]
    }
}

mapを使った場合、ネストが解消されないためStream<Stream<Character>>という扱いにくい型になります。flatMapを使うことで、これがStream<Character>という1階層のStreamにまとまります。

Optionalに対するflatMap

flatMapはOptionalクラスにも存在し、考え方は共通しています。Optional<Optional<T>>のようなネストを防ぎ、常にOptional<T>の形を維持するために使います。

import java.util.Optional;

public class OptionalFlatMap {
    static Optional<String> findUser(String id) {
        return id.equals("1") ? Optional.of("Taro") : Optional.empty();
    }

    static Optional<Integer> findAge(String name) {
        return name.equals("Taro") ? Optional.of(30) : Optional.empty();
    }

    public static void main(String[] args) {
        Optional<Integer> age = findUser("1")
            .flatMap(OptionalFlatMap::findAge);

        System.out.println(age);
        // 出力: Optional[30]
    }
}

ここでmapを使うとfindAgeの戻り値であるOptional<Integer>がさらにOptionalでラップされ、Optional<Optional<Integer>>になってしまいます。flatMapはこのネストを自動的に解消します。

試験で狙われるひっかけポイント

1Z0-826では、以下の点が選択肢の中に巧妙に紛れ込んでいることが多いです。

戻り値の型を逆に覚えてしまうミス
flatMapに渡す関数の戻り値は「Streamそのもの」である必要があります。Stream<List<T>>を返す関数を渡すと、結果はStream<List<T>>のままになり、期待した平坦化は行われません。List::streamのように、必ずStreamを返すメソッド参照やラムダを渡す必要があります。

List<List<String>> data = List.of(List.of("a", "b"), List.of("c"));

// 誤り: flatMapにStreamを返さない関数を渡すとコンパイルエラーになる
// data.stream().flatMap(list -> list); // List<String>はStreamではないためエラー

// 正しい: List::streamでStream<String>を返す
List<String> result = data.stream()
    .flatMap(List::stream)
    .toList();

空のStreamやnullの扱い
flatMapに渡す関数がnullを返すと、NullPointerExceptionが発生します。空の結果を表したい場合はStream.empty()を返す必要があります。これはOptional.empty()との混同が起きやすい部分なので注意してください。

Stream.of("a", "", "b")
    .flatMap(s -> s.isEmpty() ? Stream.empty() : Stream.of(s))
    .forEach(System.out::println);
// 出力:
// a
// b

mapToObj系メソッドとの組み合わせ
IntStreamやDoubleStreamからStream<T>へ変換する際はmapToObjを使いますが、これをflatMapの中で使うパターンも頻出です。型の流れ(int → IntStream → Stream<Character>)を一つひとつ追えるかどうかが問われます。

まとめ

flatMapは「Streamを返す関数を使ってネストを解消する」操作であり、mapとの最大の違いは戻り値の構造にあります。試験対策としては、戻り値の型がStreamになっているかどうかを必ず確認する習慣をつけることが重要です。また、OptionalのflatMapも同じ考え方で理解できるため、両者をセットで覚えておくと知識が定着しやすくなります。サンプルコードを実際に手元で動かしながら、型の変化を意識して学習を進めてみてください。