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(), чем ручное вычитание — оно защищено от переполнения.