web-dev-qa-db-ja.com

Stream reduceメソッドでは、IDは常に合計の場合は0、乗算の場合は1である必要がありますか?

Java 8学習を続行します。

私は興味深い行動を見つけました:

コードサンプルを見てみましょう:

// identity value and accumulator and combiner
Integer summaryAge = Person.getPersons().stream()
        //.parallel()  //will return surprising result
        .reduce(1,
                (intermediateResult, p) -> intermediateResult + p.age,
                (ir1, ir2) -> ir1 + ir2);
System.out.println(summaryAge);

およびモデルクラス:

public class Person {

    String name;

    Integer age;
    ///...

    public static Collection<Person> getPersons() {
        List<Person> persons = new ArrayList<>();
        persons.add(new Person("Vasya", 12));
        persons.add(new Person("Petya", 32));
        persons.add(new Person("Serj", 10));
        persons.add(new Person("Onotole", 18));
        return persons;
   }
}

12+32+10+18 = 72。シーケンシャルストリームの場合、このコードは常に73である72 + 1を返しますが、パラレルの場合、このコードは常に76である72 + 4*1を返します(4はストリーム要素数に等しい)。

この結果を見たとき、並列ストリームと順次ストリームが異なる結果を返すのは奇妙だと思いました。

私はどこかで契約を破ったのですか?

P.S.

私にとって、73は期待される結果ですが、76はそうではありません。

14
gstackoverflow

ID値は、_x op identity = x_などの値です。これは、Java Streamsに固有ではない概念です。たとえば、 Wikipedia を参照してください。

単位元の例をいくつか示します。それらのいくつかは、Javaコードで直接表現できます。例:.

  • reduce("", String::concat)
  • reduce(true, (a,b) -> a&&b)
  • reduce(false, (a,b) -> a||b)
  • reduce(Collections.emptySet(), (a,b)->{ Set<X> s=new HashSet<>(a); s.addAll(b); return s; })
  • reduce(Double.POSITIVE_INFINITY, Math::min)
  • reduce(Double.NEGATIVE_INFINITY, Math::max)

任意のxの式_x + y == x_は、_y==0_の場合にのみ満たすことができるため、_0_が加算の単位元であることは明らかです。同様に、_1_は乗算の単位元です。

より複雑な例は

  • 述語のストリームを減らす

    _reduce(x->true, Predicate::and)
    reduce(x->false, Predicate::or)
    _
  • 関数のストリームを減らす

    _reduce(Function.identity(), Function::andThen)
    _
36
Holger

@ holger answer さまざまな関数のIDとは何かを大いに説明しますが、IDが必要な理由と理由parallelストリームとsequentialストリームの間で結果が異なります。

あなたの問題は、2つの要素を合計する方法を知っている要素のリストを合計するに減らすことができます。

それでは、リストL = {12,32,10,18}と合計関数(a,b) -> a + bを見てみましょう。

あなたが学校で学ぶようにあなたはするでしょう:

(12,32) -> 12 + 32 -> 44
(44,10) -> 44 + 10 -> 54
(54,18) -> 54 + 18 -> 72

リストがL = {12}になると想像してみてください。このリストを合計するにはどうすればよいですか?ここにアイデンティティ(x op identity = x)があります。

(0,12) -> 12

これで、+1の代わりに1を入力した場合に、合計に0が得られる理由を理解できます。これは、間違った値で初期化したためです。

(1,12) -> 1 + 12 -> 13
(13,32) -> 13 + 32 -> 45
(45,10) -> 45 + 10 -> 55
(55,18) -> 55 + 18 -> 73

では、どうすれば速度を向上させることができますか?物事を並列化する

リストを分割し、それらの分割されたリストを4つの異なるスレッド(4コアCPUを想定)に渡してから結合できるとしたらどうでしょうか。これにより、L1 = {12}L2 = {32}L3 = {10}L4 = {18}が得られます。

したがって、アイデンティティ= 1

  • thread1:(1,12) -> 1+12 -> 13
  • thread2:(1,32) -> 1+32 -> 33
  • thread3:(1,10) -> 1+10 -> 11
  • thread4:(1,18) -> 1+18 -> 19

次に、13 + 33 + 11 +19と等しい76を組み合わせます。これは、エラーが4回伝播される理由を説明しています。

この場合、並列処理の効率が低下する可能性があります。

ただし、この結果はマシンと入力リストによって異なります。 Javaは1000要素に対して1000スレッドを作成せず、入力が大きくなるにつれてエラーの伝播が遅くなります。

1000 1sを合計してこのコードを実行してみてください。結果は、1000にかなり近くなります。

public class StreamReduce {

public static void main(String[] args) {
        int sum = IntStream.range(0, 1000).map(i -> 1).parallel().reduce(1, (r, e) -> r + e);
        System.out.println("sum: " + sum);
    }
}

これで、IDコントラクトを破った場合に、並列と順次で異なる結果が得られる理由を理解する必要があります。

合計を書き込む適切な方法については、 Oracle doc を参照してください。


問題の正体は何ですか?

10
user43968

はい、あなたはコンバイナー機能の契約を破っています。 reduceの最初の要素であるIDは、combiner(identity, u) == uを満たす必要があります。 _Stream.reduce_ のJavadocを引用する:

ID値は、コンバイナー関数のIDである必要があります。これは、すべてのuについて、combiner(identity, u)uに等しいことを意味します。

ただし、コンバイナ関数は加算を実行し、_1_は加算の単位元ではありません。 _0_はです。

  • 使用するIDを_0_に変更すると、驚くことはありません。2つのオプションの結果は72になります。

  • あなた自身の娯楽のために、乗算を実行するようにコンバイナー関数を変更してください(アイデンティティを1に保ちます)、そしてあなたは両方のオプションで同じ結果に気付くでしょう。

