Перейти к содержимому

Практика 6. Практическая работа 6. Дескрипторы и метаклассы

Цель

  • Освоить протокол дескрипторов (__get__ / __set__ / __set_name__) и научиться писать переиспользуемые поля-валидаторы.
  • Понять механику __slots__: экономию памяти, ограничения и взаимодействие со слотами при наследовании.
  • Научиться писать метаклассы (реестр подклассов, проверка контракта) и собирать декларативную мини-ORM из дескрипторов и метакласса.
  • Закрепить принцип наименее мощного инструмента: применять метакласс только там, где не хватает дескриптора или __init_subclass__.

Опираемся на материал:

  • Лекция 8 — модель атрибутов, __dict__, data/non-data-дескрипторы, __slots__, __set_name__.
  • Лекция 9type как метакласс, жизненный цикл создания класса, __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: Typed15%Корректный data-дескриптор, хранение в экземпляре, __set_name__, проверка типа.
Задание 2: Positive/Ranged20%Иерархия валидаторов, super().validate(), разделение «хранение/проверка», валидация на каждое присваивание.
Задание 3: __slots__15%Отсутствие __dict__, AttributeError на лишний атрибут, измерение памяти, поведение при наследовании.
Задание 4: метакласс-реестр20%Реестр подклассов, проверка контракта на этапе объявления, вариант через __init_subclass__.
Задание 5: мини-ORM20%Сборка _fields (с наследованием), валидация через дескрипторы, рабочий User, демонстрации ошибок.
Качество кода10%Аннотации типов, docstring, осмысленные демонстрации/assert, отсутствие хранения значения в self дескриптора.

Итого: 100%. Бонус до +10% за дополнительные валидаторы (Typed с несколькими допустимыми типами, __delete__), тесты на pytest или сравнение скорости доступа __slots__ против __dict__.


Вопросы для самопроверки

  1. Почему значение поля нужно хранить в instance.__dict__, а не в атрибуте дескриптора? Что произойдёт во втором случае при нескольких экземплярах?
  2. Чем data-дескриптор отличается от non-data и как это влияет на порядок поиска obj.x? Почему Positive обязан определять __set__?
  3. Зачем дескриптору __set_name__ и что пришлось бы делать без него?
  4. Что должен возвращать __get__ при доступе через класс (instance is None) и зачем?
  5. В каких случаях __slots__ реально экономит память, а в каких экономия теряется? Что произойдёт с __dict__ у подкласса без __slots__?
  6. Почему слот конфликтует с @property или переменной класса того же имени?
  7. Чем __new__ метакласса отличается от __init__? В каком из них уместно проверять наличие обязательного метода и почему?
  8. Почему проверку контракта в задании 4 удобнее делать на этапе объявления класса, а не при вызове метода?
  9. В заданиях 4 и 5 метакласс можно заменить на __init_subclass__. Когда такая замена предпочтительнее и почему действует «правило Тима Питерса»?
  10. Как мини-ORM прячет метапрограммирование за декларативным API: что пишет пользователь и что происходит «под капотом»?