Java Stream におけるソートのお作法


ソート対象

以下のような idname プロパティを持った Item を考えます。

public class Item {

    private Integer id;
    private String name;

    public Item(Integer id, String name) {
        this.id = id;
        this.name = name;
    }

    public Integer getId() { return id; }
    public String getName() { return name; }

    @Override
    public String toString() { return "Item{" + id + ", '" + name + "'}"; }

}


以下のような Item のリストをソートしていきましょう。

List<Item> list = Arrays.asList(
        new Item(9, "apple"), 
        new Item(3, "lemon"), 
        new Item(6, "peach"), 
        new Item(6, "banana"));


昔ながらの読みにくいソート

Comparator を定義してソートします。

List<Item> sorted = list.stream().sorted(new Comparator<Item>() {
        @Override
        public int compare(Item e1, Item e2) {
            return e1.getId().compareTo(e2.getId());
        }
    }).collect(Collectors.toList());

// [Item{3, 'lemon'}, Item{6, 'peach'}, Item{6, 'banana'}, Item{9, 'apple'}]

Collections.sort() を使って直接コレクションを並び替えることもできます。 昔はよく見たコードですが、醜いですね。


Lambda を利用した冗長なソート

Comparator を Lambda で渡せば少しシンプルに書けます。

List<Item> sorted = list.stream()
        .sorted((e1, e2) -> e1.getId().compareTo(e2.getId()))
        .collect(Collectors.toList());

// [Item{3, 'lemon'}, Item{6, 'peach'}, Item{6, 'banana'}, Item{9, 'apple'}]

しかし引数2つの Lambda はやっぱり読みにくいですね。


Java8 Stream における正しいソート

java.util.Comparator インターフェースには、Comparator を生成するメソッドがあります。

これを使うのが Java8 Stream ソートの望ましい書き方になります。

List<Item> sorted = list.stream()
        .sorted(Comparator.comparing(Item::getId))
        .collect(Collectors.toList());

// [Item{3, 'lemon'}, Item{6, 'peach'}, Item{6, 'banana'}, Item{9, 'apple'}]

Comparator.comparing() の引数にソート用のキーを抽出する関数を渡すことで宣言的に書くことができます。

プリミティブの場合には Comparator.comparingInt() Comparator.comparingLong() といった専用のものが用意されています。


thenComparing による複合キーのソート

thenComparing() を使い、Comparator を連結することができます。

List<Item> sorted = list.stream()
        .sorted(Comparator.comparing(Item::getId)
                          .thenComparing(Item::getName))
        .collect(Collectors.toList());

// [Item{3, 'lemon'}, Item{6, 'banana'}, Item{6, 'peach'}, Item{9, 'apple'}]

第一ソートキーが id, 第二ソートキーが name でのソート例です。


ソート条件の指定

comparing は第二引数に Comparator を渡すことでソート条件を指定できます。

List<Item> sorted = list.stream()
        .sorted(Comparator.comparing(Item::getId, Comparator.reverseOrder())
                          .thenComparing(Item::getName))
        .collect(Collectors.toList());

// [Item{9, 'apple'}, Item{6, 'banana'}, Item{6, 'peach'}, Item{3, 'lemon'}]

reversed() で逆順を指定することもできます。

List<Item> sorted = list.stream()
        .sorted(Comparator.comparing(Item::getId).reversed()
                          .thenComparing(Item::getName))
        .collect(Collectors.toList());

// [Item{9, 'apple'}, Item{6, 'banana'}, Item{6, 'peach'}, Item{3, 'lemon'}]


null コンパレータ

ソートキーに null を含む場合は NullPointerException となります。

Comparator.nullsFirst() などで null 時の挙動を指定することで、NullPointerException を回避できます。

List<Item> sorted = list.stream()
        .sorted(Comparator.comparing(Item::getId)
                          .thenComparing(Item::getName, Comparator.nullsFirst(Comparator.naturalOrder())))
        .collect(Collectors.toList());

// [Item{3, 'lemon'}, Item{6, 'banana'}, Item{6, 'peach'}, Item{9, 'apple'}]

Comparator.nullsFirst(Comparator.naturalOrder()) で、null を先頭、その他は自然順に従うソートとなります。

