web-dev-qa-db-ja.com

なぜJavaストリームは一度きりですか?

実行パイプラインを必要な回数だけ実行できるC#のIEnumerableとは異なり、Javaではストリームは1回しか「反復」できません。

端末操作を呼び出すと、ストリームが閉じられ、使用できなくなります。この「機能」は多くの力を奪います。

この理由はnot technicalであると思います。この奇妙な制限の背後にある設計上の考慮事項は何でしたか?

編集:私が話していることを実証するために、C#でのQuick-Sortの以下の実装を検討してください。

IEnumerable<int> QuickSort(IEnumerable<int> ints)
{
  if (!ints.Any()) {
    return Enumerable.Empty<int>();
  }

  int pivot = ints.First();

  IEnumerable<int> lt = ints.Where(i => i < pivot);
  IEnumerable<int> gt = ints.Where(i => i > pivot);

  return QuickSort(lt).Concat(new int[] { pivot }).Concat(QuickSort(gt));
}

確かに、これがクイックソートの優れた実装であることを主張していません!ただし、これは、ラムダ式の表現力とストリーム操作の優れた例です。

そして、それはJavaではできません!ストリームを使用できないようにせずに、ストリームが空かどうかを確認することさえできません。

223
Vitaliy

Streams APIの初期設計からいくつかの思い出があり、設計の理論的根拠を明らかにするかもしれません。

2012年に、言語にラムダを追加していましたが、並列処理を容易にするラムダを使用してプログラムされたコレクション指向または「バルクデータ」操作のセットが必要でした。この時点で、操作を一緒に遅延連鎖するという考え方は確立されました。また、中間操作に結果を保存したくありませんでした。

決定する必要があった主な問題は、チェーン内のオブジェクトがAPIでどのように見え、データソースにどのように接続されたかでした。多くの場合、ソースはコレクションですが、ファイルやネットワークからのデータ、またはオンザフライで生成されたデータ、たとえば乱数ジェネレータからのデータもサポートしたいと考えました。

設計に対する既存の作業の多くの影響がありました。より影響力があったのは、Googleの Guava ライブラリとScalaコレクションライブラリです。 (グアバの影響に誰もが驚いた場合、グアバのリード開発者である Kevin BourrillionJSR-335 Lambda エキスパートグループにいたことに注意してください。)Scalaコレクションでは、 Martin Oderskyによるこの講演は、特に興味深いものであることがわかりました。 将来の校正Scalaコレクション:可変から永続へ、並列 。 (Stanford EE380、2011年6月1日。)

当時のプロトタイプ設計は、Iterableに基づいていました。おなじみの操作filtermapなどは、Iterableの拡張(デフォルト)メソッドでした。 1つを呼び出すと、チェーンに操作が追加され、別のIterableが返されました。 countのような端末操作は、ソースまでのチェーンのiterator()を呼び出し、操作は各ステージのIterator内に実装されました。

これらはIterableであるため、iterator()メソッドを複数回呼び出すことができます。それではどうなりますか?

ソースがコレクションの場合、これはほとんど正常に機能します。コレクションはIterableであり、iterator()を呼び出すたびに、他のアクティブなインスタンスから独立した別個のIteratorインスタンスが生成され、それぞれがコレクションを個別に走査します。すばらしいです。

さて、ソースがファイルから行を読み込むようなワンショットの場合はどうでしょうか?最初のイテレータはすべての値を取得する必要がありますが、2番目以降の値は空でなければなりません。おそらく、値はイテレーター間でインターリーブされる必要があります。または、各イテレータがすべて同じ値を取得する必要があります。次に、2つのイテレーターがあり、一方が他方よりも先に進んだ場合はどうなりますか?誰かが値を読み取るまで、2番目のイテレータの値をバッファリングする必要があります。さらに悪いことに、1つのイテレータを取得してすべての値を読み取り、thenのみが2番目のイテレータを取得した場合はどうなりますか。値は今からどこに来るのですか?それらすべてを念のためにバッファリングする必要がありますか誰かが2番目のイテレータを必要としていますか?

