こまぶろ

技術のこととか仕事のこととか。

StreamとOptionalに共通して存在するメソッド(その3・完結篇):map / flatMap

StreamとOptionalに共通して存在するメソッドの第三弾。 第一弾・第二弾はこちら。

ky-yk-d.hatenablog.com

ky-yk-d.hatenablog.com

これまで、下記のメソッドについて書いてきた。

  • filter()
  • of()
  • ofNullable()
  • empty()

完結篇となる今回は、map()flatMap()について。これまでに出てきたメソッドとも関連させながら書いていきたい。

マッピング関数を引数にとるメソッド

メソッド Stream Optional
map() Stream map​(Function<? super T,​? extends R> mapper) public Optional map​(Function<? super T,​? extends U> mapper)
flatMap() Stream flatMap​(Function<? super T,​? extends Stream<? extends R>> mapper) public Optional flatMap​(Function<? super T,​? extends Optional<? extends U>> mapper)

map()flatMap()は、いずれも「マッピング関数」を引数にとるメソッドだ。マッピング関数とは、一定の入力を一定の出力に対応づけする関数で、関数型インタフェースFunction<T, R>という型で指定されていることから明らかなように、ラムダ式やメソッド参照を代入することができる。

map()flatMap()のいずれも、元のStream/Optionalの要素を取り出し、マッピング関数で変換し、その返り値を要素として含む新たなStream/Optionalを返すメソッドだ。異なるのは、マッピング関数が返す値の型と、それをどのように新たなStream/Optionalに流すかだ。それぞれ見ていこう。

map()のマッピング関数は「要素そのもの」を返す

まず、わかりやすいmap()から。このメソッドは、Stream/Optionalの要素を取り出し、引数で指定されたマッピング関数を適用し、その返り値を要素とする新たなStream/Optionalを返す。挙動としては非常にわかりやすい。少し注意が必要なのは、マッピング関数の返り値がnullだった場合だ。

Streamのmap():マッピング関数の結果のnullも1つの要素として扱う

Streamの場合は、nullも一つの要素として新たなStreamに含められるのに対して、Optionalの場合は、nullをラップしたOptionalではなく、空のOptionalが出力される。コードを示す。

/* 動作確認用のstaticメソッド定義 */
class Sample {
    static Integer stringToInteger(String str){
        try {
            return Integer.valueOf(str);
        } catch (NumberFormatException e) {
            return null;
        }
    }
}
/* JUnit5を用いた動作確認 */
@Test
void Streamのmap() {
    List<String> src = List.of("123", "456", "変換できない文字列");
    List<Integer> list = src.stream().map(Sample::stringToInteger).collect(Collectors.toList());
    assertEquals(3, list.size()); // テストはグリーン(nullも要素の一つとして含んでいる)
    list.forEach(System.out::print); // 123456null と出力される
}

Streamのmap()は、マッピング関数の返り値がnullだった場合、それをそのまま新しいStreamの1つの要素として挿入する。map()の前後でStreamの要素の数は増減していない。

Optionalのmap():マッピング関数の返り値がnullの場合は空のOptionalを返す

これに対して、Optionalのmap()で、マッピング関数の返り値がnullだった場合は、(元のOptionalが空だった場合と同じく)空のOptionalを返す。

@Test
void Optionalのmap() {
    Optional<String> opt = Optional.of("123");
    Optional<Integer> result = opt.map(Sample::stringToInteger);
    System.out.println(result); // Optional[123] と出力
    opt = Optional.of("変換できない文字列");
    result = opt.map(Sample::stringToInteger);
    System.out.println(result); // Optional.empty と出力(Optional[null]とはならない)
}

これは、map()の実装において、マッピング関数の返り値をOptionalに包む際に、Optional.ofNullable()を利用しているためだ。実装は以下のようになっている。

public <U> Optional<U> map(Function<? super T, ? extends U> mapper) {
    Objects.requireNonNull(mapper);
    if (!isPresent()) {
        return empty();  
    } else {
        return Optional.ofNullable(mapper.apply(value));
    }
}

map()のまとめ

