Gdy klasa Python definiuje __eq__, aby dostosować porównanie równości, interpreter automatycznie ustawia __hash__ na None, chyba że zostanie wyraźnie nadpisany. To sprawia, że instancja staje się niehashowalna, uniemożliwiając jej użycie jako klucza w dict lub członka set. Podstawowe prawo wymaga, aby obiekty, które porównują się jako równe za pomocą __eq__, musiały produkować identyczne wartości hash; naruszenie tego powoduje nieokreślone zachowanie w kolekcjach opartych na haszach. W konsekwencji, próba użycia takiego obiektu jako klucza w mapowaniu generuje TypeError: unhashable type.
Zespół deweloperski budował usługę zarządzania sesjami, w której obiekty User służyły jako klucze w pamięci podręcznej dict do przechowywania aktywnych sesji. Klasa User zaimplementowała __eq__, aby porównywać instancje na podstawie user_id, zapewniając, że dwa różne obiekty reprezentujące tego samego użytkownika w bazie danych były traktowane jako równe. Początkowa implementacja wyglądała tak:
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
Początkowo zespół nie zaimplementował __hash__, zakładając, że domyślne zachowanie wystarczy. Jednak gdy usługa próbowała zapisać sesję za pomocą cache[user] = session_data, Python zgłosił TypeError: unhashable type: 'User', co spowodowało awarię usługi.
Zespół rozważył trzy rozwiązania. Pierwsze podejście wykorzystywało id(self) jako wartość hashową. To zostało odrzucone, ponieważ naruszało krytyczną zasadę: dwa różne instancje User z tym samym user_id miałyby różne hasze, mimo że były równe za pomocą __eq__. Sprawiło to, że appeared jako różne klucze, całkowicie łamiąc wyszukiwanie w pamięci podręcznej i umożliwiając duplikowane wpisy dla tego samego logicznego użytkownika.
Drugie podejście wykorzystało hash(self.user_id) jako wartość hashową. To spełniało zasadę, ponieważ równi użytkownicy dzielą to samo user_id. Wymagało to jednak zapewnienia, że user_id jest niemutowalne, ponieważ mutowalne wartości hash spowodowałyby "zgubienie" obiektu w słowniku, jeśli ID zmieniłoby się po wstawieniu.
Trzecia opcja porzuciła użycie obiektów User jako kluczy, decydując się na użycie bezpośrednio ciągu user_id. Choć bezpieczne i proste, poświęciło to bezpieczeństwo typów i wymagało utrzymania osobnego mapowania z ID do obiektów User, komplikując kod z dodatkową logiką wyszukiwania.
Zespół wybrał drugie rozwiązanie, dodając następującą implementację do klasy:
def __hash__(self): return hash(self.user_id)
Uczynili również user_id właściwością tylko do odczytu, aby zapewnić niemutowalność. Umożliwiło to stosowanie instancji User jako kluczy, jednocześnie zachowując poprawne semantyki równości. Efektem była solidna pamięć podręczna, która poprawnie identyfikowała użytkowników niezależnie od tożsamości instancji obiektu.
Dlaczego Python automatycznie ustawia __hash__ na None, gdy __eq__ jest zdefiniowane, ale __hash__ nie?
Gdy klasa definiuje __eq__, domyślny hash oparty na tożsamości dziedziczonej z object staje się logicznie nieważny. Domyślny __hash__ opiera się na id(self), co oznacza, że dwa różne obiekty mają różne hasze. Jeśli __eq__ jest nadpisane w celu porównania wartości, dwa różne instancje mogą być równe, ale będą miały różne hasze, naruszając podstawową zasadę, że a == b implikuje hash(a) == hash(b). Python zapobiega tej niespójności, ustawiając __hash__ na None, wyraźnie oznaczając klasę jako niehashowalną, zamiast pozwolić na niebezpieczne domyślne zachowanie, które mogłoby spowodować nieprzewidywalną wydajność słownika lub niedostępne klucze.
Co się stanie, jeśli mutowalny obiekt zostanie użyty jako klucz w słowniku po implementacji __hash__ opartej na mutowalnych polach?
Jeśli __hash__ zależy od stanu mutowalnego, wartość hashowa może zmienić się po wstawieniu obiektu do dict. Słowniki przechowują klucze w kubełkach hash na podstawie wartości hash w momencie wstawienia. Jeśli hash później się zmienia z powodu mutacji, kolejne wyszukiwania obliczają inną wartość hash i przeszukują inny kubełek, co sprawia, że oryginalny klucz staje się niedostępny. Obiekt pozostaje w pamięci, ale nie można go znaleźć ani usunąć przez normalny dostęp do klucza. Tworzy to wyciek pamięci i niezgodność logiczną, dlatego Python wymaga, aby obiekty hashowalne były niemutowalne lub oparte na niemutowalnych identyfikatorach.
Jak dekorator @dataclass obsługuje generację __eq__ i __hash__, a jakie są ryzyka związane z używaniem unsafe_hash=True?
Domyślnie @dataclass generuje __eq__ na podstawie wartości pól, ale ustawia __hash__ na None, co czyni instancje niehashowalnymi. To konserwatywne domyślne zachowanie zapobiega błędom w mutowalnych klasach danych. Aby włączyć haszowanie, należy ustawić frozen=True (co czyni pola tylko do odczytu i generuje bezpieczny __hash__) lub wyraźnie ustawić unsafe_hash=True. Parametr unsafe_hash=True wymusza na Pythonie generowanie __hash__ na podstawie wartości pól, nawet jeśli pola są mutowalne. Jest to niebezpieczne, ponieważ jeśli jakiekolwiek pole zmieni się po użyciu obiektu jako klucza w słowniku, hash zmienia się, a klucz staje się niedostępny, prowadząc do problemu "zgubionego klucza" opisanego wcześniej. Kandydaci często nie dostrzegają, że unsafe_hash to nie tylko ostrzeżenie, ale ryzyko funkcjonalne, które łamie zasady słownika.