明らかに、ワンショットソースに対して複数のイテレータを許可すると、多くの疑問が生じます。良い答えがありませんでした。 iterator()を2回呼び出した場合の動作について、一貫した予測可能な動作が必要でした。これにより、複数の走査を禁止し、パイプラインをワンショットにしました。

また、他の人がこれらの問題にぶつかるのを観察しました。 JDKでは、ほとんどのIterableはコレクションまたはコレクションのようなオブジェクトであり、複数のトラバーサルを許可します。どこにも指定されていませんが、Iterablesが複数のトラバーサルを許可するという書かれていない期待があったようです。注目すべき例外は、NIO DirectoryStream インターフェイスです。その仕様には、この興味深い警告が含まれています。

DirectoryStreamはIterableを拡張しますが、単一のIteratorのみをサポートするため、汎用Iterableではありません。 2番目以降の反復子を取得するために反復子メソッドを呼び出すと、IllegalStateExceptionがスローされます。

[元の太字]

これは珍しくて不快に思えたので、一度限りの新しいIterableを大量に作成したくありませんでした。これはIterableの使用から私たちを遠ざけました。

この頃、 ブルース・エッケルの記事 が登場し、彼がScalaで抱えていた問題点を説明しました。彼はこのコードを書いた:

// Scala
val lines = fromString(data).getLines
val registrants = lines.map(Registrant)
registrants.foreach(println)
registrants.foreach(println)

とても簡単です。テキスト行をRegistrantオブジェクトに解析し、2回出力します。実際には一度だけ印刷されることを除きます。 registrantsはコレクションであると思っていましたが、実際にはイテレーターです。 foreachの2回目の呼び出しでは、空のイテレーターが検出されます。このイテレーターからすべての値が使い果たされているため、何も出力されません。

この種の経験は、複数のトラバーサルが試みられる場合、明確に予測可能な結果を​​得ることが非常に重要であると私たちを確信させました。また、データを格納する実際のコレクションと遅延パイプラインのような構造を区別することの重要性を強調しました。これにより、レイジーパイプライン操作は新しいストリームインターフェイスに分離され、コレクションでは直接、熱心で変更可能な操作のみが維持されました。 ブライアンゲッツが説明した その理由。

コレクションベースのパイプラインでは複数のトラバーサルを許可しますが、コレクションベースではないパイプラインでは許可しませんか?一貫性はありませんが、理にかなっています。ネットワークから値を読み取っている場合、もちろん再びトラバースすることはできません。複数回トラバースする場合は、明示的にコレクションにプルする必要があります。

しかし、コレクションベースのパイプラインから複数のトラバースを許可する方法を探ってみましょう。あなたがこれをしたとしましょう:

Iterable<?> it = source.filter(...).map(...).filter(...).map(...);
it.into(dest1);
it.into(dest2);

into操作のスペルはcollect(toList())です。)

ソースがコレクションの場合、最初のinto()呼び出しは、ソースへのイテレーターのチェーンを作成し、パイプライン操作を実行し、結果を宛先に送信します。 into()の2回目の呼び出しは、イテレーターの別のチェーンを作成し、パイプライン操作againを実行します。これは明らかに間違いではありませんが、各要素に対してすべてのフィルター操作とマップ操作を2回実行する効果があります。多くのプログラマーはこの振る舞いに驚いたと思います。

上で述べたように、私たちはグアバの開発者と話していました。彼らが持っているクールなものの1つは、 Idea Graveyard であり、彼らは彼らが決定した機能を記述しているnot理由とともに実装する。怠zyなコレクションのアイデアはとてもクールに聞こえますが、ここで彼らがそれについて言わなければならないことがあります。 Listを返すList.filter()操作を考えてみましょう。