まとめると、以下のことが言える。

  • StreamとOptionalのmap()は、元のStreamないしOptionalの要素を置き換えた新しいStreamないしOptionalを返す
  • ただし、マッピング関数の結果がnullの場合に、Streamはnullをそのまま要素に含めるが、Optionalは空のOptionalを返す(nullをラップしたOptionalが返ることはない)

flatMap()のマッピング関数は「要素の入れ物」を返す

map()に渡すマッピング関数の返り値の型は? extends R(U)であるのに対し、flatMap()に渡すマッピング関数の返り値の型はStream(Optional)<? extends R(U)>である。map()マッピング関数が「要素そのもの」を返したのに対し、flatMap()マッピング関数はStream/Optionalであり、いわば「要素の入れ物」を返す。マッピング関数の入力がそもそも、(map()と同様に)元のStream/Optionalという「入れ物」の中身である要素だから、「入れ物の要素1つから新しい入れ物を作る」というのがマッピング関数の役割になる。

そして、flatMap()全体としては、マッピング関数の出力であるそれぞれの「入れ物」から要素を取り出し、新しいStream/Optionalに送り込む。マッピング関数の出力をそのままStream/Optionalに送り込むのではなく、「入れ物」から要素を取り出すことにより、flatMap()の戻り値はネストしたStream/Optionalにはならない(これがフラット化の意味だ)。

StreamのflatMap():「1対多の変換」はミスリーディング

Streamの場合、Streamという「入れ物」には複数の要素を入れることができるから、flatMap()の典型的な利用例として、入力のStreamの各々から複数の出力を取り出してそれを新しいStreamの要素とする、という「数を増やす」形の変換に利用される。実際、StreamのflatMap()APIドキュメントの「APIの注」という項目に、「1対多の変換」という表現がある。

flatMap()操作には、ストリームの要素に対して1対多の変換を適用した後、結果の要素を新しいストリーム内にフラット化する効果があります。

しかし、ここでいう「1対多の変換」が、必ずしも「数を増やす」ものではないということには注意が必要だ。例を示そう。StreamのflatMap()が使われるシンプルなケースの1つとして、以下のように、ネストしたコレクションからそれぞれの要素を取り出して処理の対象とする場合がある。

List<List<String>> src = List.of(List.of("123","456"), List.of("789"), List.of());
List<String> dest = src.stream().flatMap(List::stream).collect(Collectors.toList());
dest.forEach(System.out::print); // 123456789 と出力

上の処理において、flatMap()に渡されているマッピング関数List::stream()は、ラムダ式では list -> list.stream()と書くこともでき、List型の入力を受け付けて、Stream型の出力を返す関数となっている。このStreamの要素であるString型の値を、flatMap()が(List::streamではなくflatMap()自体の)戻り値であるStreamに流しこむ。

ここで、srcの要素であるList型のオブジェクトのうち、最初のものであるList.of("123","456")からは"123""456"という2つの文字列を取り出す1対2の変換を行なっているが、第三要素であるList.of()からは要素を取り出していないため1対0の変換になっている。「1対多の変換」の「多」というのは「0以上」という意味だということがわかる。この意味で「多」を利用することはプログラミングの世界では珍しくないが、ミスリーディングな説明だと思う。

1対0の変換:Java9で導入されたStream.ofNullable()とOptional.stream()

上で殊更に「1対0」の場合があると書いたのには意味がある。その意味は、Java 9 で導入されたメソッドである、StreamのofNullable()とOptionalのstream()を見てみるとわかる。

前回の記事で紹介したofNullable()は、引数がnullであれば空のStreamを返すファクトリメソッドだ。一方、Optionalのstream()は、Optionalが空であれば空のStreamを、空でなければその要素を含むStreamを返すメソッドであり、以下のような実装を持つ。

public Stream<T> stream() {
    if (!isPresent()) {
        return Stream.empty();
    } else {
        return Stream.of(value);
    }
}

