LoginSignup
12
4

More than 3 years have passed since last update.

はじめに

 これは、株式会社ワークスヒューマンインテリジェンスのアドベントカレンダー Develop fun!を体現する Works Human Intelligence Advent Calendar 2020 12月20日の記事です。
 よろしければ、ぜひ他の記事もご覧になっていってください。

想定読者

  • Java プログラマ
  • Stream API を普通に使ってる
  • 関数型プログラミングにはあまり詳しくない

Stream APIとは?

 Stream API とは、Java 8 から追加された、コレクションに対する繰り返し処理を便利に扱う仕組みです。
 Oracleのドキュメントでは、次のように説明されています。

パッケージjava.util.streamの説明
コレクションに対するマップ-リデュース変換など、要素のストリームに対する関数型の操作をサポートするクラスです
https://docs.oracle.com/javase/jp/8/docs/api/java/util/stream/package-summary.html

 Stream APIを使うと、複雑な繰り返し処理をいくつかのステップに分けて書くことができ、可読性と保守性に優れたコードを書くことができるようになります。
 例えば、「1から100までの数列を合計する」という単純な例を比べてみましょう。

int sum = 0;
for (int i = 1; i <= 100; ++i) {
    sum += i;
}
int sum = IntStream.rangeClosed(1, 100).sum();

 重要なのはコードが一行になったことではなく、段階ごとに処理が分割されていることです。
 緑を数列の生成、黄を結果の生成というように色付けすると、次のようになります。

スクリーンショット 2020-11-21 135607.png

 見ての通り、古き良きfor では黄色が二か所に散っているのに対して、Stream では一か所にまとまっています。
 これは、ステップごとに処理をまとめられていることを意味します。

 通常は生成したコレクションを加工する処理も入るので、緑色と黄色の間に更にコードが増えることになります。
 繰り返しの内容が複雑になればなるほど、コレクションの生成・加工・結果の生成をそれぞれ分ける意義はより大きくなります。

 このように、関数型スタイルの良い点をJava に取り入れ、読みやすく書きやすい繰り返し処理を提供する仕組みがStream API です。

 また、Stream APIに似た仕組みは様々な言語にあるため、ここで勉強したことは様々な言語で同じように当てはめて使うことができます。

 参考: なぜ我々は頑なにforを避けるのか

Stream API はfor 文の力を分割する

 for文というのは、いかなる繰り返し処理も表現することができるいわば「繰り返し処理の神」です。
 そしてStream API は、この繰り返し処理の神をリファクタリングした結果といえます。

 次の図は繰り返しの力が分割されていく過程を示したものです。

goto (無条件ジャンプ)
+-> if (条件分岐)
+-> try-catch-finally (例外処理)
+-> for (繰り返し処理 = while,再帰関数)
    +-> unfold (展開)
        +-> Stream.iterate
        +-> Stream.generate
        +-> Stream.of
        +-> Stream.empty
        +-> IntStream.rangeClosed
        ...
    +-> Stream.reduce (畳み込み)
        +-> IntStream.sum
        +-> Stream.max
        +-> Stream.allMatch
        +-> Stream.count
        +-> Stream.flatMap
        +-> Stream.map
            +-> Stream.peek
        +-> Stream.filter
        ...

 goto を使えば条件分岐も繰り返し処理もできるように、for を使えばStream API の全てをカバーすることができます。
 裏を返せば、Stream API はそれだけ繰り返し処理を小さく分割したものだということです。

Tips: for と再帰関数は同じ表現力を持つ

 for (while, do-while) による繰り返しの他に、再帰関数を使っても繰り返し処理をすることができます。

 全く別の見た目をした繰り返し構文と再帰関数ですが、実はこれらは全く同じ機能を持ちます。
 言い換えれば、for で書ける処理は全て再帰関数で書くことができ、逆もまた成り立ちます。

 尤も、for と再帰関数とでは異なる性質があり、実用上はこれらをうまく使い分けることが求められます。

