View on GitHub

Methodics of Python Intro

Practical materials for learning Python

В данной главе разговор пойдёт о специфичных для Python структурах, позволяющих добавить дополнительные возможности работе функций и классов.

Быстрый поиск


Декораторы

Для того, чтобы понять, что из себя представляют декораторы, разберём следующую задачу. Допустим, у нас есть некоторая функция.

>>> def fun(a, b):
... return a*2 + b
...

Необходимо написать для неё обёртку, которая будет печатать аргументы функции. Изменять исходный код функции, в общем случае, мы не можем. Можно сделать обёртку непосредственно для нашей функции:

>>> def newfun(*args):
... 	print(args)
... 	res = fun(*args)
... 	print(res)
... 	return res
...
>>> r = newfun(1, 2)
(1, 2)
4

Однако такая обёртка неуниверсальна, хотя задача обёртки может требоваться вне зависимости от оборачиваемой функции. Очевидное решение — описать отдельную функцию-обёртку, которая принимает на вход функцию и аргументы, выполняет её и дополнительно выполняет свою обёрточную задачу.

>>> def newfun(fun, *args):
... 	print(args)
... 	res = fun(*args)
... 	print(res)
...
>>> fun(1, 2)
4
>>> newfun(fun, 1, 2)
(1, 2)
4

Такое решение уже универсально, но слишком громоздко. И стоит заметить, что в обоих случаях придётся в каждом месте кода явно переписать вызовы функции на вызовы обёртки.

Для избавления от громоздкого вида реализуем обёртку в виде функционала: он будет получать изначальную функцию и возвращать другую, которая в замыкании хранит изначальную, выполняет обёрточную задачу и вызывает срабатывание изначальной.

>>> def newfun(fun):
... 	def wrap(*args):
...			print(args)
...			res = fun(*args)
...			print(res)
...			return res
... 	return wrap
>>> def fun(a, b):
... return 2*a + b
...
>>> newf = newfun(fun)
>>> newf(1, 2)
(1, 2)
4
4

Возвращаемый функционалом объект можно записать под любое имя, в том числе и под старое имя самой изначальной функции. Такой фунционал и называется декоратором.

>>> fun = newfun(fun)
>>> fun(1, 2)
(1, 2)
4
4

Для действия декоратора fun = newfun(fun) специально закреплён отдельный синтаксис @декоратор — после определения декоратора его можно сразу применять к функциям в момент их определения:

>>> @newfun
... def multi(a, b, c):
... return a*b * c
...
>>> multi
<function newfun.<locals>.wrap at 0x7f2553a7f060>
>>> multi(1, 2, 3)
(1, 2, 3)
6
6

Множественное применение декораторов

Декораторы можно применять последовательно, каждый внешний декоратор применяется к результату работы внутреннего:

>>> def dec1(fun):
...     def decor1(*args):
...         return f"${fun(*args)}$"
...     return decor1
...     
>>> def dec2(fun):
...     def decor2(*args):
...         return f"<<{fun(*args)}>>"
...     return decor2
...     
>>> @dec1
... @dec2
... def multi(a, b, c):
...     return a * b * c
...     
>>> multi(1, 2, 3)
'$<<6>>$'
>>> @dec2
... @dec1
... def multi(a, b, c):
...     return a * b * c
...     
>>> multi(1, 2, 3)
'<<$6$>>'
>>> 

Параметрический декоратор

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

>>> def repeater(n):
...     def dec(fun):
...         def newfun(*args):
...             return [fun(*args) for i in range(n)]
...         return newfun
...     return dec
... 
>>> def multi(a, b, c):
...     return a * b * c
... 
>>> decor = repeater(4)
>>> multi = decor(multi)
>>> multi(2, 3, 4)
[24, 24, 24, 24]
>>> 

Синтаксис для параметрического декоратора (выподняющий буквально две вышеописанные команды) следующий:

>>> @repeater(4)
... def multi(a, b, c):
... return a*b*c
...
>>> multi(2, 3, 4)
[24, 24, 24, 24]

Декораторы и классы

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

>>> def bracer(cls):
... 	cls.__str = cls.__str__
... 	cls.__str__ = lambda self: "[" + cls.__str(self) + "]"
... 	return cls
>>>
>>> @bracer
... class C:
... 	pass
>>> c = C()
>>>
>>> print(c)
[<__main__.C object at 0x7f2553a8df90>]

Кроме этого, поскольку сам класс является Callable-объектом (у него есть операция () и соответствующий ей оператор __call__), класс может выступать декоратором.

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

