Skip to content

Latest commit

 

History

History
707 lines (490 loc) · 34 KB

File metadata and controls

707 lines (490 loc) · 34 KB

Урок 12. Метод __call__: как сделать объект вызываемым


Что происходит, когда вы «вызываете» объект

В Python скобки после имени — это не просто синтаксис вызова функции. Скобки означают: «выполни этот объект». Когда вы пишете some_function(42), Python ищет у объекта some_function специальный метод, который определяет, что должно произойти при таком вызове.

Для обычных функций этот механизм скрыт внутри интерпретатора. Но для объектов пользовательских классов вы можете определить его сами — именно для этого существует метод __call__.

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

Когда вы пишете obj = MyClass(), вы тоже используете скобки — и здесь тоже задействован __call__, но на другом уровне. Класс в Python — это тоже объект, и у него есть свой __call__, который при вызове запускает __new__ и __init__.

Этот механизм управляется метаклассами — «классами для классов». Но сейчас это для нас избыточная сложность.

В рамках этого урока важно лишь понять управление созданием объектов — это отдельный уровень.

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

obj = MyClass()   # вызов класса — создание объекта, другой механизм
obj()             # вызов экземпляра — именно это определяет наш __call__

Разница принципиальная: в первом случае мы создаём объект, во втором — выполняем уже существующий объект. Это один и тот же синтаксис, но совершенно разная семантика.


Механика __call__: простейший пример

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

class Greeter:
    def __call__(self):
        # Этот метод выполняется каждый раз, когда объект вызывают со скобками
        print("Привет!")


g = Greeter()   # создаём объект

g()             # вызываем объект — Python автоматически вызывает __call__
g()             # можно вызывать сколько угодно раз

Вывод:

Привет!
Привет!

Python не требует явного вызова g.__call__() — он делает это автоматически при виде скобок после имени объекта. Вы можете убедиться, что это один и тот же механизм:

g()              # синтаксический сахар
g.__call__()     # явный вызов — результат идентичен

__call__ может принимать любые аргументы — точно так же, как обычная функция:

class Adder:
    def __call__(self, a, b):
        # Аргументы передаются прямо в __call__
        return a + b


add = Adder()
print(add(3, 7))    # 10
print(add(10, 20))  # 30

На этом этапе объект add ведёт себя неотличимо от обычной функции с точки зрения вызывающего кода. И это именно то, что нам нужно — но пока непонятно, зачем. Ответ на этот вопрос — в следующем разделе.


Зачем нужен объект, который можно вызвать

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

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

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

Покажем разницу на конкретном примере. Предположим, нам нужен счётчик, который можно вызывать как функцию.

Вариант с замыканием:

def make_counter():
    count = 0

    def counter():
        nonlocal count
        count += 1
        return count

    return counter


c = make_counter()
print(c())   # 1
print(c())   # 2

# Сбросить счётчик или получить текущее значение без вызова — непросто.
# Придётся возвращать несколько функций или использовать изменяемый контейнер.

Вариант с классом и __call__:

class Counter:
    def __init__(self, start=0, step=1):
        self.count = start   # начальное значение счётчика
        self.step = step     # шаг увеличения

    def __call__(self):
        # При вызове увеличиваем счётчик на шаг и возвращаем текущее значение
        self.count += self.step
        return self.count

    def reset(self):
        # Дополнительный метод — с замыканием это было бы неудобно
        self.count = 0

    def peek(self):
        # Получить текущее значение без увеличения
        return self.count


c = Counter(start=0, step=2)

print(c())      # 2
print(c())      # 4
print(c.peek()) # 4  — текущее значение без вызова
c.reset()
print(c.peek()) # 0  — сброшен

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

Проверить, является ли объект вызываемым, можно с помощью встроенной функции callable():

print(callable(c))          # True  — у Counter определён __call__
print(callable(Counter))    # True  — класс тоже вызываем (создание объекта)
print(callable(42))         # False — число не вызываемо
print(callable(print))      # True  — встроенная функция

Конфигурируемые вызываемые объекты

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

class Multiplier:
    def __init__(self, factor):
        # factor — множитель, задаётся один раз при создании объекта
        self.factor = factor

    def __call__(self, x):
        return x * self.factor


