R

ReflexKR

Reflex-Dokumentation

Der Etiketten-Flughafen: Pythons Objektmodell

·

Kursüberblick

1

Das Etikett ist nicht der Koffer: Namen und Objekte

2

Versiegelte Koffer: veränderlich und unveränderlich

3

Koffer im Koffer: Container und Kopien

4

Wenn ein Koffer übergeben wird: Funktionen und Argumente

5

Zonen und Ausweise: Gültigkeitsbereiche

6

Handgepäck: Closures

7

Der Koffer-Bauplan: Klassen und Instanzen

8

Flughafen-Standardverfahren: Dunder-Methoden

9

Der Bauplan hinter dem Bauplan: Vererbung und MRO

10

Die Kontrolle: das Geheimnis des Attributzugriffs

11

Herrenlose Koffer: Speicher und GC

12

Schließfachnummern: Hash, Gleichheit und Gesamtbild

Der Etiketten-Flughafen: Pythons Objektmodell
Lektion 12

Zeit 9:10–10:00

Schließfachnummern: Hash, Gleichheit und Gesamtbild

Verlustmeldung

LOST-012

Es liegt im Schließfach und lässt sich nicht herausholen

Gemeldet von

Han, Fundsachenstelle

Was fehlt

Das Gepäck von Geisterkoffer B. Es wurde letzten Monat im Fundsachen-Schließfach (einem dict) eingetragen, doch als der Besitzer es abholen wollte, hieß es „nicht vorhanden“. Schließfach L7 ist noch immer verschlossen.

Erledigt, wenn

Mit dem Prinzip, nach dem das Schließfach seine Fächer findet, die Ursache klären, L7 öffnen und das Gepäck unter einem unveränderlichen Schlüssel neu einlagern.

Internationaler Flughafen Haneulseom · Gepäckzentrum

Gestern wurden die beiden Geisterkoffer von Band 13 weggeräumt. Übrig ist nur noch eine Zeile im Aufbewahrungsbuch. Letzten Monat wurde B im Fundsachen-Schließfach eingetragen, doch als der Besitzer es abholen wollte, antwortete das Buch „nicht vorhanden“. B kam zurück aufs Band, und der Rest ist die Geschichte bis gestern.

Han schlägt das Buch von damals noch einmal auf. Gibt man die Liste aus, steht B eindeutig darin, aber fragt man mit in, kommt False heraus. Schichtleiterin Do Miran nimmt die letzte Meldung entgegen. „Die Schließfachnummer bestimmt der Koffer. Ändert sich der Koffer nach dem Einlagern, ändert sich auch die Nummer.“

12-1. Schließfachnummern: Fach per Hash finden, mit == bestätigen (8 min)

dict und set sind Schließfachanlagen mit vielen Fächern. Beim Einlagern und beim Suchen eines Schlüssels laufen jedes Mal dieselben Schritte ab:

  1. Mit hash(key) wird der Hash (die Schließfachnummer) des Schlüssels bestimmt.
  2. Der Hash legt das Startfach fest.
  3. Ist das Fach leer, ist hier Schluss: Beim Einlagern kommt der Schlüssel in dieses Fach, beim Suchen heißt es „nicht vorhanden“.
  4. Ist das Fach belegt, wird geprüft, ob der Hash gleich ist und == denselben Schlüssel bestätigt. Wenn nicht, geht es nach einer festen Regel zum nächsten Fach.

Deshalb braucht es ein Versprechen: Gilt a == b, muss auch hash(a) == hash(b) gelten. Würde derselbe Schlüssel in verschiedenen Fächern mit der Suche beginnen, würden sie sich nie begegnen.

print(1 == 1.0 == True, hash(1) == hash(1.0) == hash(True))
# True True
print({1: "int", 1.0: "float", True: "bool"})
# {1: 'bool'}

1, 1.0 und True sind untereinander ==, haben also auch denselben Hash und sind im dict derselbe Schlüssel. Als Schlüssel bleibt die zuerst eingetragene 1, nur der Wert wird durch den letzten ersetzt.

Umgekehrt muss es nicht gelten. Eine Kollision, also gleicher Hash bei verschiedenem Wert, ist normal. Das == in Schritt 4 sortiert sie aus.