>>> class Counter:
...     counter = 0
...     def __init__(self, function):
...         self.__class__.counter += 1
...         self.counter = self.__class__.counter
...         self.function = function
...     def __call__(self, *args):
...         print(f"<decorated #{self.counter}>")
...         return self.function(*args)
...         
>>> a = Counter(lambda a, b: a + b)
>>> b = Counter(lambda a, b: a * b)
>>> a, b
(<__main__.Counter object at 0x7f548a1396a0>, <__main__.Counter object at 0x7f548a34f750>)
>>> a(2, 3)
<decorated #1>
5
>>> b(3, 4)
<decorated #2>
12
>>> 
>>> @Counter
... def fun(a, b):
...     return a ** b
...     
>>> fun(2, 3)
<decorated #3>
8
>>>
>>> class Stopper:
...     def __init__(self, stopper):
...         self.stopper = stopper
...     def __call__(self, function):
...         def decorated(*args):
...             if self.stopper:
...                 print(f"{self.stopper}: {function(*args)}")
...                 self.stopper -= 1
...             else:
...                 print("Function stopped")
...         return decorated
...         
>>> @Stopper(3)
... def fun1(a, b):
...     return a + b
...     
>>> @Stopper(5)
... def fun2(a, b):
...     return a * b
...     
>>> for i in range(7):
...     res = fun1(i, i), fun2(i, i)
...     print()
...     
3: 0
5: 0

2: 2
4: 1

1: 4
3: 4

Function stopped
2: 9

Function stopped
1: 16

Function stopped
Function stopped

Function stopped
Function stopped

>>> 

Дескрипторы

Если декораторы изменяют поведение функций и классов, то дескрипторы изменяют поведение полей классов. При обычном обращении к полям объектов за синтаксическим сахаром операторов скрываются методы __setattr__ для задания объекта, __getattr__ для получения его значения и __del__ для удаления объекта. Дескриптор — объект, который перекрывает вызов этих методов своими для реализации отдельной логики.

Протокол дескриптора Включает в себя три метода: __get__(), __set__() и __delete__(). В обязательном порядке в дескрипторе должен быть представлен хотя бы __get__(). Дексриптор описывается, как поле класса, и при обращении к нему привычными способами (например, через оператор .) вызывает срабатывание своих методов.

>>> class Dsc:
...     def __get__(self, obj, cls):
...         return f"<{obj._data}>"
...     def __set__(self, obj, val):
...         obj._data = val
...     def __delete__(self, obj):
...         obj._data *= 2
...         
>>> class C:
...     a = 10
...     b = Dsc()
...     
>>> c = C()
>>> c.a = 20
>>> c.b = 30
>>> 
>>> c.a, c.b
(20, '<30>')
>>> 
>>> del c.a
>>> c.a
10
>>> del c.b
>>> c.b
'<60>'
>>> 

Важно понимать, что для работы дескриптора где-то в памяти всё равно задаётся место хранения информации, которую мы в него подаём (исключением будут только дескрипторы, которые вообще ничего не хранят, а просто выполняют какие-то действия, например, печать полученного значения). Из самого дескриптора мы можем достать до него, его класса (self.__class__), объекта, в котором задан дескриптор (obj) и его класса (obj.__class__, cls). И в зависимости от того, как и куда сохраняет данные дескриптор, будет зависеть, будут ли разные дескрипторы пересекаться по памяти.

>>> class ObjDsc:
...     def __get__(self, obj, cls):
...         return f"<{obj._data}>"
...     def __set__(self, obj, val):
...         obj._data = val
...     def __delete__(self, obj):
...         obj._data  = None
...         
>>> class SelfDsc:
...     def __get__(self, obj, cls):
...         return f">{self._data}<"
...     def __set__(self, obj, val):
...         self._data = val
...     def __delete__(self, obj):
...         self._data = None
...         
>>> class C:
...     a = ObjDsc()
...     b = ObjDsc()
...     c = SelfDsc()
...     d = SelfDsc()
...     
>>> obj = C()
>>> obj.a, obj.b, obj.c, obj.d = 1, 2, 3, 4
>>> obj.a, obj.b, obj.c, obj.d
('<2>', '<2>', '>3<', '>4<')
>>> del obj.b, obj.d
>>> obj.a, obj.b, obj.c, obj.d
('<None>', '<None>', '>3<', '>None<')
>>> 

Возникает логичный вопрос: почему бы тогда не сохранять данные прямо в словарь основного объекта под то же самое имя? Сложность возникает в определении дескриптора в объекте. Поскольку дескриптор буквально перехватывает обращения команд, в основном объекте нет даже упоминания о существовании дескриптора в словаре: до туда просто не доходит работа. Из-за этого можно прямо под то же имя дескриптора задать в объекте другой элемент. Добраться до него можно будет только напрямую обратившись к словарю.

>>> getattr(obj, "c")
'<3>'
>>> 'c' in obj.__dict__
False
>>> obj.__dict__["c"] = "Hidden"
>>> getattr(obj, "c")
'<3>'
>>> obj.__dict__["c"]
'Hidden'
>>> 

