HOWTO · Java

Java 8 ストリームのプロパティによる区別

このチュートリアルでは、Java でストリームを使用してプロパティごとに異なる機能を示します。

このページの内容

Java のストリームから特定のプロパティを基準にオブジェクトの重複を除くには、キーごとにどのオブジェクトを残すか、結果に遭遇順序が必要かを決めます。Stream.distinct() は equals() と hashCode() を使ってオブジェクト全体を比較します。コレクターやキーに基づくフィルターを使えば、オブジェクトの等価性の契約を変えずにプロパティを選べます。

Stream.distinct() が等価性を判定する仕組み

Stream.distinct() は、equals() に従って等しいオブジェクトのグループごとに、要素を1つ残します。ハッシュベースの実装では、対応する hashCode() も使われます。Person クラスがオブジェクト同一性に基づく等価性を継承している場合、名前が同じでも別々のインスタンスは異なるものとして扱われます。クラスで両方のメソッドを name に基づいて定義すれば、distinct() で名前ごとに重複を除けますが、そのクラスを使うすべての箇所で等価性が変わります。

このストリーム内だけでキーを選ぶには、プロパティを取り出すか、そのキーを使ってオブジェクトをラップします。以下の例では、ID と名前を持つ人物を使います。ID 1 の Alice は ID 3 の Alice より先に現れ、ID 2 の Bob は ID 5 の Bob より先に現れます。各方法でどの要素を残すかは明示されています。

プロパティ別に重複を除く方法を選ぶ

キーごとにオブジェクトを1つ残し、どの重複を採用するか指定できる場合は Collectors.toMap() を使います。選択前にキーごとの全オブジェクトが必要な場合は Collectors.groupingBy() を使います。キーのラッパーを使うと、元のクラスの等価性を保ったまま distinct() で比較できます。distinctByKey の述語や Set を使うフィルターは、逐次ストリームでは簡潔ですが、変更可能な状態を持つため、一般的な並列ストリーム向けの解決策ではありません。

次の完全な Java 8 プログラムでは、同じ順序付き入力にこれらの方法を適用します。map、グループ化、ラッパー、逐次フィルターの各例では、最初のオブジェクトを残します。このパケットには実行時の出力記録がないため、出力例は掲載していません。

次の Java 8 の例を実行します。

このセクションのコードと表示されるラベルや出力は英語のままです。

import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class DistinctByPropertyExample {
  static class Person {
    final int id;
    final String name;

    Person(int id, String name) {
      this.id = id;
      this.name = name;
    }

    String getName() { return name; }
    public String toString() { return name + "#" + id; }
  }

  static class Keyed<K, V> {
    final K key;
    final V value;

    Keyed(K key, V value) {
      this.key = key;
      this.value = value;
    }

    V value() { return value; }
    public boolean equals(Object other) {
      if (this == other) return true;
      if (!(other instanceof Keyed)) return false;
      Keyed<?, ?> that = (Keyed<?, ?>) other;
      return Objects.equals(key, that.key);
    }
    public int hashCode() { return Objects.hashCode(key); }
  }

  static <T, K> Predicate<T> distinctByKey(Function<? super T, ? extends K> keyOf) {
    Set<K> seen = new HashSet<K>();
    return item -> seen.add(keyOf.apply(item));
  }

  public static void main(String[] args) {
    List<Person> people = Arrays.asList(
        new Person(1, "Alice"), new Person(2, "Bob"),
        new Person(3, "Alice"), new Person(4, "Charlie"),
        new Person(5, "Bob"));

    Map<String, Person> byName = people.stream().collect(Collectors.toMap(
        Person::getName, Function.identity(), (first, later) -> first,
        LinkedHashMap::new));
    System.out.println("toMap: " + byName.values());

    Map<String, List<Person>> groups = people.stream()
        .collect(Collectors.groupingBy(Person::getName));
    List<Person> firstFromGroups = groups.values().stream()
        .map(group -> group.get(0)).collect(Collectors.toList());
    System.out.println("groupingBy: " + firstFromGroups);

    List<Person> byWrapper = people.stream()
        .map(person -> new Keyed<String, Person>(person.getName(), person))
        .distinct().map(Keyed::value).collect(Collectors.toList());
    System.out.println("wrapper: " + byWrapper);

    List<Person> byPredicate = people.stream()
        .filter(distinctByKey(Person::getName)).collect(Collectors.toList());
    System.out.println("distinctByKey: " + byPredicate);

    Set<String> seen = new HashSet<String>();
    List<Person> bySetFilter = people.stream()
        .filter(person -> seen.add(person.getName()))
        .collect(Collectors.toList());
    System.out.println("Set filter: " + bySetFilter);
  }
}

