Cuando una clase Python define __eq__ para personalizar la comparación de igualdad, el intérprete establece automáticamente __hash__ como None a menos que se sobreescriba explícitamente. Esto hace que la instancia no sea hasheable, impidiendo su uso como clave de un dict o miembro de un set. La invariante subyacente requiere que los objetos que comparan igual a través de __eq__ deben generar hash idénticos; violar esto causa un comportamiento indefinido en colecciones basadas en hash. Como resultado, intentar usar tal objeto como clave de un mapeo genera TypeError: tipo no hasheable.
Un equipo de desarrollo estaba construyendo un servicio de gestión de sesiones donde los objetos User servían como claves en un caché en memoria dict para almacenar sesiones activas. La clase User implementó __eq__ para comparar instancias basadas en user_id, asegurando que dos objetos diferentes representando el mismo usuario de la base de datos fueran tratados como iguales. La implementación inicial se veía así:
class User: def __init__(self, user_id, name): self.user_id = user_id self.name = name def __eq__(self, other): if not isinstance(other, User): return NotImplemented return self.user_id == other.user_id
Inicialmente, el equipo no implementó __hash__, asumiendo que el comportamiento por defecto sería suficiente. Sin embargo, cuando el servicio intentó almacenar en caché una sesión usando cache[user] = session_data, Python lanzó TypeError: tipo no hasheable: 'User', lo que hizo que el servicio fallara.
El equipo consideró tres soluciones. El primer enfoque utilizó id(self) como el valor hash. Esto fue rechazado porque violaba la invariante crítica: dos instancias User distintas con el mismo user_id tendrían hashes diferentes a pesar de ser iguales a través de __eq__. Esto hizo que aparecieran como claves diferentes, rompiendo las búsquedas en la caché completamente y permitiendo entradas duplicadas para el mismo usuario lógico.
El segundo enfoque utilizó hash(self.user_id) como el valor hash. Esto satisfizo la invariante ya que los usuarios iguales comparten el mismo user_id. Sin embargo, esto requería asegurar que user_id fuera inmutable, ya que valores hash mutables harían que el objeto se volviera "perdido" en el diccionario si el ID cambiaba después de la inserción.
La tercera opción abandonó el uso de objetos User como claves, utilizando en su lugar la cadena user_id directamente. Aunque seguro y simple, esto sacrificó la seguridad de tipos y requirió mantener un mapeo separado de IDs a objetos User, complicando el código con lógica adicional de búsqueda.
El equipo eligió la segunda solución, agregando la siguiente implementación a la clase:
def __hash__(self): return hash(self.user_id)
También hicieron que user_id fuera una propiedad de solo lectura para asegurar la inmutabilidad. Esto preservó la capacidad de usar instancias de User como claves mientras mantenía la semántica de igualdad correcta. El resultado fue una caché robusta que identificaba correctamente a los usuarios independientemente de la identidad de instancia del objeto.
¿Por qué Python establece automáticamente __hash__ en None cuando se define __eq__ pero no se define __hash__?
Cuando una clase define __eq__, el hash basado en identidad por defecto heredado de object se vuelve lógicamente inválido. El __hash__ por defecto se basa en id(self), lo que significa que dos objetos distintos tienen hashes diferentes. Si __eq__ se sobreescribe para comparar valores, dos instancias diferentes podrían ser iguales pero tendrían hashes diferentes, violando la regla fundamental que dice que a == b implica que hash(a) == hash(b). Python previene esta inconsistencia estableciendo __hash__ en None, marcando explícitamente la clase como no hasheable en lugar de permitir un comportamiento predeterminado peligroso que causaría un rendimiento errático en el diccionario o claves inalcanzables.
¿Qué pasa si se utiliza un objeto mutable como clave de diccionario después de implementar __hash__ basado en campos mutables?
Si __hash__ depende de un estado mutable, el valor hash puede cambiar después de que el objeto ha sido insertado en un dict. Los diccionarios almacenan claves en cubos de hash basados en el valor hash en el momento de la inserción. Si el hash cambia más tarde debido a una mutación, las búsquedas posteriores calcularán un hash diferente y buscarán en un cubo diferente, haciendo que la clave original sea inalcanzable. El objeto permanece en la memoria pero no puede ser encontrado o eliminado a través de accesos clave normales. Esto crea una fuga de memoria y una inconsistencia lógica, por lo que Python requiere que los objetos hasheables sean inmutables o basados en identificadores inmutables.
¿Cómo maneja el decorador @dataclass la generación de __eq__ y __hash__, y cuál es el riesgo de usar unsafe_hash=True?
Por defecto, @dataclass genera __eq__ basado en los valores de los campos pero establece __hash__ en None, haciendo que las instancias no sean hasheables. Este comportamiento conservador previene errores con dataclasses mutables. Para habilitar el hashing, debes establecer frozen=True (haciendo que los campos sean de solo lectura y generando un __hash__ seguro) o establecer explícitamente unsafe_hash=True. El parámetro unsafe_hash=True obliga a Python a generar __hash__ basado en los valores de los campos incluso si los campos son mutables. Esto es peligroso porque si algún campo cambia después de que el objeto se utiliza como clave de diccionario, el hash cambia y la clave se vuelve inalcanzable, lo que conduce al problema de "clave perdida" descrito anteriormente. Los candidatos a menudo pasan por alto que unsafe_hash no es simplemente una advertencia, sino un riesgo funcional que rompe las invariantes del diccionario.