読者です 読者をやめる 読者になる 読者になる

エンタープライズギークス (Enterprise Geeks)

企業システムの企画・開発に携わる技術者集団のブログです。開発言語やフレームワークなどアプリケーション開発に関する各種情報を発信しています。ウルシステムズのエンジニア有志が運営しています。

Stream APIの拡張(1) - 生成処理の拡張

Stream APIには、他の言語の同様の機能(すなわちHaskellのリスト([])やScalaのコレクション、C#LINQなど)と比較すると、存在しない機能がいくつかあり、その中には有用なものがある。
以前の記事で紹介したzipもその中の一つだ。

今回は、HaskellScalaLINQなどがサポートしているtakeWhiledropWhile について紹介し、Stream APIの生成処理を拡張することで、これらの機能を追加する方法を解説する。

10万以下の累乗数をすべて求めるコード

Stream APIを使用して、10万以下の累乗数*1をすべて取得するには、どうすればよいだろうか。

無限ストリームとfilterを使用すればよいと考えるかもしれない。

List<Integer> less100000 = Stream.iterate(1, n -> n + 1)
                                 .map(n -> n * n)
                                 .filter(n -> n <= 100000)
                                 .collect(Collectors.toList());

残念ながら、このコードは無限ループに陥る。
iterate(1, n -> n + 1) は1から始まる自然数なので、人の目から見ればある要素を境に10万以下の累乗数が見つからなくなるのは自明だろう。
しかし、filterは全ての要素から条件に合致する要素を抽出するだけで、要素の繰り返しを停止するメカニズムを持っていない。このため、該当する要素がなくなったとしても全ての要素に対して処理を続けてしまい、無限ループになる。

limitを使えば処理件数を制限できるが、具体的な数を指定する必要があるため、10万以下の累乗数の総数がわからない場合には適用できない。

無限ストリームをやめて、有限のリストや配列などを使う方法もあるが、拡張性や実行効率の点で適切な方法とは言えない。

結論として、現状のStream APIが提供する中間操作を組み合わせるだけでは、この問題をスマートに解決することは難しいと言える。

takeWhileとは

takeWhileとは、述語(要素Tを取り、booleanを返す関数)を指定し、その述語が真となる間だけ要素を取得する機能である。 述語が偽となった時点で、以降の要素の取得は行わない(最初の要素に対する述語が偽となった場合には、何も取得しない)。

手続き型の処理で言えば、ループのbreakを任意の条件で実現する仕組みに相当する。ちなみに、このtakeWhileは、Scalaではコレクションのメソッドとして提供されており、他の多くの関数型言語でも標準関数として提供されている。

これをJava8で実現するにはどうすればよいだろうか。

以前書いたStream APIの基本(4)では、Streamの要素の走査は内部的にSpliteratorを用いており、SpliteratorIteratorから作成できることを解説した。この仕組みを利用することで、takeWhileや後述するdropWhileの性質を持ったStreamを作ることができる。

(ちなみに、Stream APIの基本(5)で紹介したzipの実装例でもこの機能を利用している)。

takeWhileの実装例

Streamは、List#streamなどの生成メソッド内部で具象クラスを生成する仕組みになっているため、その具象クラスを拡張してtakeWhileメソッドを定義する方法を使えない。
そこでここでは、ユーティリティとしてStreamUtilクラスを用意し、takeWhileの性質を内包したStreamを生成するcreateTakeWhileメソッドを定義した。

public class StreamUtil {
    
    public static <T> Stream<T> createTakeWhile(Stream<T> source, Predicate<T> p) {

        Spliterator<T> splt = source.spliterator();
        /* 
         * characteristics(性質)は、
         * Stream生成元のオブジェクト(コレクションや配列など)が持つ性質
         * (Listなら順序あり、Setなら要素がユニークなど)であり、
         * 新しく作成するSpliteratorに性質を引き継ぐため最初に取得する。
         */
        int characteristics = splt.characteristics();
        Iterator<T> base = Spliterators.iterator(splt);
        Iterator<T> itr = new Iterator<T>() {
            T next;
            boolean end; // 述語が成立しなくなった時点でtrueとなる。

            @Override
            public boolean hasNext() {
                // 元の要素がなくなるか、述語が成立しなくなるまで取得可能。
                if (!base.hasNext() || end) {
                    return false;
                }
                next = base.next();
                if (p.test(next)) {
                    return true;
                } else {
                    end = true;
                    return false;
                }
            }
            @Override
            public T next() {
                return next;
            }
        };
        
        return StreamSupport.stream(
                Spliterators.spliteratorUnknownSize(
                        itr,characteristics), false);
    }
}

Streamを生成するStreamUtil#createTakeWhileメソッドが受け取る引数は2つある。1つは元となるStreamで、もう1つは要素取得の継続の是非を判定する述語関数だ。
実際にStreamの要素の走査が行われた場合には、元のStreamから取り出したIteratorと述語関数を使って、条件が成立している間だけ値を取得できるようにしている。

Spliteratorの仕組みについては、Stream APIの基本(4)を参照のこと。)

