Comparable и Comparator: сортировка объектов

Списку строк или чисел скажешь sort() — и готово. А как сортировать список своих объектов, да ещё по нескольким полям сразу?

Вопрос с собеседования: «В чём разница между Comparable и Comparator? Как бы вы отсортировали список сотрудников сначала по отделу, а внутри отдела — по зарплате?»

Вопрос звучит просто, но проверяет сразу три вещи: знание интерфейсов, умение читать лямбды и понимание, зачем в Java два разных механизма сортировки, а не один.

Что выведет этот код?

Попробуем отсортировать список чисел «в лоб» и посмотрим, что получится с объектами.

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

class Employee {
    String name;
    int salary;
    Employee(String name, int salary) {
        this.name = name;
        this.salary = salary;
    }
    public String toString() {
        return name + ": " + salary;
    }
}

public class Main {
    public static void main(String[] args) {
        List<Employee> employees = new ArrayList<>();
        employees.add(new Employee("Иван", 80000));
        employees.add(new Employee("Оля", 95000));

        Collections.sort(employees); // Ошибка компиляции!
    }
}

Вывод:

error: no suitable method found for sort(List<Employee>)

Компилятор не знает, как сравнивать двух сотрудников — по имени? по зарплате? по обоим сразу? Для примитивов и строк Java уже «знает» порядок (числа — по величине, строки — по алфавиту), а для собственного класса такого порядка по умолчанию нет. Его нужно объявить самому — и вот здесь на сцену выходят Comparable и Comparator.

Comparable: «естественный порядок» внутри самого класса

Comparable<T> — это интерфейс, который реализует сам класс, чтобы объявить, что значит «этот объект меньше, больше или равен другому». В нём один метод — compareTo(T other), возвращающий отрицательное число (текущий объект меньше), ноль (равны) или положительное число (текущий объект больше).

class Employee implements Comparable<Employee> {
    String name;
    int salary;

    Employee(String name, int salary) {
        this.name = name;
        this.salary = salary;
    }

    @Override
    public int compareTo(Employee other) {
        return this.salary - other.salary; // сортировка по зарплате по умолчанию
    }

    public String toString() {
        return name + ": " + salary;
    }
}

public class Main {
    public static void main(String[] args) {
        List<Employee> employees = new ArrayList<>();
        employees.add(new Employee("Иван", 80000));
        employees.add(new Employee("Оля", 95000));
        employees.add(new Employee("Пётр", 70000));

        Collections.sort(employees);
        System.out.println(employees);
    }
}

Вывод:

[Пётр: 70000, Иван: 80000, Оля: 95000]

Теперь Collections.sort() знает, как сравнивать сотрудников — по зарплате. Это и есть «естественный порядок» класса: он один-единственный, зашит прямо в код класса, и работает автоматически везде, где нужна сортировка «по умолчанию» (например, в TreeSet или TreeMap без явного компаратора).

Comparator: сортировка «со стороны», сколько угодно вариантов

А что, если иногда нужно сортировать по зарплате, а иногда — по имени? Менять код класса каждый раз — плохая идея (тем более, что у класса — только один compareTo). Для этого существует Comparator<T> — отдельный, «внешний» объект-правило сравнения, который не встроен в класс, а передаётся в метод сортировки снаружи.

import java.util.Comparator;

public class Main {
    public static void main(String[] args) {
        List<Employee> employees = new ArrayList<>();
        employees.add(new Employee("Иван", 80000));
        employees.add(new Employee("Оля", 95000));
        employees.add(new Employee("Пётр", 70000));

        // Сортировка по имени вместо зарплаты — через Comparator
        Comparator<Employee> byName = (e1, e2) -> e1.name.compareTo(e2.name);
        employees.sort(byName);

        System.out.println(employees);
    }
}

Вывод:

[Иван: 80000, Оля: 95000, Пётр: 70000]

