LoginSignup
16
16

More than 5 years have passed since last update.

いまさらJavaのStream APIにおける「stream」「collect」の意味を考察する

Last updated at Posted at 2019-03-02

はじめに

既に大分前になりますが、JavaにStream APIという関数型プログラミング的手法が導入されました。
関数型プログラミングは既に一般的に広まっており、Javaはかなり後発の部類に入りますが、やはり導入された理由は、うまく使いこなせれば可読性の高いコードを効率的に書けるようになるメリットがあるからと考えています。
(参考:「平凡なプログラマにとっての関数型プログラミング」)

しかし先日、とある講演にて、「Javaのコレクション操作(Stream API)は、Haskellなど純関数型言語と比べると無理やり感があって使いたくない」という話を聞きました。
具体的に言えば、関数型言語ならlist.map(...)で済むものを、Javaの場合は一々
list.stream().map(/* */).collect(Collectors.toList())
みたいにstream()を挟んでやる必要があり、冗長な書き方になってしまうところかなと思いました。

それでも無いよりは良いので私自身Stream APIは比較的好んで使いますが、関数型言語を好む人からすれば受け付けない面はあるのかなと思いました。

では、なぜJavaがlist.map(...)のような簡単な書き方を採用せず、一々stream()を呼び出してStreamという別の型に変換して、collectにて再度変換するようにしたか、についてです。Javaは、言語設計を入念に行う文化があり、その結果に賛否はあれど何かしらの理由があるはずです。その理由は大きく次の2つであると考えています。

  1. 遅延評価
  2. オブジェクト指向による制約

以下、詳しく考えを述べていきます。

遅延評価とは

一般にコレクション操作を行う際、その過程でいちいちコレクションの作り直しをしていては無駄ですし、パフォーマンスへの懸念が生まれます。
これを防ぐには、コードの見た目上はコレクションを徐々に変化させているように見えても、実際には最後に最後にまとめて生成するようにします。このように、値が必要になるまで計算しないという計算方法を遅延評価と呼びます。
例えば、

List<String> list = Arrays.asList("foo", "bar", "hoge", "foo", "fuga");
list.stream()
  .filter(s -> s.startsWith("f"))
  .map(s -> s.toUpperCase())
  .collect(Collectors.toSet()); // ["FOO", "FUGA"]

という感じに、

  • 文字列のリストのうち、"f"から始まるものを抽出する
  • 文字列を大文字に変換する
  • 重複を取り除く(Setに変換)

というコレクション操作を行う際、filterが呼び出された時点で新たな要素数3のコレクションが生まれることはありません。
実際にコレクションが生成されるのは、最後にcollect(Collectors.toSet())が呼び出されたタイミングになります。

※ちなみに、厳密にはこれを遅延評価とは呼ばないみたいですが、他に適切な呼び方がないため遅延評価と呼ぶことにします。(参考:「遅延評価ってなんなのさ」)

遅延評価の対義語は正格評価です。値が不要であってもその時点で計算してしまう方法です。通常はこちらの方が一般的です。

遅延評価のデメリット

メリットがある一方で、注意して使わないと思わぬ落とし穴があります。
以下は(あまり好ましくない書き方ですが)実際に遅延評価により見た目と実際の実行結果の認識齟齬が生まれそうな例です。

// 入出力データ定義
List<String> input = Arrays.asList("foo", "bar", "hoge", "foo", "fuga");
List<String> copy1 = new ArrayList<>();
List<String> copy2 = new ArrayList<>();

// コレクション操作開始. filterを実行
Stream<String> stream = input.stream()
    .filter(s -> {
        copy1.add(s);
        return s.startsWith("f");
    });
System.out.println(copy1.size()); // この時点では、filter操作は実際に評価されないため、copy1は空のままで0が出力される
System.out.println(copy2.size()); // 当然copy2も空のままなので0が出力される

// 続いてコレクション操作のmapを実行
stream = stream
    .map(s -> {
        copy2.add(s);
        return s.toUpperCase();
    });
System.out.println(copy1.size());  // この時点でもまだfilter操作は評価されないため、0が出力される
System.out.println(copy2.size()); // 同様にmap操作も評価されないため、0が出力される

stream.collect(Collectors.toList());
System.out.println(copy1.size()); // stream.collectによりようやくfilterが評価されるため、5が出力される
System.out.println(copy2.size()); // 同様にmap操作も評価されるため、3が出力される

