Reflection und Dekoratoren
Kurz gesagt: Reflection heißt, die eigenen Klassen und Objekte zu betrachten, während das Programm läuft: type, vars, getattr und setattr, und Dekoratoren dienen als Marker.
Problem
Das Feld, das __str__ vergessen hat
__str__ der Bestellung ist von Hand geschrieben. Später fügt jemand ein Feld für den Rabatt hinzu:
class Order: def __init__(self): self.number = 1042 self.customer = "Maria Becker" self.total = 86.40 self.discount = 10 # später hinzugefügt
def __str__(self): return f"Order[number={self.number}, customer={self.customer}, total={self.total}]"
print(Order())Order[number=1042, customer=Maria Becker, total=86.4]Der Rabatt fehlt in der Ausgabe, weil niemand daran gedacht hat, __str__ anzupassen. Bei zehn Klassen passiert das ständig.
test_… schreibst, findet unittest sie und führt sie aus, obwohl es deine Klasse nie gesehen hat. Wie? Beides funktioniert mit derselben Idee: Ein Programm kann seine eigenen Klassen betrachten, während es läuft.Die Idee
Die Klasse als Objekt
In Python ist alles ein Objekt, auch Klassen: Order ist ein Objekt vom Typ type, das die Klasse beschreibt: Name, Basisklassen, Methoden. Klassen und Objekte zu betrachten, während das Programm läuft, heißt Reflection (Spiegelung: Das Programm sieht sich wie in einem Spiegel). Es gibt drei Wege zur Klasse:
Order, wenn du die Klasse schon beim Schreiben des Codes kennst: Der Name der Klasse ist das Objekt selbst;type(obj), wenn du ein Objekt hast;getattr(importlib.import_module("collections"), "Counter"), wenn du nur die Namen als Text hast.
import collections.abcimport importlib
module = importlib.import_module("collections")cls = getattr(module, "Counter")print("Name:", cls.__module__ + "." + cls.__qualname__)print("Kurzname:", cls.__name__)print("Basisklasse:", cls.__base__.__name__)for parent in cls.__mro__: print("In der Kette:", parent.__name__)print("Ist es ein Mapping:", issubclass(cls, collections.abc.Mapping))Name: collections.CounterKurzname: CounterBasisklasse: dictIn der Kette: CounterIn der Kette: dictIn der Kette: objectIst es ein Mapping: True__mro__ ist die Kette der Klassen, von denen geerbt wird, in der Reihenfolge, in der Python die Methoden sucht. Interfaces gibt es in Python nicht. Stattdessen gibt es abstrakte Klassen wie collections.abc.Mapping, und issubclass prüft, ob die Klasse eine davon ist. Wenn es kein Modul oder keine Klasse mit diesem Namen gibt, wirft import_module einen ModuleNotFoundError und getattr einen AttributeError.Sieh es dir an
Die Felder jedes Objekts
vars(obj) liefert die Felder des Objekts als Dictionary: Name zu Wert. Der Typ jedes Felds ist der Typ seines Werts, type(value): In Python haben Felder keinen deklarierten Typ.def dump(obj): cls = type(obj) print(f"{cls.__name__}:") for name, value in vars(obj).items(): print(f" {name} ({type(value).__name__}) = {value}")class Room: def __init__(self): self.number = 214 self.sea_view = True
dump(Order())dump(Room())Order: number (int) = 1042 customer (str) = Maria Becker total (float) = 86.4 discount (int) = 10Room: number (int) = 214 sea_view (bool) = Truediscount ist schon dabei. Private Felder gibt es in Python nicht: Ein _ oder __ vorne ist nur eine Konvention, und vars sieht alles. Deshalb brauchst du nichts wie setAccessible.def dump(obj): cls = type(obj) print(f"{cls.__name__}:") for name, value in vars(obj).items(): print(f" {name} ({type(value).__name__}) = {value}")Schritt für Schritt, in Worten
- dump bekommt ein Order-Objekt und weiß nichts über seine Klasse.
- type(obj) liefert die Klasse Order. Auch sie ist ein Objekt.
- Die erste Zeile ist der kurze Name der Klasse.
- vars(obj) liefert ein Dictionary mit 4 Feldern: Name und Wert.
- Private Felder gibt es in Python nicht: number kommt direkt mit seinem Wert, 1042. Der Typ ist der des Werts: int.
- customer (str) = Maria Becker.
- total (float) = 86.4.
- discount: Das Feld, das __str__ vergessen hat, erscheint von selbst, weil Reflection alle Felder holt.
- Die Funktion weiß nichts über Order. Für Room gibt sie number und sea_view genauso aus.
Genauer, für Neugierige
vars(obj)liefert nur die Felder des Objekts, ohne die Methoden und ohne die Attribute der Klasse.dir(obj)liefert alle Namen: Felder, Methoden, geerbte und die besonderen mit__.- Die Reihenfolge ist die, in der die Felder einen Wert bekommen haben. Dictionaries in Python behalten ihre Reihenfolge, und das ist garantiert.
- Ein Feld mit zwei Unterstrichen,
self.__price, benennt Python in_Product__priceum. Es ist trotzdem invarszu sehen, nur unter einem anderen Namen. - Eine Klasse mit
__slots__hat kein Dictionary für ihre Felder, undvarswirft einenTypeError.
Probier es aus
Wessen Klasse?
Was gibt das Programm aus?
class Document: pass
class Invoice(Document): pass
doc = Invoice()print(type(doc).__name__)print(type(doc).__base__.__name__)print(Document.__base__.__name__)type(doc) liefert die echte Klasse des Objekts: Invoice. Ihre Basisklasse (__base__) ist Document. Document nennt in den Klammern keine Basisklasse, also ist seine Basisklasse object, die gemeinsame Basisklasse aus dem Modul „Vererbung und Polymorphie“. Für object selbst ist __base__ None: Darüber gibt es nichts.Sieh es dir an
Objekt, Methode und privates Feld über den Namen
class Product: def __init__(self, name, price): self.name = name self._price = price
def apply_discount(self, percent): self._price = self._price * (100 - percent) / 100
def _label(self): return f"{self.name}: {self._price} €"cls = globals()["Product"]coffee = cls("Kaffee", 10.0)
discount = getattr(coffee, "apply_discount")discount(20)
print("Preis nach Rabatt:", getattr(coffee, "_price"))setattr(coffee, "_price", 9.5)
label = getattr(coffee, "_label")print(label())Preis nach Rabatt: 8.0Kaffee: 9.5 €globals()["Product"]findet die Klasse über ihren Namen im Modul. Du rufst die Klasse wie eine Funktion auf, und das ist der Konstruktor.getattr(coffee, "apply_discount")liefert die Methode, schon ancoffeegebunden. Du musst sie nur noch mit den Argumenten aufrufen.getattrundsetattrlesen und schreiben jedes Feld, auch_price. Auch_labellässt sich aufrufen: „privat“ heißt in Python nur „nicht von außen aufrufen“.
Die Typen der Parameter gibst du nicht an: In Python werden Methoden nur über den Namen gefunden.
Genauer, für Neugierige
list() oder .append im Code steht:import builtins
items = getattr(builtins, "list")()add = getattr(items, "append")add("Kaffee")add("Tee")print(items)['Kaffee', 'Tee']list liegen im Modul builtins.Probier es aus
Der Preis, den es nicht gibt
Wähle die Zeile mit dem Fehler.
Das Feld heißt _price, aber hier steht "_prise". Das ist gewöhnlicher Text, und niemand prüft ihn, bevor das Programm dort ankommt. Der Fehler zeigt sich erst zur Laufzeit:
AttributeError: 'Product' object has no attribute '_prise'. Did you mean: '_price'?Den Hinweis „Did you mean“ fügt Python 3.12 hinzu, wenn es den Fehler ausgibt.
Das ist der wichtigste Preis von Reflection:
- niemand prüft vorher die Namen und Typen nicht, und der Editor findet sie nicht, wenn du etwas umbenennst;
- der Code ist langsamer als ein gewöhnlicher Aufruf und schwerer zu lesen;
-
getattrundsetattrumgehen die Kapselung: Du kannst eine Regel brechen, die die Klasse schützt.
dataclasses, SQLAlchemy (es liest die Felder der Klasse und füllt die Objekte), Django. In gewöhnlichem Code schreibst du sie selten.Die Idee
Etiketten an Feldern
@ an Feldern und Methoden Annotationen. In Python bedeutet „Annotation“ etwas anderes: den Typ nach dem Doppelpunkt, guest: str. Etiketten mit @ an Feldern gibt es in Python nicht. Für ein Feld einer Dataclass ist das Etikett die metadata von field. Die Funktion label erzeugt so ein Feld, und print_labeled gibt nur die Felder mit Etikett aus:def label(text, default): return field(default=default, metadata={"label": text})@dataclassclass Booking: number: int = label("Nummer", 501) guest: str = label("Gast", "Jonas Weber") nights: int = label("Nächte", 3) internal_note: str = "VIP"def print_labeled(obj): for f in fields(obj): text = f.metadata.get("label") if text is not None: print(f"{text}: {getattr(obj, f.name)}")Nummer: 501Gast: Jonas WeberNächte: 3field(default=…, metadata={…})erzeugt ein Feld einer Dataclass mit einem Standardwert und einem Dictionary mit beliebigem Inhalt.fields(obj)liefert die Felder der Dataclass alsField-Objekte: Jedes hatname,typeundmetadata.f.metadata.get("label")gibt das Etikett zurück, oderNone, wenn das Feld keins hat.internal_notehat kein Etikett und wird nicht ausgegeben.
Genauer, für Neugierige
- Auch die Annotationen von Python (die Typen der Felder) werden mit Reflection gelesen:
inspect.get_annotations(Booking)liefert{'number': <class 'int'>, 'guest': <class 'str'>, …}. So weißdataclass, welche Felder es gibt. - Etiketten an Methoden und Klassen sind in Python die Dekoratoren mit
@. Du siehst sie in der nächsten Karte.
Sieh es dir an
Wie unittest die Tests findet
@check über der Methode ruft die Funktion check mit der Methode auf, und die Funktion gibt ihr ein Attribut is_check und gibt sie zurück. Die Testklasse prüft den Rabatt aus der ersten Lektion des Moduls (mit dem alten Fehler price > 100):def check(method): method.is_check = True return method
class DiscountChecks: @check def ten_percent_off(self): self.expect(45.0, apply_discount(50, 10))
@check def extra_five_percent_from_100(self): self.expect(85.0, apply_discount(100, 10))
def expect(self, expected, actual): if expected != actual: raise AssertionError(f"erwartet {expected}, ist aber {actual}")def run_checks(cls): passed = 0 failed = 0 for name in sorted(dir(cls)): if not getattr(getattr(cls, name), "is_check", False): continue instance = cls() try: getattr(instance, name)() print(f"OK {name}") passed += 1 except Exception as e: print(f"FEHLER {name}: {e}") failed += 1 print(f"Prüfungen: {passed + failed}, bestanden: {passed}, fehlgeschlagen: {failed}")FEHLER extra_five_percent_from_100: erwartet 85.0, ist aber 90.0OK ten_percent_offPrüfungen: 2, bestanden: 1, fehlgeschlagen: 1unittest macht dasselbe, nur mit viel mehr Details:
- Es holt mit
dir(cls)die Namen in der Klasse und sortiert sie, damit die Reihenfolge immer gleich ist; - es überspringt die ohne den Marker. unittest benutzt keinen Marker, sondern den Namen: Es führt die Methoden aus, deren Namen mit
testbeginnen; - es erzeugt für jeden Test ein neues Objekt, damit sich die Tests nicht gegenseitig stören;
- es holt die Methode mit
getattraus dem Objekt und ruft sie auf. Wenn der Test eine Exception wirft, kommt sie direkt durch, ohne Verpackung wie in Java.
assertEqual in unittest ist auch eine Methode, die einen AssertionError wirft, wenn sich die Werte unterscheiden. Genau wie expect hier.
Probier es aus
Der Marker, der verschwand
check hat am Ende kein return method, alles andere ist gleich:Was gibt run_checks aus?
def check(method): method.is_check = True
class DiscountChecks: @check def ten_percent_off(self): ...
@check def extra_five_percent_from_100(self): ...
run_checks(DiscountChecks)@check über def ist dasselbe wie ten_percent_off = check(ten_percent_off). Ohne return gibt check None zurück, und None nimmt den Platz der Methode ein. getattr(None, "is_check", False) ist False, und keine einzige Prüfung wird ausgeführt. Das Programm läuft, es tut nur nichts. Deshalb gibt jeder Dekorator die Funktion zurück, die er bekommen hat (oder eine neue an ihrer Stelle).Übung
Code, der mit jeder Klasse funktioniert
Löse die Aufgaben in deinem Editor.
1. Alle Felder, Klasse für Klasse. Schreibprint_fields(obj) für eine Dataclass, das jedes Feld als Name = Wert ausgibt, auch die geerbten, und dabei Klasse für Klasse vorgeht. Fang bei type(obj).__mro__ von hinten an und nimm für jede Klasse nur ihre eigenen Felder mit inspect.get_annotations(cls); die Felder der Basisklasse kommen zuerst.@dataclassclass Vehicle: plate: str = "M-AB 1234" year: int = 2019
@dataclassclass Truck(Vehicle): load_tons: float = 12.5plate = M-AB 1234year = 2019load_tons = 12.5to_json(obj), das die Felder als JSON zurückgibt: die Namen in Anführungszeichen, Text in Anführungszeichen, Zahlen und true/false ohne Anführungszeichen. Benutze vars, isinstance(value, str) und isinstance(value, bool): str(False) ist False, aber JSON braucht false. (Die fertige Version ist json.dumps(vars(obj)).)class Ticket: def __init__(self): self.seat = 14 self.event = "Konzert" self.price = 45.0 self.vip = False{"seat": 14, "event": "Konzert", "price": 45.0, "vip": false}{"number": 214, "sea_view": true}my_test und my_before_each (wie check) und einen Runner run(cls). Für jede Methode mit my_test: ein neues Objekt, dann die Methode mit my_before_each (falls es eine gibt), dann der Test. Wenn der Test eine Exception wirft, gib ihre Art (type(e).__name__) und ihre Meldung aus. Probier ihn mit diesen Tests aus:def my_test(method): method.is_test = True return method
def my_before_each(method): method.is_before_each = True return method
class Account: def __init__(self, balance): self.balance = balance
def withdraw(self, amount): if amount > self.balance: raise ValueError(f"Es fehlen {amount - self.balance} €") self.balance -= amount
class AccountChecks: @my_before_each def fresh(self): self.account = Account(100)
@my_test def withdraw_40(self): self.account.withdraw(40) if self.account.balance != 60: raise AssertionError(f"erwartet 60, ist aber {self.account.balance}")
@my_test def withdraw_70(self): self.account.withdraw(70) if self.account.balance != 30: raise AssertionError(f"erwartet 30, ist aber {self.account.balance}")
@my_test def withdraw_too_much(self): self.account.withdraw(130)OK withdraw_40OK withdraw_70FEHLER withdraw_too_much: ValueError: Es fehlen 30 €Tests: 3, bestanden: 2, fehlgeschlagen: 1Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Listen: append, remove und sort · Klassen und Daten in Klassen · Lambdas · Tests mit unittest
- Aufgaben zum Thema: Programme in der echten Welt