print(hash(42), hash(-1), hash(-2))
# 42 -2 -2
print(hash(-1) == hash(-2), -1 == -2)
# True False
lockers = {-1: "A", -2: "B"}
print(lockers[-1], lockers[-2])
# A B

In CPython ist der Hash einer kleinen Ganzzahl die Zahl selbst. Die -1 bedeutet intern in C aber „Fehler“, deshalb ergibt hash(-1) die -2. Das ist eine Kollision, und trotzdem hält das dict die beiden problemlos auseinander.

Der Hash eines Strings ändert sich bei jedem Programmstart. Das soll Angriffe verhindern, die Hashwerte vorausberechnen. Braucht man stets denselben Wert (etwa um Tests reproduzierbar zu machen), legt man die Umgebungsvariable PYTHONHASHSEED fest.

$ python -c "print(hash('T1'))"
# z. B.: 4646783657884298203
$ python -c "print(hash('T1'))"
# z. B.: -1769311965495050168
$ PYTHONHASHSEED=0 python -c "print(hash('T1'))"
# bei jedem Start derselbe Wert

Deshalb darf man Hashwerte weder in Dateien speichern noch mit anderen Prozessen austauschen. Ein Hash ist eine Nummer, die nur innerhalb eines einzigen Programmlaufs gilt.

Das Schließfach unten ist auf 8 Fächer vereinfacht. Es beginnt im Fach „Hash modulo 8“ und geht, wenn es belegt ist, direkt zum nächsten Fach. Lagere nacheinander 7 und 15 sowie -1 und -2 ein, um Kollisionen zu erzeugen.

Schließfachraum

Eine vereinfachte Hashtabelle mit 8 Fächern. CPythons dict wählt die Fächer in einer komplizierteren Reihenfolge, aber das Prinzip ist gleich: Fach per Hash finden, mit == bestätigen.

7 und 15 beginnen beide in Fach 7, also wandert der später eingelagerte Schlüssel ins nächste Fach (nach Fach 7 kommt Fach 0). -1 und -2 haben beide den Hash -2 und beginnen in Fach 6. Beim Suchen wird derselbe Weg gegangen.

12-2. Der Schlüssel, der sich nach dem Einlagern ändert (10 min)

Das Schließfach legt den Schlüssel in das Fach mit der Nummer, die beim Einlagern berechnet wurde, und berechnet diese Nummer danach nie wieder. Was passiert, wenn sich der Wert des Schlüssels nach dem Einlagern ändert und damit sein Hash? Wir probieren es mit einem absichtlich falsch gebauten Schlüssel aus.

class Tag:
    def __init__(self, n):
        self.n = n
    def __eq__(self, other):
        return isinstance(other, Tag) and self.n == other.n
    def __hash__(self):
        return hash(self.n)   # Fehler: Hash aus einem veränderlichen Wert gebildet
    def __repr__(self):
        return f"Tag({self.n})"

key = Tag(3)
lockers = {key: "bag B"}
print(Tag(3) in lockers)
# True
key.n = 13
print(lockers)
# {Tag(13): 'bag B'}
print(Tag(13) in lockers, Tag(3) in lockers, key in lockers)
# False False False

In der Ausgabe steht Tag(13), doch egal wie man fragt, es kommt False heraus.

  • Sucht man mit Tag(13): Die Suche beginnt im Fach für Hash 13. Der Koffer liegt im Fach für Hash 3, die beiden begegnen sich nicht.
  • Sucht man mit Tag(3): Man kommt zwar im Fach für Hash 3 an. Der Schlüssel dort ist jetzt aber Tag(13), also ergibt == False.
  • Sucht man mit dem eingelagerten key selbst: Sein Hash ist jetzt 13, also geht es denselben Weg wie im ersten Fall.
# Fortsetzung des Beispiels oben
key.n = 3
print(Tag(3) in lockers)
# True

Setzt man den Wert auf 3 zurück, wird er wieder gefunden. Der Koffer lag die ganze Zeit in dem Fach, in das er eingelagert wurde. Geändert hat sich nur die Nummer, mit der man das Fach sucht.

Schließfachraum

