Класс-декоратор и декоратор класса

Эти две темы не так близки, как кажется, но я не мог разнести их в разные посты, лишая себя такого заголовка. Узнаем, как из класса сделать декоратор, и как написать декоратор для класса. Код примеров доступен в GIST под каждым из разделов.

Класс как декоратор

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

class Functor:
    def __call__(self, a, b):
        print(a * b)

f = Functor()
# вызов как будто функция
f(10, 20)

Как мы помним из https://tirinox.ru/parametric-decorator/ , справа от собачки в декораторе может стоять не только функция-декоратор, но любой вызываемый объект, например, функтор. __call__, которого будет принимать на вход единственный параметр – декорируемую функцию. На примере того же декоратора-повторителя вызовов:

from functools import wraps

class Repeater:
    def __init__(self, n):
        self.n = n

    def __call__(self, f):
        @wraps(f)
        def wrapper(*args, **kwargs):
            for _ in range(self.n):
                f(*args, **kwargs)
        return wrapper

@Repeater(3)
def foo():
    print('foo')

foo() 
# foo
# foo
# foo

Обратите внимание, что получился сразу декоратор с параметрами, где параметры – это всего лишь аргументы конструктора (нам ведь нужен экземпляр класса, а не сам класс). Поэтому если вы пишите сложный декоратор да еще и с параметрами, стоит присмотреться к его реализации классом-функтором, чтобы избежать трехэтажных вложенных функций.

Код здесь https://gist.github.com/tirinox/b6fd34de1b9de229ec2666f160c1ad82.

Декоратор для класса

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

  1. Декоратор класса имеет более глубокие возможности по влиянию на класс, он может удалять, добавлять, менять, переименовывать атрибуты и методы класса. Он может возвращать совершенно другой класс.
  2. Старый класс «затирается» и не может быть использован, как базовый класс при полиморфизме
  3. Декорировать можно любой класс одним и тем же универсальный декоратором, а при наследовании – мы ограничены иерархией классов и должны считаться с интерфейсами базовых классов.
  4. Презираются все принципы и ограничения ООП (из-за пунктов 1-3).

Декораторы классов полезны, чтобы внедриться в класс (иногда незаметно) и массово воздействовать на его методы и атрибуты. Типичный пример – создадим декоратор, который будет измерять время выполнения каждого метода класса. При этом сам класс никаких изменений не претерпит и не будет знать, что за ним следят:

import time

# это вспомогательный декоратор будет декорировать каждый метод класса, см. ниже
def timeit(method):
    def timed(*args, **kw):
        ts = time.time()
        result = method(*args, **kw)
        te = time.time()
        delta = (te - ts) * 1000
        print(f'{method.__name__} выполнялся {delta:2.2f} ms')
        return result
    return timed


def timeit_all_methods(cls):
    class NewCls:
        def __init__(self, *args, **kwargs):
            # проксируем полностью создание класса
            # как создали этот NewCls, также создадим и декорируемый класс
            self._obj = cls(*args, **kwargs)

        def __getattribute__(self, s):
            try:
                # папа, у меня есть атрибут s?
                x = super().__getattribute__(s)
            except AttributeError:
                # нет сынок, это не твой атрибут
                pass
            else:
                # да сынок, это твое
                return x

            # объект, значит у тебя должен быть атрибут s
            attr = self._obj.__getattribute__(s)

            # метод ли он?
            if isinstance(attr, type(self.__init__)):
                # да, обернуть его в измеритель времени
                return timeit(attr)
            else:
                # не метод, что-то другое
                return attr
    return NewCls


@time_all_class_methods
class Foo:
    def a(self):
        print("метод a начался")
        time.sleep(0.666)
        print("метод a кончился")


f = Foo()
f.a()

# метод a начался
# метод a кончился
# a 668.74 ms

Рассмотрим подробно части кода. timeit – это простой декоратор для функций, мы его уже умеем делать. Он нужен для того, чтобы декоратор класса timeit_all_methods обернул в timeit каждый метод декорируемого класса.

Декоратор timeit_all_methods содержит в себе определение нового класса NewCls и возвращает его вместо оригинального класса. Т.е. класс Foo – это уже не Foo, а NewCls. Конструктор класса NewCls принимает произвольные аргументы (ведь нам не известно заранее, какой конструктор у Foo, и у любого другого класса, который мы декорируем). Поэтому конструктор просто создает поле, где будет хранить экземпляр оригинального класса, и передает ему в конструктор все свои аргументы.

Самый сложный метод – __getattribute__ – он полон магии. Он вызывается, когда кто-то пытается обратиться как какому угодно атрибуту (полю, методы и т. п.) класса NewCls. Первым делом мы должны обратиться к своему родителю super() и спросить у него, не обладаем ли мы сами атрибутом, который проверяем. Именно к родителю, чтобы избежать рекурсии (иначе мы попадем в тот же метод, в котором уже находимся)! Если это наш атрибут (атрибут класса декоратора) – вернем его сразу, с ним ничего не надо делать. Иначе, вероятно, это атрибут исходного класса – получим его у него. И проверим его тип, сравним его с типом любого метода. Если тип – метод (bound method), то обернем его в декоратор timeit и вернем, иначе (это не метод, а свойство или статический метод) – вернем без изменений.

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

