Шаг 162.
Python: сборник рецептов. Метапрограммирование. Принудительная проверка типов в функции с использованием декоратора

    На этом шаге мы рассмотрим решение этой задачи.

Задача

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

Решение

    Перед тем как показывать код решения, напомним, что цель этого рецепта - получить средства для принудительной проверки правильности типов входных аргументов функции. Вот короткий пример, который иллюстрирует идею:

>>> @typeassert(int, int)
def add(x, y):
	return x + y

>>> add(2, 3)
5
>>> add(2, 'hello')
Traceback (most recent call last):
  File "<pyshell#7>", line 1, in <module>
    add(2, 'hello')
  File "<pyshell#2>", line 18, in wrapper
    raise TypeError(
TypeError: Argument y must be <class 'int'>
>>> 

    Теперь приведем реализацию декоратора @typeassert:

>>> from inspect import signature
>>> from functools import wraps
>>> def typeassert(*ty_args, **ty_kwargs):
    def decorate(func):
        # Если мы в оптимизированном режиме, отключаем проверку типов
        if not __debug__:
            return func

        # Отображаем имена аргументов функции на предоставленные типы
        sig = signature(func)
        bound_types = sig.bind_partial(*ty_args, **ty_kwargs).arguments

        @wraps(func)
        def wrapper(*args, **kwargs):
            bound_values = sig.bind(*args, **kwargs)
            # Принудительно проверяем типы предоставленных аргументов ассертами
            for name, value in bound_values.arguments.items():
                if name in bound_types:
                    if not isinstance(value, bound_types[name]):
                        raise TypeError(
                            'Argument {} must be {}'.format(name, bound_types[name])
                            )
            return func(*args, **kwargs)
        return wrapper
    return decorate

>>> 

    Вы обнаружите, что этот декоратор достаточно гибок и позволяет указать типы для всех (или для подмножества) аргументов функции. Более того, типы могут быть указаны позиционно или с помощью именованных аргументов. Вот пример:

>>> @typeassert(int, z=int)
def spam(x, y, z=42):
    print(x, y, z)

>>> spam(1, 2, 3)
1 2 3
>>> spam(1, 'hello', 3)
1 hello 3
>>> spam(1, 'hello', 'world')
Traceback (most recent call last):
  File "<pyshell#12>", line 1, in <module>
    spam(1, 'hello', 'world')
  File "<pyshell#2>", line 18, in wrapper
    raise TypeError(
TypeError: Argument z must be <class 'int'>
>>> 


Обсуждение

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

    Во-первых, одна из особенностей декораторов в том, что они применяются только один раз, во время определения функции. В некоторых случаях вы можете захотеть отключить функциональность, добавленную декоратором. Чтобы сделать это, просто заставьте ваш декоратор вернуть необернутую функцию. В решении приведенный ниже фрагмент кода возвращает неизмененную функцию, если значение глобальной переменной __debug__ установлено на False (как и в том случае, когда интерпретатор Python запускается в оптимизированном режиме с параметрами -O или -OO): . . .

def decorate(func):
    # Если мы в оптимизированном режиме, отключаем проверку типов
    if not __debug__:
        return func
    .  .  .

    Следующая тонкость написания декораторов в том, что это подразумевает изучение и работу с аргументной сигнатурой оборачиваемой функции. Оптимальный инструмент для этого - функция inspect.signature(). Она позволяет вам извлечь информацию о сигнатуре из вызываемого объекта. Например:

>>> from inspect import signature
>>> def spam(x, y, z=42):
	pass

>>> sig = signature(spam)
>>> print(sig)
(x, y, z=42)
>>> sig.parameters
mappingproxy(OrderedDict([('x', <Parameter "x">), ('y', <Parameter "y">), 
('z', <Parameter "z=42">)]))
>>> sig.parameters['z'].name
'z'
>>> sig.parameters['z'].default
42
>>> sig.parameters['z'].kind
<_ParameterKind.POSITIONAL_OR_KEYWORD: 1>
>>> 

    В первой части нашего декоратора мы используем метод сигнатур bind_partial(), чтобы выполнить частичную привязку предоставленных типов к именам аргументов. Вот пример того, как это работает:

>>> bound_types = sig.bind_partial(int, z=int)
>>> bound_types
<BoundArguments (x=<class 'int'>, z=<class 'int'>)>
>>> bound_types.arguments
OrderedDict([('x', <class 'int'>), ('z', <class 'int'>)])
>>> 

    На примере этой частичной привязки вы заметите, что недостающие аргументы просто игнорируются (то есть нет привязки для аргумента у). Однако наиболее важная часть привязки - это создание упорядоченного словаря bound_types.arguments. Этот словарь отображает имена аргументов на предоставленные значения в том же порядке, что и сигнатура функции. В случае нашего декоратора это отображение содержит ассерты типов, которые мы будем принудительно проверять.

    В функции-обертке, созданной декоратором, используется метод sig.bind(). bind() похож на bind_partial(), за исключением того, что он не позволяет пропускать аргументы. Вот как это работает:

>>> bound_values = sig.bind(1, 2, 3)
>>> bound_values.arguments
OrderedDict([('x', 1), ('y', 2), ('z', 3)])
>>> 

    Используя это отображение, относительно легко обеспечить требуемые проверки:

>>> for name, value in bound_values.arguments.items():
	if name in bound_types.arguments:
		if not isinstance(value, bound_types.arguments[name]):
			raise TypeError()

		
>>> 

    В этом решении есть тонкий аспект: ассерты не применяются к непредоставленным аргументам со значениями по умолчанию. Например, этот код работает, хотя значение items по умолчанию имеет "неправильный" тип:

>>> @typeassert(int, list)
def bar(x, items=None):
	if items is None:
		items = []
	items.append(x)
	return items

>>> bar(2)
[2]
>>> bar(2, 3)
Traceback (most recent call last):
  File "<pyshell#37>", line 1, in <module>
    bar(2, 3)
  File "<pyshell#2>", line 18, in wrapper
    raise TypeError(
TypeError: Argument items must be <class 'list'>
>>> bar(4, [1, 2, 3])
[1, 2, 3, 4]
>>> 

    Последнюю точку в обсуждении этого приема проектирования ставит такой вопрос: использовать аргументы декораторов или аннотации функций? Почему бы, например, не написать вот такой декоратор, который будет "обращать внимание" на аннотации:

@typeassert
def spam(x:int, y, z:int=42): 
    print(x, y, z)

    Возможная причина не использовать аннотации в том, что к каждому аргументу функции можно прикрепить только одну аннотацию. Поэтому если аннотации используются для проверки типов, они уже не могут быть использованы ни для чего другого. Также в этом случае декоратор @typeassert не будет работать с функциями, которые используют аннотации для других целей. Путем использования аргументов декоратора, как показано в решении, декоратор получает более общее назначение и может быть использован с любой функцией - даже с теми, которые используют аннотации.

    Дополнительную информацию об объектах сигнатур функций можно получить в PEP 362, а также в документации модуля inspect.

    На следующем шаге мы рассмотрим определение декораторов как части класса.




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