Reflection и декоратори
Проблем
Полето, което __str__ забрави
__str__ на поръчката е писан на ръка. По-късно някой добавя поле за отстъпката:
class Order: def __init__(self): self.number = 1042 self.customer = "Мария Петрова" self.total = 86.40 self.discount = 10 # добавено по-късно
def __str__(self): return f"Order[number={self.number}, customer={self.customer}, total={self.total}]"
print(Order())Order[number=1042, customer=Мария Петрова, total=86.4]Отстъпката я няма в изхода, защото никой не се е сетил да обнови __str__. При десет класа това се случва постоянно.
test_…, unittest го намира и пуска, макар че никога не е виждал твоя клас. Как? И двете неща стават с едно и също: програмата може да разглежда собствените си класове, докато работи.Идеята
Класът като обект
В Python всичко е обект, и класовете: Order е обект от тип type, който описва класа: име, родители, методи. Разглеждането на класове и обекти, докато програмата работи, се казва reflection (отражение: програмата се вижда като в огледало). Има три начина да вземеш класа:
Order, когато знаеш класа, докато пишеш кода: името на класа е самият обект;type(obj), когато имаш обект;getattr(importlib.import_module("collections"), "Counter"), когато имаш само имената като текст.
import collections.abcimport importlib
module = importlib.import_module("collections")cls = getattr(module, "Counter")print("Име:", cls.__module__ + "." + cls.__qualname__)print("Кратко име:", cls.__name__)print("Родител:", cls.__base__.__name__)for parent in cls.__mro__: print("По веригата:", parent.__name__)print("Mapping ли е:", issubclass(cls, collections.abc.Mapping))Име: collections.CounterКратко име: CounterРодител: dictПо веригата: CounterПо веригата: dictПо веригата: objectMapping ли е: True__mro__ е веригата от класове, от които се наследява, по реда, в който Python търси методите. Интерфейси в Python няма. Вместо тях има абстрактни класове като collections.abc.Mapping, а issubclass проверява дали класът е такъв. Ако модул или клас с това име няма, import_module хвърля ModuleNotFoundError, а getattr хвърля AttributeError.Виж го
Полетата на всеки обект
vars(obj) дава полетата на обекта като речник: име към стойност. Типът на всяко поле е типът на стойността му, type(value): в Python полетата нямат обявен тип.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) = Мария Петрова total (float) = 86.4 discount (int) = 10Room: number (int) = 214 sea_view (bool) = Truediscount вече е там. Частни полета в Python няма: _ или __ отпред е само уговорка, и vars вижда всичко. Затова нещо като 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}")Стъпка по стъпка, с думи
- dump получава обект Order и не знае нищо за класа му.
- type(obj) дава класа Order. И той е обект.
- Първият ред е краткото име на класа.
- vars(obj) дава речник с 4 полета: име и стойност.
- Частни полета в Python няма: number идва направо със стойността си, 1042. Типът е на стойността: int.
- customer (str) = Мария Петрова.
- total (float) = 86.4.
- discount: полето, което toString забрави, излиза само, защото reflection взима всички полета.
- Функцията не знае нищо за Order. За Room печата number и sea_view по същия начин.
По-точно, за любопитните
vars(obj)дава само полетата на обекта, без методите и без атрибутите на класа.dir(obj)дава всички имена: полета, методи, наследени и специалните с__.- Редът е редът, в който полетата са получили стойност. Речниците в Python пазят реда, и това е обещано.
- Поле с две долни черти,
self.__price, Python преименува на_Product__price. И то се вижда въвvars, просто с друго име. - Клас с
__slots__няма речник за полетата иvarsхвърляTypeError.
Пробвай
Чий клас?
Какво ще отпечата програмата?
class Document: pass
class Invoice(Document): pass
doc = Invoice()print(type(doc).__name__)print(type(doc).__base__.__name__)print(Document.__base__.__name__)type(doc) дава истинския клас на обекта: Invoice. Негов родител (__base__) е Document. Document не изрежда родител в скобите, затова родителят му е object, общият родител от модула за наследяването. За самия object __base__ е None: над него няма нищо.Виж го
Обект, метод и частно поле по име
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("Кафе", 10.0)
discount = getattr(coffee, "apply_discount")discount(20)
print("Цена след отстъпка:", getattr(coffee, "_price"))setattr(coffee, "_price", 9.5)
label = getattr(coffee, "_label")print(label())Цена след отстъпка: 8.0Кафе: 9.5 €globals()["Product"]намира класа по името му в модула. Класът се вика като функция и това е конструкторът.getattr(coffee, "apply_discount")дава метода, вече вързан заcoffee. Остава само да го извикаш с аргументите.getattrиsetattrчетат и пишат всяко поле, и_price._labelсъщо се вика: „частно“ в Python значи само „не го викай отвън“.
Типове на параметрите не се подават: в Python методите се намират само по име.
По-точно, за любопитните
list() или .append:import builtins
items = getattr(builtins, "list")()add = getattr(items, "append")add("Кафе")add("Чай")print(items)['Кафе', 'Чай']list са в модула builtins.Пробвай
Цената, която я няма
Избери реда с грешката.
Полето се казва _price, а тук пише "_prise". Това е обикновен текст и никой не го проверява, преди програмата да стигне до него. Грешката излиза чак при изпълнение:
AttributeError: 'Product' object has no attribute '_prise'. Did you mean: '_price'?Подсказката „Did you mean“ я добавя Python 3.12 при печата на грешката.
Това е главната цена на reflection:
- никой не проверява предварително имената и типовете, а редакторът не ги намира при преименуване;
- кодът е по-бавен от обикновено извикване и по-труден за четене;
-
getattrиsetattrзаобикалят капсулацията: можеш да счупиш правило, което класът пази.
dataclasses, SQLAlchemy (той чете полетата на класа и пълни обектите), Django. В обикновения код се пише рядко.Идеята
Етикети на полета
@ на полета и методи се казват анотации. В Python „анотация“ значи друго: типът след двоеточието, guest: str. Етикети с @ на полета в Python няма. За поле на dataclass етикет е metadata на field. Функцията label прави такова поле, а print_labeled печата само полетата с етикет:def label(text, default): return field(default=default, metadata={"label": text})@dataclassclass Booking: number: int = label("Номер", 501) guest: str = label("Гост", "Иван Георгиев") nights: int = label("Нощувки", 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)}")Номер: 501Гост: Иван ГеоргиевНощувки: 3field(default=…, metadata={…})прави поле на dataclass със стойност по подразбиране и речник с каквото искаш.fields(obj)дава полетата на dataclass-а като обектиField: всяко имаname,typeиmetadata.f.metadata.get("label")връща етикета илиNone, ако полето го няма.internal_noteняма етикет и не се печата.
По-точно, за любопитните
- Анотациите на Python (типовете на полетата) също се четат с reflection:
inspect.get_annotations(Booking)дава{'number': <class 'int'>, 'guest': <class 'str'>, …}. Такаdataclassразбира кои са полетата. - Етикети на методи и класове в Python са декораторите с
@. Ще ги видим в следващата карта.
Виж го
Как unittest намира тестовете
@check над метода вика функцията check с метода, а тя му слага атрибут is_check и го връща. Тестовият клас проверява отстъпката от първия урок на модула (със старата грешка 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"очаквах {expected}, а е {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"ОК {name}") passed += 1 except Exception as e: print(f"ПАДА {name}: {e}") failed += 1 print(f"Проверки: {passed + failed}, минали: {passed}, паднали: {failed}")ПАДА extra_five_percent_from_100: очаквах 85.0, а е 90.0ОК ten_percent_offПроверки: 2, минали: 1, паднали: 1Това прави и unittest, само с много повече подробности:
- взима имената в класа с
dir(cls)и ги подрежда, за да е редът един и същ; - пропуска тези без маркера. unittest не ползва маркер, а името: пуска методите, които започват с
test; - прави нов обект за всеки тест, затова тестовете не си пречат;
- взима метода от обекта с
getattrи го вика. Ако тестът хвърли, изключението идва направо, без обвивка като в Java.
assertEqual в unittest също е метод, който хвърля AssertionError, когато стойностите се различават. Точно като expect тук.
Пробвай
Маркерът, който изчезна
check е без return method накрая, всичко останало е същото:Какво ще отпечата run_checks?
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 над def е същото като ten_percent_off = check(ten_percent_off). Без return check връща None и на мястото на метода остава None. getattr(None, "is_check", False) е False и нито една проверка не се пуска. Програмата работи, просто не прави нищо. Затова всеки декоратор връща функцията, която е получил (или нова на нейно място).Упражнение
Код, който работи с всеки клас
Реши задачите в редактора си.
1. Всички полета, клас по клас. Напишиprint_fields(obj) за dataclass, който печата всяко поле като име = стойност, включително наследените, като минава клас по клас. Тръгни от type(obj).__mro__ отзад напред и за всеки клас вземи само неговите полета с inspect.get_annotations(cls); полетата на родителя са първи.@dataclassclass Vehicle: plate: str = "СА 1234 ВК" year: int = 2019
@dataclassclass Truck(Vehicle): load_tons: float = 12.5plate = СА 1234 ВКyear = 2019load_tons = 12.5to_json(obj), който връща полетата като JSON: имената в кавички, текстът в кавички, числата и true/false без кавички. Ползвай vars, isinstance(value, str) и isinstance(value, bool): str(False) е False, а в JSON трябва false. (Готовото е json.dumps(vars(obj)).)class Ticket: def __init__(self): self.seat = 14 self.event = "Концерт" self.price = 45.0 self.vip = False{"seat": 14, "event": "Концерт", "price": 45.0, "vip": false}{"number": 214, "sea_view": true}my_test и my_before_each (като check) и пускач run(cls). За всеки метод с my_test: нов обект, после методът с my_before_each (ако има), после тестът. Ако тестът хвърли, отпечатай вида (type(e).__name__) и съобщението на изключението. Пробвай го с тези тестове: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"Не стигат {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"очаквах 60, а е {self.account.balance}")
@my_test def withdraw_70(self): self.account.withdraw(70) if self.account.balance != 30: raise AssertionError(f"очаквах 30, а е {self.account.balance}")
@my_test def withdraw_too_much(self): self.account.withdraw(130)ОК withdraw_40ОК withdraw_70ПАДА withdraw_too_much: ValueError: Не стигат 30 €Тестове: 3, минали: 2, паднали: 1Тест