Zum Inhalt
wannacode

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())
Ausgabe
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.

Dazu kommt eine Frage vom Anfang des Moduls. Wenn du eine Methode 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.abc
import 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))
Ausgabe
Name: collections.Counter
Kurzname: Counter
Basisklasse: dict
In der Kette: Counter
In der Kette: dict
In der Kette: object
Ist 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())
Ausgabe
Order:
number (int) = 1042
customer (str) = Maria Becker
total (float) = 86.4
discount (int) = 10
Room:
number (int) = 214
sea_view (bool) = True
Eine Funktion gibt jedes Objekt aus, und das neue Feld discount 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
  1. dump bekommt ein Order-Objekt und weiß nichts über seine Klasse.
  2. type(obj) liefert die Klasse Order. Auch sie ist ein Objekt.
  3. Die erste Zeile ist der kurze Name der Klasse.
  4. vars(obj) liefert ein Dictionary mit 4 Feldern: Name und Wert.
  5. Private Felder gibt es in Python nicht: number kommt direkt mit seinem Wert, 1042. Der Typ ist der des Werts: int.
  6. customer (str) = Maria Becker.
  7. total (float) = 86.4.
  8. discount: Das Feld, das __str__ vergessen hat, erscheint von selbst, weil Reflection alle Felder holt.
  9. 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__price um. Es ist trotzdem in vars zu sehen, nur unter einem anderen Namen.
  • Eine Klasse mit __slots__ hat kein Dictionary für ihre Felder, und vars wirft einen TypeError.

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__)
Antwort

Sieh es dir an

Objekt, Methode und privates Feld über den Namen

Mit Reflection kannst du ein Objekt erzeugen, eine Methode aufrufen und ein Feld ändern, wenn du nur ihre Namen kennst:
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())
Ausgabe
Preis nach Rabatt: 8.0
Kaffee: 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 an coffee gebunden. Du musst sie nur noch mit den Argumenten aufrufen.
  • getattr und setattr lesen und schreiben jedes Feld, auch _price. Auch _label lä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
Eine Liste, erzeugt und gefüllt, ohne dass list() oder .append im Code steht:
import builtins
items = getattr(builtins, "list")()
add = getattr(items, "append")
add("Kaffee")
add("Tee")
print(items)
Ausgabe
['Kaffee', 'Tee']
Eingebaute Klassen wie list liegen im Modul builtins.

Probier es aus

Der Preis, den es nicht gibt

Das Programm startet, aber es bricht ab. In welcher Zeile ist der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Etiketten an Feldern

In Java heißen Etiketten mit @ 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})
@dataclass
class 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)}")
Ausgabe
Nummer: 501
Gast: Jonas Weber
Nächte: 3
  • field(default=…, metadata={…}) erzeugt ein Feld einer Dataclass mit einem Standardwert und einem Dictionary mit beliebigem Inhalt.
  • fields(obj) liefert die Felder der Dataclass als Field-Objekte: Jedes hat name, type und metadata.
  • f.metadata.get("label") gibt das Etikett zurück, oder None, wenn das Feld keins hat. internal_note hat 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

Hier ist ein winziger Test-Runner. Der Marker ist ein Dekorator: @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}")
Ausgabe
FEHLER extra_five_percent_from_100: erwartet 85.0, ist aber 90.0
OK ten_percent_off
Prüfungen: 2, bestanden: 1, fehlgeschlagen: 1

unittest macht dasselbe, nur mit viel mehr Details:

  1. Es holt mit dir(cls) die Namen in der Klasse und sortiert sie, damit die Reihenfolge immer gleich ist;
  2. es überspringt die ohne den Marker. unittest benutzt keinen Marker, sondern den Namen: Es führt die Methoden aus, deren Namen mit test beginnen;
  3. es erzeugt für jeden Test ein neues Objekt, damit sich die Tests nicht gegenseitig stören;
  4. es holt die Methode mit getattr aus 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)
Antwort

Übung

Code, der mit jeder Klasse funktioniert

Löse die Aufgaben in deinem Editor.

1. Alle Felder, Klasse für Klasse. Schreib print_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.
@dataclass
class Vehicle:
plate: str = "M-AB 1234"
year: int = 2019
@dataclass
class Truck(Vehicle):
load_tons: float = 12.5
Ausgabe für Truck()
plate = M-AB 1234
year = 2019
load_tons = 12.5
2. Mini-JSON. Schreib to_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
Ausgabe für Ticket() und Room()
{"seat": 14, "event": "Konzert", "price": 45.0, "vip": false}
{"number": 214, "sea_view": true}
3. Dein eigener Test-Runner. Mach die Dekoratoren 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)
Ausgabe
OK withdraw_40
OK withdraw_70
FEHLER withdraw_too_much: ValueError: Es fehlen 30 €
Tests: 3, bestanden: 2, fehlgeschlagen: 1

Test

Teste dich

1. Was liefert type(obj)?
2. Was musst du tun, bevor du das Feld _price mit getattr(obj, "_price") liest?
3. In getattr(tea, "_prise") ist ein Tippfehler. Wann merkst du es?
4. Warum endet der Dekorator check mit return method?
5. Wie findet unittest die Tests?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·