Към съдържанието
wannacode

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.abc
import 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
По веригата: object
Mapping ли е: 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) = 10
Room:
number (int) = 214
sea_view (bool) = True
Една функция отпечатва всеки обект, и новото поле discount вече е там. Частни полета в 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}")
Стъпка по стъпка, с думи
  1. dump получава обект Order и не знае нищо за класа му.
  2. type(obj) дава класа Order. И той е обект.
  3. Първият ред е краткото име на класа.
  4. vars(obj) дава речник с 4 полета: име и стойност.
  5. Частни полета в Python няма: number идва направо със стойността си, 1042. Типът е на стойността: int.
  6. customer (str) = Мария Петрова.
  7. total (float) = 86.4.
  8. discount: полето, което toString забрави, излиза само, защото reflection взима всички полета.
  9. Функцията не знае нищо за 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__)
Отговор

Виж го

Обект, метод и частно поле по име

През reflection можеш да създадеш обект, да извикаш метод и да промениш поле, като знаеш само имената им:
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.

Пробвай

Цената, която я няма

Програмата тръгва, но спира. На кой ред е грешката?

Избери реда с грешката.

Идеята

Етикети на полета

В Java етикетите с @ на полета и методи се казват анотации. В Python „анотация“ значи друго: типът след двоеточието, guest: str. Етикети с @ на полета в Python няма. За поле на dataclass етикет е metadata на field. Функцията label прави такова поле, а print_labeled печата само полетата с етикет:
def label(text, default):
return field(default=default, metadata={"label": text})
@dataclass
class 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
Гост: Иван Георгиев
Нощувки: 3
  • field(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, само с много повече подробности:

  1. взима имената в класа с dir(cls) и ги подрежда, за да е редът един и същ;
  2. пропуска тези без маркера. unittest не ползва маркер, а името: пуска методите, които започват с test;
  3. прави нов обект за всеки тест, затова тестовете не си пречат;
  4. взима метода от обекта с 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)
Отговор

Упражнение

Код, който работи с всеки клас

Реши задачите в редактора си.

1. Всички полета, клас по клас. Напиши print_fields(obj) за dataclass, който печата всяко поле като име = стойност, включително наследените, като минава клас по клас. Тръгни от type(obj).__mro__ отзад напред и за всеки клас вземи само неговите полета с inspect.get_annotations(cls); полетата на родителя са първи.
@dataclass
class Vehicle:
plate: str = "СА 1234 ВК"
year: int = 2019
@dataclass
class Truck(Vehicle):
load_tons: float = 12.5
Изход за Truck()
plate = СА 1234 ВК
year = 2019
load_tons = 12.5
2. Мини JSON. Напиши to_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
Изход за Ticket() и Room()
{"seat": 14, "event": "Концерт", "price": 45.0, "vip": false}
{"number": 214, "sea_view": true}
3. Свой пускач на тестове. Направи декоратори 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

Тест

Провери се

1. Какво дава type(obj)?
2. Какво трябва да направиш, преди да прочетеш полето _price с getattr(obj, "_price")?
3. В getattr(tea, "_prise") има печатна грешка. Кога ще разбереш?
4. Защо декораторът check завършва с return method?
5. Как unittest намира тестовете?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·