Интерфейсы Comparable и Comparator в Java - Вопросы

Всего: 6 вопросов

1. 

Чем отличаются Comparable и Comparator в Java и когда что использовать?

Comparable<T> (пакет java.lang) задаёт естественный порядок класса: класс сам реализует int compareTo(T o) и сравнивает this с аргументом. Такой порядок у класса один. Его уже реализуют String, Integer, LocalDate, BigDecimal и все enum.

Comparator<T> (пакет java.util) — внешнее правило сравнения: int compare(T o1, T o2) реализуют в отдельном классе, анонимном классе или лямбдой, и компараторов для одного класса может быть сколько угодно. Менять сам класс не нужно, поэтому компаратор подходит для String и чужих классов. Comparator — функциональный интерфейс с готовыми методами Java 8: comparing(), thenComparing(), reversed().

Если порядок очевиден и единственный (числа, даты, коды), реализуют Comparable; для остальных случаев пишут компаратор. Подходы совмещаются: переданный компаратор (list.sort(cmp), new TreeSet<>(cmp)) всегда имеет приоритет над естественным порядком, а без него (или с null) используется compareTo().

2. 

Что возвращают compareTo() и compare() и какой контракт они должны соблюдать?

Оба метода возвращают int: отрицательное число — первый объект (this или o1) стоит перед вторым, положительное — после, ноль — объекты равны для этого порядка (что не обязательно означает equals() == true).

Важен только знак, а не значение: String.compareTo() возвращает разность кодов символов, поэтому проверка a.compareTo(b) == 1 — ошибка, правильно a.compareTo(b) > 0.

Контракт:

  • знак a.compareTo(b) противоположен знаку b.compareTo(a);
  • порядок транзитивен: если a > b и b > c, то a > c;
  • элементы, равные при сравнении, одинаково сравниваются с любым третьим.

При нарушении контракта сортировка даёт неверный результат или падает с IllegalArgumentException: Comparison method violates its general contract!.

3. 

Почему в compareTo() и compare() нельзя писать return a - b и чем это заменить?

Вычитание даёт правильный знак только пока разность помещается в int. При переполнении знак меняется: Integer.MIN_VALUE - 1 превращается в Integer.MAX_VALUE (2147483647), и компаратор (a, b) -> a - b решает, что минус два миллиарда больше единицы. Список [1, -2147483648] после такой сортировки остаётся неотсортированным, а нарушенный контракт может привести к IllegalArgumentException.

Вместо вычитания используют:

  • Integer.compare(this.age, other.age), Long.compare(), Double.compare();
  • ссылку на метод Integer::compare как компаратор;
  • в цепочках — Comparator.comparingInt(), comparingLong(), comparingDouble(): внутри они вызывают compare() и не делают автоупаковку.

Тесты на маленьких числах такую ошибку не ловят: она проявляется на идентификаторах, суммах в копейках или отрицательных значениях.

4. 

Что значит «compareTo согласован с equals» и как это влияет на TreeSet и TreeMap?

Порядок согласован с equals(), если a.compareTo(b) == 0 тогда и только тогда, когда a.equals(b). Документация Java настоятельно рекомендует это, но не требует.

На сортировку списка нарушение не влияет, но TreeSet и ключи TreeMap ищут и отбрасывают дубликаты по compareTo()/compare(), а не по equals(): если сравнение вернуло 0, новый элемент считается уже существующим, и add() возвращает false без исключения. HashSet при этом опирается на equals() и hashCode().

Пример из стандартной библиотеки — BigDecimal: new BigDecimal("1.0") и new BigDecimal("1.00") не равны по equals() (учитывается масштаб), но compareTo() возвращает 0. В HashSet будет два элемента, в TreeSet — один. Так же компаратор только по фамилии «теряет» второго Иванова. Если объекты хранятся в TreeSet или служат ключами TreeMap, в сравнение включают все поля, которые участвуют в equals(), например через thenComparing().

5. 

Как собрать компаратор методами Comparator.comparing, thenComparing и reversed и как отсортировать по убыванию?

С Java 8 компаратор собирают из статических и default-методов интерфейса Comparator:

  • comparing(Person::getLastName) — сравнение по ключу (ключ должен быть Comparable); вторым аргументом можно передать компаратор для ключа;
  • comparingInt() / comparingLong() / comparingDouble() — по примитивному ключу, без автоупаковки и переполнения;
  • thenComparing(), thenComparingInt() — следующий критерий, если предыдущий вернул 0;
  • reversed() — обратный порядок всей цепочки, к которой применён;
  • naturalOrder() / reverseOrder() — естественный порядок и обратный ему;
  • nullsFirst(cmp) / nullsLast(cmp) — null в начало или в конец, без них null в списке вызывает NullPointerException.
people.sort(Comparator.comparing(Person::getLastName)
        .thenComparingInt(Person::getAge));                    // фамилия, затем возраст
people.sort(Comparator.comparingInt(Person::getAge).reversed()); // по убыванию возраста
names.sort(Comparator.reverseOrder());                          // строки по убыванию

Чтобы перевернуть только один критерий цепочки, его передают вторым аргументом: thenComparing(Person::getAge, Comparator.reverseOrder()).

6. 

Как String.compareTo() сравнивает строки и как отсортировать русские слова по алфавиту?

String.compareTo() сравнивает строки посимвольно по кодам UTF-16 и возвращает разность первых несовпавших символов. Поэтому заглавные буквы идут раньше строчных, а «Ё» (U+0401) — даже раньше «А» (U+0410): естественный порядок списка яблоко, Ёлка, арбуз, Банан — [Ёлка, Банан, арбуз, яблоко].

String.CASE_INSENSITIVE_ORDER убирает разницу регистров, но «Ёлка» оказывается в конце: строчная «ё» (U+0451) стоит в таблице после «я» (U+044F). Результат — [арбуз, Банан, яблоко, Ёлка].

Словарный порядок даёт Collator: он реализует Comparator и учитывает правила языка.

words.sort(Collator.getInstance(Locale.forLanguageTag("ru")));
// [арбуз, Банан, Ёлка, яблоко]
Страница 1 из 1