【Java】Stream, Collectorsを使ってオブジェクトのListに対してsort, distinctを行う

# はじめに
`var hogeList = new ArrayList<Hoge>();`のようなオブジェクトのリストに対してソートや重複排除を行う際「どうやるんだっけ？」といつもググってしまうので、自分用にまとめてみる。

# ソート
結論：`Comparator.comparing().thenComparing()`を使え。

ググるとComparatorクラスを自前で作るだの、ラムダでどうだの出てくるがそれらはJava8くらいの昔話なのでスルーしてよい。
現代Java人はStream APIを使おう。
```java:Item.java
@Builder
@Getter
@ToString
public class Item {
    private int id;
    private String name;
}
```

```java
        var items = List.of(
                Item.builder().id(5).name("item5").build(),
                Item.builder().id(4).name("item4").build(),
                Item.builder().id(1).name("item1").build(),
                Item.builder().id(3).name("item3").build(),
                Item.builder().id(2).name("item2").build()
        );

        // 昇順ソート
        var asc = items.stream()
                .sorted(Comparator.comparing(Item::getId))
                .toList();
        System.out.println(asc.toString());
　　　　　/**
           [Main.Item(id=1, name=item1),
            Main.Item(id=2, name=item2), 
            Main.Item(id=3, name=item3), 
            Main.Item(id=4, name=item4), 
            Main.Item(id=5, name=item5)]
         **/


        // 降順ソート
        var desc = items.stream()
                .sorted(Comparator.comparing(Item::getId).reversed())
                .toList();
        System.out.println(desc.toString());
        /**
           [Main.Item(id=5, name=item5), 
            Main.Item(id=4, name=item4), 
            Main.Item(id=3, name=item3), 
            Main.Item(id=2, name=item2), 
            Main.Item(id=1, name=item1)]
        **/


```

id, nameの両方でソートすることも可能。`thenComparing()`を使う。
```java
        var items2 = List.of(
                Item.builder().id(1).name("item1-2").build(),
                Item.builder().id(1).name("item1-1").build(),
                Item.builder().id(1).name("item1-3").build(),
                Item.builder().id(3).name("item3-3").build(),
                Item.builder().id(3).name("item3-2").build(),
                Item.builder().id(3).name("item3-1").build(),
                Item.builder().id(2).name("item2-1").build(),
                Item.builder().id(2).name("item2-2").build()
        );

        var sorted = items2.stream()
                .sorted(Comparator.comparing(Item::getId).thenComparing(Item::getName))
                .toList();
        System.out.println(sorted.toString());
        /**
         [Main.Item(id=1, name=item1-1),
         Main.Item(id=1, name=item1-2), 
         Main.Item(id=1, name=item1-3), 
         Main.Item(id=2, name=item2-1), 
         Main.Item(id=2, name=item2-2), 
         Main.Item(id=3, name=item3-1), 
         Main.Item(id=3, name=item3-2), 
         Main.Item(id=3, name=item3-3)]
         */
```

## 余談
Integer, Stringなどのリストのソートはもっと単純。
```java
        var list = List.of(1, 3, 2, 9, 4, 8, 5, 7, 6);
        var sorted = list.stream().sorted().toList();
        System.out.println(sorted); //[1, 2, 3, 4, 5, 6, 7, 8, 9]

        var reversed = list.stream().sorted(Comparator.reverseOrder()).toList();
        System.out.println(reversed); //[9, 8, 7, 6, 5, 4, 3, 2, 1]
```

# 重複を排除

方法は色々ある。
以下オブジェクトのリストに対して重複排除する例をいくつか挙げる。

```java:Device.java
@Builder //lombok
@Getter //lombok
@ToString //lombok
public class Device {
    private String name;
    private String os;
    private String type;
}
```

## Collectors.groupingBy
```java
        var devices = List.of(
                Device.builder().name("FUJITSU").os("Windows").type("desktop").build(),
                Device.builder().name("Dynabook").os("Windows").type("laptop").build(),
                Device.builder().name("Surface").os("Windows").type("laptop").build(),
                Device.builder().name("MacBook Air").os("Mac").type("laptop").build(),
                Device.builder().name("MacBook Pro").os("Mac").type("laptop").build(),
                Device.builder().name("iMac").os("Mac").type("desktop").build(),
                Device.builder().name("CentOS").os("Linux").type("desktop").build(),
                Device.builder().name("Ubuntu").os("Linux").type("desktop").build()
        );

        // OSで重複排除
        var osDistinct = devices.stream()
                .collect(Collectors.groupingBy(Device::getOs))
                .values().stream().map(device -> device.get(0))
                .toList();
        osDistinct.forEach(e -> System.out.println(e.getOs()));
        /**
         Linux
         Windows
         Mac
         **/

        //OS, typeの複合キーで重複排除
        var osAndTypeDistinct = devices.stream()
                .collect(Collectors.groupingBy(device -> StringUtils.join(device.os, "-", device.type)))
                .values().stream().map(device -> device.get(0))
                .sorted(Comparator.comparing(Device::getOs).thenComparing(Device::getType))
                .toList();
        osAndTypeDistinct.forEach(device -> System.out.println(StringUtils.join(device.os, "-", device.type)));
        /**
         Linux-desktop
         Mac-desktop
         Mac-laptop
         Windows-desktop
         Windows-laptop
         **/
```