Eine vereinfachte Hashtabelle mit 8 Fächern. CPythons dict wählt die Fächer in einer komplizierteren Reihenfolge, aber das Prinzip ist gleich: Fach per Hash finden, mit == bestätigen.

Lagerst du Tag(3) ein und klickst auf „Gespeicherten Schlüssel ändern“, wird der Schlüssel im Fach zu Tag(13), aber der im Fach notierte Hash bleibt 3. Jetzt findet man ihn weder mit Tag(13) noch mit Tag(3). Lagerst du 3 ein, ist der Hash zwar gleich, aber == fällt anders aus, also geht es ins nächste Fach.

Deshalb nimmt Python eingebaute Typen, deren Wert sich ändern kann, gar nicht erst als Schlüssel an. list, dict und set haben keinen Hash.

lockers = {}
lockers[[1, 2]] = "bag"
# TypeError: cannot use 'list' as a dict key (unhashable type: 'list')
print(hash((1, 2)) == hash((1, 2)))
# True
hash((1, [2]))
# TypeError: unhashable type: 'list'

Ein Tupel kann Schlüssel sein, aber steckt eine Liste darin, lässt sich kein Hash bilden. Wie in Lektion 2 gesehen, bedeutet die Unveränderlichkeit eines Tupels nur, dass seine Referenzen feststehen. Ändert sich die Liste darin, müsste sich auch der Hash ändern. Als Schlüssel nimmt man Werte, die bis ganz innen unveränderlich sind, etwa (1, (2,)) oder ein frozenset.

Pin sagt

→ Schlüssel in dicts und Elemente in sets müssen „nach dem Einlagern unverändert“ bleiben. Musst du etwas ändern, nimm es heraus (pop) und lagere es unter dem neuen Schlüssel wieder ein.

12-3. Wer Gleichheit festlegt, legt auch den Hash fest (7 min)

Eine Klasse, die nichts davon definiert, vergleicht auf Gleichheit per is und bildet auch den Hash aus der Identität. Deshalb kann man ihre Instanzen als Schlüssel benutzen. Definiert man aber nur __eq__, setzt Python __hash__ auf None (wie in Lektion 8 angekündigt). Hat sich der Maßstab für Gleichheit geändert und benutzt man trotzdem den alten Hash, wäre das Versprechen gebrochen.

class Label:
    def __init__(self, code):
        self.code = code
    def __eq__(self, other):
        return isinstance(other, Label) and self.code == other.code

print(Label("T1") == Label("T1"), Label.__hash__)
# True None
{Label("T1")}
# TypeError: cannot use 'Label' as a set element (unhashable type: 'Label')

Braucht man einen Hash, gibt es zwei Wege.

1. @dataclass(frozen=True): erzeugt __eq__ und __hash__ aus den Feldern und verhindert, dass Felder geändert werden.

from dataclasses import dataclass

@dataclass(frozen=True)
class Tag:
    n: int

key = Tag(3)
lockers = {key: "bag B"}
print(lockers[Tag(3)])
# bag B
key.n = 13
# dataclasses.FrozenInstanceError: cannot assign to field 'n'

Ein @dataclass ohne frozen erzeugt nur __eq__, also ist __hash__ dann None. Bei einer Klasse, die als Schlüssel dienen soll, gehört frozen=True dazu.

2. __hash__ selbst schreiben: Man packt genau die Felder, die __eq__ vergleicht, in ein Tupel und bildet daraus den Hash.

class Route:
    def __init__(self, origin, dest):
        self.origin = origin
        self.dest = dest
    def __eq__(self, other):
        if not isinstance(other, Route):
            return NotImplemented
        return (self.origin, self.dest) == (other.origin, other.dest)
    def __hash__(self):
        return hash((self.origin, self.dest))

seen = {Route("ICN", "NRT")}
print(Route("ICN", "NRT") in seen, len({Route("ICN", "NRT"), Route("ICN", "NRT")}))
# True 1

Wer ihn selbst schreibt, hält sich an drei Regeln:

  • Den Hash aus denselben Feldern bilden, die __eq__ vergleicht.
  • Diese Felder nicht ändern, solange das Objekt als Schlüssel dient.
  • Bei einem Gegenüber, mit dem man nicht vergleichen kann, NotImplemented statt False zurückgeben (Lektion 8).

