Интерфейсы Comparable и Comparator в Java
В TreeSet добавили четырёх человек, а вывели троих: «Вася Иванов» пропал без исключения и предупреждения. Компаратор сравнивал только фамилии, вернул 0 для двух Ивановых — и для TreeSet это означало «такой элемент уже есть». Чтобы такие ошибки не случались, нужно понимать, как работают Comparable и Comparator.
Comparable и Comparator — два интерфейса Java для сравнения объектов. Comparable<T> (пакет java.lang) задаёт естественный порядок: класс сам реализует метод compareTo(T o). Comparator<T> (пакет java.util), или компаратор, — внешнее правило сравнения: метод compare(T o1, T o2) реализуют в отдельном классе или лямбдой, и таких правил для одного класса может быть сколько угодно. Оба метода возвращают отрицательное число, ноль или положительное число.
Интерфейс Comparable и метод compareTo
Интерфейс Comparable<T> реализуют классы, у объектов которых есть естественный порядок: числа сравниваются по величине, строки — по алфавиту, даты — по времени. У интерфейса единственный метод int compareTo(T o): он сравнивает текущий объект (this) с переданным. Его уже реализуют String, Integer, LocalDate, BigDecimal и все enum.
Результат compareTo() читается так:
- отрицательное число — текущий объект должен стоять перед переданным;
- положительное число — после переданного;
- ноль — объекты равны с точки зрения сортировки (это не обязательно значит, что
equals()вернётtrue, см. раздел Контракт compareTo).
Параметр типа в Comparable<Person> важен: метод получает compareTo(Person other), и приведение типа не нужно. С «сырым» Comparable сигнатура была бы compareTo(Object o), а ошибка типа обнаружилась бы только при выполнении.
Пример реализации интерфейса Comparable в Java — класс Person, естественный порядок которого задан по возрасту:
import java.util.Objects;
public class Person implements Comparable<Person> {
private final String firstName;
private final String lastName;
private final int age;
public Person(String firstName, String lastName, int age) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}
public String getFirstName() {
return firstName;
}
public String getLastName() {
return lastName;
}
public int getAge() {
return age;
}
@Override
public int compareTo(Person other) {
return Integer.compare(this.age, other.age);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return age == person.age
&& Objects.equals(firstName, person.firstName)
&& Objects.equals(lastName, person.lastName);
}
@Override
public int hashCode() {
return Objects.hash(firstName, lastName, age);
}
@Override
public String toString() {
return firstName + " " + lastName + ", " + age;
}
} TreeSet, созданный без компаратора, расставляет элементы по compareTo():
import java.util.SortedSet;
import java.util.TreeSet;
public class ComparePersonExample {
public static void main(String[] args) {
SortedSet<Person> set = new TreeSet<>();
set.add(new Person("Саша", "Иванов", 36));
set.add(new Person("Маша", "Петрова", 23));
set.add(new Person("Даша", "Сидорова", 34));
set.add(new Person("Вася", "Иванов", 25));
set.forEach(System.out::println);
}
} Вывод:
Маша Петрова, 23
Вася Иванов, 25
Даша Сидорова, 34
Саша Иванов, 36 Если класс не реализует Comparable, а компаратор не передан, TreeSet упадёт уже на первом add() с ClassCastException: class Cat cannot be cast to class java.lang.Comparable. Ошибку не поймает компилятор: конструктор new TreeSet<>() принимает любой тип элементов. Сами методы TreeSet (first(), headSet(), subSet()) разобраны в уроке SortedSet и TreeSet.
Как compareTo сравнивает строки
String.compareTo() сравнивает строки посимвольно по кодам UTF-16 и возвращает разность первых несовпавших символов. Поэтому все заглавные буквы идут раньше строчных, а буква «Ё» (U+0401) — даже раньше «А» (U+0410). Что выведет этот код?
import java.text.Collator;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public class StringOrderExample {
public static void main(String[] args) {
List<String> words = new ArrayList<>(List.of("яблоко", "Ёлка", "арбуз", "Банан"));
words.sort(null); // естественный порядок
System.out.println(words);
words.sort(String.CASE_INSENSITIVE_ORDER); // без учёта регистра
System.out.println(words);
words.sort(Collator.getInstance(Locale.forLanguageTag("ru"))); // по правилам языка
System.out.println(words);
}
} Вывод:
[Ёлка, Банан, арбуз, яблоко]
[арбуз, Банан, яблоко, Ёлка]
[арбуз, Банан, Ёлка, яблоко] Даже CASE_INSENSITIVE_ORDER ставит «Ёлку» в конец: строчная «ё» (U+0451) идёт в таблице после «я» (U+044F). Алфавитный порядок, как в словаре, даёт только Collator — он реализует Comparator и учитывает правила языка.
Интерфейс Comparator и метод compare
Интерфейс Comparator<T> используют, когда класс нельзя или не нужно менять (например, String или класс из библиотеки) или когда объекты нужно сортировать по-разному: сегодня по фамилии, завтра по возрасту. Главный метод — int compare(T o1, T o2): отрицательный результат означает, что o1 стоит перед o2, положительный — после, ноль — что для этого порядка объекты равны.
Классический способ — отдельный класс, который реализует компаратор:
import java.util.Comparator;
public class PersonComparator implements Comparator<Person> {
@Override
public int compare(Person o1, Person o2) {
return o1.getLastName().compareTo(o2.getLastName());
}
} Компаратор передают в конструктор TreeSet, и он заменяет естественный порядок по возрасту:
import java.util.SortedSet;
import java.util.TreeSet;
public class PersonComparatorExample {
public static void main(String[] args) {
SortedSet<Person> set = new TreeSet<>(new PersonComparator());
set.add(new Person("Саша", "Иванов", 36));
set.add(new Person("Маша", "Петрова", 23));
set.add(new Person("Даша", "Сидорова", 34));
boolean added = set.add(new Person("Вася", "Иванов", 25));
System.out.println("Вася добавлен: " + added + ", размер: " + set.size());
set.forEach(System.out::println);
}
} Вывод:
Вася добавлен: false, размер: 3
Саша Иванов, 36
Маша Петрова, 23
Даша Сидорова, 34 Куда пропал второй Иванов
TreeSet и ключи TreeMap определяют дубликаты не через equals(), а через компаратор: если compare() вернул 0, новый элемент считается уже существующим и не добавляется. «Вася Иванов» и «Саша Иванов» по equals() разные, но компаратор смотрит только на фамилию. Чтобы сохранить обоих, добавьте в компаратор второй критерий — thenComparing() из следующего раздела.
Разница между Comparable и Comparator
Главная разница: Comparable описывает один естественный порядок внутри самого класса, а Comparator — любое количество внешних правил сортировки, для которых исходный класс менять не нужно. Если порядок очевиден и единственный (числа, даты, коды), реализуют Comparable; для всех остальных случаев пишут компаратор.
| Признак | Comparable | Comparator |
|---|---|---|
| Пакет | java.lang | java.util |
| Метод | int compareTo(T o) — сравнивает this с o | int compare(T o1, T o2) — сравнивает два переданных объекта |
| Где реализуется | В самом сравниваемом классе | В отдельном классе, анонимном классе или лямбде |
| Сколько порядков | Один — естественный | Сколько угодно |
| Нужен доступ к коду класса | Да | Нет — подходит для String и чужих классов |
| Как подключается | Collections.sort(list), list.sort(null), new TreeSet<>() | list.sort(cmp), Arrays.sort(arr, cmp), new TreeSet<>(cmp) |
| Готовые методы | Нет, только compareTo() | comparing(), thenComparing(), reversed(), nullsFirst() и другие (Java 8) |
Аннотация @FunctionalInterface | Нет | Да |
Подходы не исключают друг друга: класс Person из примеров реализует Comparable по возрасту, и при этом его сортируют компаратором по фамилии. Переданный компаратор всегда имеет приоритет над естественным порядком.
Comparator.comparing, thenComparing, reversed
С Java 8 компаратор почти никогда не пишут отдельным классом: его собирают из статических и default-методов интерфейса Comparator. Comparator.comparing(Person::getLastName) сравнивает по фамилии, thenComparing() добавляет следующий критерий для равных значений, reversed() переворачивает порядок.
| Метод | Что делает | Нюанс |
|---|---|---|
static comparing(Function keyExtractor) | Сравнивает по ключу, который возвращает функция (ключ должен быть Comparable) | Ключ null — NullPointerException при сравнении |
static comparing(Function keyExtractor, Comparator keyCmp) | Сравнивает ключи указанным компаратором | Например, comparing(Person::getLastName, Comparator.reverseOrder()) |
static comparingInt / comparingLong / comparingDouble | Сравнивает по примитивному ключу | Без автоупаковки в Integer; внутри — Integer.compare(), переполнения нет |
default thenComparing(...), thenComparingInt(...) | Следующий критерий, если предыдущий вернул 0 | Можно вызывать цепочкой сколько угодно раз |
default reversed() | Обратный порядок | Переворачивает всю цепочку, к которой применён, а не только последний критерий |
static naturalOrder() / reverseOrder() | Естественный порядок Comparable / обратный ему | Comparator.reverseOrder() делает то же, что Collections.reverseOrder() |
static nullsFirst(cmp) / nullsLast(cmp) | Ставит null в начало / в конец, остальные сравнивает через cmp | Без них null в сортируемом списке — NullPointerException |
Пример: сортировка списка по фамилии, при равных фамилиях — по возрасту, а затем по возрасту в обратном порядке:
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class PersonComparingExample {
public static void main(String[] args) {
List<Person> people = new ArrayList<>(List.of(
new Person("Саша", "Иванов", 36),
new Person("Маша", "Петрова", 23),
new Person("Даша", "Сидорова", 34),
new Person("Вася", "Иванов", 25)));
people.sort(Comparator.comparing(Person::getLastName)
.thenComparingInt(Person::getAge));
people.forEach(System.out::println);
System.out.println("---");
people.sort(Comparator.comparingInt(Person::getAge).reversed());
people.forEach(System.out::println);
}
} Вывод:
Вася Иванов, 25
Саша Иванов, 36
Маша Петрова, 23
Даша Сидорова, 34
---
Саша Иванов, 36
Даша Сидорова, 34
Вася Иванов, 25
Маша Петрова, 23 Теперь оба Ивановых на месте: при равных фамилиях работает второй критерий. Тот же компаратор, переданный в new TreeSet<>(...), тоже сохранит обоих.
Сортировка списка с null
Естественный порядок не умеет сравнивать null: "Анна".compareTo(null) бросает NullPointerException. Обёртка nullsFirst() или nullsLast() решает это без проверок в каждом сравнении:
List<String> names = new ArrayList<>(Arrays.asList("Олег", null, "Анна"));
names.sort(Comparator.nullsFirst(Comparator.naturalOrder()));
System.out.println(names); // Вывод: [null, Анна, Олег]
names.sort(Comparator.naturalOrder()); // NullPointerException Компаратор через лямбду и ссылку на метод
Comparator — функциональный интерфейс, поэтому вместо класса PersonComparator достаточно лямбда-выражения (p1, p2) -> p1.getLastName().compareTo(p2.getLastName()) или ещё короче — Comparator.comparing(Person::getLastName). Все три записи задают один и тот же порядок.
Comparator<Person> byClass = new PersonComparator();
Comparator<Person> byLambda = (p1, p2) -> p1.getLastName().compareTo(p2.getLastName());
Comparator<Person> byRef = Comparator.comparing(Person::getLastName); На чём чаще всего спотыкаются — на лямбде внутри цепочки. Этот код не скомпилируется:
people.sort(Comparator.comparing(p -> p.getLastName()).reversed());
// error: cannot find symbol
// symbol: method getLastName()
// location: variable p of type Object Компилятор выводит тип p из выражения comparing(...), а у него нет целевого типа: следом вызывается .reversed(), и информация из people.sort() до лямбды не доходит. Тип p становится Object. Исправления: ссылка на метод Comparator.comparing(Person::getLastName).reversed(), явный тип параметра comparing((Person p) -> p.getLastName()) или явный тип-аргумент Comparator.<Person, String>comparing(p -> p.getLastName()). Без .reversed() лямбда comparing(p -> p.getLastName()) компилируется нормально.
Контракт compareTo: переполнение и согласованность с equals
Методы compareTo() и compare() должны соблюдать контракт: знак a.compareTo(b) противоположен знаку b.compareTo(a), порядок транзитивен (если a > b и b > c, то a > c), а равные элементы одинаково сравниваются с третьими. Если контракт нарушен, сортировка даёт неверный результат или падает с IllegalArgumentException: Comparison method violates its general contract!.
Почему нельзя писать return a - b
Вычитание — самый частый способ нарушить контракт. Для небольших чисел a - b даёт правильный знак, но при переполнении int знак меняется на противоположный:
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class OverflowExample {
public static void main(String[] args) {
Comparator<Integer> bad = (a, b) -> a - b;
Comparator<Integer> good = Integer::compare;
System.out.println(bad.compare(Integer.MIN_VALUE, 1));
System.out.println(good.compare(Integer.MIN_VALUE, 1));
List<Integer> nums = new ArrayList<>(List.of(1, Integer.MIN_VALUE));
nums.sort(bad);
System.out.println(nums);
nums.sort(good);
System.out.println(nums);
}
} Вывод:
2147483647
-1
[1, -2147483648]
[-2147483648, 1] Integer.MIN_VALUE - 1 переполняется и превращается в Integer.MAX_VALUE, поэтому «плохой» компаратор считает минус два миллиарда больше единицы, и список остаётся неотсортированным.
Сравнивайте числа без вычитания
Вместо return this.age - other.age пишите Integer.compare(this.age, other.age), для long и double — Long.compare() и Double.compare(), а в цепочках — Comparator.comparingInt(). Возраст до переполнения не дорастёт, но привычка к вычитанию однажды сработает на идентификаторах, суммах в копейках или отрицательных значениях, и такую ошибку тесты на маленьких числах не поймают.
Согласованность compareTo с equals
Порядок называют согласованным с equals, если a.compareTo(b) == 0 тогда и только тогда, когда a.equals(b). Документация Java настоятельно рекомендует, но не требует этого. Нарушение не ломает сортировку списка, но меняет поведение TreeSet и TreeMap: они ищут и отбрасывают дубликаты по compareTo()/compare(), а HashSet — по equals() и hashCode(). Классический пример из стандартной библиотеки — BigDecimal:
List<BigDecimal> values = List.of(new BigDecimal("1.0"), new BigDecimal("1.00"));
Set<BigDecimal> hashSet = new HashSet<>(values);
Set<BigDecimal> treeSet = new TreeSet<>(values);
System.out.println(hashSet.size()); // Вывод: 2 — equals() учитывает масштаб
System.out.println(treeSet.size()); // Вывод: 1 — compareTo() вернул 0
System.out.println(treeSet); // Вывод: [1.0] Наш Person тоже не согласован с equals(): два разных человека одного возраста для compareTo() равны, и второго из них TreeSet без компаратора не примет — как в истории с Ивановыми. Если объекты будут храниться в TreeSet или быть ключами TreeMap, включайте в сравнение все поля, которые участвуют в equals(). Как equals() и hashCode() работают в хеш-коллекциях, объясняет урок Set и HashSet.
Где используются Comparable и Comparator
Comparable и Comparator используют все отсортированные коллекции и методы сортировки Java: Collections.sort(), List.sort(), Arrays.sort(), TreeSet, TreeMap, PriorityQueue, Stream.sorted(), Collections.max(). Везде действует одно правило: если компаратор передан, используется он, если нет (или передан null) — естественный порядок Comparable.
| Где | Без компаратора | С компаратором | Подробнее |
|---|---|---|---|
| Сортировка списка | Collections.sort(list), list.sort(null) | list.sort(cmp), Collections.sort(list, cmp) | Collections.sort(), List и ArrayList |
| Сортировка массива | Arrays.sort(arr) | Arrays.sort(arr, cmp) | Компаратор принимают только массивы объектов: для int[] такой перегрузки нет, нужен Integer[] |
TreeSet | new TreeSet<>() | new TreeSet<>(cmp) | SortedSet и TreeSet |
TreeMap (порядок ключей) | new TreeMap<>() | new TreeMap<>(cmp) | Map |
PriorityQueue | new PriorityQueue<>() — первым выходит наименьший | new PriorityQueue<>(cmp) | PriorityQueue |
| Поиск максимума и минимума | Collections.max(coll), Collections.min(coll) | Collections.max(coll, cmp), stream.max(cmp) | Collections.max(), min() |
| Stream API | stream.sorted() | stream.sorted(cmp) | Исходная коллекция не меняется |
Все сортировки объектов в Java стабильны: элементы, для которых компаратор вернул 0, сохраняют исходный взаимный порядок. Поэтому сортировку по нескольким полям можно собрать и цепочкой thenComparing(), и несколькими сортировками подряд — от последнего критерия к первому.
Часто задаваемые вопросы
Обязательно ли compareTo() возвращать -1, 0 или 1?
Нет, важен только знак: String.compareTo(), например, возвращает разность кодов символов ("Б".compareTo("а") даёт минус 31). Поэтому проверка a.compareTo(b) == 1 — ошибка, правильно писать a.compareTo(b) > 0.
Почему Comparator считается функциональным интерфейсом, если в нём есть метод equals()?
Метод boolean equals(Object obj) в Comparator переобъявляет метод класса Object, а такие методы не учитываются при подсчёте абстрактных. Остальные методы (reversed(), thenComparing(), comparing()) — default и static. Абстрактный метод один — compare(), поэтому компаратор можно задать лямбдой.
Как сравниваются элементы enum?
Все перечисления реализуют Comparable, и естественный порядок — порядок объявления констант (по ordinal()). Метод compareTo() у enum объявлен final, переопределить его нельзя; другой порядок задают компаратором, например Comparator.comparing(Enum::name).
Как отсортировать объекты по убыванию?
Для естественного порядка — list.sort(Comparator.reverseOrder()), для компаратора — добавить .reversed(): Comparator.comparingInt(Person::getAge).reversed(). Если нужно перевернуть только один критерий из цепочки, его передают вторым аргументом: thenComparing(Person::getAge, Comparator.reverseOrder()). Пример с выводом — в разделе Comparator.comparing, thenComparing, reversed.
Практика по теме
Закрепите урок на задачах — у каждой есть уровень и оценка времени.
Все задачи модуля →Видео объяснение
Предпочитаете видеоформат? Посмотрите этот урок с примерами и объяснениями.
Комментарии