ぱっと見「なにやってんだ？」となるけど、よく読めば大したことはない。
1つ目の`// OSで重複排除`の`.collect(Collectors.groupingBy(Device::getOs))`で`Map<String, List<Device>>`のMapを生成している。
```java
devices.stream().collect(Collectors.groupingBy(Device::getOs))
```
↓
```
{Linux=[Main.Device(name=CentOS, os=Linux, type=desktop), Main.Device(name=Ubuntu, os=Linux, type=desktop)],
 Windows=[Main.Device(name=FUJITSU, os=Windows, type=desktop), Main.Device(name=Dynabook, os=Windows, type=laptop), Main.Device(name=Surface, os=Windows, type=laptop)], 
 Mac=[Main.Device(name=MacBook Air, os=Mac, type=laptop), Main.Device(name=MacBook Pro, os=Mac, type=laptop), Main.Device(name=iMac, os=Mac, type=desktop)]}
```

このMapのvalue(`List<Device>`)のそれぞれ一個目の要素だけを引っこ抜いて再びリストに詰めて返す、といった感じ。

## Set
```java
        var devices = List.of(
                Device.builder().name("FUJITSU").os("Windows").type("desktop").build(),
                Device.builder().name("Dynabook").os("Windows").type("laptop").build(),
                Device.builder().name("Surface").os("Windows").type("laptop").build(),
                Device.builder().name("MacBook Air").os("Mac").type("laptop").build(),
                Device.builder().name("MacBook Pro").os("Mac").type("laptop").build(),
                Device.builder().name("iMac").os("Mac").type("desktop").build(),
                Device.builder().name("CentOS").os("Linux").type("desktop").build(),
                Device.builder().name("Ubuntu").os("Linux").type("desktop").build()
        );

        // OSで重複排除
        var osSet = new HashSet<String>();
        var osDistinct = devices.stream()
                .filter(d -> osSet.add(d.getOs()))
                .sorted(Comparator.comparing(Device::getOs))
                .toList();
        osDistinct.forEach(e -> System.out.println(e.getOs()));
        /**
         Linux
         Mac
         Windows
         **/
```

HashSetに対象のプロパティを詰めていきながら重複排除を行う。

## stream().distinct()
### 単一キー
```java:Device.java
@Builder
@Getter
@ToString
public class Device {
    private String name;
    private String os;
    private String type;
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Device device = (Device) o;
        return Objects.equals(os, device.os);
    }

    @Override
    public int hashCode() {
        return Objects.hash(os);
    }
}
```

```java
        var devices = List.of(
                Device.builder().name("FUJITSU").os("Windows").type("desktop").build(),
                Device.builder().name("Dynabook").os("Windows").type("laptop").build(),
                Device.builder().name("Surface").os("Windows").type("laptop").build(),
                Device.builder().name("MacBook Air").os("Mac").type("laptop").build(),
                Device.builder().name("MacBook Pro").os("Mac").type("laptop").build(),
                Device.builder().name("iMac").os("Mac").type("desktop").build(),
                Device.builder().name("CentOS").os("Linux").type("desktop").build(),
                Device.builder().name("Ubuntu").os("Linux").type("desktop").build()
        );

        // OSで重複排除
        var osDistinct = devices.stream()
                .distinct()
                .sorted(Comparator.comparing(Device::getOs))
                .toList();
        osDistinct.forEach(e -> System.out.println(e.getOs()));
        /**
         Linux
         Mac
         Windows
         **/
```

まずオブジェクトにequalsメソッドを実装する。
（`os`の重複排除を行うなら、`os`に対するequals）
あとはstream().distinct()を実行するだけでequalsに沿って`os`に対して重複排除が行われる。

### 複合キー

```java
@Builder
@Getter
@ToString
public class Device {
    private String name;
    private String os;
    private String type;
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Device device = (Device) o;
        return Objects.equals(os, device.os) && Objects.equals(type, device.type);       
    }

    @Override
    public int hashCode() {
        return Objects.hash(os, type);
    }
}
```

```java
        //OS, typeの複合キーで重複排除
        var osAndTypeDistinct = devices.stream()
                .distinct()
                .sorted(Comparator.comparing(Device::getOs).thenComparing(Device::getType))
                .toList();
        osAndTypeDistinct.forEach(device -> System.out.println(StringUtils.join(device.os, "-", device.type)));
        /**
         Linux-desktop
         Mac-desktop
         Mac-laptop
         Windows-desktop
         Windows-laptop
         **/
```

equalsを`os`, `type`の両方に対して実装する。

# 参考
https://qiita.com/t_t238/items/d9fc84c135d5cd909249

https://qiita.com/someone7140/items/be7feaf804ae7aa065e2

https://codechacha.com/ja/java8-stream-distinct/

https://qiita.com/t_t238/items/d9fc84c135d5cd909249