Java Comparable и Comparator
Определяйте естественный порядок через Comparable и внешний через Comparator в Java, а также составляйте компараторы.
Два интерфейса, одна задача: сообщить Java, когда один объект «меньше» другого. На месте вызова они выглядят почти одинаково, а их методы возвращают одно и то же — отрицательный int, ноль или положительный int. Разница в том, где хранится порядок:
Comparable<T>— тип сам знает, как упорядочивать свои экземпляры. Его методint compareTo(T other)задаёт естественный порядок типа.Comparator<T>— внешний объект, упорядочивающий экземпляры. Его методint compare(T a, T b)описывает один из множества возможных порядков.
Comparable реализуют тогда, когда для типа есть один очевидный «меньше» — Integer, String, LocalDate. Comparator пишут для всех остальных порядков — по длине, по имени без учёта регистра, по убыванию цены, по любому критерию, который можно выразить кодом. У большинства типов один Comparable (или ни одного) и множество полезных Comparator-ов.
Контракт: −/0/+
Оба метода возвращают int, знак которого является ответом:
- отрицательный —
aстоит передb - ноль — равны с точки зрения порядка
- положительный —
aстоит послеb
Конкретная величина не важна. -1 и -1_000_000 означают одно и то же. Никогда не пишите return a.size - b.size, если возможно переполнение: вычитание Integer.MIN_VALUE из положительного числа приводит к переполнению. Вместо этого используйте Integer.compare(a.size(), b.size()) — он безопасен в отношении переполнения и содержит столько же символов.
Comparable<T> — естественный порядок
Тип реализует Comparable<Self> и предоставляет compareTo:
public record Version(int major, int minor, int patch) implements Comparable<Version> {
@Override public int compareTo(Version other) {
int m = Integer.compare(this.major, other.major);
if (m != 0) return m;
int n = Integer.compare(this.minor, other.minor);
if (n != 0) return n;
return Integer.compare(this.patch, other.patch);
}
}Теперь Collections.sort(versions), versions.stream().sorted(), new TreeSet<Version>() и new TreeMap<Version, X>() работают без передачи каких-либо дополнительных аргументов.
Контракт предусматривает три правила, которые должен соблюдать каждый compareTo:
- Антисимметричность —
a.compareTo(b)иb.compareTo(a)имеют противоположные знаки. - Транзитивность — если
a < bиb < c, тоa < c. - Согласованность с
equals(настоятельно рекомендуется) —a.compareTo(b) == 0тогда и только тогда, когдаa.equals(b).
Третье правило нарушают чаще всего. BigDecimal — классический пример: new BigDecimal("1.0").compareTo(new BigDecimal("1.00")) равно 0, но .equals возвращает false. В результате TreeSet<BigDecimal> и HashSet<BigDecimal> будут расходиться во мнениях о том, являются ли "1.0" и "1.00" дубликатами. По возможности придерживайтесь согласованности.
Comparator<T> — внешний порядок
Comparator — это отдельный объект. Он может сравнивать любые два T, в том числе типы, которые вы не писали:
Comparator<String> byLength = (a, b) -> Integer.compare(a.length(), b.length());
list.sort(byLength);Поскольку Comparator<T> является функциональным интерфейсом (один абстрактный метод compare), каждый Comparator — это просто лямбда или ссылка на метод. Именно так выглядит современный код с компараторами — анонимные классы почти не используются.
Строители на Comparator
Класс содержит статические фабричные методы, делающие построение компараторов кратким и читаемым:
Comparator<Person> byAge = Comparator.comparingInt(Person::age);
Comparator<Person> byName = Comparator.comparing(Person::name);
Comparator<Person> byNameCi = Comparator.comparing(Person::name, String.CASE_INSENSITIVE_ORDER);
Comparator<Person> oldestFirst = byAge.reversed();
Comparator<String> nullsFirst = Comparator.nullsFirst(Comparator.naturalOrder());Используйте специализированные примитивные строители — comparingInt, comparingLong, comparingDouble — когда ключ является примитивом. Они позволяют избежать автоупаковки при каждом сравнении, что ощутимо сказывается при длинной сортировке.
Цепочки компараторов с thenComparing
Ещё одна причина отдавать предпочтение строителям: можно объединять несколько ключей в цепочку.
Comparator<Person> ordering =
Comparator.comparing(Person::lastName)
.thenComparing(Person::firstName)
.thenComparingInt(Person::age);Читается сверху вниз: «первичный ключ — фамилия; при равенстве — имя; затем — возраст». thenComparing вызывается на предыдущем компараторе и возвращает новый, который обращается ко второму ключу только при равенстве по первому. Длина цепочки не ограничена.
reversed(), nullsFirst, nullsLast
Три модификатора используются постоянно:
reversed()переворачивает порядок любого компаратора.byAge.reversed()— «сначала старшие».nullsFirst(cmp)оборачивает компаратор так, чтобыnull-значения считались меньше любого ненулевого. Полезно при сортировке коллекций, которые могут содержатьnull.nullsLast(cmp)— симметричный аналог.
Не вызывайте reversed() на составном компараторе в ожидании, что перевернётся только последний ключ — reversed() переворачивает весь порядок, каждый ключ в цепочке.
Comparable и Comparator в API JDK
Многие методы существуют в двух вариантах — один использует естественный порядок, другой принимает Comparator:
| Операция | Перегрузка с естественным порядком | Перегрузка с Comparator |
|---|---|---|
| Сортировка списка | Collections.sort(list) | Collections.sort(list, cmp) |
| Сортировка списка (современный способ) | list.sort(null) | list.sort(cmp) |
| Сортировка потока | stream.sorted() | stream.sorted(cmp) |
| Множество на основе дерева | new TreeSet<>() | new TreeSet<>(cmp) |
| Карта на основе дерева | new TreeMap<>() | new TreeMap<>(cmp) |
| Минимум/максимум | Collections.min(list) | Collections.min(list, cmp) |
| Бинарный поиск | Collections.binarySearch(list, key) | Collections.binarySearch(list, key, cmp) |
PriorityQueue | естественный порядок типа элемента | конструктор принимает Comparator |
Перегрузки с естественным порядком требуют, чтобы тип элемента реализовывал Comparable. Если ваш тип его не реализует, при вызове этих методов вы получите ClassCastException во время выполнения — а не ошибку компиляции, — поскольку приведение типа происходит внутри реализации сортировки.
Практический пример: естественный порядок, пользовательские компараторы, цепочки ключей, null
Программа ниже определяет запись с естественным порядком (Comparable) и тремя внешними порядками: по одному ключу, по цепочке ключей с инвертированным вторичным ключом, и один, допускающий записи null.
Что следует вынести из выполнения программы:
- Реализация
Comparableвыполнила сортировку по имени и разрешала совпадения имён по возрасту. Явный компаратор не потребовался — естественный порядок используетсяCollections.sortи другими подобными методами по умолчанию. Comparator.comparingDouble(Person::salary)короче и быстрее, чем(a, b) -> Double.compare(a.salary(), b.salary()), поскольку избегает автоупаковки.- Составной компаратор отсортировал прежде всего по возрасту, а
reversed()применил только к части salary — это правильный паттерн, когда нужны разные направления для разных ключей. Сравните с вызовом.reversed()на всей цепочке, который перевернул бы оба ключа. nullsFirstпозволил компаратору обработать список, содержащий записиnull, безNullPointerException. Без этой обёртки первое сравнение с участиемnullзавершилось бы ошибкой.- «Трюк с вычитанием» дал неверный результат для
Integer.MAX_VALUE - (-1): это вычисление переполняется до отрицательного числа, поэтомуbadсообщает, чтоMAX_VALUEменьше-1.Integer.compareвсегда возвращает правильный знак. Всегда предпочитайте его.
Что дальше
Теперь вы разобрались с итерацией (Iterator / ListIterator) и упорядочением (Comparable / Comparator). Следующая глава объединяет всё это в служебном классе java.util.Collections — статическом наборе методов sort, search, reverse, shuffle, min, max и «обернуть эту коллекцию как неизменяемую», работающих с любым List, Set или Map. После этого две короткие главы посвящены сортировке и поиску.