12-4. Gesamtbild: Prüfung des Verwaltungsprogramms im Gepäckzentrum (15 min)

Als letzte Aufgabe legt die Schichtleiterin das alte Verwaltungsprogramm des Zentrums vor. Alle Fehler aus den elf Meldungen stecken in einer einzigen Datei. Sehen wir uns zuerst das Ergebnis der Ausführung an.

import gc
import weakref

class Bag:
    stickers = []                          # veränderliche Liste als Klassenattribut

    def __init__(self, code, items=[]):    # Liste als Standardargument
        self.code = code
        self.items = items
        self.partner = None

    def __eq__(self, other):
        return isinstance(other, Bag) and self.code == other.code

    def __hash__(self):
        return hash(self.code)             # Hash aus einem veränderlichen Wert

a = Bag("A1")
b = Bag("B1")
a.items.append("camera")
a.stickers.append("fragile")
print(b.items, b.stickers)
# ['camera'] ['fragile']

shelves = [[]] * 3                         # dreimal dieselbe Liste
shelves[0].append("A1")
print(shelves)
# [['A1'], ['A1'], ['A1']]

calls = [lambda: f"gate {g}" for g in (1, 2, 3)]
print([call() for call in calls])
# ['gate 3', 'gate 3', 'gate 3']

lockers = {b: "L7"}
b.code = "B2"                              # Schlüssel nach dem Einlagern geändert
print(Bag("B1") in lockers, Bag("B2") in lockers)
# False False

a.partner, b.partner = b, a                # zwei Koffer, die einander enthalten
watch = weakref.ref(a)
del a, b, lockers
print(watch() is None)
# False
gc.collect()
print(watch() is None)
# True

Wir gehen Zeile für Zeile die Ursachen durch und machen daraus eine Prüfliste.

SymptomUrsacheLösungLektion
Die Kamera, die in A gelegt wurde, ist auch in BDas Standardargument items=[] wird nur einmal bei der Definition erzeugtmit items=None entgegennehmen und darin eine neue Liste anlegen4
Der Aufkleber klebt an allen KoffernAlle Instanzen teilen sich das Klassenattribut stickers = []in __init__ als Instanzattribut7
Alle drei Regalfächer ändern sich zusammen[[]] * 3 sind drei Referenzen auf dieselbe Liste[[] for _ in range(3)]3
Jede Benachrichtigung sagt gate 3Das Lambda hält nicht den Wert, sondern die Variable g (späte Bindung)lambda g=g: ...6
B wird im Schließfach nicht gefundenDer Wert des Schlüssels wurde nach dem Einlagern geändert, sein Hash ist ein andererunveränderliche Werte als Schlüssel, beim Ändern umlagern12
Koffer bleiben, obwohl alle Namensschilder ab sindReferenzzyklus über die beiden partnereine Seite mit weakref.ref11

Hier das korrigierte Programm. Alle Ausgaben sind jetzt so, wie sie gemeint waren.

import weakref

class Bag:
    def __init__(self, code, items=None):
        self.code = code
        self.items = [] if items is None else list(items)
        self.stickers = []
        self._partner = None

    @property
    def partner(self):
        return self._partner() if self._partner is not None else None

    @partner.setter
    def partner(self, other):
        self._partner = weakref.ref(other)

a = Bag("A1")
b = Bag("B1")
a.items.append("camera")
a.stickers.append("fragile")
print(b.items, b.stickers)
# [] []

shelves = [[] for _ in range(3)]
shelves[0].append("A1")
print(shelves)
# [['A1'], [], []]

calls = [lambda g=g: f"gate {g}" for g in (1, 2, 3)]
print([call() for call in calls])
# ['gate 1', 'gate 2', 'gate 3']

lockers = {b.code: "L7"}                   # der Schlüssel ist ein unveränderlicher String
lockers["B2"] = lockers.pop(b.code)        # beim Ändern des Codes wird der Schlüssel umgelagert
b.code = "B2"
print("B1" in lockers, "B2" in lockers, lockers[b.code])
# False True L7

a.partner, b.partner = b, a
watch = weakref.ref(a)
del a, b, lockers
print(watch() is None)
# True

