Класс переопределяет eq, но не объявляет hash: почему его экземпляры перестают быть допустимыми ключами словаря?
При явном переопределении eq Python автоматически делает hash класса равным None, если hash не задан явно. Поэтому экземпляры становятся нехешируемыми: их нельзя использовать ключами словаря или элементами множества.
Это защищает инвариант хеш-таблиц: если два объекта считаются равными, их хеши должны совпадать. Автоматически унаследованный хеш мог бы нарушить это правило.
Словари и множества используют хеш-таблицы. Для корректной работы объект-ключ должен иметь стабильный хеш в течение всего времени хранения в контейнере, а равные объекты должны попадать в совместимые позиции поиска.
Модель данных Python связывает операции равенства и хеширования. Если класс меняет смысл равенства, Python не предполагает, что прежний способ вычисления хеша всё ещё корректен, поэтому по умолчанию запрещает хеширование.
Рассмотрим объект, у которого равенство определяется значением поля. Если при этом сохранить хеширование по идентичности объекта, два разных экземпляра с одинаковыми значениями будут равны, но могут иметь разные хеши.
Такой объект нельзя безопасно использовать как ключ. Ещё опаснее ситуация, когда хеш зависит от изменяемого поля: после изменения поля объект может оказаться в словаре, но перестать находиться по самому себе.
Если класс определяет eq и не определяет hash, Python устанавливает специальное значение __hash__ = None. Это делает вызов hash(obj) ошибочным, а проверка через collections.abc.Hashable сообщает, что объект нехешируем.
В практическом примере правильную проверку следует записать через Hashable:
Если равенство основано на неизменяемом идентификаторе, можно явно определить согласованный хеш:
Другой вариант — явно унаследовать хеш родительского класса через __hash__ = Parent.__hash__, но это допустимо только при сохранении совместимой семантики равенства. Нельзя бездумно восстанавливать хеш по идентичности, если eq сравнивает содержимое.
Для составных объектов хеш обычно строят из неизменяемых компонентов, например кортежа. Все значения, участвующие в равенстве, должны участвовать в хеше, а их изменение после помещения объекта в словарь или множество должно быть запрещено либо практически исключено.
В системе авторизации объект пользователя сравнивался по tenant_id и user_id. После добавления eq такие объекты перестали приниматься множеством активных пользователей, что сразу выявило проблему на тестах.
Рассматривались три варианта. Оставить объекты нехешируемыми было безопасно, но не подходило для множества. Вернуть хеш по id было просто, но нарушало бы правило: разные экземпляры одного пользователя равны, а их хеши различаются. Сделать объект неизменяемым и вычислять хеш из (tenant_id, user_id) оказалось корректным решением.
В итоге идентификационные поля сделали доступными только для чтения, а hash построили из того же неизменяемого набора значений, который используется в eq. Объекты стали безопасными ключами, а изменение идентичности после помещения в контейнер стало невозможным.
object.__hash__, если класс переопределил __eq__?Технически можно явно присвоить __hash__ = object.__hash__, но это корректно только тогда, когда равенство фактически совместимо с идентичностью. Если два разных объекта могут быть равны по содержимому, хеш по идентичности нарушит требование одинакового хеша для равных объектов.
Нет. Обязательное требование только одно: равные объекты должны иметь одинаковый хеш. Неравные объекты тоже могут иметь одинаковый хеш — это коллизия, которую словарь или множество обрабатывают дополнительным сравнением равенства.
Контейнер запоминает позицию ключа на основе его хеша в момент добавления. Если значение, влияющее на равенство или хеш, изменится, поиск может вычислить уже другую позицию и не найти объект, хотя объект всё ещё физически находится в контейнере.
Поэтому хешируемый объект должен сохранять результат hash и эквивалентность с другими объектами на протяжении всего пребывания в словаре или множестве. На практике для этого используют неизменяемые поля, свойства без сеттеров или отдельные неизменяемые ключи вместо изменяемых доменных объектов.