StreamUtil#createTakeWhileの使用例

10万以下の累乗数をすべて求めるコードを、上記のStreamUtil#createTakeWhile使って実装すると次のようになる。

Stream<Integer> squareNums =
   Stream.iterate(1, n -> n + 1).map(n -> n * n);
List<Integer> less100000= StreamUtil
    .createTakeWhile(squareNums, n -> n <= 100000)
    .collect(Collectors.toList());
System.out.println(less100000.size()); // 316個

dropWhileとは

takeWhileが述語が真の間だけ値を取得する処理であったのに対し、dropWhileは 述語が真の間は要素を取得せず読み飛ばす機能である。条件が成立するまで処理を行わない場合に適用できる。

手続き型の処理でいえば、任意の条件に対するcontinue に相当する。 こちらもStreamUtil#createTakeWhileの実装同様、Iteratorを使って実現する。

dropWhileの実装例

dropWhile機能の実装例を以下に示す。
基本的な仕組みはStreamUtil#createTakeWhileと同じである。

public class StreamUtil {
    
    // takeWhileの定義は割愛

    public static <T> Stream<T> createDropWhile(Stream<T> source, Predicate<T> p) {
        
        Spliterator<T> splt = source.spliterator();
        int characteristics = splt.characteristics();
        Iterator<T> base = Spliterators.iterator(splt);
        Iterator<T> itr = new Iterator<T>() {
            T next;
            boolean start;

            @Override
            public boolean hasNext() {
                if (!base.hasNext()) {
                    return false;
                }
                
                next = base.next();
                // 述語が成立しなくなるまで、要素を読み飛ばす。
                if (!start) {
                    while(p.test(next) && base.hasNext()) {
                        next = base.next();
                    }
                    if (base.hasNext()) {
                        start = true;
                    } else {
                        return false;
                    }
                }
                return true;
            }
            @Override
            public T next() {
                return next;
            }
        };

        return StreamSupport.stream(
                Spliterators.spliteratorUnknownSize(
                        itr, characteristics), false);
    }
}

StreamUtil#createDropWhileの使用例

10万を超えた最初の累乗数を取得するコードは次のようになる。

Stream<Integer> squareNums =
        Stream.iterate(1, n -> n + 1).map(n -> n * n);
Optional<Integer> over100000 = StreamUtil
        .createDropWhile(squareNums, n -> n < 100000)
        .findFirst();
over100000.ifPresent(System.out::println);

生成処理を拡張する方法の限界

今回の記事では、Streamの生成処理に手を入れることで、Stream APIに機能を追加できることを紹介した。

ここで紹介したStreamUtil#createTakeWhileStreamUtil#createDropWhileのような仕組みを用意することで、より高度な繰り返し処理をStream APIに追加できる。
一方で、上記の使用例のコードを見てもわかるように、Streamインタフェースの中間操作として、メソッドチェーンでこれらのメソッドを連結することはできない。このため、このような機能拡張を多用すると、コードが見づらくなるデメリットがある。

Stream APIを拡張するもう一つの方法としては、委譲を用いる方法もあるが、これに関しては次回紹介する。

[前多 賢太郎]

*1:他の自然数の累乗になっている数。1, 4, 9, 16, 25, 36...