Stream API の中でも強い関数

 前章でStream APIはforを機能ごとに分割したものだと説明しました。
 しかし、このStream API の中にも比較的強い関数があります。

 まず繰り返し処理は大きく2つに分けられます。
 数列などのコレクションを作る処理(展開, unfold)と、コレクションを何らかの値に集計する処理(畳み込み, fold ≒ reduce)です。

 すなわち、この展開(unfold)と畳み込み(fold)がStream APIの機能分割の柱だ1ということです。
 Stream APIの関数群は基本的にこのどちらかに属します。

reduce

 Stream APIにあるreduceは、いわば「畳み込みの神」のようなもので、これらを使えばほとんどの畳み込み操作を行うことができます。

 畳み込み操作の幅は広く、Stream API で提供されている基本的なものや、そんなものまで!? と思える関数までreduceで実装できたりします。

intstream.sum();     // IntStream<Integer>をIntに畳み込む
list.size();         // List<T>をIntに畳み込む
list.flatMap(f);     // List<T>をList<U>に畳み込む
insertionSort(list); // List<Integer>をList<Integer>に畳み込む

flatMap

 flatMapは、singletonと組み合わせることでmapfilterを実装できるので、比較的強い関数といえます。

 これに加えて、flatMapは多重ループを行える性質を持ちます。
 実用上flatMapを使いたくなるのはこのケースがほとんどではないでしょうか。

var list = List.of(1, 2, 3);
var result = list.stream().flatMap(x -> {
        return list.stream().map(y -> {
            return x * y;
        });
//      map を flatMap + singleton で書く例
//      return list.stream().flatMap(y -> {
//          return Collections.singletonList(x * y);
//      });
    })
    .collect(Collectors.toList());

 flatMapのこの特性はHaskellをはじめとする関数型言語に注目され、flatMapsingletonだけを持たせたインターフェースはモナドと呼ばれ大切にされています。
 Javaっぽい疑似コードを書くとこんな感じです。

interface Monad<T> { 
    <U> Monad<U> flatMap(Function<T, Monad<U>> f);
    static Monad<T> singleton(T a);
}

 ところでこのインターフェース、JavaScript のPromiseに似ていませんか?

 天を仰いで目を閉じ、JavaScriptのPromiseを思い浮かべ、モニターに向き直って薄目を開くと、flatMapthenに、Monad.singletonPromise.resolveに見えてくる気がしませんか……?

 Promise……お前は……モナド……?

Promise.resolve(value); // 値からPromiseを作り出している
p.then(value => {       // 値を受け取ってPromiseを返す関数を渡している
    console.log(value);
    return Promise.resolve(value);
});

Tips: 展開 (unfold)

 プログラミングにおける展開(unfold)とは、畳み込みとは対照的に、ある一つの値からある再帰的構造を作る操作のことを言います。
 畳み込み(reduce)とは違い、Stream API ではずばりという関数がありませんが、もしあったとすると次のような関数です。

public static <T, U> Stream<U> iterate(
        T seed,                                    // 種
        Predicate<? super T> hasNext,              // 種から次の種と値を得られるかどうか
        UnaryOperator<T> next,                     // 種から次の種を得る
        Function<? super T, ? extends U> mapper    // 種から値を得る
);

 Java11で追加されたiterateのオーバーロードにmapperを渡せるようにした関数です。
 これは次のように使います。

iterate(1,
        n -> true,
        n -> n + 1,
        n -> fizzbuzz(n))
    .limit(15)
    .forEach(System.out::println);

 この値を集めたものが最終的にできるStream になります。

 fizzbuzz の例では、 は1以上の数列で値は整数をfizzbuzzで変換したもののコレクションですね。
 上のプログラムは、(fizzbuzz関数を補えば)次のような出力をします。

1
2
fizz
4
buzz
fizz
7
8
fizz
buzz
11
fizz
13
14
fizzbuzz

 展開操作の幅は広く、大雑把に言って下のような型を持つ関数は全て展開処理をしています。