Соответственно для того, чтобы сохранять данные прямо в словарь, надо в дескрипторе обращаться в него напрямую по его имени. А как узнать имя дескриптора? Для этого в протоколе описан ещё дин метод — __set_name__, как раз позвляющий обработать имя созданного объекта.

>>> class Dsc:
...     def __get__(self, obj, cls):
...         return f"<{obj.__dict__[self.key]}>"
...     def __set__(self, obj, val):
...         obj.__dict__[self.key] = val
...     def __set_name__(self, cls, name):
...         print(self, cls, name)
...         self.key = name
...         
>>> 
>>> class C:
...     d = Dsc()
...     e = Dsc()
...     
<__main__.Dsc object at 0x7fdcc11a1d30> <class '__main__.C'> d
<__main__.Dsc object at 0x7fdcc13b7750> <class '__main__.C'> e
>>> C.__dict__
mappingproxy({'__module__': '__main__', '__firstlineno__': 1, 'd': <__main__.Dsc object at 0x7fdcc11a1d30>, 'e': <__main__.Dsc object at 0x7fdcc13b7750>, '__static_attributes__': (), '__dict__': <attribute '__dict__' of 'C' objects>, '__weakref__': <attribute '__weakref__' of 'C' objects>, '__doc__': None})
>>> c = C()
>>> c.d, c.e = 1, 2
>>> c.__dict__
{'d': 1, 'e': 2}
>>>

Cлоты

Для понимания слотов обсудим следующую тему. Структура классов, а именно их словарей объектов, в Python представляет из себя множества пространств имён, вкладываемых и дополняющих друг друга. Каждый наследник, будь то наследуемый класс или просто экземпляр, притягивает себе полный словарь своего источника и как-то локально его дополняет. В старых версиях Python эта проблема была особенно острой. В экземплярах мы почти никогда не создаём новые элементы, не описанные в классе, при этом вынуждены хранить полный словарь со всеми его поддержками вставки, добавления и модификации элементов даже когда нам этого не нужно. Необходимо было найти решение, позволяющее работать с пусть и ограниченным по количеству и функционалу набором полей, однако делать это быстро и без копирования увесистого словаря самого класса. Этим решением стали слоты.

Слоты — это фиксированные пространство имён доступных полей объекта. В классе фиксируются изменяемые параметры (описываются списком имён в переменную __slots__) и неизменяемые (просто описанные переменные при наличии __slots__ в классе).

>>> class DictC:  
...     a, b, c = 1, 2, 3  
...
>>> class SlotC:  
...     __slots__ = "a", "b"  
...     c = 100500  
...  
>>>

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

>>> dc = DictC()  
>>> 
>>> dc.a, dc.b, dc.c  
(1, 2, 3)  
>>> dc.a, dc.b, dc.c = 6, 5, 4  
>>> dc.a, dc.b, dc.c  
(6, 5, 4)  
>>> dc.__dict__  
{'a': 6, 'b': 5, 'c': 4}
>>> 
>>> sc = SlotC()
>>> sc.a, sc.b = 20, 42  
>>> sc.c = 2000
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'SlotC' object attribute 'c' is read-only
>>> 
>>> sc.d = 13  
Traceback (most recent call last):  
  File "<python-input-20>", line 1, in <module>
    sc.d = 13
    ^^^^
AttributeError: 'SlotC' object has no attribute 'd' and no __dict__ for setting new attributes  
>>>
>>> sc.a, sc.b, sc.c  
(20, 42, 100500)  
>>> 
>>> sc.__dict__  
Traceback (most recent call last):  
  File "<python-input-13>", line 1, in <module>
    sc.__dict__
AttributeError: 'SlotC' object has no attribute '__dict__'. Did you mean: '__dir__'?
>>> 
>>> SlotC.a  
<member 'a' of 'SlotC' objects>  
>>> type(SlotC.a)  
<class 'member_descriptor'>  
>>>   

Сам класс при этом остаётся обычным классом, в нём доступны любые действия с любыми объектами.

>>> SlotC.d = 123
>>> sc.d
123
>>> sc.d = 124
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'SlotC' object attribute 'd' is read-only

Библиотека functools

Большое количество различных декораторовпредставлено в библиотеке functools. Рассмотрим лишь некоторые из них.

Декоратор @total_ordering позволяет настроить все методы сравнения над любым классом, в котором задан хотя бы один из них. Декоратор автоматически достроит все оставшиеся методы, позволив сравнивать объекты любым способом.