double = Multiplier(2)
triple = Multiplier(3)
ten_times = Multiplier(10)

print(double(5))     # 10
print(triple(5))     # 15
print(ten_times(5))  # 50

# Объект можно передавать туда, где ожидается функция
numbers = [1, 2, 3, 4, 5]
print(list(map(double, numbers)))    # [2, 4, 6, 8, 10]
print(list(map(triple, numbers)))    # [3, 6, 9, 12, 15]

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


__call__ и декораторы: от функции к классу

Одно из важнейших практических применений __call__ — реализация декораторов на основе классов.

Вы уже работали с декораторами как с функциями.

Теперь разберём, как тот же механизм реализуется через класс, и почему это часто удобнее.

Напомним структуру декоратора-функции:

def logger(func):
    def wrapper(*args, **kwargs):
        print(f"Вызов функции {func.__name__}")
        result = func(*args, **kwargs)
        print(f"Функция {func.__name__} завершена")
        return result
    return wrapper


@logger
def process_payment(amount):
    print(f"Обрабатываем платёж: {amount} руб.")
    return True

Когда Python видит декоратор @logger, он выполняет: process_payment = logger(process_payment). После этого process_payment указывает не на исходную функцию, а на wrapper.

Теперь реализуем тот же декоратор через класс:

class Logger:
    def __init__(self, func):
        # При декорировании Python передаёт функцию в __init__.
        # Мы сохраняем её как атрибут объекта.
        self.func = func
        self.call_count = 0   # счётчик вызовов — с замыканием это было бы неудобно

    def __call__(self, *args, **kwargs):
        # __call__ выполняется каждый раз, когда вызывают задекорированную функцию.
        self.call_count += 1
        print(f"[LOG] Вызов {self.func.__name__} (всего вызовов: {self.call_count})")
        result = self.func(*args, **kwargs)
        print(f"[LOG] {self.func.__name__} завершена, результат: {result}")
        return result


@Logger
def process_payment(amount):
    print(f"Обрабатываем платёж: {amount} руб.")
    return True

Разберём, что происходит по шагам:

# Шаг 1: Python видит @Logger и выполняет:
process_payment = Logger(process_payment)
# Теперь process_payment — это объект класса Logger,
# а исходная функция сохранена в self.func

# Шаг 2: Вызов задекорированной функции:
process_payment(1500)
# Python видит скобки и вызывает __call__ объекта Logger

Вывод:

[LOG] Вызов process_payment (всего вызовов: 1)
Обрабатываем платёж: 1500 руб.
[LOG] process_payment завершена, результат: True

process_payment(2000)

[LOG] Вызов process_payment (всего вызовов: 2)
Обрабатываем платёж: 2000 руб.
[LOG] process_payment завершена, результат: True

Ключевое преимущество над декоратором-функцией: счётчик call_count хранится в объекте и доступен напрямую:

print(process_payment.call_count)   # 2

С замыканием получить доступ к внутренней переменной счётчика было бы значительно сложнее.


Декоратор с параметрами через класс

Когда декоратор принимает параметры, структура на основе функций становится трёхуровневой и трудно читаемой. Класс решает эту проблему элегантно.

Сравним оба подхода на реальном примере — декораторе, проверяющем роль пользователя в веб-приложении:

Декоратор-функция с параметром:

def require_role(role):
    def decorator(func):
        def wrapper(user, *args, **kwargs):
            if user.get("role") != role:
                raise PermissionError(f"Требуется роль: {role}")
            return func(user, *args, **kwargs)
        return wrapper
    return decorator

Три уровня вложенности: require_roledecoratorwrapper. Это работает, но при усложнении логики становится трудно читаемым.

Тот же декоратор через класс:

class RequireRole:
    def __init__(self, role):
        # Параметр сохраняется при создании объекта-декоратора
        self.role = role

    def __call__(self, func):
        # __call__ вызывается в момент декорирования — получает функцию
        # и возвращает обёртку
        def wrapper(user, *args, **kwargs):
            if user.get("role") != self.role:
                raise PermissionError(
                    f"Доступ запрещён. Требуется роль: {self.role!r}, "
                    f"у пользователя: {user.get('role', 'не задана')!r}"
                )
            return func(user, *args, **kwargs)
        return wrapper