ここでの最大の懸念は、非常に多くの操作がコストのかかる線形時間の命題になることです。リストをフィルタリングして、コレクションやIterableだけでなくリストを取得したい場合は、ImmutableList.copyOf(Iterables.filter(list, predicate))を使用できます。

特定の例を挙げると、リストのget(0)またはsize()のコストはいくらですか? ArrayListのような一般的に使用されるクラスの場合、それらはO(1)です。ただし、遅延フィルタリングリストでこれらのいずれかを呼び出すと、バッキングリストに対してフィルターを実行する必要があり、これらの操作はすべて突然O(n)になります。さらに悪いことに、every操作でバッキングリストを走査する必要があります。

これは、too muchlazinessのように思えました。いくつかの操作をセットアップし、実際に実行を延期するのは、「進む」までです。潜在的に大量の再計算を隠すような方法で物事を設定することは別です。

非線形または「再利用なし」のストリームを禁止することを提案する際に、 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()を介したピボット選択など、特定の無害に見える呼び出しが、見た目よりも高くなるようです。もちろん、最初のレベルではO(1)です。ただし、ツリーの奥、右側のエッジにパーティションを検討してください。このパーティションの最初の要素を計算するには、ソース全体(O(N)操作)を走査する必要があります。ただし、上記のパーティションは遅延しているため、再計算する必要があり、O(lg N)比較が必要です。そのため、ピボットの選択はO(N lg N)操作になりますが、これはソート全体と同じくらい高価です。

しかし、返されたIEnumerableをトラバースするまで、実際にはソートしません。標準のクイックソートアルゴリズムでは、パーティション分割の各レベルでパーティション数が2倍になります。各パーティションのサイズは半分に過ぎないため、各レベルはO(N)複雑度のままです。パーティションのツリーの高さはO(lg N)なので、合計作業量はO(N lg N)です。

遅延IEnumerablesのツリーでは、ツリーの下部にN個のパーティションがあります。各パーティションの計算には、N個の要素のトラバースが必要です。各要素では、ツリーの上のlg(N)比較が必要です。ツリーの下部にあるすべてのパーティションを計算するには、O(N ^ 2 lg N)比較が必要です。

(これは正しいですか?私はこれを信じることができません。誰かが私のためにこれをチェックしてください。)

いずれにせよ、IEnumerableをこの方法で使用して、複雑な計算構造を構築できるのは本当に素晴らしいことです。しかし、もしそれが私が思っているほど計算の複雑さを増すなら、この方法でプログラミングすることは、非常に注意しない限り避けるべきであるように思えます。

352
Stuart Marks

バックグラウンド

質問は単純に見えますが、実際の答えには理にかなった背景が必要です。結論にスキップしたい場合は、下にスクロールしてください...

比較ポイントを選ぶ-基本機能

基本的な概念を使用すると、C#のIEnumerableの概念は JavaのIterable とより密接に関連します。これは、必要なだけ Iterators を作成できます。 IEnumerables create IEnumerators 。 JavaのIterable create Iterators

IEnumerableIterableの両方に、データコレクションのメンバーに対する「for-each」スタイルのループを許可する基本的な動機があるという点で、各概念の歴史は似ています。両方ともそれ以上のものを許可しているため、これは単純化しすぎており、異なる段階を経てその段階に到達しましたが、それは重要な共通機能です。