Ключевое отличие: Comparable отвечает на вопрос «как сравнивать объекты этого типа по умолчанию» и живёт внутри класса, а Comparator отвечает на вопрос «как сравнивать в этой конкретной ситуации» и живёт снаружи — можно создать сколько угодно разных компараторов для одного и того же класса, не трогая сам класс.

Сортировка сразу по нескольким полям

Вот где вопрос с собеседования становится интересным: сначала по отделу, а внутри отдела — по зарплате. Раньше это писали через цепочку if/else внутри compare(), но с Java 8 появился элегантный способ — метод Comparator.comparing() с цепочкой .thenComparing().

class Employee {
    String name;
    String department;
    int salary;

    Employee(String name, String department, int salary) {
        this.name = name;
        this.department = department;
        this.salary = salary;
    }

    public String toString() {
        return department + " / " + name + ": " + salary;
    }
}

public class Main {
    public static void main(String[] args) {
        List<Employee> employees = new ArrayList<>();
        employees.add(new Employee("Иван", "IT", 90000));
        employees.add(new Employee("Оля", "IT", 80000));
        employees.add(new Employee("Пётр", "HR", 60000));

        Comparator<Employee> byDeptThenSalary = Comparator
                .comparing((Employee e) -> e.department)
                .thenComparing(e -> e.salary);

        employees.sort(byDeptThenSalary);
        System.out.println(employees);
    }
}

Вывод:

[HR / Пётр: 60000, IT / Оля: 80000, IT / Иван: 90000]

Сначала список группируется по отделу в алфавитном порядке («HR» раньше «IT»), а внутри одного отдела — по возрастанию зарплаты. Метод thenComparing() вступает в силу только тогда, когда предыдущее сравнение дало «равно» — то есть отделы совпали, и нужно смотреть следующий критерий.

Частые ошибки на собеседовании

  • Путать, какой интерфейс где реализуется. Comparable реализует сам сортируемый класс (class Employee implements Comparable<Employee>), а Comparator — это отдельный объект, который передаётся в метод сортировки как параметр.
  • Забывать про переполнение при вычитании в compareTo(). Писать return this.salary - other.salary; для типов вроде int с большими значениями рискованно — при экстремальных значениях возможно переполнение. Надёжнее использовать Integer.compare(this.salary, other.salary).
  • Путать порядок thenComparing(). Порядок вызовов задаёт приоритет: первый критерий — главный, второй — «тай-брейк», применяется только при равенстве первого.
  • Забывать про Collections.reverseOrder() и .reversed(). Для сортировки по убыванию не нужно писать логику наоборот вручную — у готового компаратора есть метод .reversed(), а для естественного порядка — Collections.reverseOrder().

Итоги-шпаргалка

  • Comparable<T> — интерфейс, который реализует сам класс, задаёт «естественный порядок» через метод compareTo(). Один на класс.
  • Comparator<T> — внешний объект-правило сравнения, задаёт порядок «со стороны» через метод compare(). Можно создавать сколько угодно разных для одного класса.
  • Сортировка по нескольким полям — через Comparator.comparing(...).thenComparing(...): первый критерий главный, каждый следующий — тай-брейк для равенства предыдущих.
  • Для чисел в compareTo() безопаснее использовать Integer.compare(), чем ручное вычитание — оно защищено от переполнения.
Проверьте себя
1. В чём главное отличие Comparator от Comparable?
AComparator работает только со строками, а Comparable — с любыми объектами
BComparable реализуется самим классом и задаёт единственный порядок по умолчанию, а Comparator — внешний объект, можно создать сколько угодно разных вариантов
CComparable нужен только для примитивных типов, Comparator — только для объектов
DРазницы нет, это два названия одного и того же интерфейса
2. Что делает вызов .thenComparing() в цепочке Comparator.comparing(...).thenComparing(...)?
AПолностью заменяет первый критерий сравнения на новый
BПрименяется как дополнительный критерий только тогда, когда первое сравнение дало «равно»
CСортирует список сначала по второму критерию, а потом по первому
DОтменяет действие первого comparing() и сортирует список заново