Задание на дом: создать класс декоратор класса, иначе говоря скрестить два раздела статьи и сделать класс-функтор, который может декорировать другой класс. Идея: декоратор, который измеряет время выполнения каждого метода, и печатает предупреждение, только если время выполнения было больше критического (параметр):

@TimeItCritical(critical_time=0.3)
class Foo:
    def a(self):
        print("медленный метод начался")
        time.sleep(1.0)
        print("медленный метод кончился")

    def b(self):
        time.sleep(0.1)
        print('быстрый метод')

f = Foo()
f.a()
f.b()

# медленный метод начался
# медленный метод кончился
# a выполнялся медленно 1.0011 s
# быстрый метод

Код доступен в https://gist.github.com/tirinox/507258b36e77dfec1448f8cf1d259356

🤩 Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway! 👈

Сокрытие в Python

Инкапсуляция – это упаковка данных в единый компонент. Сокрытие – это механизм, позволяющий ограничить доступ к данным вне какой-то области. Это немного разные принципы, хотя обычно о них говорят как об одном: мы что-то упаковали в класс, а потом сокрыли от нежелательного доступа из-вне. Можете козырнуть где-нибудь на собеседовании.

Скрывать от доступа из-вне имеет смысл, когда в вашем классе есть данные только для внутреннего использования, и вам нужно защитить от их изменения (будь-то случайного или преднамеренного) из других участков кода.

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

class PrivateClass:
    def __init__(self):
        self.secret_private_data = 5


c = PrivateClass()
c.secret_private_data = 10
print(c.secret_private_data)

Никаких вам private/protected.

Ладно, разработчики договорились, что если добавляем подчеркивание перед именем переменной или функции, то она становится как-бы приватной, только для внутренного использования классом. «Как-бы» потому что вы запросто по-прежнему можете менять ее.

class PrivateClass:
    def __init__(self):
        self._secret_private_data = 5


c = PrivateClass()
c._secret_private_data = 10
print(c._secret_private_data)

Максимум, что вам грозит – недовольство со стороны вашей IDE и коллег на code-review.Давайте добавим два знака подчеркивания перед именем. Будем более убедительными. Может, это немного контр-интуитивно, но этот трюк сработает. Такой атрибут останется видим внутри определения класса, но как-бы «пропадет» из видимости снаружи класса:

class PrivateClass:
    def __init__(self):
        self.__secret_private_data = 5

    def how_are_you(self):
        assert self.__secret_private_data == 5
        print('OK => ', self.__secret_private_data)


c = PrivateClass()
c.__secret_private_data = 10
print(c.__secret_private_data)
c.how_are_you()

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

class PrivateClass:
    def __init__(self):
        self.__secret_private_data = 5

    def how_are_you(self):
        assert self.__secret_private_data == 5
        print('OK => ', self.__secret_private_data)


c = PrivateClass()
c._PrivateClass__secret_private_data = 20
print(c._PrivateClass__secret_private_data)
c.how_are_you()

Python не смог окончательно спрятать атрибут.

Работает это так: если имя атрибута начинается с двух подчеркиваний, то Python автоматически переименует его по шаблону _ИмяКласса__ИмяАтрибута. Но! Внутри класса он будет доступен по старому имени: __ИмяАтрибута.

Эта смешная игра в прятки в действительности имеет смысл. От злонамеренного изменения мы не сможем защититься таким образом, а от случайного – да. Вообразим ситуацию с наследованием:

class Base:
    def __init__(self):
        self.__x = 5


class Derived(Base):
    def __init__(self):
        super().__init__()
        self.__x = 10  # не поменяет __x из класса Base

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

Тот же эффект будет и с методами, и с переменными на уровне класса (а не экземпляра).

class Foo:
    __hidden_var = 1
    
    def __hidden_method(self, x):
        print(x)
        
    def __init__(self):
        self.__hidden_method(self.__hidden_var)

__setattr__

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

class Foo:
    def __init__(self):
        self.field1 = 0
        
    def __setattr__(self, attr, value):
        if attr == 'field1':  # можно писать только в field1 
            self.__dict__[attr] = value
        else:
            raise AttributeError


f = Foo()
f.field1 = 10  # это можно
f.field2 = 20  # это нельзя

Но сразу скажу, обычно такую защиту никто не делает. Давайте без фанатизма…

Ограничение экспорта из модулей

Еще один механизм сокрытия – сокрытие сущностей (классов, функций и т. п.) на уровне модулей от бездумного импорта.

Напишем модуль. То есть создаем папку с именем testpack, туда кладем файл __init__.py

Пишем код в __init__.py:

def foo():
    print('foo')


def bar():
    print('bar')

__all__ = ['foo']

Далее в другом файле (из основной директории) мы попытаемся импортировать (все – *) из модуля testpack.

from testpack import *

foo()
bar()  # будет ошибка! NameError: name 'bar' is not defined

Имя foo было импортировано, так как мы упомянули его в переменной __all__, а bar – нет. Эта все та же защита от дурака, потому что если мы намеренно импортируем bar – ни ошибок, ни предупреждений не последует.

from testpack import foo, bar

foo()
bar()

Еще одно замечание: если имя переменной (класса, функции) начинается с подчеркивания, то оно так же не будет импортировано при import *, даже если мы не применяем __all__. И как всегда, вы можете импортировать его вручную.

Специально для канала PyWay.