Comparator.nullsLast() を使うと null を最後に持ってくることができます。


Comparable

オブジェクトに対してソート方法を規定できる場合は、Comparable を実装して以下のようにすることができます。

public class Item implements Comparable<Item> {
  // ...
  @Override
  public int compareTo(Item other) {
    return Comparator.comparing(Item::getId,
                Comparator.nullsFirst(Comparator.naturalOrder()))
        .thenComparing(Item::getName,
                Comparator.nullsLast(Comparator.naturalOrder()))
        .compare(this, other);
    }
}

Comparable の実装があれば、ソートは単に以下のように実現できます。

List<Item> sorted = items.stream().sorted();


Map のソート

List ではなく Map をソートしたいケースもたまにあります。

Map のソートには Map.Entry.comparingByKeyMap.Entry.comparingByValue が用意されています。


以下の様な例で見ていきましょう。

Map<Item, Integer> map = new HashMap<>();
map.put(new Item(9, "apple"), 10);
map.put(new Item(3, "lemon"), 12);
map.put(new Item(6, "banana"), 8);


idname をキーにソートしてみましょう。

Map<Item, Integer> sorted = map.entrySet().stream()
        .sorted(Map.Entry.comparingByKey(
                Comparator.comparing(Item::getId).thenComparing(Item::getName)))
        .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue,
                (e1, e2) -> e1, LinkedHashMap::new));

// {Item{3, 'lemon'}=12, Item{6, 'banana'}=8, Item{9, 'apple'}=10}


ソートキーが Comparable なオブジェクトであれば、単純に以下のように書くことができます。

Map<Item, Integer> sorted = map.entrySet().stream()
        .sorted(Map.Entry.comparingByValue())
        .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue,
                (e1, e2) -> e1, LinkedHashMap::new))

// {Item{6, 'banana'}=8, Item{9, 'apple'}=10, Item{3, 'lemon'}=12}

Entry の値でソートしています。


まとめ

java.util.Comparator インターフェースにはコンパレータを生成する以下のメソッドが用意されています。

ソートキーの指定

  • Comparator.comparing(e) でキー抽出関数 e を指定してコンパレータを生成
  • Comparator.thenComparing(e) で複合条件を追加したコンパレータを生成(e はキー抽出関数)
.sorted(Comparator.comparing(Item::getId))
.sorted(Comparator.comparing(Item::getId)
                  .thenComparing(Item::getName))


ソート条件の指定

比較条件を指定する場合は第二引数に Comparator を渡します。

  • Comparator.comparing(e, c) でキー抽出関数eとソート条件指定の コンパレータcを指定
  • Comparator.thenComparing(e, c) で複合条件を追加したコンパレータを生成(ソート条件指定)
  • Comparator.thenComparing(c) でコンパレータ c を追加した複合コンパレータを生成
  • c.reversed() で順序を逆転したコンパレータを生成
.sorted(Comparator.comparing(Item::getId,
                             Comparator.reverseOrder())
.sorted(Comparator.comparing(Item::getId).reversed()
                  .thenComparing(Item::getName,
                                 Comparator.reverseOrder()))


ソート条件

条件指定に使うコンパレータは以下のものが用意されています。

  • naturalOrder() 自然順序付けのコンパレータ
  • reverseOrder() 逆順コンパレータ
  • nullsFirst(c) null を最小値とみなすコンパレータ(その他はコンパレータc の条件で順序付け)
  • nullsLast(c) null を最大値とみなすコンパレータ(その他はコンパレータcの条件で順序付け)
.sorted(Comparator.comparing(Item::getId)
                  .thenComparing(Item::getName,
                          Comparator.nullsFirst(Comparator.naturalOrder())))


Map のソート

Map のソートには Map.Entry に以下が用意されています。

  • comparingByKey() キーでソート
  • comparingByValue() 値でソート
  • comparingByKey(c) コンパレータ c を指定してキーでソート
  • comparingByValue(c) コンパレータ c を指定して値でソート
.sorted(Map.Entry.comparingByValue())
.sorted(Map.Entry.comparingByKey(
            Comparator.comparing(Item::getId)
                      .thenComparing(Item::getName)))


Guava の Ordering を使わずとも、良いソート生活が送れますね。