Итерация и порядок в коллекциях
Компаратор через вычитание выглядит невинно. Я скормил ему два числа: минус два миллиарда и плюс два миллиарда. Результат вышел +294 967 296, то есть положительный - компаратор утверждает, что минус два миллиарда БОЛЬШЕ плюс двух миллиардов. Integer.compare на тех же числах даёт минус единицу.
Тема выглядит служебной, но здесь живут два продакшен-инцидента: сообщение «Comparison method violates its general contract» посреди ночи и коллекция, испорченная удалением на обходе. Оба - про нарушенные контракты.
// Формулировки: «Comparable или Comparator?», «почему сортировка кинула исключение?», «что такое стабильная сортировка?»
Comparable, Comparator и сборка компараторов
Comparable - естественный порядок, зашитый в сам класс через compareTo. У числа, строки, даты он один и очевиден. Comparator - внешний порядок, и таких может быть сколько угодно: по возрасту, по имени, по дате регистрации.
Современные компараторы собирают комбинаторами: comparing по ключу, дальше thenComparing для разрешения равенства. Есть и явная политика для пустых значений через nullsFirst и nullsLast - вместо NPE (NullPointerException) в середине сортировки.
// Про reversed я проверил обе формы на одном наборе. comparing(age).reversed().thenComparing(name) даёт возраст по убыванию, а имена внутри по алфавиту. А comparing(age).thenComparing(name).reversed() разворачивает ВСЮ цепочку: и возраст, и имена идут по убыванию. Разница в одной точке, а результат разный.
- Comparable
- естественный порядок внутри класса, он один
- reversed
- разворачивает всю цепочку до себя, а не последний ключ
Контракт сравнения: что ловится, а что нет
Контракт требует трёх вещей: антисимметрии, транзитивности и стабильности между вызовами. Сортировка в JDK - это TimSort, и он контракт ПРОВЕРЯЕТ. Я скормил ему нетранзитивный компаратор (считает «почти равными» числа, отличающиеся меньше чем на десять) и получил ровно то самое: Comparison method violates its general contract! Компаратор, который никогда не возвращает ноль, ловится так же.
А теперь неприятное. Тот же TimSort на компараторе через вычитание НЕ упал. Сто тысяч чисел отсортировались молча. Проверил результат на маленьком наборе - он просто неправильный: вычитанием получилось [1500000000, -1500000000, 0, 2000000000, -2000000000], а через Integer.compare нормальное [-2000000000, -1500000000, 0, 1500000000, 2000000000].
// Вывод для практики жёсткий: исключение про контракт - это подарок, оно хотя бы кричит. Переполнение при вычитании обычно не кричит вовсе и отдаёт молча перепутанный порядок. Сравнивать числа надо через Integer.compare или Long.compare, всегда.
Comparator<Integer> bad = (x, y) -> x - y;
bad.compare(-2_000_000_000, 2_000_000_000); // +294967296, знак врёт
// правильно: Integer.compare(x, y)- TimSort
- сортировка JDK, проверяет контракт компаратора
- молчаливое переполнение
- вычитание врёт знаком, исключения при этом нет
Стабильность и два лагеря итераторов
TimSort стабилен: элементы, равные по компаратору, сохраняют исходный взаимный порядок. Проверил на четырёх людях - отсортировал сначала по имени, потом по возрасту, и внутри каждого возраста имена остались по алфавиту. Отсюда практический приём: многоключевую сортировку можно делать последовательными проходами, от менее важного ключа к более важному.
Итераторы обычных коллекций работают на опережение: счётчик модификаций ловит изменение во время обхода и бросает исключение. Но это детектор, а не гарантия - я показывал в теме про списки, что удаление предпоследнего элемента проходит молча.
// Другой лагерь - итераторы, которые не падают вовсе. CopyOnWriteArrayList обходит снимок, сделанный в момент создания итератора, ConcurrentHashMap даёт слабо согласованный обход. Исключений нет, но и свежести данных никто не обещает: увидишь состояние где-то между началом и концом обхода.
- стабильная сортировка
- равные элементы не меняют взаимный порядок
- обход снимка
- итератор работает по копии, исключений нет
Как отвечать: «Comparable или Comparator - когда что берёшь?»
Comparable беру, когда у класса есть один естественный бесспорный порядок: числа, даты, версии. Он зашивается в сам класс через compareTo. Comparator - для всех остальных случаев: внешних порядков бывает много, и им не место внутри доменного класса. Сортировку пользователей по возрасту или по имени я собираю комбинаторами comparing и thenComparing прямо на месте использования. Дальше три вещи из практики. Первая: сравнивать числа вычитанием нельзя, оно переполняется - я проверял, для минус двух и плюс двух миллиардов результат выходит положительным, то есть знак врёт; правильный способ Integer.compare. Вторая: метод reversed разворачивает всю цепочку до себя, а не последний ключ, и это регулярно ловят на код-ревью. Третья: compareTo стоит согласовывать с equals, потому что TreeSet считает дубликатом всё, что даёт ноль при сравнении, и один и тот же набор в HashSet и TreeSet может дать разное число элементов.
Сильный ответ: чёткий критерий выбора - один бесспорный порядок против многих внешних, плюс три подвоха с последствиями. Каждый подвох назван вместе с правильным решением, а не брошен упоминанием.
На чём валят
- −Пишут компаратор через вычитание. Переполнение врёт знаком, и сортировка молча даёт неправильный порядок - исключения при этом может и не быть.
- −Ловят «violates its general contract» в try и идут дальше. Чинить надо компаратор: сообщение означает, что он несогласован.
- −Ставят reversed в конце цепочки thenComparing. Разворачивается вся цепочка, а не последний ключ.
- −Не согласуют compareTo с equals. TreeSet съедает неравные объекты как дубликаты, живой пример - BigDecimal.
- −Считают детектор изменения на обходе гарантией. Он лучше, чем ничего, но пропуски бывают.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 17, остальные разбираются в тренажёре.
- Что определяет Comparator, переданный в TreeMap или Collections.sort?A)Правило, по которому из коллекции удаляются дубликаты перед тем, как начать сортировку элементовB)Признак того, поддерживает ли коллекция параллельную сортировку сразу в нескольких рабочих потокахC)Порядок элементов, отличный или заменяющий естественныйD)Функцию хеширования элементов, по которой они раскладываются по бакетам перед упорядочиванием
показать ответ и разбор
+C)Порядок элементов, отличный или заменяющий естественный// разбор: Comparator — объект/лямбда, задающий порядок сравнения элементов. Его передают в sort, TreeMap/TreeSet, min/max, priority-очереди, чтобы упорядочить не по естественному порядку (Comparable), а по своему правилу — или для классов, у которых Comparable нет. Удобно комбинировать: comparing(...).thenComparing(...).reversed(). В TreeMap Comparator ещё и определяет равенство ключей (compareTo==0), а не equals.
- Гарантирует ли Collections.sort() стабильность (сохранение порядка равных элементов)?A)Нет, порядок равных элементов после сортировки случаен и меняется от запуска к запуску программыB)Да, но только если элементы реализуют Comparable; при использовании Comparator стабильность теряетсяC)Нет, стабильность нужно включать отдельным флагом, иначе сортировка переставляет равные элементыD)Да, сортировка списков стабильна — равные элементы сохраняют относительный порядок
показать ответ и разбор
+D)Да, сортировка списков стабильна — равные элементы сохраняют относительный порядок// разбор: Сортировка списков (Collections.sort / List.sort) — стабильная: элементы, равные по компаратору, остаются в исходном относительном порядке. Внутри это вариант merge sort (TimSort). Стабильность важна для многоуровневой сортировки: отсортировав сначала по одному ключу, потом по другому, получаем предсказуемый комбинированный порядок. Заметьте: сортировка примитивных массивов (Arrays.sort(int[])) — быстрая, но НЕ стабильная (quicksort).
- Чем ListIterator мощнее обычного Iterator?A)Умеет идти в обе стороны, менять и вставлять элементы, знает индексB)Он потокобезопасен и позволяет нескольким потокам одновременно обходить один и тот же список без блокировокC)Он автоматически пропускает null-элементы и дубликаты, обходя только уникальные ненулевые значенияD)Он кэширует весь список в памяти заранее, поэтому его обход быстрее обычного итератора
показать ответ и разбор
+A)Умеет идти в обе стороны, менять и вставлять элементы, знает индекс// разбор: ListIterator (только у списков) расширяет Iterator: обход вперёд И назад (hasPrevious/previous), изменение текущего элемента set(), вставка add(), а также nextIndex/previousIndex. Обычный Iterator умеет лишь hasNext/next/remove в одном направлении. ListIterator удобен, когда по ходу обхода нужно править список или двигаться в обе стороны. Потокобезопасности он не добавляет — тоже fail-fast.
- Во что компилятор разворачивает for-each по коллекции?A)В обычный индексный цикл for(i=0; i<size; i++) с обращением к элементам через get(i) по номеруB)В цикл с Iterator: hasNext()/next()C)В параллельный Stream, который распределяет элементы коллекции по потокам пула ForkJoinPoolD)В рекурсивный обход, где каждый следующий элемент обрабатывается вложенным вызовом того же метода
показать ответ и разбор
+B)В цикл с Iterator: hasNext()/next()// разбор: for-each по Iterable компилируется в цикл, берущий Iterator и вызывающий hasNext()/next(). Поэтому он работает с любой Iterable-коллекцией (не только списками) и почему нельзя менять её структуру мимо итератора (CME). Для LinkedList это эффективнее, чем for по get(i) (у которого доступ по индексу O(n)). Для массивов же for-each разворачивается в индексный цикл — там итератора нет.
- Что произойдёт при обходе HashMap: гарантирован ли порядок ключей?A)Да, HashMap обходит ключи в порядке их вставки, как LinkedHashMap, но чуть медленнееB)Да, ключи обходятся строго отсортированными по возрастанию их естественного порядка сравненияC)Нет, порядок обхода HashMap не гарантирован и может менятьсяD)Да, порядок фиксирован их хеш-кодами и одинаков в разных JVM и версиях Java для тех же ключей
показать ответ и разбор
+C)Нет, порядок обхода HashMap не гарантирован и может меняться// разбор: HashMap не даёт никаких гарантий порядка обхода: он зависит от хешей ключей и текущей ёмкости таблицы, а после resize может измениться. Полагаться на него — источник плавающих багов. Нужен порядок вставки — LinkedHashMap; отсортированный — TreeMap. Это частая ошибка: тесты «случайно» проходят на одном наборе данных, а на другом/в другой версии Java порядок другой.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.