Практика 6. Практическая работа 6. Дескрипторы и метаклассы
Цель
- Освоить протокол дескрипторов (
__get__/__set__/__set_name__) и научиться писать переиспользуемые поля-валидаторы. - Понять механику
__slots__: экономию памяти, ограничения и взаимодействие со слотами при наследовании. - Научиться писать метаклассы (реестр подклассов, проверка контракта) и собирать декларативную мини-ORM из дескрипторов и метакласса.
- Закрепить принцип наименее мощного инструмента: применять метакласс только там, где не хватает дескриптора или
__init_subclass__.
Опираемся на материал:
- Лекция 8 — модель атрибутов,
__dict__, data/non-data-дескрипторы,__slots__,__set_name__. - Лекция 9 —
typeкак метакласс, жизненный цикл создания класса,__new__/__init__метакласса,__init_subclass__, мини-ORM.
Краткая теория
Дескриптор — объект, чей класс реализует хотя бы один метод протокола: __get__(self, instance, owner), __set__(self, instance, value), __delete__(self, instance). Дескриптор «оживает» только как атрибут класса, а не экземпляра.
- Data-дескриптор (есть
__set__/__delete__) имеет приоритет над__dict__экземпляра. - Non-data-дескриптор (только
__get__) уступает данным экземпляра. __set_name__(self, owner, name)Python вызывает сам при создании класса-владельца — так дескриптор узнаёт своё имя. Состояние храните в экземпляре (instance.__dict__), а не вselfдескриптора: дескриптор один на класс, иначе все объекты разделят одно значение.
__slots__ заменяет __dict__ экземпляра набором слотов-дескрипторов: меньше памяти, быстрее доступ, но нельзя добавлять необъявленные атрибуты. Если хотя бы один класс в иерархии не объявил __slots__, __dict__ возвращается и экономия теряется.
Метакласс — класс класса (по умолчанию type). Инструкция class — сахар над type(name, bases, namespace). Переопределяя __new__ (меняем состав будущего класса) или __init__ (донастраиваем готовый), мы единообразно контролируем семейство классов. Лёгкие альтернативы — __init_subclass__ (реакция на подкласс) и __set_name__.
Задания
Во всех заданиях используйте аннотации типов и краткие docstring. Полные решения не приводятся — реализуйте по скелетам и требованиям. Каждое задание сопроводите минимальной демонстрацией (несколько
assertили вывод).
Задание 1. Базовый дескриптор-валидатор Typed
Реализуйте data-дескриптор, проверяющий тип значения при присваивании.
class Typed: """Поле, допускающее только значения заданного типа."""
def __init__(self, expected_type, *, default=None): ...
def __set_name__(self, owner, name): # запомнить публичное имя и имя слота хранения в __dict__ экземпляра ...
def __get__(self, instance, owner): # доступ через класс (instance is None) -> вернуть сам дескриптор ...
def __set__(self, instance, value): # проверить isinstance, при ошибке -> TypeError; иначе сохранить в экземпляре ...Требования:
- Значение хранится в
instance.__dict__под именем, полученным из__set_name__(например,_<name>), а не в самом дескрипторе. - При несоответствии типа —
TypeErrorс понятным сообщением (имя поля, ожидаемый и фактический тип). - Класс
Productс полямиname = Typed(str),price = Typed(float, default=0.0),quantity = Typed(int, default=0)корректно создаётся и отвергает неверные типы.
Задание 2. Иерархия валидаторов: Positive, Ranged
На основе базового поля постройте иерархию валидаторов с шаблонным методом validate.
class Field: """Базовый data-дескриптор: хранит значение в экземпляре, валидирует в __set__."""
def __set_name__(self, owner, name): ... def __get__(self, instance, owner): ... def __set__(self, instance, value): self.validate(value) # сохранить значение ... def validate(self, value): """Переопределяется в потомках; по умолчанию проверок нет."""
class Number(Field): def validate(self, value): # требовать int/float, иначе TypeError ...
class Positive(Number): def validate(self, value): super().validate(value) # требовать value > 0, иначе ValueError ...
class Ranged(Number): def __init__(self, min_value, max_value): ... def validate(self, value): super().validate(value) # требовать min_value <= value <= max_value, иначе ValueError ...Требования:
Fieldотвечает только за хранение, потомки — только за проверку (разделение ответственности).Positiveнаследует проверку числа отNumberчерезsuper().validate().Ranged(min_value, max_value)хранит границы в дескрипторе (это его собственные параметры, не значение поля) и проверяет диапазон включительно.- Класс
Accountсbalance = Positive()иrate = Ranged(0.0, 1.0); продемонстрируйте срабатывание валидации на каждое присваивание (в том числе после создания объекта).
Задание 3. __slots__ и измерение памяти
Сравните «обычный» класс и класс со слотами.
Требования:
- Реализуйте
PointDict(без слотов) иPointSlotsс__slots__ = ("x", "y"); оба с__init__(self, x, y). - Покажите, что у экземпляра
PointDictесть__dict__, а уPointSlots— нет (hasattr(..., "__dict__")). - Покажите, что присваивание необъявленного атрибута (
p.z = 1) наPointSlotsдаётAttributeError. - Сравните размер экземпляров (например, через
sys.getsizeofили массовое созданиеNобъектов иtracemalloc), сделайте вывод об экономии. - Подзадача (наследование): создайте
PointSlots3D(PointSlots)со__slots__ = ("z",)и убедитесь, что__dict__по-прежнему отсутствует; затем создайте подкласс без__slots__и покажите, что__dict__появился снова. Объясните, почему.
Задание 4. Метакласс-реестр и проверка контракта
Напишите метакласс, который ведёт реестр подклассов и проверяет наличие обязательных методов.
class PluginMeta(type): registry = {}
def __new__(mcs, name, bases, namespace): cls = super().__new__(mcs, name, bases, namespace) # 1) пропустить сам базовый класс Plugin (у него нет родителя-плагина) # 2) проверить, что в классе есть метод run (иначе TypeError на этапе объявления) # 3) зарегистрировать класс в registry под ключом (имя в нижнем регистре) return cls
class Plugin(metaclass=PluginMeta): passТребования:
- Регистрируются только реальные плагины, базовый
Pluginв реестр не попадает. - Если у подкласса нет метода
run— выбрасываетсяTypeErrorв момент объявления класса, а не при вызове. - Покажите, что объявление корректного
EchoPlugin(Plugin)с методомrunдобавляет его вPluginMeta.registry, а объявление плагина безrunпадает. - Подзадача: перепишите ту же логику регистрации через
__init_subclass__без метакласса и объясните в комментарии, какой вариант предпочтительнее и почему (правило наименее мощного инструмента).
Задание 5. Мини-ORM: дескрипторы + метакласс
Соберите декларативную модель в духе Django/SQLAlchemy: поля описываются дескрипторами, а метакласс собирает их в реестр.
class ModelMeta(type): """Собирает все Field класса (и родителей) в cls._fields.""" def __new__(mcs, name, bases, namespace): ...
class Model(metaclass=ModelMeta): def __init__(self, **kwargs): # для каждого поля взять значение из kwargs (или default) и присвоить через дескриптор ... def to_dict(self): ... def __repr__(self): ...Требования:
- Используйте поля из заданий 1–2 (
IntegerField/StringFieldилиTyped+ валидаторы) с параметрамиrequired,defaultи проверками. ModelMeta.__new__собирает все атрибуты-Fieldв словарь_fields, включая унаследованные от родительских моделей.Model.__init__(**kwargs)присваивает значения через дескрипторы, поэтому валидация срабатывает; отсутствующее обязательное поле даётValueError.- Объявите
User(Model)с полямиid = IntegerField(min_value=1),name = StringField(max_length=50),age = IntegerField(required=False, default=0, min_value=0). - Продемонстрируйте: успешное создание и
to_dict()/repr; ошибку приid=0(нарушенmin_value); ошибку при нечисловом/нестроковом значении. - Подзадача: в комментарии покажите, как ту же сборку
_fieldsсделать через__init_subclass__вModelбез метакласса.
Критерии оценки
| Критерий | Вес | Что проверяется |
|---|---|---|
Задание 1: Typed | 15% | Корректный data-дескриптор, хранение в экземпляре, __set_name__, проверка типа. |
Задание 2: Positive/Ranged | 20% | Иерархия валидаторов, super().validate(), разделение «хранение/проверка», валидация на каждое присваивание. |
Задание 3: __slots__ | 15% | Отсутствие __dict__, AttributeError на лишний атрибут, измерение памяти, поведение при наследовании. |
| Задание 4: метакласс-реестр | 20% | Реестр подклассов, проверка контракта на этапе объявления, вариант через __init_subclass__. |
| Задание 5: мини-ORM | 20% | Сборка _fields (с наследованием), валидация через дескрипторы, рабочий User, демонстрации ошибок. |
| Качество кода | 10% | Аннотации типов, docstring, осмысленные демонстрации/assert, отсутствие хранения значения в self дескриптора. |
Итого: 100%. Бонус до +10% за дополнительные валидаторы (Typed с несколькими допустимыми типами, __delete__), тесты на pytest или сравнение скорости доступа __slots__ против __dict__.
Вопросы для самопроверки
- Почему значение поля нужно хранить в
instance.__dict__, а не в атрибуте дескриптора? Что произойдёт во втором случае при нескольких экземплярах? - Чем data-дескриптор отличается от non-data и как это влияет на порядок поиска
obj.x? ПочемуPositiveобязан определять__set__? - Зачем дескриптору
__set_name__и что пришлось бы делать без него? - Что должен возвращать
__get__при доступе через класс (instance is None) и зачем? - В каких случаях
__slots__реально экономит память, а в каких экономия теряется? Что произойдёт с__dict__у подкласса без__slots__? - Почему слот конфликтует с
@propertyили переменной класса того же имени? - Чем
__new__метакласса отличается от__init__? В каком из них уместно проверять наличие обязательного метода и почему? - Почему проверку контракта в задании 4 удобнее делать на этапе объявления класса, а не при вызове метода?
- В заданиях 4 и 5 метакласс можно заменить на
__init_subclass__. Когда такая замена предпочтительнее и почему действует «правило Тима Питерса»? - Как мини-ORM прячет метапрограммирование за декларативным API: что пишет пользователь и что происходит «под капотом»?