これら2つのメソッドは、似たような場面で用いることができる。具体的には、下記のコードにおいて、2つのテストケースは同じ動きを示す。SampleクラスのstringToOptionalInteger()メソッドは、先に登場したstringToInteger()メソッドとほぼ同じで、異なるのは変換が失敗したときに、stringToInteger()がnullを返すのに対して、stringToOptionalInteger()は空のOptionalを返すという点だ(したがって、後者は返り値の型がOptional<Integer>になっている。

class Sample {
    /* 変換失敗時に空のOptionalを返すstaticメソッド */
    static Optional<Integer> stringToOptionalInteger(String str){
        try {
            return Optional.of(Integer.valueOf(str));
        } catch (NumberFormatException e) {
            return Optional.empty();
        }
    }
}

動作確認用のコードは以下の通り。

@Test
void StreamのofNullableの利用() {
    List.of("123", "456", "変換できない文字列").stream()
            .flatMap(str -> Stream.ofNullable(Sample.stringToInteger(str)))
            .forEach(System.out::print); // 123456 と出力
}
        
@Test
void Optionalのstreamの利用() {
    List.of("123", "456", "変換できない文字列").stream()
            .flatMap(str -> Sample.stringToOptionalInteger(str).stream())
            .forEach(System.out::print); // 123456 と出力
}

上記のテストメソッド、StreamのofNullableの利用()Optionalのstreamの利用()は同じ動きをする。前者ではnullを返すメソッドを呼び出し、それをStream.ofNullable()に渡すことでStreamを作っているのに対し、後者では空のOptionalを返すメソッドを呼び出し、それをstream()によってStream化している。いずれもマッピング関数の返り値はStream型になっていて、その要素がflatMap()により新しいStreamに投入されている。

ここでは、flatMap()は、(先に見た例のように)入力のStreamの各要素から複数の要素を生み出す(数を増やす)ために用いられているのではなく、むしろ数を減らすために用いられている。この例で用いているStream.ofNullable()Optional.stream()の両方がJava9で導入されたメソッドなのは、利用方法から考えると納得がいくことではないだろうか。

OptionalのflatMap():1対0 or 1対1の変換

さて、以上のように考えると、OptionalのflatMap()も理解しやすい。これもまた、StreamのflatMap()同様に「1対多の変換」を行うマッピング関数を受け取るものであり、たまたまOptionalというものの性格上、数を増やすためには用いることができないのだ、という理解の仕方もできるだろう。flatMap()は、以下のように実装されている。

public <U> Optional<U> flatMap(Function<? super T, ? extends Optional<? extends U>> mapper) {
    Objects.requireNonNull(mapper);
    if (!isPresent()) {
        return empty();
    } else {
        @SuppressWarnings("unchecked")
        Optional<U> r = (Optional<U>) mapper.apply(value);
        return Objects.requireNonNull(r);
    }
}

map()マッピング関数の返り値がnullだった場合の挙動と、flatMap()マッピング関数の返り値が空のOptionalだった場合の挙動は同じで、いずれも空のOptionalが返る。「要素ありのOptional→空のOptional」という置き換えで行われている変換を「1対0の変換」と呼ぶならば、Optionalのmap()flatMap()はいずれも1対0の変換を行うことができるものだと言える。どちらを使うかは、マッピング関数で利用したい関数がnullを返すのか空のOptionalを返すかによって決めればいい。これは、Streamのmap()がnullを要素として扱い、要素の数を減らすような動きはしない(nullを消すためには先述のようにflatMap()を利用する)のと異なっている。

まとめとモナドに関する参考記事

3回に渡って、StreamとOptionalに共通して存在するメソッドについて書いた。自分自身の理解を作るためという趣旨で書き始めた記事だったが、今回記事にするために挙動を一つずつ確認していくなかで、StreamとOptionalの同一名称のメソッドが似ているようで少しずつ違うのがわかった。同じ名称だと仕様の違いに鈍感なまま雰囲気で使ってしまいがちだが、今後は細かい挙動の違いも意識してコードを書くことができると思う。

なお、今回の記事を書いている過程で、モナドというキーワードに接した。Haskellを学ぶと出会うことになる概念という印象を持っていたが、OptionalとStreamというJava関数型プログラミング的な側面においてはこの概念が顔を出すようだ。詳しくは下記に列挙した記事をご覧いただきたい。

d.hatena.ne.jp

qiita.com