その機能を比較しましょう:両方の言語で、クラスがIEnumerable/Iterableを実装する場合、そのクラスは少なくとも1つのメソッドを実装する必要があります(C#の場合はGetEnumerator、Javaの場合はiterator()です)。いずれの場合も、その(IEnumerator/Iterator)から返されるインスタンスを使用して、データの現在および後続のメンバーにアクセスできます。この機能は、for-each言語構文で使用されます。

比較ポイントを選択-拡張機能

C#のIEnumerableは、他の多くの言語機能を使用できるように拡張されました( 主にLinqに関連 )。追加された機能には、選択、予測、集計などがあります。これらの拡張機能には、SQLおよびリレーショナルデータベースの概念と同様に、集合論での使用から強い動機があります。

Java 8には、StreamsとLambdasを使用したある程度の関数型プログラミングを可能にする機能も追加されています。 Java 8ストリームの主な目的は集合論ではなく、関数型プログラミングです。とにかく、多くの類似点があります。

だから、これは2番目のポイントです。 C#に対する機能強化は、IEnumerableコンセプトの機能強化として実装されました。ただし、Javaでは、LambdasおよびStreamsの新しい基本概念を作成し、IteratorsおよびIterablesからStreams、およびvisa-versaに変換する比較的簡単な方法を作成することにより、強化が実装されました。

そのため、IEnumerableをJavaのStreamコンセプトと比較することは不完全です。 JavaのStreams and Collections APIの組み合わせと比較する必要があります。

Javaでは、ストリームはイテラブルまたはイテレータと同じではありません

ストリームは、イテレータと同じように問題を解決するようには設計されていません。

  • イテレータは、データのシーケンスを記述する方法です。
  • ストリームは、データ変換のシーケンスを記述する方法です。

Iteratorを使用すると、データ値を取得して処理し、別のデータ値を取得できます。

Streamsでは、一連の関数を連鎖させてから、入力値をストリームにフィードし、結合されたシーケンスから出力値を取得します。 Javaの用語では、各関数は単一のStreamインスタンスにカプセル化されていることに注意してください。 Streams APIを使用すると、一連の変換式をチェーンする方法で、一連のStreamインスタンスをリンクできます。

Streamの概念を完成させるには、ストリームをフィードするデータのソースと、ストリームを消費する端末関数が必要です。

ストリームに値を入力する方法は、実際にはIterableからのものかもしれませんが、Streamシーケンス自体はIterableではなく、複合関数です。

Streamは、値を要求したときにのみ機能するという意味で、遅延することも意図しています。

Streamsのこれらの重要な仮定と機能に注意してください。

  • JavaのStreamは変換エンジンであり、ある状態のデータ項目を別の状態に変換します。
  • ストリームにはデータの順序や位置の概念はなく、要求されたものをすべて単純に変換します。
  • ストリームには、他のストリーム、イテレーター、イテラブル、コレクション、
  • ストリームを「リセット」することはできません。これは「変換の再プログラミング」のようなものです。データソースのリセットはおそらくあなたが望むものです。
  • 論理的には、常にストリーム内に「飛行中」のデータ項目は1つだけです(ストリームが並列ストリームである場合を除き、その時点でスレッドごとに1つの項目があります)。これは、ストリームに「準備ができた」現在のアイテムよりも多くのデータソース、または複数の値を集約して削減する必要のあるストリームコレクターには依存しません。
  • ストリームは、バインドされていない(無限)場合があり、データソースまたはコレクター(無限の場合もあります)によってのみ制限されます。
  • ストリームは「チェーン可能」です。1つのストリームのフィルタリングの出力は別のストリームです。ストリームに入力され、ストリームによって変換された値は、異なる変換を行う別のストリームに順番に提供できます。データは、変換された状態で、あるストリームから次のストリームに流れます。あるストリームから介入してデータをプルし、次のストリームにプラグインする必要はありません。

C#の比較

Javaストリームが供給、ストリーム、および収集システムの一部であり、ストリームおよびイテレーターがコレクションと一緒に使用されることが多いことを考慮すると、同じ概念に関連するのが難しいのも不思議ではありませんほとんどすべてが、C#の単一のIEnumerableコンセプトに埋め込まれています。

IEnumerableの一部(および密接に関連する概念)は、Java Iterator、Iterable、Lambda、およびStreamのすべての概念で明らかです。

Javaの概念でできる小さなことは、IEnumerableやその逆では難しいことです。


結論

  • ここには設計上の問題はなく、言語間の概念のマッチングに問題があります。
  • ストリームは別の方法で問題を解決します
  • ストリームはJavaに機能を追加します(それらは物事の別の方法を追加し、機能を奪いません)

Streamsを追加すると、問題を解決する際により多くの選択肢が得られます。これは、「削減」、「排除」、または「制限」ではなく、「力の強化」として分類するのに適しています。

なぜJavaストリームは一度きりですか?

ストリームはデータではなく関数シーケンスであるため、この質問は見当違いです。ストリームをフィードするデータソースに応じて、データソースをリセットし、同じストリームまたは異なるストリームをフィードできます。

実行パイプラインを必要な回数だけ実行できるC#のIEnumerableとは異なり、Javaではストリームを1回だけ「反復」できます。

IEnumerableStreamの比較は間違っています。 IEnumerableを言うために使用しているコンテキストは、何度でも実行できるJava Iterablesに比べて、何度でも実行できます。 Java Streamは、IEnumerableコンセプトのサブセットを表し、データを提供するサブセットではないため、「再実行」できません。

端末操作を呼び出すと、ストリームが閉じられ、使用できなくなります。この「機能」は多くの力を奪います。

ある意味で、最初のステートメントは真実です。 「権力を奪う」声明はそうではありません。あなたはまだIEnumerablesとStreamsを比較しています。ストリームの端末操作は、forループの「break」句のようなものです。必要に応じて、必要なデータを再供給できる場合は、いつでも別のストリームを自由に使用できます。繰り返しになりますが、IEnumerableIterableのようなものと考えると、このステートメントでは、Javaでうまくいきます。

この理由は技術的なものではないと思います。この奇妙な制限の背後にある設計上の考慮事項は何でしたか?

理由は技術的なものであり、単純な理由により、ストリームはそれが何であるかを考えるサブセットです。ストリームのサブセットはデータの供給を制御しないため、ストリームではなく供給をリセットする必要があります。その文脈では、それはそれほど奇妙ではありません。

QuickSortの例

クイックソートの例には次の署名があります。

IEnumerable<int> QuickSort(IEnumerable<int> ints)

入力IEnumerableをデータソースとして扱っています:

IEnumerable<int> lt = ints.Where(i => i < pivot);

また、戻り値もIEnumerableです。これはデータの供給であり、これはソート操作であるため、その供給の順序は重要です。 Java Iterableクラスがこれに適切な一致であると見なす場合、具体的にはListIterable特殊化、Listは保証された順序または反復を持つデータの供給であるため、コードと同等のJavaコードは次のようになります:

Stream<Integer> quickSort(List<Integer> ints) {
    // Using a stream to access the data, instead of the simpler ints.isEmpty()
    if (!ints.stream().findAny().isPresent()) {
        return Stream.of();
    }

    // treating the ints as a data collection, just like the C#
    final Integer pivot = ints.get(0);

    // Using streams to get the two partitions
    List<Integer> lt = ints.stream().filter(i -> i < pivot).collect(Collectors.toList());
    List<Integer> gt = ints.stream().filter(i -> i > pivot).collect(Collectors.toList());

    return Stream.concat(Stream.concat(quickSort(lt), Stream.of(pivot)),quickSort(gt));
}    

バグが存在することに注意してください(これを再現しました)。このソートは重複値を適切に処理せず、「一意の値」ソートです。

また、Javaコードがデータソース(List)をどのように使用し、異なるポイントでストリームの概念をどのように使用するか、C#ではこれら2つの「パーソナリティ」をIEnumerableで表現できることに注意してください。また、基本型としてListを使用していますが、より一般的なCollectionを使用することもできます。また、イテレーターからストリームへの小さな変換で、さらに一般的なIterableを使用することもできます。

120
rolfl

Streamname__sは、ステートフルな可変オブジェクトであるSpliteratorname__sを中心に構築されます。 「リセット」アクションはありません。実際、そのような巻き戻しアクションをサポートする必要があると、「多くのパワーを奪う」ことになります。どのように Random.ints() はそのようなリクエストを処理することになっていますか?

一方、リトレース可能なOriginを持つStreamname__sの場合、再度使用する同等のStreamname__を簡単に作成できます。 Streamname__を作成するためのステップを再利用可能なメソッドに入れるだけです。これらの手順はすべて遅延操作であるため、これらの手順を繰り返すことは高価な操作ではないことに注意してください。実際の作業は端末操作から始まり、実際の端末操作によってまったく異なるコードが実行される場合があります。

このようなメソッドの作成者であるあなたは、メソッドを2回呼び出すことで何が暗示されるかを指定するのはあなた次第です:変更されていない配列またはコレクション用に作成されたストリームとまったく同じシーケンスを再現するか、ランダムな整数のストリームやコンソール入力行のストリームなど、セマンティクスは似ていますが要素が異なります。


ちなみに、混乱を避けるために、端末操作consumesストリームでclose()を呼び出すときにcloseStreamname__とは異なるStreamname__が(関連付けられているストリームに必要です)たとえば、Files.lines()によって生成されるリソース。


IEnumerablename__とStreamname__の比較を誤解しているため、多くの混乱が生じているようです。 IEnumerablename__は、実際のIEnumeratorname__を提供する機能を表すため、JavaのIterablename__に似ています。対照的に、Streamname__はイテレーターの一種であり、IEnumeratorname__に匹敵するため、この種のデータ型を.NETで複数回使用できると主張するのは間違っています。IEnumerator.Resetのサポートはオプションです。ここで説明する例では、IEnumerablename__を使用してnewIEnumeratorname__sを取得でき、JavaのCollectionname__sでも機能するという事実を使用しています。新しいStreamname__を取得できます。 Java開発者がStreamname__操作をIterablename__に直接追加し、中間操作が別のIterablename__を返すことを決定した場合、それは実際に同等であり、同じように機能します。

ただし、開発者はそれに対して決定し、決定は この質問 で説明されています。最大のポイントは、熱心なコレクション操作と遅延ストリーム操作に関する混乱です。 .NET APIを見ると、私は(はい、個人的に)それが正当であると感じています。 IEnumerablename__だけを見ると妥当なように見えますが、特定のコレクションには、コレクションを直接操作する多くのメソッドと、怠laなIEnumerablename__を返す多くのメソッドがありますが、メソッドの特定の性質は常に直感的に認識できるとは限りません。私が見つけた最悪の例(数分以内に見た)は、名前が一致する List.Reverse()exactly継承された名前(これは拡張メソッドの正しい終端ですか? ) Enumerable.Reverse() 完全に矛盾する動作をしています。


もちろん、これらは2つの明確な決定です。 Streamname__をIterablename __/Collectionname__とは異なる型にする最初のメソッドと、Streamname__を別の種類の反復可能要素ではなく、ある種の1回限りの反復子にするための2番目の要素。しかし、これらの決定は一緒に行われたものであり、これら2つの決定を分離することは考慮されなかった可能性があります。 .NETに匹敵するものとして作成されたのではありません。

実際のAPI設計の決定は、イテレーターの改良型であるSpliteratorname__を追加することでした。 Spliteratorname__sは、古いIterablename__s(これが後付けされた方法です)または完全に新しい実装によって提供されます。次に、Streamname__が、かなり低レベルのSpliteratorname__sに高レベルのフロントエンドとして追加されました。それでおしまい。別のデザインの方が良いかどうかについて話し合うこともできますが、それは生産的ではなく、現在のデザイン方法を考えると変わりません。

考慮しなければならない別の実装の側面があります。 Streamname__sはnot不変のデータ構造です。各中間操作は、古いものをカプセル化する新しいStreamname__インスタンスを返すことがありますが、代わりに独自のインスタンスを操作してそれ自体を返すこともあります(同じ操作に対して両方を行うことを妨げません)。よく知られている例は、parallelname__やunorderedname__などの操作です。これらの操作は、別のステップを追加するのではなく、パイプライン全体を操作します)。このような可変データ構造を持ち、再利用(またはさらに悪いことに、同時に複数回使用)しようとすると、うまくいきません…