Использование в обоих случаях одинаково:

@RequireRole("admin")
def delete_user(user, user_id):
    print(f"Пользователь {user_id} удалён")
    return True

@RequireRole("moderator")
def ban_user(user, user_id):
    print(f"Пользователь {user_id} заблокирован")
    return True

Проверим работу:

admin_user = {"id": 1, "name": "Alice", "role": "admin"}
regular_user = {"id": 2, "name": "Bob", "role": "user"}

delete_user(admin_user, 99)
# Пользователь 99 удалён

try:
    delete_user(regular_user, 99)
except PermissionError as e:
    print(e)
# Доступ запрещён. Требуется роль: 'admin', у пользователя: 'user'

try:
    ban_user(admin_user, 99)
except PermissionError as e:
    print(e)
# Доступ запрещён. Требуется роль: 'moderator', у пользователя: 'admin'

Важно разобрать последовательность вызовов при использовании декоратора с параметрами:

@RequireRole("admin")
def delete_user(user, user_id): ...

# Шаг 1: RequireRole("admin") → создаётся объект, self.role = "admin"
# Шаг 2: объект вызывается с функцией delete_user → __call__(delete_user)
#         → возвращается wrapper
# Шаг 3: delete_user = wrapper
#
# При вызове delete_user(admin_user, 99):
# Шаг 4: выполняется wrapper(admin_user, 99)

В этом случае __call__ вызывается один раз — в момент декорирования, а не при каждом вызове функции. Это принципиально отличается от декоратора без параметров, где __call__ выполняется при каждом вызове задекорированной функции.


Практический пример: класс RateLimiter

Рассмотрим пример, который напрямую встречается в веб-разработке — ограничитель частоты запросов (rate limiter). Это компонент, который не позволяет одной и той же операции выполняться слишком часто. Его используют для защиты API от злоупотреблений, ограничения попыток входа в систему и контроля нагрузки на внешние сервисы.

import time


class RateLimiter:
    """
    Декоратор-ограничитель частоты вызовов.
    Разрешает не более max_calls вызовов в период period секунд.
    При превышении лимита выбрасывает исключение.
    """

    def __init__(self, max_calls, period):
        self.max_calls = max_calls   # максимальное число вызовов
        self.period = period         # период в секундах
        self.calls = []              # список временных меток вызовов

    def __call__(self, func):
        # __call__ вызывается при декорировании — получает функцию
        def wrapper(*args, **kwargs):
            now = time.time()

            # Убираем из истории вызовы, которые вышли за пределы периода
            self.calls = [t for t in self.calls if now - t < self.period]

            if len(self.calls) >= self.max_calls:
                raise RuntimeError(
                    f"Превышен лимит: не более {self.max_calls} вызовов "
                    f"за {self.period} сек. Повторите попытку позже."
                )

            # Фиксируем текущий вызов
            self.calls.append(now)
            return func(*args, **kwargs)

        return wrapper


# Разрешаем не более 3 запросов в 10 секунд
@RateLimiter(max_calls=3, period=10)
def send_email(address, subject):
    print(f"Письмо отправлено: {address}{subject}")
    return True


# Первые три вызова проходят
send_email("alice@example.com", "Подтверждение заказа")
send_email("bob@example.com", "Сброс пароля")
send_email("carol@example.com", "Добро пожаловать")

# Четвёртый вызов в течение 10 секунд — исключение
try:
    send_email("dave@example.com", "Уведомление")
except RuntimeError as e:
    print(e)
# Превышен лимит: не более 3 вызовов за 10 сек. Повторите попытку позже.

Этот пример демонстрирует, почему класс здесь уместнее функции: self.calls — это состояние, которое живёт между вызовами и сохраняется на весь период работы декоратора. С замыканием реализовать то же самое можно, но менее наглядно.


Практический пример: класс QueryFilter

Рассмотрим ещё один пример из веб-разработки — фильтр для выборки данных. Такие объекты встречаются при реализации поиска и фильтрации в API: клиент передаёт параметры фильтрации, сервер создаёт объект-фильтр и применяет его к набору данных.