上のコードは、一見するとfilter, mapを呼び出した際にcopy1, copy2のサイズが増えるように見えますが、
実際の動きとしてはcopy1, copy2のサイズが増えるのはstream.collectを呼び出したタイミングとなります。
このように、見た目と実際の評価タイミングにずれがあると、何か問題が起きた際にデバッグしづらく原因の特定が難しくなってしまう危険性があります。

どのようにバランスを取るか

遅延評価は使い方を誤ると複雑なバグを埋め込む危険性があります。かといって、遅延評価を全く使わない場合、無駄にコレクションが生成されてパフォーマンス低下の危険性があります。

Javaの場合、バックエンドで大量データを扱う可能性が普通に考えられる以上、後者のリスクは避けたいため、遅延評価を導入せざるを得ません。しかも、意図せず正格評価が使われないよう、自然(?)と遅延評価になっているようなものが好ましいでしょう。

しかし、だからといってJava標準機能の広範囲に渡り遅延評価を適用できるようにするのはリスキーです。
そのため、遅延評価は特定の型に限定させ、他の型では遅延評価が使われないような作りが妥当な落としどころと考えたのでしょう。

「Stream」の登場

ストリーム とは:

ストリーム(stream)とはデータを「流れるもの」として捉え、流れ込んでくるデータを入力、流れ出ていくデータを出力として扱う抽象データ型である。

先に述べた、唯一、遅延評価を行える型を「Stream」と名付けました。
そして、コレクション操作APIの名前もそのまま「Stream API」、要はコレクション操作がやりたかったら、とりあえず名前の通りstream()を使えということでしょう。

そうすることにより、遅延評価を強制させ正格評価によるパフォーマンス低下のリスクを回避しようとしたと考えています。

オブジェクト指向による制約

streamを介する別の理由として、オブジェクト指向による制約があります。
(厳密にはオブジェクト指向というよりは、型の扱いによる制約に該当しますが、関数型とオブジェクト指向とで対比されることが多いため、ここでは「オブジェクト指向」という言葉を使います。)
仮に、List型に対してdefaultのmapメソッドを定義したとします。

interface List<E> {
    default <R> List<R> map(Function<? super E, ? extends R> mapper) {
        List<R> result = new ArrayList<>();
        for (E elem : this) {
            result.add(mapper.apply(elem));
        }
        return result;
    }
}

このようにしておけば、とりあえずList型の変換をlist.map(...)のように行うことができます。
filter等のメソッドや、他のコレクション型も同じような実装すれば、streamを介さずとも簡潔なという書き方でコレクションの変換を行うことができます。

しかし、この方法は重大な欠点を抱えています。
それは標準ライブラリ以外のコレクション型です。

例えば、ある開発者がListインターフェースを実装したMyListを作成しており、固有のメソッドdoSomethingを追加していたとします。
ここで、MyList型を上記方法でmap変換すると、変換後は別のList型になってしまい、doSomethingが呼び出せなくなってしまいます。

MyList<> mylist = new MyList<>();
// 中略
mylist.doSomething(); //OK
myList.map(x -> new AnotherType(x)).doSomething(); //コンパイルエラー

このあたり、オブジェクト指向言語に関数型プログラミングを取り入れる際の難点となるところでしょう。
とはいえ、実際にこういったケースはあまり見かけないため気にしなくても良い気もしますが、やはりそこはJavaという言語の性格上、許容できないところなのでしょう。

なお、Scalaに関して言えば、この難点を暗黙的な型解決とやらで見事突破しています。
下記補足Aで紹介する書籍に記述されていますので、興味がある方は是非見てみてください。

「Collector」の登場

上記理由により、コレクションの変換操作を始めると、もともとあったコレクションと別のものを再生成せざるを得なくなり、またコレクションの指定はライブラリ側ではなく呼び出し側の責務となります。
それを担うのが「Collector」であり、Stream.collectにより呼び出し側でどのコレクション型に変換するか指定します。
下のソースコードはCollectors.toListの実装内容です。

public static <T>
Collector<T, ?, List<T>> toList() {
    return new CollectorImpl<>((Supplier<List<T>>) ArrayList::new, List::add,
                               (left, right) -> { left.addAll(right); return left; },
                               CH_ID);
}

自前で作成したコレクション型MyListも、これと同様の方法でCollectorインスタンスを生成するメソッドを用意しておけば、MyListからMyListへの変換を行うことが可能でしょう。
これにより、Stream API導入以前に作成されたコレクション型も、Stream API上で特別大きな変更を加えず使うことができます。

