← Назад к списку
ПрограммированиеJava и KotlinMiddle

Найдите баг: объект кладут в HashSet, затем меняют его поле — и contains возвращает false. Объясните и исправьте.

Короткий ответ

  • hashCode зависит от изменяемого поля, вошедшего в equals/hashCode
  • При вставке объект лёг в корзину по старому хешу
  • После мутации contains ищет по новому хешу — в другой корзине
  • Объект «потерялся»: он в сете, но не находится
  • Исправление: неизменяемые ключи или не мутировать после вставки
  • Вариант — удалить из сета, изменить, положить обратно

Ключ хеш-коллекции нельзя мутировать: поиск идёт по новому хешу, а лежит объект по старому.

Как сказать вслух

пример ответа

Объект положили в HashSet — он попал в корзину, вычисленную из хеша на тот момент. Потом поле, которое участвует в hashCode, изменили. Теперь contains считает уже новый хеш и смотрит в другую корзину, где объекта нет. То есть элемент физически в сете, но найти его нельзя, и удалить тоже. Правильное решение — делать ключи неизменяемыми, например record, либо никогда не менять объект, пока он лежит в хеш-коллекции.

Подробный ответ

Основной ответ

HashSet (внутри — HashMap) размещает элемент в корзине по hashCode на момент add. Если после вставки изменить поле, входящее в hashCode, повторный поиск вычислит другой хеш и пойдёт в другую корзину — contains и remove вернут false, итерация при этом объект покажет. Это приводит к утечкам: «неудаляемые» элементы накапливаются. Исправления по убыванию надёжности: сделать ключ неизменяемым (record, final-поля); исключить изменяемые поля из equals/hashCode, строя их на стабильном бизнес-идентификаторе; дисциплина «удалил — изменил — вставил обратно», если мутация неизбежна. Тот же баг существует для ключей HashMap и элементов, по которым построен TreeSet (там ломается порядок сравнения).

Ключевые моменты

  • Корзина фиксируется при вставке. Коллекция не пересчитывает хеши лежащих элементов — она не знает о мутации.
  • Симптомы. contains == false при «видимом» элементе в итерации, remove не удаляет, дубликаты в сете.
  • Исправление. Неизменяемый ключ (record) или hashCode по неизменяемому id.
  • Шире, чем HashSet. Та же проблема с ключами HashMap и компараторами TreeMap/TreeSet.

Практический контекст

Классическая задача «найди баг» на лайвкодинге: проверяет связку устройства HashMap и контракта equals/hashCode на живом примере. В реальном коде так теряются объекты в кэшах и реестрах подписчиков. Интервьюер ждёт точного объяснения механики (старая корзина против нового хеша), а не общего «так нельзя», и пары вариантов исправления с оценкой их надёжности.

Пример кода

// Класс с изменяемым полем в equals/hashCode
class User {
    String email; // участвует в equals/hashCode
    User(String email) { this.email = email; }
    @Override public boolean equals(Object o) {
        return o instanceof User u && email.equals(u.email);
    }
    @Override public int hashCode() { return email.hashCode(); }
}

Set<User> users = new HashSet<>();
User u = new User("a@mail.com");
users.add(u);
u.email = "b@mail.com";            // мутация ключа!
System.out.println(users.contains(u)); // false
System.out.println(users.remove(u));   // false — утечка
// Fix: final String email или record User(String email)

Частые ошибки

  • Объясняют баг «сломался equals», хотя equals корректен — ищут не в той корзине
  • Предлагают пересоздавать коллекцию после каждой мутации вместо исправления ключа
  • Не замечают, что remove тоже не сработает и элемент утёк навсегда

ИП Кочкин Алексей Сергеевич · ИНН 390509026279 · ОГРНИП 325390000030973 · jiniys2005@yandex.ru