На этом шаге мы рассмотрим особенности реализаций таких моделей.
Вам нужно определить различные структуры данных, но вы хотите установить принудительные ограничения на значения, которые можно назначить определенным атрибутам.
В этой задаче вы сталкиваетесь с необходимостью создать проверки, или ассерты (assertions), которые вызываются при присваивании значения определенным атрибутам экземпляра. Чтобы сделать это, вам нужно кастомизировать установку атрибутов отдельно для каждого атрибута. Для этого нужно использовать дескрипторы.
Следующий пример иллюстрирует использование дескрипторов для реализации системы типов и фреймворка проверки значений:
>>> # Базовый класс. Использует дескриптор для установки значения >>> class Descriptor: def __init__(self, name=None, **opts): self.name = name for key, value in opts.items(): setattr(self, key, value) def __set__(self, instance, value): instance.__dict__[self.name] = value >>> # Дескриптор для принудительного определения типов >>> class Typed(Descriptor): expected_type = type(None) def __set__(self, instance, value): if not isinstance(value, self.expected_type): raise TypeError('expected ' + str(self.expected_type)) super().__set__(instance, value) >>> # Дескриптор для принудительного определения значений >>> class Unsigned(Descriptor): def __set__(self, instance, value): if value < 0: raise ValueError('Expected >= 0') super().__set__(instance, value) >>> class MaxSized(Descriptor): def __init__(self, name=None, **opts): if 'size' not in opts: raise TypeError('missing size option') super().__init__(name, **opts) def __set__(self, instance, value): if len(value) >= self.size: raise ValueError('size must be < ' + str(self.size)) super().__set__(instance, value) >>>
Эти классы нужно рассматривать как базовые строительные блоки, из которых вы создаете модель данных или систему типов. Продолжая пример, приведем код, который реализует некоторые другие типы данных:
>>> class Integer(Typed): expected_type = int >>> class UnsignedInteger(Integer, Unsigned): pass >>> class Float(Typed): expected_type = float >>> class UnsignedFloat(Float, Unsigned): pass >>> class String(Typed): expected_type = str >>> class SizedString(String, MaxSized): pass >>>
Используя эти объекты типов, можно определить такой класс:
>>> class Stock: # Определяем ограничения name = SizedString('name', size=8) shares = UnsignedInteger('shares') price = UnsignedFloat('price') def __init__(self, name, shares, price): self.name = name self.shares = shares self.price = price >>>
Применив ограничения, вы обнаружите, что присвоение атрибутов теперь валидируется. Например:
>>> s = Stock('ACME', 50, 91.1) >>> s.name 'ACME' >>> s.shares = 75 >>> s.shares = -10 Traceback (most recent call last): File "<pyshell#29>", line 1, in <module> s.shares = -10 File "<pyshell#5>", line 6, in __set__ super().__set__(instance, value) File "<pyshell#8>", line 4, in __set__ raise ValueError('Expected >= 0') ValueError: Expected >= 0 >>> s.price = 'a lot' Traceback (most recent call last): File "<pyshell#30>", line 1, in <module> s.price = 'a lot' File "<pyshell#5>", line 5, in __set__ raise TypeError('expected ' + str(self.expected_type)) TypeError: expected <class 'float'> >>> s.name = 'ABRACADABRA' Traceback (most recent call last): File "<pyshell#31>", line 1, in <module> s.name = 'ABRACADABRA' File "<pyshell#5>", line 6, in __set__ super().__set__(instance, value) File "<pyshell#10>", line 8, in __set__ raise ValueError('size must be < ' + str(self.size)) ValueError: size must be < 8 >>>
Есть несколько приемов для упрощения спецификации ограничений в классах. Один из них - это использование декоратора класса:
# Декоратор класса для применения ограничений def check_attributes(**kwargs): def decorate(cls): for key, value in kwargs.items(): if isinstance(value, Descriptor): value.name = key setattr(cls, key, value) else: setattr(cls, key, value(key)) return cls return decorate # Пример @check_attributes(name=SizedString(size=8), shares=UnsignedInteger, price=UnsignedFloat) class Stock: def __init__(self, name, shares, price): self.name = name self.shares = shares self.price = price
Еще один подход к упрощению спецификации ограничений - использование метакласса:
# Метакласс, который применяет проверку class checkedmeta(type): def __new__(cls, clsname, bases, methods): # Прикрепление имен атрибутов к дескрипторам for key, value in methods.items(): if isinstance(value, Descriptor): value.name = key return type.__new__(cls, clsname, bases, methods) # Пример class Stock(metaclass=checkedmeta): name = SizedString(size=8) shares = UnsignedInteger() price = UnsignedFloat() def __init__(self, name, shares, price): self.name = name self.shares = shares self.price = price
Этот рецепт использует несколько продвинутых приемов, включая дескрипторы, классы-миксины (mixin), функцию super(), декораторы классов и метаклассы. Однако несколько тонких моментов все же стоит осветить.
Во-первых, в базовом классе Descriptor есть метод __set__(), но нет соответствующего __get__(). Если дескриптор не делает ничего, кроме как извлекает значение с таким же именем из словаря экземпляра, определять __get__() не нужно - на самом деле это сделает программу медленнее. Поэтому этот рецепт сосредоточен только на реализации __set__().
Различные классы-дескрипторы в общем проектируются на базе классов-миксин. Например, классы Unsigned и MaxSized предназначены для смешивания с другими классами-дескрипторами, полученными от Typed. Чтобы обрабатывать конкретные типы данных, для получения нужной функциональности используется множественное наследование.
Вы также заметите, что все методы __init__() различных дескрипторов запрограммированы так, чтобы иметь одинаковую сигнатуру вызовов, использующую именованные аргументы **opts. Класс MaxSized ищет требуемые атрибуты в opts, но просто передает их базовому классу Descriptor, который их устанавливает. Трудность композиции таких классов (и миксин в особенности) состоит в том, что вы не всегда знаете, как классы будут связаны друг с другом или что будет вызывать функция super(). Поэтому вам нужно заставить все это работать для любой возможной комбинации классов.
Определения различных типов классов, таких как Integer, Float и String, иллюстрируют полезный прием использования переменных класса для кастомизации реализации. Дескриптор Typed просто ищет атрибут expected_type, который предоставляется каждым из этих подклассов.
Использование декоратора класса или метакласса часто является полезным для упрощения спецификации пользователем. Вы заметите, что в этих примерах пользователь больше не должен прописывать имя атрибута больше, нежели один раз:
# Обычно class Point: x = Integer('x') y = Integer('y') # Метакласс class Point(metaclass=checkedmeta): x = Integer() y = Integer()
Код декоратора класса и метакласса просто сканирует словарь класса в поиске дескрипторов. Когда дескриптор найден, они просто заполняют имя дескриптора, основываясь на значении ключа.
Из всех этих подходов решение с декоратором класса может предоставить наилучшую гибкость и ясность. Во-первых, оно не рассчитывает ни на какую продвинутую магию типа метаклассов. Во-вторых, декоратор может быть легко добавлен или удален из определения класса. Например, внутри декоратора может быть возможность просто пропустить все добавленные проверки. Это может позволить проверкам стать чем-то, что можно выключить или выключить в зависимости от текущих потребностей (например, во время отладки выключить, а в продакшне включить).
И последнее: подход с использованием декоратора класса может быть также применен в качестве замены классам-миксинам, множественному наследованию и сложному использованию функции super(). Вот альтернативная реализация этого рецепта, использующая декораторы классов:
# Базовый класс. Использует дескриптор для установки значения class Descriptor: def __init__(self, name=None, **opts): self.name = name for key, value in opts.items(): setattr(self, key, value) def __set__(self, instance, value): instance.dict[self.name] = value # Декоратор для применения проверки типов def Typed(expected_type, cls=None): if cls is None: return lambda cls: Typed(expected_type, cls) super_set = cls.__set__ def __set__(self, instance, value): if not isinstance(value, expected_type): raise TypeError('expected ' + str(expected_type)) super_set(self, instance, value) cls.__set__ = __set__ return cls # Декоратор для беззнаковых значений def Unsigned(cls): super_set = cls.__set__ def __set__(self, instance, value): if value < 0: raise ValueError('Expected >= 0') super_set(self, instance, value) cls.__set__ = __set__ return cls # Декоратор, разрешающий значения с определенным размером def MaxSized(cls): super_init = cls.__init__ def __init__(self, name=None, **opts): if 'size' not in opts: raise TypeError('missing size option') super_init(self, name, **opts) cls.__init__ = __init__ super_set = cls.__set__ def __set__(self, instance, value): if len(value) >= self.size: raise ValueError('size must be < ' + str(self.size)) super_set(self, instance, value) cls.__set__ = __set__ return cls # Специализированные дескрипторы @Typed(int) class Integer(Descriptor): pass @Unsigned class UnsignedInteger(Integer): pass @Typed(float) class Float(Descriptor): pass @Unsigned class UnsignedFloat(Float): pass @Typed(str) class String(Descriptor): pass @MaxSized class SizedString(String): pass
Классы, определенные в этом альтернативном решении, работают так же, как и раньше (ранее показанный в примерах код не изменился), за исключением того, что все работает намного быстрее. Например, простая проверка времени исполнения присваивания типизированного атрибута обнаруживает, что подход с использованием декораторов классов работает почти на 100% быстрее, чем поход на основе миксин (примесей). Теперь-то вы рады, что дочитали весь этот рецепт до конца?
На следующем шаге мы рассмотрим реализацию собственных контейнеров.