Collectors.toMap() でキーごとに1つの値を残す

Collectors.toMap() は、取り出したキーごとに map のエントリーを1つ作ります。この入力には同じ名前が複数あるため、3引数のオーバーロードでマージ関数を指定します。(first, later) -> first は最初に遭遇した人物を残し、(first, later) -> later なら最後の人物を残します。マージ関数を指定せずに重複キーを渡すと、コレクションへの変換は失敗します。

この例では LinkedHashMap::new を使っているため、この逐次入力では map のキーの挿入順が保たれます。デフォルトの map コレクターでは、values() の反復順序が遭遇順序と一致する保証はありません。順序が重要な場合は、順序付き map を明示的に選び、その要件を記載してください。ハッシュベースのコレクションと同様、map のキーにも適切な等価性とハッシュコードの動作が必要です。

Collectors.groupingBy() で値をグループ化する

Collectors.groupingBy(Person::getName) は、名前ごとにすべての人物を含むリストを作ります。その後 group.get(0) を選ぶと、この順序付き逐次ストリームでは各グループの最初の要素が残ります。後続の処理でグループ全体が必要な場合に便利です。キーごとに値を1つだけ得たいなら、toMap() のほうが意図を直接表し、重複分をリストに保存せずに済みます。

デフォルトの groupingBy() が返す map では、キーの反復順序は保証されません。そのため、この例が示すのはどの要素を選ぶかであり、表示されるグループの移植可能な順序ではありません。結果の要件にキーの順序が含まれる場合は、map の生成方法を明示してください。

distinct() の前にキーでラップする

プログラム内の Keyed<K, V> ラッパーは、取り出したキーと元のオブジェクトの両方を保持します。その equals() と hashCode() はキーだけを使うため、distinct() は名前を比較し、その後の map(Keyed::value) で元の人物を取り出せます。これにより Person の等価性を変えずに、順序付き逐次ストリームで最初に一致したラッパーを残せます。

ストリームのパイプラインとして処理を続けたい場合や、キーの処理を小さな再利用可能な型にまとめたい場合にラッパーが役立ちます。両方の等価性メソッドで同じキーを使ってください。一方だけをオーバーライドすると、ハッシュベースの処理で使われる等価性の契約が崩れます。

distinctByKey 述語でフィルターする

distinctByKey はキー抽出関数を受け取り、遭遇したキーをセットに追加する述語を返します。Set.add() は未登録のキーに対してだけ true を返すため、このフィルターは名前ごとに最初の人物を残します。同じルールを繰り返し使う場合に便利ですが、述語のインスタンスごとに変更可能な状態を持つため、逐次ストリームで使ってください。

この状態を持つ述語を並列ストリームに適用しないでください。並行評価によって、結果が安全でなくなったり、実行タイミングに左右されたりする可能性があります。並列処理では、マージの動作と順序の要件を明示したコレクターを選んでください。

状態を持つフィルターで Set を使う

最後の例では同じ考え方を直接示します。HashSet が、ストリームを filter で処理する間に名前を記録します。1回だけ使うならヘルパーより短く書け、逐次ストリームでは遭遇順で最初に一致した人物をリストに残します。distinctByKey と同様、変更可能な状態を持ち、並列処理には制約があります。

一意にしたいプロパティに合う型をセットの要素型に選んでください。複合キーには、小さな不変のキー型か、正しい equals() と hashCode() を持つ別の値を使います。フィールドを文字列に連結すると、キーが曖昧になることがあります。

意図した結果を保てる方法を選ぶ

キーごとに1つの値を直接得るには toMap() を使い、マージ規則を指定します。グループの全要素を残すことに意味がある場合は groupingBy() を使います。元のオブジェクトの等価性を変えず、キーに基づく distinct() がパイプラインに合う場合はラッパーを使います。短い逐次パイプラインでは、セットを使うフィルターが簡潔ですが、変更可能な状態を持つという制約を明確にしてください。

選ぶ前に、重複時にどの要素を残すか、出力で遭遇順序を保つ必要があるか、選んだプロパティによる等価性をオブジェクトのあらゆる用途に適用するかこのパイプライン内だけに適用するか、の3点を確認します。こうした条件を確認するほうが、特定のコレクターが常に高速だと一般化するより、適切な実装を選びやすくなります。