完全を期すために、Java StreamAPIに翻訳されたクイックソートの例を次に示します。それは実際には「多くの力を奪う」わけではないことを示しています。

static Stream<Integer> quickSort(Supplier<Stream<Integer>> ints) {

  final Optional<Integer> optPivot = ints.get().findAny();
  if(!optPivot.isPresent()) return Stream.empty();

  final int pivot = optPivot.get();

  Supplier<Stream<Integer>> lt = ()->ints.get().filter(i -> i < pivot);
  Supplier<Stream<Integer>> gt = ()->ints.get().filter(i -> i > pivot);

  return Stream.of(quickSort(lt), Stream.of(pivot), quickSort(gt)).flatMap(s->s);
}

次のように使用できます

List<Integer> l=new Random().ints(100, 0, 1000).boxed().collect(Collectors.toList());
System.out.println(l);
System.out.println(quickSort(l::stream)
    .map(Object::toString).collect(Collectors.joining(", ")));

さらにコンパクトに書くことができます

static Stream<Integer> quickSort(Supplier<Stream<Integer>> ints) {
    return ints.get().findAny().map(pivot ->
         Stream.of(
                   quickSort(()->ints.get().filter(i -> i < pivot)),
                   Stream.of(pivot),
                   quickSort(()->ints.get().filter(i -> i > pivot)))
        .flatMap(s->s)).orElse(Stream.empty());
}
20
Holger