class QueryFilter:
    """
    Фильтр для выборки записей по заданным критериям.
    Хранит условия фильтрации и применяется к любому итерируемому набору данных.
    """

    def __init__(self, **criteria):
        # Критерии фильтрации сохраняются в объекте при создании.
        # Например: QueryFilter(role="admin", is_active=True)
        self.criteria = criteria

    def __call__(self, record):
        # При вызове проверяем, соответствует ли запись всем критериям.
        # record — это словарь с данными одной записи.
        for field, expected_value in self.criteria.items():
            if record.get(field) != expected_value:
                return False
        return True

    def __repr__(self):
        criteria_str = ", ".join(f"{k}={v!r}" for k, v in self.criteria.items())
        return f"QueryFilter({criteria_str})"


# Имитируем таблицу пользователей
users = [
    {"id": 1, "name": "Alice",   "role": "admin",     "is_active": True},
    {"id": 2, "name": "Bob",     "role": "user",      "is_active": True},
    {"id": 3, "name": "Carol",   "role": "moderator", "is_active": False},
    {"id": 4, "name": "Dave",    "role": "user",      "is_active": False},
    {"id": 5, "name": "Eve",     "role": "admin",     "is_active": True},
    {"id": 6, "name": "Frank",   "role": "user",      "is_active": True},
]

# Создаём фильтры с разными критериями
active_admins = QueryFilter(role="admin", is_active=True)
inactive_users = QueryFilter(role="user", is_active=False)

print(active_admins)    # QueryFilter(role='admin', is_active=True)

# Применяем фильтр через filter() — объект ведёт себя как функция-предикат
result = list(filter(active_admins, users))
for u in result:
    print(u["name"], u["role"])
# Alice  admin
# Eve    admin

result = list(filter(inactive_users, users))
for u in result:
    print(u["name"], u["role"])
# Dave  user

Этот паттерн — «сконфигурированный объект, используемый как функция» — встречается в Django при написании кастомных менеджеров запросов и в DRF при создании фильтров для ViewSet.


Когда использовать __call__

Метод __call__ уместен в тех случаях, когда объект логически «делает одно главное действие», но при этом несёт в себе состояние или конфигурацию, необходимую для этого действия. Хороший признак того, что __call__ подходит: если вы обнаруживаете, что пишете замыкание и начинаете добавлять к нему вспомогательные функции для сброса состояния или доступа к внутренним значениям — класс с __call__ будет чище.

Типичные сценарии применения в веб-разработке: декораторы с состоянием (логирование, rate limiting, кеширование), объекты-предикаты для фильтрации данных, конфигурируемые обработчики событий, компоненты middleware.

Не стоит использовать __call__ там, где достаточно обычной функции или метода. Если объект не несёт состояния и не имеет конфигурации — лишняя обёртка в виде класса только усложнит код.


Итоги урока

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

Наиболее важное практическое применение — декораторы на основе классов. Декоратор без параметров сохраняет оборачиваемую функцию в __init__ и выполняет её в __call__. Декоратор с параметрами сохраняет параметры в __init__, а в __call__ получает функцию и возвращает обёртку.

Функция callable() позволяет проверить, реализует ли объект __call__, не вызывая его.


Вопросы

  1. Чем принципиально отличается вызов MyClass() от вызова obj(), если obj — экземпляр MyClass?
  2. Что вернёт callable(obj), если в классе объекта не определён метод __call__?
  3. В декораторе на основе класса без параметров — в какой момент вызывается __init__, а в какой __call__?
  4. В декораторе с параметрами на основе класса — что именно возвращает __call__? Почему?
  5. В чём главное преимущество класса с __call__ перед замыканием, когда декоратору нужно хранить состояние?
  6. Почему в декораторе без параметров __call__ принимает *args, **kwargs, а не конкретные аргументы?
  7. В примере с RateLimiter из лекции __call__ вызывается при декорировании и возвращает wrapper. Где хранится список временных меток вызовов self.calls? Будет ли он сбрасываться при каждом вызове задекорированной функции?
  8. Можно ли передать объект с определённым __call__ в функцию map() или filter() вместо обычной функции? Почему?

Задачи

Задача 1.

Класс Multiplier