Dass das Übergebene mit items=list(items) kopiert wird, ist ebenfalls die defensive Kopie aus Lektion 4. __eq__ und __hash__ sind aus Bag verschwunden, also werden die Koffer selbst über ihre Identität verglichen, und die Rolle des Schlüssels übernimmt der Code als String.

Die ersten beiden Fehler (Standardargument, Klassenattribut) vergleichen wir auf der Etikettentafel. Bei bug laufen die items-Pfeile beider Instanzen in einer Liste zusammen, und stickers hängt am Kasten des Bauplans (class).

Etikettentafel

Schritt 1 / 0

Als Nächstes läuft: 0

Labor wird vorbereitet…

Gerade geändert

Versiegelter Koffer (unveränderlich)

Bei bug zeigen die items von a und b auf dieselbe Liste und stickers auf die eine Liste der Klasse. Bei fixed bekommt jeder Koffer seine eigenen Listen, und die beiden Listen von b sind leer.

Ein Rückblick auf die zwölf Lektionen, je eine Zeile:

LektionTitelIn einer Zeile
1Das Etikett ist nicht der Koffer: Namen und Objekte= hängt ein Namensschild an ein Objekt. is heißt dasselbe Objekt, == derselbe Wert.
2Versiegelte Koffer: veränderlich und unveränderlichEine „Änderung“ an einem unveränderlichen Objekt ist ein neues Objekt plus Neubindung. += hat je nach Typ zwei Gesichter.
3Koffer im Koffer: Container und KopienContainer enthalten Referenzen. Eine flache Kopie geht eine Ebene tief, deepcopy bis ganz nach innen.
4Wenn ein Koffer übergeben wird: Funktionen und ArgumenteEine Funktion hängt ein weiteres Etikett an dasselbe Objekt. Standardargumente entstehen nur einmal.
5Zonen und Ausweise: GültigkeitsbereicheNamen werden in der Reihenfolge L → E → G → B gesucht, und wo zugewiesen wird, bestimmt den Bereich.
6Handgepäck: ClosuresInnere Funktionen tragen keine Werte mit sich, sondern Variablen (Zellen).
7Der Koffer-Bauplan: Klassen und InstanzenKlassenattribute teilen sich alle Instanzen. Instanzattribute gehören in __init__.
8Flughafen-Standardverfahren: Dunder-MethodenSyntax ruft Dunder-Methoden auf. len(x) ist type(x).__len__(x).
9Der Bauplan hinter dem Bauplan: Vererbung und MROAttribute werden in der Reihenfolge von __mro__ gesucht, super() ist die nächste Klasse in der MRO.
10Die Kontrolle: das Geheimnis des Attributzugriffsproperty und Deskriptoren fangen den Attributzugriff ab. Auch Funktionen sind Deskriptoren.
11Herrenlose Koffer: Speicher und GCBei Referenzzähler 0 wird entsorgt, Zyklen räumt der GC weg. Eine schwache Referenz ist ein gepunktetes Etikett.
12Schließfachnummern: Hash, Gleichheit und GesamtbildGilt a == b, dann hash(a) == hash(b). Schlüssel dürfen sich nach dem Einlagern nicht ändern.

Fall abgeschlossen (10 min)

Geisterkoffer B war ein Koffer, der seinen Hash aus der Nummer no bildete. Nach dem Einlagern wurde bei einer Umsortierung die Nummer überschrieben, und damit änderte sich der Weg zum Fach. Geht man ohne Hash Fach für Fach durch, ist B noch da.

from dataclasses import dataclass

class Bag:
    def __init__(self, no):
        self.no = no
    def __eq__(self, other):
        return isinstance(other, Bag) and self.no == other.no
    def __hash__(self):
        return hash(self.no)

ghost = Bag(3)
lockers = {ghost: "L7"}
ghost.no = 13                                         # Nummer bei der Umsortierung überschrieben
print(ghost in lockers)
# False
print([v for k, v in lockers.items() if k is ghost])  # ohne Hash Fach für Fach durchgehen: da ist er
# ['L7']

@dataclass(frozen=True)
class BagNo:
    no: int