よく見ると、両者の違いはほとんどないと思います。

一見、IEnumerableは再利用可能な構造体のように見えます。

IEnumerable<int> numbers = new int[] { 1, 2, 3, 4, 5 };

foreach (var n in numbers) {
    Console.WriteLine(n);
}

ただし、実際にはコンパイラーは私たちを助けるために少しの作業を行っています。次のコードを生成します。

IEnumerable<int> numbers = new int[] { 1, 2, 3, 4, 5 };

IEnumerator<int> enumerator = numbers.GetEnumerator();
while (enumerator.MoveNext()) {
    Console.WriteLine(enumerator.Current);
}

列挙型を実際に反復処理するたびに、コンパイラは列挙子を作成します。列挙子は再利用できません。 MoveNextをさらに呼び出すと、falseが返されるだけで、先頭にリセットする方法はありません。再度数値を反復処理する場合は、別の列挙子インスタンスを作成する必要があります。


IEnumerableがJavaストリームと同じ「機能」を持っている(持つことができる)ことをわかりやすく説明するために、数値のソースが静的コレクションではない列挙型を考えます。たとえば、5つの乱数のシーケンスを生成する列挙可能なオブジェクトを作成できます。

class Generator : IEnumerator<int> {
    Random _r;
    int _current;
    int _count = 0;

