PCPP1 Block 1 (Teil 3a): Erweiterte Exceptions und Kopien
Track Python · PCPP1 Block 1 (35 %, 15 Fragen laut Quelle) · Ziele 1.9 und 1.10 · ca. 55 Min.
Worum es geht
Das ist der dritte Teil von PCPP1 Block 1 (Fortgeschrittene OOP, laut Quelle 35 % und 15 Fragen), erste Hälfte. Du kennst try/catch und structuredClone aus JS/TS. In Python steckt in beiden eine Falle, die in der Prüfung und im Alltag auftaucht: Fehlerketten (from) und der Unterschied zwischen flacher und tiefer Kopie. Nach der Lektion kannst du Exception-Ketten vorhersagen, eine eigene Fehlerfamilie bauen, das Ergebnis von copy und deepcopy vorhersagen und __deepcopy__ schreiben.
Ablauf: Übersetzung JS/TS nach Python, zwei Konzeptschritte, die Fallen, dann vier Übungen. In Teil 3b folgen pickle, shelve und Metaklassen.
Von JS/TS her gedacht
| Thema | JS/TS | Python |
|---|---|---|
| Fehlerursache mitgeben | new Error("x", { cause: err }) |
raise Neu from alt setzt __cause__ |
| Fehler beim Fehlerbehandeln | alte Exception geht verloren | wird automatisch in __context__ gemerkt |
| Fehlerfamilie | class AppError extends Error |
class AppError(Exception), Unterklassen erben davon |
| Flache Kopie | {...obj}, [...arr], Object.assign |
copy.copy(obj), list(x), x[:] |
| Tiefe Kopie | structuredClone(obj) |
copy.deepcopy(obj) (erkennt Zyklen, kann eigene Logik) |
Der Unterschied, der am meisten überrascht: structuredClone kopiert immer tief. In Python ist copy.copy flach, und copy.deepcopy gibt bei unveränderbaren Objekten (immutable) oft dasselbe Objekt zurück, statt eine Kopie zu machen.
Konzept in kleinen Schritten
Schritt 1: Erweiterte Exceptions
Laut Quelle (Abschnitt 1.9) gilt:
- Exceptions sind Objekte mit den Attributen
args,__cause__,__context__und__traceback__. Eigene Exceptions können zusätzliche Attribute tragen. - Implizite Verkettung (implicit chaining): Löst du innerhalb eines
except-Blocks eine neue Exception aus, merkt sich Python die alte in__context__. - Explizite Verkettung (explicit chaining):
raise Neu from Altsetzt__cause__und unterdrückt die Standardanzeige des Kontexts.raise Neu from Noneunterdrückt die Anzeige der alten Exception komplett. - Eine eigene Exception-Hierarchie beginnt mit einer Basis, von der alle weiteren erben. So fängt ein Aufrufer die ganze Familie mit einem
except.
Drei Wege, im except einen neuen Fehler auszulösen, und was sie in den Attributen hinterlassen (mit Python 3.13 ausgeführt):
Merke: __context__ ist bei allen drei Varianten gesetzt, denn der neue Fehler entstand ja im except. from None löscht ihn nicht, es setzt nur das Flag __suppress_context__ (nur die Anzeige ändert sich). __cause__ gibt es nur bei from Alt.
Das Traceback-Objekt (e.__traceback__) hat tb_lineno, tb_frame und tb_next (der nächste Stackframe nach innen). Das Modul traceback formatiert es (traceback.print_tb, traceback.format_exc). Mit e.with_traceback(tb) hängst du einen Traceback an.
Das ist die Zeile, die du in Logs siehst, wenn eine Exception beim Behandeln einer anderen entsteht.
Schritt 2: Flache und tiefe Kopien
Ein Name (Label) zeigt auf ein Objekt. Die Identität liefert id(obj), is vergleicht sie. Der Wert wird mit == verglichen. Die Grundlagen zu Zuweisung und Kopie stehen in Lektion 03. Hier geht es um das Verhalten des Moduls copy (Quelle, Abschnitt 1.10):
- Flache Kopie (shallow copy,
copy.copy): neues äußeres Objekt, die inneren Objekte werden geteilt. - Tiefe Kopie (deep copy,
copy.deepcopy): kopiert rekursiv und erkennt Zyklen. Eigene Logik über__copy__und__deepcopy__. - Bei unveränderbaren Objekten liefert
copymeist dasselbe Objekt zurück.
Die Sonderfälle, die in der Prüfung gern gefragt werden (mit Python 3.13 ausgeführt):
Wie erkennt deepcopy Zyklen? Es führt ein Dict memo mit (id des Originals zeigt auf die Kopie). Ist ein Objekt schon im memo, wird die Kopie wiederverwendet. Wenn du __deepcopy__(self, memo) selbst schreibst, musst du dieses memo an Aufrufe von copy.deepcopy weitergeben, und bei möglichen Zyklen die neue Instanz vor dem Kopieren der Attribute eintragen (memo[id(self)] = neu). Das ist die Übung 4.
Falle: die Prüfungs- und Praxisklassiker
Die Liste “Typische Fallen in Block 1” der Quelle, soweit sie zu diesem Teil gehört, ergänzt um die Beobachtungen aus dieser Lektion:
raise X from Noneunterdrückt die Anzeige des Kontexts,raise Ximexceptverkettet automatisch.__context__ist in beiden Fällen gesetzt.from Nonesetzt nur__suppress_context__ = True.__cause__gibt es nur nachfrom,__context__immer, wenn der Fehler während einer Behandlung entstand.copy.copyteilt innere Objekte. Wer nur äußere Listen kopiert, ändert mita[0].append(...)auch die Kopie.deepcopygibt unveränderbare Objekte ohne veränderbaren Inhalt oft unverändert zurück (copy.deepcopy((1, 2)) is tistTrue).
Übungen
Übung 1: Exception-Ketten vorhersagen
class ConfigError(Exception): pass
def a():
try: {}["port"]
except KeyError as e: raise ConfigError("a") from e
def b():
try: {}["port"]
except KeyError: raise ConfigError("b")
def c():
try: {}["port"]
except KeyError: raise ConfigError("c") from NoneFür jede Funktion wird der ConfigError gefangen (except ConfigError as e). Trage pro Funktion ein Tripel ein: der Klassenname von e.__cause__ (oder None), der Klassenname von e.__context__ (oder None) und e.__suppress_context__. Die Klassennamen sind Strings wie "KeyError".
Frage dich pro Funktion getrennt: Wo entsteht der neue Fehler (innerhalb oder außerhalb des except)? Wer setzt __cause__, und was verändert from None tatsächlich: das Attribut mit dem alten Fehler oder nur ein Flag?
antwort = [("KeyError", "KeyError", True),
(None, "KeyError", False),
(None, "KeyError", True)]
antwortÜbung 2: Eine Fehlerfamilie mit Verkettung schreiben
Schreibe die Hierarchie und die Funktion parse_port für einen Konfigurationsparser:
PortErrorerbt vonAppError(die Basis der Familie, ist vorgegeben) und speichert den Eingabetext als Attributwert. Die Meldungstr(err)istungültiger Port:plus dierepr-Darstellung des Werts, z. B.ungültiger Port: 'abc'.parse_port(text)gibt den Port alsintzurück, wenntexteine Ganzzahl von 1 bis 65535 ist (Leerzeichen am Rand sind wie beiint()erlaubt).- Ist
textgar keine Zahl, kommt einPortError, bei dem die ursprüngliche Exception als Ursache (__cause__) mitgeliefert wird. - Ist es eine Zahl, aber außerhalb des Bereichs, kommt ein
PortErrorohne Ursache (__cause__ is None), denn da ging nichts schief, die Eingabe ist nur ungültig. - Ein Aufrufer muss alle diese Fehler mit
except AppErrorfangen können.
Zwei verschiedene Fehlerquellen brauchen zwei verschiedene Auslöser: eine, bei der Python dir schon eine Exception liefert, und eine, bei der du selbst prüfst. Welche von beiden soll eine Ursache tragen, und wo darf der raise ... from ... deshalb nur stehen?
class AppError(Exception):
pass
class PortError(AppError):
def __init__(self, wert):
super().__init__(f"ungültiger Port: {wert!r}")
self.wert = wert
def parse_port(text):
try:
n = int(text)
except ValueError as e:
raise PortError(text) from e
if not 1 <= n <= 65535:
raise PortError(text)
return n
(AppError, PortError, parse_port)Übung 3: Flach, tief und der Sonderfall Tupel vorhersagen
import copy
a = {"liste": [1, 2], "t": (1, 2), "tl": ([0], 1)}
b = copy.copy(a)
c = copy.deepcopy(a)
a["liste"].append(3)
a["neu"] = 0Trage das Tupel (b["liste"], c["liste"], "neu" in b, b["t"] is a["t"], c["t"] is a["t"], c["tl"] is a["tl"]) ein.
Trenne die Änderungen: Das Anhängen verändert ein inneres Objekt, a["neu"] = 0 das äußere Dict. Bei den drei is-Vergleichen frage dich, ob copy überhaupt etwas zu kopieren hatte. Bei welchem Wert gibt es innen etwas Veränderbares?
antwort = ([1, 2, 3], [1, 2], False, True, True, False)
antwortÜbung 4: __deepcopy__ schreiben
Die Klasse Tabelle hält zeilen (eine Liste, die sich auch selbst enthalten kann) und einen _cache mit abgeleiteten Daten. Eine tiefe Kopie soll:
- die
zeilenrekursiv kopieren (innere Listen sind danach unabhängig), - den
_cachenicht mitkopieren, die Kopie bekommt einen frischen leeren_cache, - auch bei Zyklen (
t.zeilen.append(t)) und geteilten Objekten funktionieren, - für Unterklassen von
Tabelleeine Instanz derselben Klasse liefern.
Ohne __init__-Aufruf bekommst du eine leere Instanz über cls.__new__(cls). Überlege, in welcher Reihenfolge du Instanz anlegen, ins memo eintragen und Attribute kopieren musst, damit ein Zyklus nicht endlos läuft, und was du beim inneren deepcopy-Aufruf mitgeben musst.
import copy
class Tabelle:
def __init__(self, zeilen):
self.zeilen = zeilen
self._cache = {}
def __deepcopy__(self, memo):
cls = type(self)
neu = cls.__new__(cls)
memo[id(self)] = neu
neu.zeilen = copy.deepcopy(self.zeilen, memo)
neu._cache = {}
return neu
TabelleMerksatz und Prüfstein
Merksatz: from setzt die Ursache, der Kontext entsteht von selbst, copy.copy teilt das Innere, und deepcopy kopiert es und merkt sich Zyklen im memo.
Prüfstein (offene Frage): Ein Kollege schreibt in einem except-Block raise ServiceError("Zahlung fehlgeschlagen"), ein anderer raise ServiceError("Zahlung fehlgeschlagen") from None. Was steht jeweils im Traceback, was in __cause__, __context__ und __suppress_context__? Wann würdest du from None bewusst einsetzen, und warum ist es keine gute Idee, es zur Gewohnheit zu machen?
Weiter geht es mit Teil 3b: pickle, shelve und Metaklassen.
Quelle: quellen/python-glossar-pcap-pcpp1.md, Abschnitte “1.9 Erweiterte Exceptions”, “1.10 Flache und tiefe Kopien” und “Typische Fallen in Block 1”. Hinweis: Das Verhalten von __suppress_context__, copy.deepcopy bei Tupeln und der memo-Mechanismus stehen nicht (oder nur teilweise) in der Quelle, wurden aber mit Python 3.13 ausgeführt und geprüft (bitte prüfen, ob sie in der PCPP1 geprüft werden). Die Gewichtung “35 %, 15 Fragen” ist die Angabe der Quelle (bitte prüfen).