ちなみに、Stream型にtoListメソッドを作成しない理由

コレクションの変換はCollector型に集約することで見通しが良くなるとはいえ、List型などは日常で頻繁に出てきます。
せめて、stream.collect(Collectors.toList())ではなくstream.toList()ぐらいに簡潔に書けても良いんじゃないかという気もしてきます。
これを行わない理由はおそらく、型の依存関係にあると考えています。
要は、Collection型からStream型への参照は必須ですが、Stream型からCollection型を参照すると相互参照することになり型設計として好ましくない、という理由かなと考えています。

おまじないを唱えて安全に使おう

上記のように、様々なバランス、整合性を考えた結果がlist.stream().map(/* */).collect(Collectors.toList())という冗長とも取れるコレクション操作の形に落ち着いたのだと考察しています。

ある意味で、とてもJavaらしい結論ではないかなと考えています。

Javaを使う際に危ないからStream API使っちゃいけないよという謎プロジェクトが世の中には存在するみたいですが、このように安全性を考慮した作りになっているので、普通に使う分には取り立てて不安がる必要はありません。定型通りにstream, collectを唱えれば、よっぽどのことがない限り問題は起きないでしょう。

おわりに

純関数型言語に拘りがある人以外は、冗長な記述もまぁ許容範囲に収まると思います。
関数型プログラミングをうまく使いこなせれば、可読性の高いコードを効率的に書けるようになります。まだ使っていない人はぜひ試してみてください。
(参考:Java Stream APIをいまさら入門

補足A. Java以外の言語

あまり詳しくありませんが、他の言語がどのような機能を提供しているか参考程度に記載します。

C#

C#のLINQは、Javaと同様に遅延評価です。
Javaと異なるのは、stream()を呼び出して開始する必要がなく、コレクション化する際も例えばToListを呼び出すだけで済むことが多いので、Javaより大分簡潔に書けて便利です。
(参考:「[雑記] LINQ と遅延評価」)
(参考:「C#erなら当然知ってるよね!?LINQ遅延評価のメリット・デメリット」)

Scala

Scalaは関数型言語というのもあり、遅延評価・正格評価の使い分けが可能です。
例えば、list.map(...)で正格評価による別コレクションへの変換ができます。
また、list.view.map(...).filter(...).forceのようにview, forceという形で遅延評価による別コレクションへの変換も可能です。
(参考:「Scala でジェネレータを作ったり、遅延評価してみる」)

なお、はるか昔は正格評価と遅延評価の区別が付き辛く混乱を招いていた時期もあったらしいですが、ある時境界を明確にして

  • View
  • Stream

という2つの型のみ遅延評価の対象として整理したらしいです。
なお、Scalaに関しては「Scalaスケーラブルプログラミング」という本に恐ろしく詳しいことが色々と書かれているので、興味がある方はぜひ読んでみてください。

JavaScript

JavaScript標準には遅延評価はありません。Array.prototypeに、map, filterなど標準的なコレクション操作APIがありますが、どれも正格評価です。
これはおそらく、ほぼクライアントサイドで使用されるJavaScriptで大量データ処理を扱うことはないという前提で、遅延評価は標準搭載不要と考えたのでしょう。

Haskell

Haskellは純関数型言語というのもあり、上に挙げたものとまた毛色が違うようです。
通常の言語は基本が正格評価なのに対して、Haskellは基本が遅延評価です。
そのため、本記事で気にしているような正格評価と遅延評価のバランスと、そもそも無縁のようです。
(参考:「正格評価と遅延評価(詳細編)」)

上記以外(PHP, Ruby, Python, etc...)

そのうち調べます。

補足B. 他のライブラリを使うという選択肢

Java標準以外に、Eclipse collectionsというコレクション操作ライブラリがあります。
これを使うと、Stream APIでは冗長になるものがすっきりと記述できます。
(参考:「Eclipse Collectionsを触ってみた」)
(参考:「Eclipse Collectionsチートシート」)

また、ImmutableListという、不変なListインターフェースが用意されており、より関数型手法の色合いが濃いライブラリです。
Stream APIよりもより関数型的なコレクション操作を扱いたいなら導入も1つの選択肢かと思います。

ただし、全面的にStream APIをEclipse Collectionsに置き換えようとしたら相当な工夫が必要でしょう。
導入する際は、実際に導入を敢行した現場の話「Eclipse Collectionsを現場に浸透させるためのフレームワーク対応」が参考になるかと思います。

16
16
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
16
16