    public Generator(Random r) {
        _r = r;
    }

    public bool MoveNext() {
        _current= _r.Next();
        _count++;
        return _count <= 5;
    }

    public int Current {
        get { return _current; }
    }
 }

class RandomNumberStream : IEnumerable<int> {
    Random _r = new Random();
    public IEnumerator<int> GetEnumerator() {
        return new Generator(_r);
    }
    public IEnumerator IEnumerable.GetEnumerator() {
        return this.GetEnumerator();
    }
}

これで、以前の配列ベースの列挙型と非常によく似たコードができましたが、numbersの2回目の反復があります。

IEnumerable<int> numbers = new RandomNumberStream();

foreach (var n in numbers) {
    Console.WriteLine(n);
}
foreach (var n in numbers) {
    Console.WriteLine(n);
}

2回目にnumbersを反復処理すると、異なる数のシーケンスが得られますが、これは同じ意味では再利用できません。または、RandomNumberStreamを記述して、それを複数回反復しようとすると例外をスローし、列挙型を実際に使用できなくします(Javaストリームのように)。

また、RandomNumberStreamに適用された場合、列挙ベースのクイックソートは何を意味しますか?


結論

したがって、最大の違いは、.NETを使用すると、シーケンス内の要素にアクセスする必要があるときに、バックグラウンドで暗黙的に新しいIEnumerableを作成することでIEnumeratorを再利用できることです。

