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 Alt setzt __cause__ und unterdrückt die Standardanzeige des Kontexts. raise Neu from None unterdrü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 copy meist 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:

  1. raise X from None unterdrückt die Anzeige des Kontexts, raise X im except verkettet automatisch. __context__ ist in beiden Fällen gesetzt. from None setzt nur __suppress_context__ = True.
  2. __cause__ gibt es nur nach from, __context__ immer, wenn der Fehler während einer Behandlung entstand.
  3. copy.copy teilt innere Objekte. Wer nur äußere Listen kopiert, ändert mit a[0].append(...) auch die Kopie.
  4. deepcopy gibt unveränderbare Objekte ohne veränderbaren Inhalt oft unverändert zurück (copy.deepcopy((1, 2)) is t ist True).

Ü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 None

Fü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:

  • PortError erbt von AppError (die Basis der Familie, ist vorgegeben) und speichert den Eingabetext als Attribut wert. Die Meldung str(err) ist ungültiger Port: plus die repr-Darstellung des Werts, z. B. ungültiger Port: 'abc'.
  • parse_port(text) gibt den Port als int zurück, wenn text eine Ganzzahl von 1 bis 65535 ist (Leerzeichen am Rand sind wie bei int() erlaubt).
  • Ist text gar keine Zahl, kommt ein PortError, bei dem die ursprüngliche Exception als Ursache (__cause__) mitgeliefert wird.
  • Ist es eine Zahl, aber außerhalb des Bereichs, kommt ein PortError ohne Ursache (__cause__ is None), denn da ging nichts schief, die Eingabe ist nur ungültig.
  • Ein Aufrufer muss alle diese Fehler mit except AppError fangen 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"] = 0

Trage 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 zeilen rekursiv kopieren (innere Listen sind danach unabhängig),
  • den _cache nicht mitkopieren, die Kopie bekommt einen frischen leeren _cache,
  • auch bei Zyklen (t.zeilen.append(t)) und geteilten Objekten funktionieren,
  • für Unterklassen von Tabelle eine 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

Tabelle

Merksatz 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).