RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

Javaのソート〜CollectionsはやめてStreamを使おう〜

はじめに

こんにちは!新卒1年目の hachimd です!

先日、先輩エンジニアの方に「Javaでソートする時って Collections 使うんですよね?」って話をしたら「いやいや、Javaのソートは Stream が便利だよ!」って教えていただきました。

そこで今回は、Stream を使って、どうやってソートするのか、どう便利なのかを、Collections を使ったソートと比較してゼロから解説してみようと思います。

考え方から言及しているので、ソートの方法が全くわからない人でも大丈夫です。

なお、今回は JDK11 を使用しています。

目次

基本的なソートの考え方

ソートは一言で言うと、「昇順 or 降順で並び替えること」と言えます。
ここで、「名前」と「年齢」を持つ『人オブジェクト』を並び替える際に、「まず名前順で並び替え、名前が同じ時は年齢順で並び替える」というルールにすると、単に昇順・降順では並べられないと感じてしまいますが、『人オブジェクト』の大小関係を決める際に「名前を最優先、次点で年齢」と考えると、あくまで『人オブジェクト』の大小関係に従って昇順・降順に並べているのでシンプルになります。

つまり、ソートしたい要素に対して、どのように大小を決定するかを決めてやれば、あとは API を使ってソートができるということです。

  • 【重要】ソートするのに必要なことは、要素の大小関係を決めてあげること

Collections を使ったソート

ここではまず、大小関係が明らかなものをソートする方法と、大小関係が明らかでない『人オブジェクト』をソートする方法を解説します。

どちらも Stream が使えるようになる以前によく使われていた、Collections.sort()を使って解説をして、Collections.sort()のデメリットにも言及します。その後、Streamを使ったシンプルでわかりやすいソートの方法を解説していきます。

要素の大小関係が明らかなとき

数値や文字列のように大小関係が明らかなときは、大小関係を決めてやらなくてもソートすることができます。また、ソートの結果は自然順序(明らかな大小関係に基づく昇順)になります。

ソートするには Collections.sort()を呼び出すだけです。

List<Integer> numbers = Arrays.asList(5,4,8,3,1);
// ソートを実行
Collections.sort(numbers);
// 結果を出力
for (int num: numbers){
    System.out.print(num + " "); // 1 3 4 5 8
}

大小比較の方法を制御したい時

次に、age と name をプロパティにもつ Person をソートすることにします。
Person オブジェクトの年齢差を返す ageDifference メソッドも定義しています。

public class Person {

  private final int age;
  private final String name;

  public Person(String name, int age) {
      this.name = name;
      this.age = age;
  }
  public int getAge() {
      return age;
  }

  public String getName() {
      return name;
  }

  @Override
   public String toString() {
       return "Person{" +
               "age=" + age +
               ", name='" + name + '\'' +
               '}';
   }

  public int ageDifference(final Person other) {
      return this.age - other.age;
  }
}

Person の大小関係がどう決まるのかは我々が定義してあげる必要があります。

Java では、要素の大小比較の方法を自分で決定するには Comparator というインタフェースを利用します。

Comparator は関数型インタフェースで、抽象メソッドとして compare メソッドが定義されています。 cpmpare メソッドの実際の処理に「大小比較の方法」を記述していきます。また、この処理はある決まりに従って書く必要があります。具体的には、「2 つの引数をとり、 第 1 引数が第 2 引数より小さいとする場合は負の整数、両方が等しい場合は 0、第 1 引数が第 2 引数より大きいとする場合は正の整数を返す」ような処理になります。
(参考:Comparator インタフェースの compare メソッド

それでは「まず年齢順で並び替え、年齢が同じ時は名前順で並び替える」というルールで、実際にソートをする処理を見ていきましょう(ここでは Collectons を使ったソートはめんどくさいなと感じてもらえれば大丈夫です)。

まずはコードを見てみましょう。

List<Person> people = Arrays.asList(
       new Person("John", 20),
       new Person("Sara", 21),
       new Person("Jane", 21),
       new Person("Greg", 35)
);

// compareメソッドをオーバーライドしてPersonオブジェクトの大小比較の方法を定義する
// 比較方法:年齢で比較して同じなら名前で比較
Comparator<Person> personComparator = new Comparator<Person>() {
   @Override
   public int compare(Person p1, Person p2) {
       int ageDiff = p1.ageDifference(p2);
       if (ageDiff != 0) {
           return ageDiff;
       } else {
           int nameDiff = p1.getName().compareTo(p2.getName());
           return nameDiff;
       }
   }
};
// 年齢でソートする
// 第1引数:ソート対象のpeople
// 第2引数:Personの大小比較方法が定義されたComparator
Collections.sort(people, personComparator);

// 並びを確認
for (Person person : people) {
   System.out.println(person);
}
// ソート結果
// Person{age=20, name='John'}
// Person{age=21, name='Jane'}
// Person{age=21, name='Sara'}
// Person{age=35, name='Greg'}

それでは解説です。

重要なのは Person オブジェクトの大小比較の方法を定義する部分です。

ここでは、compare メソッドの実装の決まりに従うと、

  • 2 つの Person オブジェクト p1, p2 が引数
  • p1p2より小さいとする場合は負の整数を返す
  • p1p2 より大きいとする場合は正の整数を返す
  • 両方が等しい場合は 0 を返す

ような処理にする必要があります。

また、並べ方は「まず年齢順で並び替え、年齢が同じ時は名前順で並び替える」というルールですので、Person の大小関係と compare メソッドで返す値の対応は以下のようになります。

  1. p1の年齢がp2の年齢より小さければp1p2より小さい負の整数を返す)
  2. p1の年齢がp2の年齢より大きければp1p2より大きい正の整数を返す)
    もし年齢が同じなら、
  3. p1の名前がp2の名前より小さければp1p2より小さい負の整数を返す)
  4. p1の名前がp2の名前より大きければp1p2より大きい正の整数を返す)
  5. 名前の大小も同じなら、p1p2の大小は同じ0 を返す)