lockers = {BagNo(13): "L7"}                           # unter einem unveränderlichen Schlüssel neu einlagern
print(lockers[BagNo(13)], BagNo(13) in lockers)
# L7 True
  • Das Durchgehen mit items() benutzt keinen Hash und findet B deshalb. Dafür dauert es so lange, wie es Fächer gibt.
  • Der neue Schlüssel BagNo ist frozen=True, seine Nummer lässt sich nach dem Einlagern nicht mehr ändern. Ändert sich die Nummer, erzeugt man ein neues BagNo und lagert um.

Schließfach L7 geht auf. Bs Gepäck, einen Monat lang eingeschlossen, kommt zum Vorschein, und Han verständigt den Besitzer. Schichtleiterin Do Miran legt das Bündel Etiketten zurück in die Schublade. „Weißt du noch, was ich am ersten Tag gesagt habe? Ein Koffer, viele Etiketten. Jetzt siehst du, wo die Etiketten hängen, wer einen Koffer festhält und in welchem Fach ein Koffer liegt, oder?“ Pin zeigt auf Band 13. Heute dreht dort nichts seine Runden.

Abschlussbericht — LOST-012. B war ein Schlüssel, dessen Hash sich nach dem Einlagern geändert hatte, deshalb wurde er im Schließfach nicht gefunden. L7 per items() gefunden und unter einem Schlüssel mit @dataclass(frozen=True) neu eingelagert. Der Fall der Geisterkoffer von Band 13 ist damit vollständig abgeschlossen.

Übungen

  1. Wie viele Fächer belegt {(1, 2): "A", (2, 1): "B"}? Vergleich für beide Tupel == und hash, sag das Ergebnis voraus und prüfe es dann.
  2. Bau den Tag aus 12-2 mit @dataclass(frozen=True) neu und prüfe, wo key.n = 13 abgefangen wird.
  3. Korrigiere das fehlerhafte Programm aus 12-4 selbst, ohne in die korrigierte Fassung zu schauen. Notiere bei jeder Korrektur, zu welcher Zeile der Prüfliste sie gehört.

Das Notizbuch der Schichtleiterin

  • dict und set finden das Fach per Hash und bestätigen mit ==. Gilt a == b, dann hash(a) == hash(b).

  • Hash-Kollisionen sind normal (hash(-1) == hash(-2)). String-Hashes ändern sich bei jedem Start (PYTHONHASHSEED).

  • Ändert sich ein Schlüssel nach dem Einlagern, findet man ihn weder mit dem neuen noch mit dem alten Wert. Schlüssel unveränderlich halten.

  • list, dict und set können keine Schlüssel sein. Ein Tupel mit einer Liste darin auch nicht.

  • Wer nur __eq__ definiert, bekommt __hash__ = None. @dataclass(frozen=True) nehmen oder __hash__ aus denselben Feldern schreiben.

  • Prüfliste: geteilte veränderliche Objekte, Standardargumente, späte Bindung, Klassenattribute, Referenzzyklen, geänderte Schlüssel.

  • Ein Koffer, viele Etiketten. Wenn du feststeckst, zeichne Namensschilder und Koffer.

← Zurück

Lektion 11 · Herrenlose Koffer: Speicher und GC

Über neue Lektionen informiert werden

Wir schicken dir eine E-Mail, wenn dieser Kurs neue Lektionen bekommt oder ein Folgekurs erscheint.

Erhoben: deine E-Mail-Adresse · Zweck: Hinweise auf neue Lektionen oder den Start dieses Kurses · Speicherdauer: bis du die Löschung verlangst. Keine andere Nutzung, keine Weitergabe. Antworte einfach auf eine Benachrichtigung, dann löschen wir sie sofort.

Fragen & Antworten

Frag alles zu dieser Lektion. Fragen aus allen Sprachversionen dieser Seite erscheinen hier gemeinsam.

Wird als reiner Text angezeigt, ohne Links oder Formatierung.

R

ReflexKR

Python und Reflex zum Anfassen. Alle Beispiele wurden mit Reflex 0.10.0 ausgeführt und geprüft.

ReflexKR ist kostenlos und ohne Werbebanner. Wenn es dir geholfen hat, kannst du es mit einem Kaffee unterstützen. ☕ Spendier uns einen Kaffee

Hinweis zu Affiliate-Links
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex