Streams APIの初期の設計から、設計の理論的根拠を明らかにするかもしれない思い出がいくつかあります。
2012年には、言語にラムダを追加していました。並列処理を容易にする、ラムダを使用してプログラムされたコレクション指向または「バルクデータ」操作セットが必要でした。操作を遅延して連鎖させるという考えは、この時点で十分に確立されていました。また、中間操作に結果を保存したくありませんでした。
決定する必要がある主な問題は、チェーン内のオブジェクトがAPIでどのように見えるか、およびそれらがどのようにデータソースに接続するかでした。ソースは多くの場合コレクションでしたが、ファイルやネットワークからのデータ、または乱数ジェネレーターなどのオンザフライで生成されたデータもサポートする必要がありました。
デザインに対する既存の作業の影響が多かった。最も影響力のあるのは、GoogleのGuavaライブラリーとScalaコレクションライブラリーでした。(誰かがグアバからの影響に驚いている場合、グアバの主任開発者であるケビンブーリリオンがJSR-335 Lambdaエキスパートグループに参加していたことに注意してください。)Scalaコレクションでは、Martin Oderskyによるこの講演が特に興味深いことがわかりました:Future- Scalaコレクションの校正:可変から永続、並列へ。(スタンフォードEE380、2011年6月1日)
当時の私たちのプロトタイプ設計はに基づいていましたIterable
。おなじみの操作filter
、map
などは、の拡張(デフォルト)メソッドIterable
でした。1つを呼び出すと、チェーンに操作が追加され、もう1つが返されIterable
ます。のようなターミナル操作はソースへのチェーンをcount
呼び出しiterator()
、操作は各ステージのイテレーター内に実装されました。
これらはIterableであるため、iterator()
メソッドを複数回呼び出すことができます。それから何が起こるでしょうか?
ソースがコレクションの場合、これはほとんど問題なく機能します。コレクションは反復可能で、を呼び出すたびiterator()
に、他のアクティブなインスタンスから独立した個別のIteratorインスタンスが生成され、それぞれが独立してコレクションを走査します。すごい。
ファイルから行を読み取るなど、ソースがワンショットの場合はどうでしょうか。たぶん、最初のイテレータはすべての値を取得するはずですが、2番目以降のイテレータは空でなければなりません。おそらく値はイテレータの間でインターリーブされるべきです。または、各イテレータはすべて同じ値を取得する必要があります。次に、イテレータが2つあり、一方がもう一方よりも先に進んでいる場合はどうでしょうか。誰かが値が読み込まれるまで、2番目のイテレータの値をバッファする必要があります。さらに悪いことに、1つのイテレーターを取得してすべての値を読み取り、その後 2番目のイテレーターを取得した場合はどうなるでしょうか。値は今どこから来ますか?誰かが2番目のイテレータを必要とする場合に備えて、それらすべてをバッファする必要がありますか?
明らかに、ワンショットソースに対して複数のイテレータを許可すると、多くの疑問が生じます。彼らには良い答えがありませんでした。iterator()
2回呼び出すとどうなるかについて、一貫した予測可能な動作が必要でした。これにより、複数のトラバーサルを禁止し、パイプラインをワンショットにすることができました。
また、他のユーザーがこれらの問題にぶつかることも確認しました。JDKでは、ほとんどのIterableはコレクションまたはコレクションのようなオブジェクトであり、複数の走査が可能です。どこにも指定されていませんが、Iterablesが複数のトラバーサルを許可するという予期せぬ期待があったようです。注目すべき例外は、NIO DirectoryStreamインターフェースです。その仕様には、この興味深い警告が含まれています。
DirectoryStreamはIterableを拡張しますが、単一のイテレータのみをサポートするため、汎用のIterableではありません。イテレータメソッドを呼び出して2番目以降のイテレータを取得すると、IllegalStateExceptionがスローされます。
【原文で太字】
これは異常で不愉快に思えたため、1回だけである可能性のある新しいIterableの全体を作成したくありませんでした。これにより、Iterableを使用する必要がなくなりました。
このころ、ブルース・エッケルによる記事が現れ、彼がScalaで抱えていた問題点を説明した。彼はこのコードを書きました:
// Scala
val lines = fromString(data).getLines
val registrants = lines.map(Registrant)
registrants.foreach(println)
registrants.foreach(println)
とても簡単です。テキストの行を解析してRegistrant
オブジェクトに変換し、2回出力します。ただし、実際に印刷されるのは1回だけです。registrants
実際にはイテレータであるにもかかわらず、彼はそれをコレクションだと思ったことがわかりました。への2番目の呼び出しでforeach
は、すべての値を使い尽くした空のイテレータが検出されるため、何も出力されません。
この種の経験から、複数のトラバーサルを試行する場合、明確に予測可能な結果を得ることが非常に重要であることがわかりました。また、遅延パイプラインのような構造と、データを格納する実際のコレクションを区別することの重要性も強調されました。これにより、レイジーパイプライン操作が新しいStreamインターフェースに分離され、熱心な変異操作のみが直接コレクションに保持されます。ブライアン・ゲッツはその理由を説明しています。
コレクションベースのパイプラインでは複数のトラバーサルを許可し、非コレクションベースのパイプラインでは許可しないことはどうですか?一貫性はありませんが、理にかなっています。ネットワークから値を読み取っている場合は、もちろんそれらを再びトラバースすることはできません。それらを複数回トラバースする場合は、それらを明示的にコレクションにプルする必要があります。
しかし、コレクションベースのパイプラインから複数のトラバーサルを許可する方法を見てみましょう。あなたがこれをしたとしましょう:
Iterable<?> it = source.filter(...).map(...).filter(...).map(...);
it.into(dest1);
it.into(dest2);
(into
操作は今スペルされていcollect(toList())
ます。)
ソースがコレクションの場合、最初のinto()
呼び出しはイテレーターのチェーンをソースに戻し、パイプライン操作を実行して、結果を宛先に送信します。への2番目の呼び出しinto()
は、イテレータの別のチェーンを作成し、パイプライン操作を再度実行します。これは明らかに間違っているわけではありませんが、各要素に対してすべてのフィルター操作とマップ操作をもう一度実行する効果があります。多くのプログラマーがこの振る舞いに驚いたと思います。
上で述べたように、私たちはグアバの開発者と話していました。彼らが持っているクールなものの1つは、アイデアの墓地で、実装しないことに決めた機能とその理由を説明しています。レイジーコレクションのアイデアはかなりクールに聞こえますが、これについて彼らが言わなければならないことがあります。List.filter()
を返す操作を考えてみましょうList
:
ここでの最大の懸念は、あまりにも多くの操作が費用のかかる線形時間の命題になることです。コレクションやIterableだけでなく、リストをフィルタリングしてリストを取得したい場合は、を使用できますImmutableList.copyOf(Iterables.filter(list, predicate))
。これは、何をしているのか、どのくらいの費用がかかるかを「前もって表明」します。
具体的な例を取るために、コストの何get(0)
かsize()
一覧には?のような一般的に使用されるクラスの場合ArrayList
、それらはO(1)です。しかし、遅延フィルタリングされたリストでこれらの1つを呼び出す場合、バッキングリストに対してフィルターを実行する必要があり、突然これらの操作はすべてO(n)になります。さらに悪いことに、すべての操作でバッキングリストを走査する必要があります。
これはあまりにも怠惰であるように見えました。いくつかの操作を設定し、実際に実行を延期することは1つです。潜在的に大量の再計算を隠すような方法で設定することも別の方法です。
非線形または「再利用しない」ストリームを禁止することを提案する際に、Paul Sandozは、それらを許可することの潜在的な結果が「予期しないまたは混乱する結果」を引き起こすと説明しました。彼はまた、並列実行は物事をさらに難しくするだろうと述べました。最後に、予期しない操作が複数回、または少なくともプログラマーの予想とは異なる回数実行された場合、副作用のあるパイプライン操作は困難で不明瞭なバグにつながることを付け加えておきます。(しかし、Javaプログラマーは副作用のあるラムダ式を記述しませんか?それらを実行しますか?)
つまり、これがJava 8 Streams API設計の基本的な根拠であり、ワンショットトラバーサルを可能にし、厳密に線形(分岐なし)のパイプラインを必要とします。複数の異なるストリームソース間で一貫した動作を提供し、遅延操作と積極的な操作を明確に分離し、簡単な実行モデルを提供します。
に関してはIEnumerable
、私はC#と.NETの専門家とはほど遠いので、間違った結論を出した場合は(穏やかに)修正されることを願っています。ただし、IEnumerable
複数のトラバーサルがソースごとに異なる動作をすることは可能です。また、ネストされたIEnumerable
操作の分岐構造を許可するため、かなりの再計算が必要になる場合があります。システムによってトレードオフが異なることを理解していますが、これらはJava 8 Streams APIの設計で回避しようとした2つの特性です。
OPが提供するクイックソートの例は興味深く、不可解で、申し訳ありませんが、少し恐ろしいものです。呼び出しがQuickSort
かかるIEnumerable
と返しIEnumerable
、最終的にまではソートが実際に行われていないので、IEnumerable
横断されます。ただし、呼び出しで行われているように見えるのIEnumerables
は、実際にそれを行わずに、クイックソートが行うパーティション分割を反映するツリー構造を構築することです。(結局のところ、これは遅延計算です。)ソースにN個の要素がある場合、ツリーは最大でN個の要素の幅になり、深さはlg(N)レベルになります。
これは、C#や.NETの専門家ではないようですが、これによりints.First()
、を介したピボット選択などの特定の無害に見える呼び出しが、見た目よりも高価になるようです。もちろん、第1レベルではO(1)です。ただし、ツリーの深い右側のパーティションを検討してください。このパーティションの最初の要素を計算するには、ソース全体をトラバースする必要があります(O(N)操作)。ただし、上記のパーティションは遅延しているため、再計算する必要があり、O(lg N)比較が必要です。したがって、ピボットの選択はO(N lg N)演算であり、これはソート全体と同じくらい高価です。
しかし、返されたを走査するまで、実際にはソートしませんIEnumerable
。標準のクイックソートアルゴリズムでは、パーティション化のレベルごとにパーティション数が2倍になります。各パーティションのサイズは半分だけなので、各レベルはO(N)の複雑さのままです。パーティションのツリーはO(lg N)高であるため、総作業量はO(N lg N)です。
遅延IEnumerableのツリーでは、ツリーの下部にN個のパーティションがあります。各パーティションの計算には、N個の要素のトラバーサルが必要です。各要素のトラバースには、ツリー全体でlg(N)の比較が必要です。ツリーの下部にあるすべてのパーティションを計算するには、O(N ^ 2 lg N)の比較が必要です。
(これで正しいですか?信じられません。誰か確認してください。)
いずれにせよ、IEnumerable
複雑な計算構造を構築するためにこの方法を使用できることは確かに素晴らしいです。しかし、それが計算の複雑さを私が考えているほど増加させた場合、この方法でのプログラミングは、非常に注意深い場合を除いて避けられるべきものであるように思われます。