上記のコードの実装では 1,2 は年齢差を返すことで実現しています。 3,4,5 は String クラスに用意された compareTo()メソッドを使って実現しています。

なお、Comparatorのcompareメソッドをオーバーライドする部分に関して、ラムダ式を使って処理だけを直接引数に渡すことも可能です。

Collections.sort(people, (p1, p2) -> {
            int ageDiff = p1.ageDifference(p2);
            if (ageDiff != 0) {
                return ageDiff;
            } else {
                int nameDiff = p1.getName().compareTo(p2.getName());
                return nameDiff;
            }});

補足
compareTo()は String クラスに用意された 2 つの文字列を辞書的に比較するメソッド。 Comparable インタフェースの実装であり、これも、「2 つの引数をとり、 第 1 引数が第 2 引数より小さいとする場合は負の整数、両方が等しい場合は 0、第 1 引数が第 2 引数より大きいとする場合は正の整数を返す」ように実装されている。
(参考:Comparable インタフェースの compareTo メソッド

Collections.sort のデメリット

  1. コードを見ただけでは何をしているか分かりにくい
    上の実装のようなコードを理解するには、Comparator インタフェースの compare メソッドをどのように実装するかを知っている必要があります。さらに、正の整数を返すのか負の整数を返すのか常に覚えておくことはなかなか大変です。しかもそのような処理を書く必要があるのでコード自体も長く読みづらいものににってしまいます。
  2. 元のリストが破壊されてしまう
    このデメリットはとても重要です。
    Collectons の sort メソッドは、ソートしたいリストの要素を直接並び替えています。プログラミングの世界では、このようにオブジェクトを直接変更するようなメソッドは破壊的メソッドと呼ばれます。破壊的メソッドを使うときは特に注意が必要です。それは、オブジェクトを直接変更してしまうと、後でまたそのオブジェクトを利用する際にソートされているのかされていないのかなどを意識しなければならず、処理を追うのが大変になりバグが混入しやすくなるからです。そのため、元のオブジェクトは変更せず、処理結果の新しいオブジェクトを返すような非破壊的メソッドを利用することが好ましいと言えるでしょう。

Stream を使ったソート

それでは Stream を使って見やすくかつ簡潔に記述できるソート方法を解説していきます。

ここまで Collections を使ったソートを説明してきましたが、「Collections を使うソートは大変だな」と感じていただければそれで大丈夫です。

Stream はデータの集まりに対して、さまざまな処理(ソートや絞り込み、各データに対する処理など)を簡潔に記述できるようにするインタフェースです。 Stream を使うことで、正の整数 or 負の整数 or 0 を返す処理(compare メソッドの実装)を意識しなくても柔軟なソートができるようになります。 もちろん今まで通り、 3 種類の整数を返す処理を書いてソートすることもできます。

ここでは、『人オブジェクト』に対して、これまで通り 3 種類の整数を返す処理を Stream を使って書くところから始め、コードを改良していきながら、最後に、compare メソッドの実装を意識しなくてもソートできるような方法を紹介します。

Stream を使った単純なソート

Stream を使った『人オブジェクト』のソートに行く前に、数値のように要素の大小関係が明らかなものをソートする方法について触れておきます。

List<Integer> numbers = Arrays.asList(5,4,8,3,1);
List<Integer> sortedNumbers = numbers.stream().sorted().collect(toList());

Stream でソートをする場合は sorted メソッドを使用します。

大小関係が明らかな場合は、引数なしで使うことで自然順序でソートできます。

また、これは非破壊的メソッドなので、ソート結果を新しい変数に格納しています。

collect(toList())は sorted メソッドの戻り値型が Stream なので型をListに戻しているだけです。

また、Integerは Comparable インタフェースを実装している(自然順序が定義されている)ので、以下のように、naturalOrder()reverseOrder()を使用して昇順、降順にすることもできます。

// 昇順
List<Integer> sortedNumbers = numbers.stream().sorted(Comparator.naturalOrder()).collect(toList());
// 降順
List<Integer> sortedNumbers = numbers.stream().sorted(Comparator.reverseOrder()).collect(toList());

※Person クラスが Comparable インタフェースを実装していれば、同様にnaturalOrder()reverseOrder()を使用できます(今回は Comparable は実装していません)。

Stream を使った『人オブジェクト』のソート

それでは Stream を使った『人オブジェクト』のソートを解説していきます。

3 種類の整数を返す処理でソート

Comparator インタフェースの compare メソッドを実装して大小関係(比較方法)を定義するのはこれまでと同じです。 先ほどの繰り返しになりますが、「2 つの引数をとり、 第 1 引数が第 2 引数より小さいとする場合は負の整数、両方が等しい場合は 0、第 1 引数が第 2 引数より大きいとする場合は正の整数を返すような処理」を渡してあげます。

年齢でソート

// Comparatorはラムダ式を使って処理だけを直接渡す
List<Person> sortedPeopleByAge =
    people.stream().sorted((person1, person2) -> person1.ageDifference(person2)).collect(toList());

名前でソート

List<Person> sortedPeopleByName =
        people.stream().sorted((person1, person2) -> person1.getName().compareTo(person2.getName())).collect(toList());

大小関係が明らかでない場合は、sorted メソッドの引数に Comparator を入れます(ラムダ式で入れています)。

Collections のソートと比較して、非破壊的メソッドになっているのは良いことですが、どんな整数を返すか意識する必要があるので、まだ、それほどメリットが感じられません。次は comparing メソッドを使って改良していきます。

(参考:Stream クラスの sorted メソッド

comparing によるキーの指定だけでソート

comparing メソッドは、ソートに使うキーを返す関数を受け取り、そのキーで大小比較する Comparator を返します。

例えば、「Person を受け取って Person の name を返す関数」を受け取ると、Person の name でソートする Comparator を返すことができます。

つまり、「名前の大小関係を使う」ということを指定してあげれば、「3 種類の整数を返す Comparator 」を返してくれます。 言い換えれば compare メソッドを実装した Comparator をキーの指定だけで作ってくれるとても便利なやつです。

List<Person> sortedPeopleByName =
    people.stream().sorted(comparing((Person person) -> person.getName())).collect(toList());

3 種類の整数を返す処理ではなく、ソートするキーを返す処理を渡すだけになり、どんな整数を返すのかを考えることが無くなりました!

キーを返す処理自体を定数としてやるとコードも見やすくなります。

final Function<Person, String> byName = person -> person.getName();
final Function<Person, Integer> byAge = person -> person.getAge();

List<Person> sortedPeopleByName =
        people.stream().sorted(comparing(byName)).collect(toList());

List<Person> sortedPeopleByAge =
        people.stream().sorted(comparing(byAge)).collect(toList());

メソッド参照を使うとさらに簡略化できます。

List<Person> sortedPeopleByName =
        people.stream().sorted(comparing(Person::getName)).collect(toList());

List<Person> sortedPeopleByAge =
        people.stream().sorted(comparing(Person::getAge)).collect(toList());

ここまで見やすく簡略化できると、Stream の機能を使わない理由がないのがわかってくるのではないでしょうか。 正の整数を返すんだったかな?負の整数を返すんだったかな?なんて迷うこともなく、コードも記述しやすく理解しやすいものになりました。

(参考:Comparator インタフェースの comparing メソッド

複数のキーでソート

最後に、年齢で比較した後に名前で比較して『人オブジェクト』をソートしてみます。 Stream を使うとこの処理も非常に簡単に書けます。comparing に続けて thenComparing メソッドを使うだけです。

List<Person> sortedPeopleByAgeAndName =
        people.stream().sorted(comparing(Person::getAge).thenComparing(Person::getName)).collect(toList());

序盤に紹介した、 comparing メソッドの実装を自分で書いて Collections.sort を使ってソートしたコードと比較すると、非破壊的メソッドになりコードも非常にシンプルでわかりやすいものになっていますね。

ソートするなら Stream が圧倒的に便利です。

(参考:Comparator インタフェースの thenComparing メソッド

最後に

今回は Stream を使って簡潔にソートをする方法を、従来の Collections を使用した方法と比較して解説しました。

これからソートを使う人も、これまで Collections でソートをしていた人も、ぜひ Stream を使ってシンプルでわかりやすいコードで実装してみてください!

参考書籍


エンジニア中途採用サイト
ラクスでは、エンジニア・デザイナーの中途採用を積極的に行っております!
ご興味ありましたら是非ご確認をお願いします。
20210916153018
https://career-recruit.rakus.co.jp/career_engineer/

カジュアル面談お申込みフォーム
どの職種に応募すれば良いかわからないという方は、カジュアル面談も随時行っております。
以下フォームよりお申込みください。
rakus.hubspotpagebuilder.com

ラクスDevelopers登録フォーム
20220701175429
https://career-recruit.rakus.co.jp/career_engineer/form_rakusdev/

イベント情報
会社の雰囲気を知りたい方は、毎週開催しているイベントにご参加ください!

◆TECH PLAY
techplay.jp

◆connpass
rakus.connpass.com

Copyright © RAKUS Co., Ltd. All rights reserved.