>>> from functools import total_ordering
>>> from itertools import accumulate
>>>
>>> @total_ordering
... class People:
...     def __init__(self, name, size):
...         self.name = name
...         self.size = size
...     def __lt__(self, other):
...         if self.size != other.size:
...             return self.size < other.size
...         names_number = [accumulate(ord(s) for s in name) for name in (self.name, other.name)]
...         return any(self_sum > other_sum \
...                 for self_sum, other_sum in zip(names_number[0], names_number[1]))
...                 
>>> a, b, c = People("Anya", 175), People("Borya", 180), People("Vanya", 175)
>>> b > a > c
True
>>> b > a
True
>>> a > c
True
>>> 

Следующий декоратор — @wraps(f). Он описывается в пользовательском декораторе над декорирующей функцией и подменяет информационные поля обёртки на поля функции-параметра. Это позволяет пробрасывать в декорируемую функцию комментарий-документацию, название исходной функции, её __repr__-строку.

>>> def decor(f):
...     def decorated(*args):
...         return f"<{f(*args)}>"
...     return decorated
...     
>>> @decor
... def fun(a, b):
...     """Super help-string for my function"""
...     return a + b
...     
>>> fun
<function decor.<locals>.decorated at 0x7f22a0ec2840>
>>> fun.__name__
'decorated'
>>> fun.__doc__
>>> 
>>> from functools import wraps
>>> def decor(f):
...     @wraps(f)
...     def decorated(*args):
...         return f"<{f(*args)}>"
...     return decorated
...     
>>> @decor
... def fun(a, b):
...     """Super help-string for my function"""
...     return a + b
...     
>>> fun
<function fun at 0x7f22a0ec2e80>
>>> fun.__name__
'fun'
>>> fun.__doc__
'Super help-string for my function'
>>> fun(2, 3)
'<5>'
>>> 

Функция partial по структуре и работе очень похожа декоратор, но не поддерживает синтаксис через @, поскольку принимает сразу и функцию, и аргументы. Она позволяет создать предзаполненную аргументами функцию и вернуть её, чтобы далее работать только с ограниченным числом параметров.

>>> int('10100')
10100
>>> int('10100', base=2)
20
>>> 
>>> from functools import partial
>>> b2int = partial(int, base=2)
>>> b2int('10100')
20
>>>

Для её работы дополнительно определён объект Placeholder. Объекты задаются в качестве параметров для partial наряду с фиксированными параметрами, и при вызове полученной предзаполненной функции на их места последовательно записываются аргументы.

>>> say_to_world = partial(print, Placeholder, Placeholder, "world!")
>>> say_to_world('Hello', 'dear')
Hello dear world!
>>>

Классовые и статические методы

Отдельного внимания заслуживают декораторы @classmethod и @staticmethod.

>>> class C:
...     def f1(*args):
...         print(args)
...     @classmethod
...     def f2(*args):
...         print(args)
...     @staticmethod
...     def f3(*args):
...         print(args)

Как мы помним, для самого класса его методы определяются просто функциями, при их вызове дополнительно не добавляются аргументы. Для экземпляра класса они явно определяются методами класса, при их вызове первым аргументом добавляется объект класса.

>>> print(C.f1, c.f1, sep='\n')
<function C.f1 at 0x7f8cd75423e0>
<bound method C.f1 of <__main__.C object at 0x7f8cd75392b0>>
>>> 
>>> res = C.f1(1), c.f1(1)
(1,)
(<__main__.C object at 0x7f8cd75392b0>, 1)
>>>

При определении классового метода в обоих случаях функции определяются как методы класса, первым параметром при вызове у обоих выступает ссылка на класс.

>>> print(C.f2, c.f2, sep='\n')
<bound method C.f2 of <class '__main__.C'>>
<bound method C.f2 of <class '__main__.C'>>
>>> 
>>> res = C.f2(2), c.f2(2)
(<class '__main__.C'>, 2)
(<class '__main__.C'>, 2)
>>>

Статический метод в обоих случаях определяется просто функцией и не добавляет никаких параметров при вызове.

>>> print(C.f3, c.f3, sep='\n')
<function C.f3 at 0x7f8cd7542520>
<function C.f3 at 0x7f8cd7542520>
>>> 
>>> res = C.f3(3), c.f3(3)
(3,)
(3,)
>>>

Декоратор property

Данный декоратор позволяет не описывать класс дескриптора, а сразу явно определить его в классе. Описываются методы дескриптора с единым именем, это имя дальеш используется для работы с дескриптором.

>>> class C:
...     def __init__(self):
...         self._var = None
... 
...     @property
...     def x(self):
...         """I'm the 'x' property."""
...         return self._var
... 
...     @x.setter
...     def x(self, value):
...         self._var = value
... 
...     @x.deleter
...     def x(self):
...         del self._var
...         
>>> c = C()
>>> c.x
>>> c.x = 20
>>> c.x
20
>>> del c.x
>>> '_var' in c.__dict__
False
>>> c.x = 42
>>> '_var' in c.__dict__
True
>>>