Шаг 142.
Python: сборник рецептов.
Классы и объекты. Реализации модели данных или системы типов

    На этом шаге мы рассмотрим особенности реализаций таких моделей.

Задача

    Вам нужно определить различные структуры данных, но вы хотите установить принудительные ограничения на значения, которые можно назначить определенным атрибутам.

Решение

    В этой задаче вы сталкиваетесь с необходимостью создать проверки, или ассерты (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% быстрее, чем поход на основе миксин (примесей). Теперь-то вы рады, что дочитали весь этот рецепт до конца?

    На следующем шаге мы рассмотрим реализацию собственных контейнеров.




Предыдущий шаг Содержание Следующий шаг