この暗黙的な動作は、コレクションに対して繰り返し処理を繰り返すことができるため、多くの場合に便利です(そして、あなたが述べているように「強力」です)。

ただし、この暗黙の動作が実際に問題を引き起こす場合があります。データソースが静的でない場合、またはデータベースやWebサイトのようにアクセスにコストがかかる場合は、IEnumerableに関する多くの前提を破棄する必要があります。再利用はそれほど単純ではありません

8
Andrew Vermie

Stream APIの「1回実行」保護の一部をバイパスすることができます。たとえば、Spliteratorを直接参照するのではなく、Streamを参照して再利用することで、Java.lang.IllegalStateException例外を回避できます(「ストリームは既に操作されているか閉じられています」というメッセージが表示されます)。

たとえば、次のコードは例外をスローせずに実行されます。

    Spliterator<String> split = Stream.of("hello","world")
                                      .map(s->"prefix-"+s)
                                      .spliterator();

    Stream<String> replayable1 = StreamSupport.stream(split,false);
    Stream<String> replayable2 = StreamSupport.stream(split,false);


    replayable1.forEach(System.out::println);
    replayable2.forEach(System.out::println);

ただし、出力はに制限されます

prefix-hello
prefix-world

出力を2回繰り返すのではなく。これは、ArraySpliteratorソースとして使用されるStreamがステートフルであり、現在の位置を格納するためです。このStreamを再生すると、最後から再開します。

この課題を解決するためのオプションがいくつかあります。

  1. Stream#generate()などのステートレスStream作成メソッドを使用できます。独自のコードで状態を外部で管理し、Stream "replays"の間でリセットする必要があります。

    Spliterator<String> split = Stream.generate(this::nextValue)
                                      .map(s->"prefix-"+s)
                                      .spliterator();
    
    Stream<String> replayable1 = StreamSupport.stream(split,false);
    Stream<String> replayable2 = StreamSupport.stream(split,false);
    
    
    replayable1.forEach(System.out::println);
    this.resetCounter();
    replayable2.forEach(System.out::println);
    
  2. これに対する別の(わずかに優れているが完全ではない)解決策は、現在のカウンターをリセットするための容量を含む独自のArraySpliterator(または同様のStreamソース)を記述することです。それを使用してStreamを生成する場合、それらを正常に再生できる可能性があります。

    MyArraySpliterator<String> arraySplit = new MyArraySpliterator("hello","world");
    Spliterator<String> split = StreamSupport.stream(arraySplit,false)
                                            .map(s->"prefix-"+s)
                                            .spliterator();
    
    Stream<String> replayable1 = StreamSupport.stream(split,false);
    Stream<String> replayable2 = StreamSupport.stream(split,false);
    
    
    replayable1.forEach(System.out::println);
    arraySplit.reset();
    replayable2.forEach(System.out::println);
    
  3. (私の意見では)この問題の最善の解決策は、Spliteratorで新しい演算子が呼び出されたときに、Streamパイプラインで使用されるステートフルなStreamsの新しいコピーを作成することです。これは実装がより複雑で複雑ですが、サードパーティのライブラリを使用してもかまわない場合、 cyclops-react にはまさにこれを行うStream実装があります。 (開示:私はこのプロジェクトの主任開発者です。)

    Stream<String> replayableStream = ReactiveSeq.of("hello","world")
                                                 .map(s->"prefix-"+s);
    
    
    
    
    replayableStream.forEach(System.out::println);
    replayableStream.forEach(System.out::println);
    

これは印刷されます

prefix-hello
prefix-world
prefix-hello
prefix-world

予想通り。

1
John McClean