IDが0でも1でもない例を作成しましょう。独自のドメインクラスがある場合は、次のことを考慮してください。

_System.out.println(Person.getPersons().stream()
                    .reduce("", 
                            (acc, p) -> acc.length() > p.name.length() ? acc : p.name,
                            (n1, n2) -> n1.length() > n2.length() ? n1 : n2));
_

これにより、Personのストリームが最も長い人物名になります。

5
Tunaki

Stream.reduce のJavaDocドキュメントには、具体的に次のように記載されています。

ID値は、コンバイナー関数のIDである必要があります

1は加算演算子のID値ではないため、予期しない結果が得られます。 0(加算演算子のID値)を使用した場合、シリアルストリームとパラレルストリームから同じ結果が得られます。

3
Ian Roberts

あなたの質問は本当に2つの部分に分かれています。シーケンシャルを使用して73を取得するのに、なぜパラレルを使用して76を取得するのですか。そして、乗算と加算がReduceに行く限り、アイデンティティは何ですか。

後者に答えると、最初の部分に答えるのに役立ちます。単位元は数学的な概念です。数学以外のオタクのために、簡単な言葉で表現しようと思います。 IDは、それ自体に適用された値が同じ値を返すことです。

加法単位元は0です。aが任意の数であると仮定すると、数の単位元プロパティは- aプラスそのIDはa。 (基本的に、a + 0 = a)。乗法的単位元はbその単位元(1)を掛けると、常にそれ自体を返しますb

Java reduceメソッドは、単位元をもう少し可変的に使用します。言うことができるように、必要に応じて、加算と乗算の演算を追加のステップで実行したいと思います。あなたはあなたの例を取ることになっていました:そしてアイデンティティを0に変えると、あなたは72を得るでしょう。

    Integer summaryAge = Person.getPersons().stream()
            .reduce(0, (intermediateResult, p) -> intermediateResult + p.age,
                    (ir1, ir2) -> ir1 + ir2);
    System.out.println(summaryAge);

これは単に年齢を合計し、その値を返します。 100に変更すると、172が返されます。しかし、並列で実行すると、結果が76になり、私の例では472が返されるのはなぜですか。これは、ストリームを使用すると、結果が個々の要素ではなくセットと見なされるためです。ストリームのJavaDocsによると:

ストリームは、個々の要素に対する必須の操作としてではなく、集約操作のパイプラインとして計算を再構成することにより、並列実行を容易にします。

標準ストリーム(非:parallelまたはparallelStream)を使用して、セットの処理が重要なのはなぜですか。例で行っているのは、合計を取り、それを単一の数値として処理することです。したがって、73を取得し、IDを100に変更すると、172を取得します。しかし、並列を使用すると、76を取得するのはなぜですか。または私の例では472? Javaはセットをより小さな(単一の)要素に分割し、そのアイデンティティ(1と述べた)を追加してそれを合計し、その結果を残りの要素に合計します。同じ操作。

結果に1を追加する場合は、Tagirの提案に従い、ストリームが戻った後の最後に1を追加する方が安全です。

2
chris m

前に投稿された優れた回答に加えて、ゼロ以外のもので合計を開始したい場合は、最初の加数をストリーム操作から移動するだけで済みます。

_Integer summaryAge = Person.getPersons().stream()
        //.parallel()  //will return no surprising result
        .reduce(0, (intermediateResult, p) -> intermediateResult + p.age,
                    (ir1, ir2) -> ir1 + ir2)+1;
_

他の削減操作についても同じことが可能です。たとえば、間違った.reduce(2, (a, b) -> a*b)を実行する代わりに、_2_で始まる積を計算する場合は、.reduce(1, (a, b) -> a*b)*2を実行できます。操作の実際のIDを見つけ、「偽のID」を外部に移動するだけで、シーケンシャルとパラレルの両方の場合に正しい結果が得られます。

最後に、問題を解決するためのより効率的な方法があることに注意してください。

_Integer summaryAge = Person.getPersons().stream()
        //.parallel()  //will return no surprising result
        .collect(Collectors.summingInt(p -> p.age))+1;
_

または代わりに

_Integer summaryAge = Person.getPersons().stream()
        //.parallel()  //will return no surprising result
        .mapToInt(p -> p.age).sum()+1;
_

ここでは、すべての中間ステップでボックス化せずに合計が実行されるため、はるかに高速になります。

1
Tagir Valeev

ここでは少し違った見方をしています。 @ user43968の回答 は、並列処理にIDが必要な理由を正当化するもっともらしい理由を示していますが、それは本当に必要ですか?二項演算子自体の結合性が、reduceジョブを並列化するのに十分だからではないと思います。

A op B op C op Dが与えられると、結合性はその評価が(A op B) op (C op D)と同等であることを保証します。これにより、サブ式(A op B)(C op D)を並行して評価し、後で結果を組み合わせることができます。最終結果を変更します。たとえば、加算演算、初期値= 10、L = [1、2、3]の場合、10 + 1 + 2 + 3 = 16を計算します。10+ 1 = 11と2を計算しても問題ありません。 + 3 = 5を並列に実行し、最後に11 + 5 = 16を実行します。

Javaが初期値を私が考えることができるアイデンティティである必要がある唯一の理由は、言語開発者が実装を単純にし、すべての並列化されたサブジョブを対称にしたかったからです。初期値を入力として受け取る最初のサブジョブとそうでない他のサブジョブを区別するために、今では、初期値を各サブジョブに均等に分配する必要があります。これは、それ自体が「削減」でもあります。

ただし、それは実装の制限に関するものであり、言語ユーザーのIMOには表示されるべきではありません。私の直感は、初期値がIDである必要のない単純な実装が存在する必要があることを示しています。

0
czheo