<T, R> Stream<R> someunfold(T seed);

 最も単純なものはStream.ofStream.emptyですね。
 Stream.generateIntStream.rangeClosedなんかもこれに分類されます。

 要は、Stream やコレクションを作る操作が展開だということです。

Stream API の問題点

 みなさんと同じように、私は常にfor 文をStream API に置き換えてしまいたいと思っています。

 しかし現実にはfor はまだまだ現役であり、全てをStream API に置き換えたら良いかと言われると残念ながらそうではありません。

 ここでは少し脇道に逸れて、Java のStream API の問題点をいくつか指摘したいと思います。

  1. 高階関数とChecked 例外の相性が悪い
  2. Stream がネストすると読みづらくなる

高階関数とChecked 例外の相性が悪い

 個人的にStream API を使っていて一番つらいのはこれです。
 綺麗にStream を使って書けそうなのに、throwsのついた関数を途中で呼び出したいがためにfor 書き直す憂き目にあった人は多いと思います。
 かといって一度RuntimeExceptionで包んで後で剥がすのは美しくありません。

 解決策としてSneaky throw2というテクニックがありますが、これも使うのは少し躊躇われるような代物です。3

public static <E extends Throwable> void sneakyThrow(Throwable e) throws E {
    throw (E) e;
}

Stream がネストすると読みづらくなる

 Stream で多重ループを回そうとしたとき、Stream がネストします。
 元々多重ループは複雑な処理ですし、ネストすること自体はforでも同じなのですが、Stream がネストしたときの読みづらさは相当なものです。

var list = List.of(1, 2, 3);
var result = list.stream().flatMap(x -> {
        return list.stream().map(y -> {
            return x * y;
        });
    })
    .collect(Collectors.toList());

 ひと目で何をやっているか分かるでしょうか。
 この例では、むしろfor の方がシンプルに見えるかもしれません。

var list = List.of(1, 2, 3);
var result = new ArrayList<Integer>();
for (var x : list) {
    for (var y : list) {
        result.add(x * y);
    }
}

 この問題はJava やStream APIの問題ではなく、単に高階関数がネストすると読みづらいという話です。
 現状のJava ではこれに対する解決策はなく、多重ループが必要な場面では従来通りfor を使うのが妥当といえそうです。

Tips: 関数型言語での解決策

 flatMapと多重ループが深く結びついていること、関数型言語ではflatMapが重要視されていることは先に述べました。
 そして高階関数がネストすると読みづらくなる問題はパラダイム関係なく起こりそうに思えます。

 それでは、関数型言語ではどのようにこの問題を解決しているのでしょうか?

 実は、関数型言語ではこの問題を糖衣構文を使って解決しています。
 例えばScalaのfor文では、上と同じ多重ループを下のように書けます。

val list = 1 to 3
val result = for (x <- list; y <- list) yield {
    x * y
}

 ネストがなくなっていますね。

 Scala のfor は、flatMapmapの呼び出しに脱糖されます。
 すなわち、上のScalaコードは下のコードと同等ということです。

val list = 1 to 3
val result = list.flatMap { x =>
    list.map { y =>
        x * y
    }
}

 これはJava のStream API で書いたflatMapmapを駆使したコードと全く同じであることが分かります。
 このようにして高階関数のネストを(視覚上)なくすことができます。

おわりに

 この記事では「Stream API をもっと理解する」という目的のもとStream API の分類や性質について紹介しました。
 少しでも新しい視点や学びを得られたと思って頂けたら幸いです。

 よろしければ、他記事もぜひご覧になって行ってください。
 Develop fun!を体現する Works Human Intelligence Advent Calendar 2020


  1. 中間操作と終端操作という区別とは異なります 

  2. 参考: "Sneaky Throws" in Java https://www.baeldung.com/java-sneaky-throws 

  3. 使用を許される環境であれば、Lombokのアノテーションを使う手もあります https://projectlombok.org/features/SneakyThrows 

12
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
4