Создайте класс Multiplier, объект которого при создании принимает один аргумент: factor (числовой коэффициент, int или float).

Реализуйте __call__, который принимает один аргумент x и возвращает x * factor.

Если x не является числом (int или float), выбрасывайте TypeError с сообщением "Аргумент должен быть числом".

Объект должен корректно работать с функцией map().

Пример использования:

double = Multiplier(2)
triple = Multiplier(3)

print(double(10))    # 20
print(triple(10))    # 30

numbers = [1, 2, 3, 4, 5]
print(list(map(double, numbers)))    # [2, 4, 6, 8, 10]

try:
    double("abc")
except TypeError as e:
    print(e)    # Аргумент должен быть числом

Задача 2.

Класс RenderList

Создайте класс RenderList, который при создании принимает один аргумент: list_type — строку "ul" или "ol".

Если передано любое другое значение, использовать "ul" по умолчанию.

Реализуйте __call__, который принимает список строк и возвращает корректную HTML-разметку: открывающий тег списка, каждый элемент в тегах <li>, закрывающий тег.

Метод должен возвращать строку, а не выводить её напрямую.

Пример использования:

render_ul = RenderList("ul")
render_ol = RenderList("ol")
render_bad = RenderList("div")  # некорректный тип — используем "ul"

menu = ["Главная", "О нас", "Контакты"]

print(render_ul(menu))
# <ul>
# <li>Главная</li>
# <li>О нас</li>
# <li>Контакты</li>
# </ul>

print(render_ol(["Шаг 1", "Шаг 2", "Шаг 3"]))
# <ol>
# <li>Шаг 1</li>
# <li>Шаг 2</li>
# <li>Шаг 3</li>
# </ol>

print(render_bad.list_type)   # ul

Задача 3.

Класс ImageFileAcceptor

Создайте класс ImageFileAcceptor, объект которого при создании принимает один аргумент: extensions — кортеж допустимых расширений файлов без точки, например ('jpg', 'jpeg', 'png').

Реализуйте __call__, который принимает имя файла в виде строки и возвращает True, если расширение файла входит в список допустимых, и False в противном случае.

Сравнение расширений должно быть нечувствительным к регистру. Используйте объект как предикат в функции filter().

Пример использования:

filenames = ["photo.jpg", "document.PDF", "avatar.PNG", "script.py", "logo.svg", "banner.JPEG"]

image_filter = ImageFileAcceptor(('jpg', 'jpeg', 'png'))

images = list(filter(image_filter, filenames))
print(images)
# ['photo.jpg', 'avatar.PNG', 'banner.JPEG']

print(image_filter("report.pdf"))    # False
print(image_filter("cover.JPG"))     # True

Задача 4.

Класс Logger (декоратор)

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

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

Номер вызова должен быть доступен через атрибут call_count объекта-декоратора.

Пример использования:

@Logger
def add(a, b):
    return a + b

@Logger
def greet(name):
    return f"Привет, {name}!"

print(add(2, 3))
# Функция add вызов 1
# 5

print(add(10, 20))
# Функция add вызов 2
# 30

print(greet("Alice"))
# Функция greet вызов 1
# Привет, Alice!

print(add.call_count)    # 2
print(greet.call_count)  # 1

Задача 5.

Класс Cache (декоратор с кешированием)

Реализуйте класс Cache, который работает как декоратор и кеширует результаты вызовов функции.

Все результаты должны храниться в словаре _cache объекта-декоратора — ключами служат аргументы вызова, значениями — возвращённые результаты.

При повторном вызове с теми же аргументами функция не должна выполняться заново — результат берётся из кеша.

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

При первом вычислении выводите строку Вычисляем..., при получении из кеша — Из кеша.

Пример использования:

@Cache
def add(a, b):
    return a + b

print(add(2, 3))    # Вычисляем... / 5
print(add(2, 3))    # Из кеша / 5
print(add(10, 5))   # Вычисляем... / 15
print(add(10, 5))   # Из кеша / 15
print(add(2, 3))    # Из кеша / 5

# Вызов с изменяемым аргументом — кеш не используется, ошибки нет
print(add(2, [3, 4]))   # Вычисляем... — список нельзя сохранить как ключ словаря

Предыдущий урок | Следующий урок