1. Жизненный цикл объекта: Рождение, жизнь и смерть

Частая ошибка новичков — называть __init__ конструктором. По факту, когда вы пишете obj = MyClass(), Python запускает цепочку событий, в которой __init__ играет лишь вторую скрипку. Давайте разберем, как объекты рождаются и умирают на самом деле.

__new__(cls): Настоящий конструктор

Именно __new__ отвечает за создание объекта. Он выделяет память и возвращает ту самую пустую «болванку», которая затем передается в __init__. Обратите внимание: метод принимает класс (cls), а не экземпляр (self), потому что экземпляра на этот момент физически не существует.

В 99% случаев вам не нужно трогать __new__. Базовый класс object отлично справляется сам. Но если вам нужно вмешаться в процесс создания — например, реализовать паттерн Singleton (когда в системе может существовать только один экземпляр класса), — без __new__ не обойтись.

class DatabaseConnection:
    _instance = None

    def __new__(cls, *args, **kwargs):
        if cls._instance is None:
            # Если объекта еще нет, создаем его через базовый класс
            cls._instance = super().__new__(cls)
        # Если есть — просто возвращаем существующий
        return cls._instance

# Проверяем:
db1 = DatabaseConnection()
db2 = DatabaseConnection()
print(db1 is db2)  # Выведет: True (это один и тот же объект в памяти)

__init__(self): Инициализатор

Итак, __new__ построил «коробку» и вернул её. Теперь за дело берется __init__. Его единственная задача — наполнить эту коробку данными (задать начальное состояние атрибутов).

Важное правило: __init__ никогда ничего не возвращает (строго говоря, он возвращает None). Если вы попытаетесь сделать return "success" внутри __init__, Python бросит исключение TypeError.

class User:
    def __init__(self, username: str):
        # Экземпляр (self) уже создан, мы просто вешаем на него атрибуты
        self.username = username

Краткая аналогия: __new__ — это застройщик, который строит дом. __init__ — дизайнер интерьера, который расставляет в нем мебель.

__del__(self): Разрушитель (и почему ему нельзя доверять)

Метод __del__ вызывается сборщиком мусора (Garbage Collector), когда счетчик ссылок на объект падает до нуля. Звучит как идеальное место, чтобы закрыть файл или разорвать соединение с базой данных, верно? Нет.

Использовать __del__ для критически важной логики — это выстрел себе в ногу. Вот почему:

  1. Непредсказуемость: Вы никогда не знаете точно, когда именно отработает сборщик мусора. Объект может провисеть в памяти гораздо дольше, чем вы ожидаете.

  2. Циклические ссылки: Если объекты ссылаются друг на друга, они могут вообще никогда не удалиться.

  3. Завершение программы: При выходе из скрипта Python не гарантирует вызов __del__ для всех оставшихся объектов. Глобальные переменные или модули, от которых зависит ваш деструктор, могут быть уничтожены раньше него, что приведет к Exception ignored in: <function ...>.

class DangerFileWrapper:
    def __init__(self, filename: str):
        self.file = open(filename, 'w')

    def __del__(self):
        # ПЛОХАЯ ПРАКТИКА! Файл может остаться открытым или 
        # упасть с ошибкой при завершении скрипта.
        self.file.close() 

Резюме: Забудьте про __del__ для управления ресурсами. Если вам нужно гарантированно что-то закрыть или очистить, всегда используйте контекстные менеджеры (оператор with и связку методов __enter__ / __exit__.

2. Лицо объекта: Как мы его видим

Вывели свежесозданный объект через print() и увидели унылое <__main__.MyClass object at 0x000001B...>? Это питоновский способ сказать: «Я понятия не имею, как это описать, вот тебе просто адрес в памяти».

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

__str__(self): Для людей

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

Он вызывается автоматически, когда вы используете print(obj), явно приводите объект к строке str(obj) или вставляете его в f-строку f"Мой объект: {obj}".

__repr__(self): Для разработчиков

__repr__ (от representation) — это технический паспорт объекта. Его задача — показать однозначную информацию для отладки и логирования.

Золотое правило хорошего __repr__: по возможности он должен возвращать строку, которая выглядит как валидный Python-код для создания этого же объекта. Идеальный сценарий — когда выполняется условие eval(repr(obj)) == obj.

Важный нюанс из практики: если вы поместите свои объекты в список и распечатаете его (print([obj1, obj2])), Python вызовет для элементов списка именно __repr__, а не __str__. Кроме того, если вы поленитесь и напишете только __repr__, Python автоматически будет использовать его как фоллбек (запасной вариант) для __str__. В обратную сторону это не работает.

Давайте посмотрим, как это выглядит в коде:

class User:
    def __init__(self, username: str, role: str):
        self.username = username
        self.role = role

    def __str__(self):
        # Причесанный вывод для UI / пользователя
        return f"Пользователь {self.username} (Роль: {self.role})"

    def __repr__(self):
        # Однозначный вывод для логов / консоли разработчика
        return f"User('{self.username}', '{self.role}')"

# Создаем пользователя
user = User("alice_neo", "admin")

# 1. Как это видит пользователь
print(user)  
# Вывод: Пользователь alice_neo (Роль: admin)
print(f"Привет, {user}!") 
# Вывод: Привет, Пользователь alice_neo (Роль: admin)!

# 2. Как это видит разработчик (или интерпретатор)
print(repr(user)) 
# Вывод: User('alice_neo', 'admin')

# 3. Вывод внутри коллекций всегда использует __repr__
print([user]) 
# Вывод: [User('alice_neo', 'admin')]

# Проверка "Золотого правила" (просто для демонстрации логики)
user_clone = eval(repr(user))
print(user_clone.username) 
# Вывод: alice_neo

Резюме: пишите __str__ так, чтобы это было легко читать. Пишите __repr__ так, чтобы по нему можно было за секунду понять точное состояние объекта при дебаге. Если лень писать оба — пишите только __repr__.

3. Перегрузка операторов: Арифметика и Сравнение

В Python нет операторов вроде + или == в их чистом виде. Когда вы пишете a + b, интерпретатор под капотом переводит это в вызов a.__add__(b). Если вы реализуете эти методы, ваши объекты станут полноправными участниками языка.

Математика: __add__ (+), __sub__ (-), __mul__ (*)

Главное правило обычной арифметики: она не должна менять исходные объекты. Результатом сложения двух объектов должен быть новый объект.

class Vector:
    def __init__(self, x: int, y: int):
        self.x = x
        self.y = y

    # Обычное сложение: Vector + Vector
    def __add__(self, other):
        if not isinstance(other, Vector):
            # Возвращаем NotImplemented, а не бросаем ошибку!
            # Это дает Python шанс попробовать другие способы сложения.
            return NotImplemented 
        return Vector(self.x + other.x, self.y + other.y)

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2)  # Вывод: Vector(4, 6)

Отраженная (Right) арифметика: __radd__

А что если мы захотим прибавить к нашему вектору просто число (скаляр), сдвинув его координаты? Допишем проверку в __add__. Но что произойдет при 2 + v1?

Число 2 (объект класса int) понятия не имеет, как прибавлять к себе Vector. Оно попытается вызвать свой __add__, потерпит фиаско и вернет NotImplemented. И вот тут Python делает «ход конем»: он смотрит на правый операнд (v1) и спрашивает: “Эй, а у тебя нет метода __radd__ (Right Add)?”.

class Vector:
    # ... (предыдущий код) ...

    # Сложение: Vector + int
    def __add__(self, other):
        if isinstance(other, int):
            return Vector(self.x + other, self.y + other)
        return NotImplemented

    # Сложение: int + Vector
    def __radd__(self, other):
        # Логика та же самая, поэтому просто вызываем __add__
        return self.__add__(other)

v1 = Vector(1, 2)
print(v1 + 10)  # Сработает __add__ -> Vector(11, 12)
print(10 + v1)  # Сработает __radd__ -> Vector(11, 12)

In-place арифметика: __iadd__ (+=)

Оператор += модифицирует объект на месте (in-place).

Если вы не напишете __iadd__, Python просто сделает v1 = v1 + v2 (создаст новый объект и перезапишет переменную). Но если ваш объект тяжелый (например, большая матрица данных), постоянное создание копий убьет производительность. __iadd__ позволяет изменить текущий объект и **обязательно должен вернуть self**.

    def __iadd__(self, other):
        if isinstance(other, Vector):
            self.x += other.x
            self.y += other.y
            return self # Обязательно возвращаем себя!
        return NotImplemented

v1 = Vector(1, 1)
print(id(v1)) # Запоминаем адрес в памяти
v1 += Vector(2, 2)
print(id(v1)) # Адрес тот же! Мы изменили сам объект, а не создали копию.

Сравнения: __eq__ (==), __lt__ (<), __gt__ (>)

По умолчанию объекты пользовательских классов равны (==), только если это один и тот же объект в памяти. Это редко бывает полезно.

Для полноценного сравнения нужно реализовать 6 методов: __eq__ (==), __ne__ (!=), __lt__ (<), __le__ (<=), __gt__ (>), __ge__ (>=). Писать их все — невероятно скучно и нарушает принцип DRY.

Тут на сцену выходит декоратор @total_ordering из встроенного модуля functools. Лайфхак для ленивых профи: достаточно реализовать метод __eq__ и один любой метод неравенства (например, __lt__). Всю остальную магию декоратор сгенерирует за вас.

from functools import total_ordering

@total_ordering
class Player:
    def __init__(self, name: str, score: int):
        self.name = name
        self.score = score

    # Проверка на равенство
    def __eq__(self, other):
        if not isinstance(other, Player):
            return NotImplemented
        return self.score == other.score

    # Проверка "Меньше" (Less Than)
    def __lt__(self, other):
        if not isinstance(other, Player):
            return NotImplemented
        return self.score < other.score

p1 = Player("NoobMaster", 100)
p2 = Player("ProGamer", 9000)

print(p1 == p2)  # False (из __eq__)
print(p1 < p2)   # True (из __lt__)
print(p1 >= p2)  # False (сгенерировано total_ordering!)

Маленький нюанс: @total_ordering слегка замедляет выполнение (из-за накладных расходов на вызов сгенерированных методов). В 95% случаев вы этого не заметите, но если пишете highload, возможно, придется написать все 6 методов руками.

4. Создаем свои коллекции и контейнеры

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

Длина: __len__ и сишная оптимизация

Вы никогда не задумывались, почему в Python мы пишем len(my_list), а не my_list.length(), как в Java или JavaScript? Гвидо ван Россум сделал так ради производительности.

История про CPython: Внутри интерпретатора (написанного на C) все базовые коллекции переменной длины (списки, строки, словари) представлены C-структурой PyVarObject. У этой структуры есть готовое поле ob_size. Когда вы вызываете len() для встроенного типа, Python не тратит процессорное время на поиск метода в словаре класса и его вызов. Он тупо читает значение ob_size напрямую из памяти. Это работает мгновенно.

Для ваших кастомных объектов функция len() попытается вызвать метод __len__. Он должен возвращать целое число (>= 0).

class Playlist:
    def __init__(self, name: str, tracks: list):
        self.name = name
        self.tracks = tracks

    def __len__(self):
        # Возвращаем количество треков
        return len(self.tracks)

my_music = Playlist("Road Trip", ["Track 1", "Track 2", "Track 3"])
print(len(my_music))  # Вывод: 3

Индексация и срезы: __getitem__, __setitem__, __delitem__

Чтобы ваш объект начал понимать квадратные скобки, ему нужна эта троица.

  • __getitem__(self, key): для чтения val = obj[key]

  • __setitem__(self, key, value): для записи obj[key] = value

  • __delitem__(self, key): для удаления del obj[key]

Важный нюанс со срезами: Когда вы запрашиваете срез my_obj[1:5], Python не вызывает метод несколько раз. Он вызывает __getitem__ ровно один раз, но вместо индекса передает туда специальный объект slice. Ваш код должен уметь с ним работать.

class CustomList:
    def __init__(self, *args):
        self._data = list(args)

    def __getitem__(self, index):
        # index может быть числом (int) или срезом (slice)
        if isinstance(index, slice):
            print(f"Запрошен срез: старт={index.start}, стоп={index.stop}, шаг={index.step}")
            return self._data[index]
        return self._data[index]

    def __setitem__(self, index, value):
        self._data[index] = value

custom = CustomList(10, 20, 30, 40, 50)
print(custom[1])     # Вывод: 20
print(custom[1:4:2]) # Запрошен срез: старт=1, стоп=4, шаг=2. Вывод: [20, 40]

Проверка вхождения: __contains__

Этот метод отвечает за то, как ваш объект реагирует на оператор in (например, if item in my_obj:).

Хак производительности: Если вы не напишете __contains__, Python не сдастся. Он начнет перебирать объект с нулевого индекса через __getitem__ (или __iter__, если он есть) до тех пор, пока не найдет совпадение или не словит IndexError. Это медленно — O(N).

Если у вас под капотом сложная структура или вы можете проверять наличие элемента за O(1) (например, используя внутренний словарь или множество), обязательно переопределите __contains__.

class AccessControl:
    def __init__(self, allowed_users: list):
        # Конвертируем список в set для мгновенного поиска за O(1)
        self._allowed = set(allowed_users)

    def __contains__(self, user: str):
        # Вызывается при 'user in access_control'
        return user in self._allowed

door = AccessControl(["admin", "root", "ceo"])

print("admin" in door)  # True (сработает быстро через set)
print("hacker" in door) # False

5. Управление доступом к атрибутам

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

__getattr__ vs __getattribute__: Классика собеседований

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

  • __getattribute__(self, name) (Диктатор): Вызывается всегда и абсолютно при любом обращении к атрибуту. Неважно, существует атрибут или нет. Если вы переопределили этот метод, Python делегирует ему все полномочия. Использовать его нужно крайне редко, потому что он ломает стандартный механизм поиска атрибутов и работает медленно.

  • __getattr__(self, name) (Служба спасения): Вызывается только тогда, когда Python уже поискал атрибут в объекте, поискал в классе, поискал в родительских классах, ничего не нашел и готов выбросить AttributeError. Это идеальное место для реализации динамических API (например, когда вы хотите превратить ключи словаря в атрибуты).

Опасная ловушка: Бесконечная рекурсия в __getattribute__. Если внутри __getattribute__ вы напишете self.name или даже self.__dict__, Python снова вызовет __getattribute__. Произойдет рекурсия, и программа упадет с RecursionError. Чтобы получить реальное значение, всегда нужно использовать метод базового класса: super().__getattribute__(name).

Посмотрим на безопасный и полезный паттерн с __getattr__:

class DynamicConfig:
    def __init__(self, data: dict):
        self._data = data

    def __getattr__(self, item):
        # Этот код сработает ТОЛЬКО если атрибута нет в классе
        if item in self._data:
            return self._data[item]
        # Если ключа нет и в словаре, честно бросаем ошибку
        raise AttributeError(f"В конфиге нет настройки '{item}'")

config = DynamicConfig({"host": "localhost", "port": 8080})

# 'port' нет как реального атрибута класса, но __getattr__ его перехватит:
print(config.port)  # Вывод: 8080
print(config.password) # Бросит AttributeError

__setattr__ и __delattr__: Перехват записи и удаления

Метод __setattr__(self, name, value) срабатывает каждый раз, когда вы пишете obj.name = value. Метод __delattr__(self, name) — при вызове del obj.name.

Здесь вас поджидает та же ловушка рекурсии, что и в __getattribute__. Если внутри __setattr__ вы попытаетесь сделать self.name = value, это снова вызовет __setattr__.

Как писать правильно? Есть два пути: либо обращаться напрямую к словарю атрибутов self.__dict__[name] = value (работает быстрее, но обходит логику родительских классов), либо использовать super().__setattr__(name, value) (безопаснее и предпочтительнее).

Давайте напишем класс, который запрещает изменять атрибуты после их создания (эдакий строгий Read-Only объект):

class ReadOnlyBox:
    def __init__(self, name: str):
        # Внутри __init__ тоже вызывается __setattr__!
        self.name = name

    def __setattr__(self, name, value):
        # Проверяем, есть ли уже такой атрибут
        if hasattr(self, name):
            raise TypeError(f"Атрибут '{name}' доступен только для чтения!")
        
        # Если атрибута еще нет (инициализация), используем super()
        super().__setattr__(name, value)

box = ReadOnlyBox("Секретная посылка")
print(box.name) # Вывод: Секретная посылка

# Попытка изменить существующий атрибут:
# box.name = "Пустая коробка" 
# Выбросит TypeError: Атрибут 'name' доступен только для чтения!

# А вот добавить новый — можно (если мы не запретили это отдельно)
box.weight = 10 

Резюме: Методы управления доступом — мощнейший инструмент. Используйте __getattr__ для изящной обработки отсутствующих атрибутов, а __setattr__ для валидации данных “на лету” (например, чтобы запретить присваивать отрицательные значения возрасту). И всегда держите в голове спасительный super(), чтобы не похоронить программу в бесконечной рекурсии.

6. Дескрипторы (Descriptor Protocol)

Вы когда-нибудь задумывались, как работают поля в моделях Django (name = models.CharField(...)) или SQLAlchemy? Или что на самом деле скрывается под капотом декораторов @property, @classmethod и @staticmethod?

Ответ один: дескрипторы.

Дескриптор — это не какая-то встроенная в CPython магия. Это просто класс, который реализует хотя бы один из трех dunder-методов: __get__, __set__ или __delete__. Суть дескриптора в том, чтобы взять на себя управление доступом к атрибуту другого класса.

Если в классе есть атрибут-дескриптор, стандартное поведение (чтение или запись из __dict__ объекта) перехватывается, и вызываются методы дескриптора.

__get__, __set__, __delete__: Пишем валидатор типов

Представьте, что мы пишем строгую систему, где атрибуты класса должны принимать только определенный тип данных. Делать это через @property для каждого поля — больно и многословно. Напишем универсальный дескриптор-валидатор.

Для создания полноценного дескриптора (data descriptor) нам понадобятся:

  1. __get__(self, instance, owner) — вызывается при чтении атрибута.

  2. __set__(self, instance, value) — вызывается при записи.

  3. __delete__(self, instance) — вызывается при удалении (через del).

Секретный бонус для Senior: начиная с Python 3.6 добавили метод __set_name__(self, owner, name). Он автоматически вызывается при создании класса и позволяет дескриптору узнать, как именно называется переменная, к которой его привязали.

class TypedValidator:
    """Дескриптор для строгой типизации атрибутов."""
    
    def __init__(self, expected_type):
        self.expected_type = expected_type

    def __set_name__(self, owner, name):
        # owner - это класс, где создали дескриптор (например, User)
        # name - имя переменной (например, 'age')
        self.public_name = name 
        # Придумываем приватное имя для хранения данных в словаре объекта
        self.private_name = '_' + name

    def __get__(self, instance, owner):
        # Если к дескриптору обращаются от самого класса (User.age), 
        # instance будет None. Возвращаем сам дескриптор.
        if instance is None:
            return self
        
        # Достаем значение из словаря конкретного объекта
        return getattr(instance, self.private_name)

    def __set__(self, instance, value):
        # ТА САМАЯ ВАЛИДАЦИЯ
        if not isinstance(value, self.expected_type):
            raise TypeError(
                f"Атрибут '{self.public_name}' должен быть типа "
                f"{self.expected_type.__name__}, а получено {type(value).__name__}!"
            )
        # Если всё ок, сохраняем значение в объект
        setattr(instance, self.private_name, value)


# Применяем наш дескриптор на практике
class User:
    # Дескрипторы создаются на уровне КЛАССА, а не в __init__
    name = TypedValidator(str)
    age = TypedValidator(int)

    def __init__(self, name, age):
        self.name = name  # Тут неявно вызовется TypedValidator.__set__
        self.age = age    # И тут тоже

# Проверяем работу
user = User("Алексей", 30)
print(user.name)  # Вывод: Алексей (сработал __get__)

user.age = 31     # Ок
# user.age = "тридцать"  
# Выбросит TypeError: Атрибут 'age' должен быть типа int, а получено str!

Почему это круто? Мы написали логику проверки один раз в классе TypedValidator, а теперь можем вешать её на сотни разных полей в десятках разных классов простым присваиванием поле = TypedValidator(тип). Это основа DRY (Don’t Repeat Yourself) в архитектуре фреймворков.

7. Итераторы и Генераторы

В Python нет классического цикла со счетчиком, как в C++ (for (int i = 0; i < 10; i++)). Вместо этого мы пишем элегантное for item in collection. Чтобы ваш собственный объект поддерживал такую фишку, он должен реализовать протокол итератора.

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

Разница между Iterable и Iterator

  1. Iterable (Итерируемый объект) — это коллекция данных. У него есть только один метод: __iter__. Этот метод обязан вернуть итератор.

  • Аналогия: Это книга. Вы можете её прочитать.

  1. Iterator (Итератор) — это объект, который помнит текущее состояние (на каком элементе мы остановились) и умеет выдавать следующий элемент. У него есть метод __next__ (выдает значение) и метод __iter__ (возвращает самого себя — self).

Стандартный список (list) — это Iterable, но не Iterator. Вы не можете вызвать next(my_list). Но когда вы пишете for x in my_list, цикл под капотом незаметно вызывает iter(my_list), получает итератор и уже у нее запрашивает значения.

Пишем свой итератор: __iter__ и __next__

Давайте напишем класс обратного отсчета.

class Countdown:
    """Итератор для обратного отсчета."""
    def __init__(self, start: int):
        self.current = start

    def __iter__(self):
        # Итератор обязан возвращать самого себя
        return self

    def __next__(self):
        # Если дошли до нуля — бросаем специальное исключение
        if self.current <= 0:
            raise StopIteration
        
        # Запоминаем текущее значение, уменьшаем счетчик и отдаем
        result = self.current
        self.current -= 1
        return result

# Как это выглядит для пользователя:
timer = Countdown(3)
for number in timer:
    print(number)
# Выведет: 3, 2, 1

Что на самом деле делает Python, когда видит цикл for? Чтобы понять силу этих dunder-методов, посмотрите на этот сниппет. Вот во что интерпретатор превращает ваш красивый цикл for:

timer = Countdown(3)

# Под капотом цикл for работает так:
iterator = iter(timer)  # Вызов timer.__iter__()
while True:
    try:
        number = next(iterator)  # Вызов iterator.__next__()
        print(number)
    except StopIteration:
        break  # Как только поймали StopIteration — выходим из цикла

Эволюция: Генераторы (Функции с yield)

Писать классы с __iter__ и __next__ руками — это хардкорно, но часто слишком многословно. Для 90% задач Гвидо придумал синтаксический сахар — генераторы.

Как только Python видит в функции ключевое слово yield, он автоматически превращает эту функцию в функцию-генератор. При ее вызове создается тот самый объект-итератор (с готовыми __iter__ и __next__), который замораживает свое состояние при каждом yield.

Тот же самый код отсчета через генератор:

def countdown_gen(start: int):
    while start > 0:
        yield start
        start -= 1

for number in countdown_gen(3):
    print(number)

Резюме: Хотите, чтобы по вашему объекту можно было пройтись циклом — реализуйте __iter__. Если объект содержит сложную логику обхода (например, пагинация по API или обход графа/дерева), выделите эту логику в отдельный класс-итератор с методами __iter__ и __next__, либо используйте yield для лаконичности. И главное — не забывайте бросать StopIteration, иначе ваш цикл никогда не закончится.

8. Контекстные менеджеры (Оператор with)

Любой программист знает: открыл файл — закрой, начал транзакцию в БД — сделай коммит или откат (rollback), открыл сетевой сокет — освободи порт.

Раньше для этого приходилось писать громоздкие конструкции try...except...finally. Оператор with позволяет спрятать всю эту логику очистки ресурсов под капот. Чтобы ваш класс научился работать с with, ему нужны всего два метода.

__enter__(self): Подготовка

Этот метод вызывается в самом начале блока with. Здесь вы открываете соединения, блокируете потоки (мьютексы) или инициализируете ресурсы.

Важно: То, что вернет __enter__, будет записано в переменную после ключевого слова as. Если вы пишете with open('file.txt') as f:, то f — это именно то, что вернул __enter__. Часто метод возвращает просто self.

__exit__(self, exc_type, exc_val, traceback): Уборка и обработка ошибок

Этот метод гарантированно вызывается при выходе из блока with. Даже если внутри блока произошел return, break или скрипт упал с критической ошибкой.

Обратите внимание на три аргумента. Если внутри блока with всё прошло гладко, в них прилетит (None, None, None). Но если случился сбой, __exit__ получит полную информацию об ошибке: тип исключения, само значение и объект трейсбека (историю вызовов).

Здесь кроется главная фишка, которую часто спрашивают на позицию Middle+: как подавить ошибку внутри __exit__? Если ваш __exit__ возвращает True, Python считает, что вы успешно разобрались с проблемой, и программа продолжит работу, как ни в чем не бывало. Если он возвращает False (или по умолчанию ничего не возвращает, то есть None), ошибка пробросится дальше и программа упадет.

Давайте напишем безопасный менеджер транзакций базы данных:

class DBTransaction:
    def __init__(self, db_connection):
        self.db = db_connection

    def __enter__(self):
        print(">> Начинаем транзакцию...")
        # Возвращаем объект базы, чтобы с ним можно было работать внутри with
        return self.db 

    def __exit__(self, exc_type, exc_val, traceback):
        if exc_type is not None:
            # Если случилась ошибка (любая)
            print(f"<< Ошибка {exc_type.__name__}: {exc_val}. Делаем ROLLBACK!")
            # Делаем откат изменений
            # self.db.rollback()
            
            # Если мы хотим «проглотить» ошибку и не дать скрипту упасть:
            return True 
            
            # Если хотим, чтобы программа упала с этой ошибкой, 
            # возвращаем False или просто ничего не пишем.
        else:
            # Ошибок нет
            print("<< Всё отлично, делаем COMMIT!")
            # self.db.commit()


# Эмулируем базу данных
mock_db = {"user": "admin"}

print("--- Успешный сценарий ---")
with DBTransaction(mock_db) as db:
    db["score"] = 100
    print("Работаем с БД...")

print("\n--- Сценарий с ошибкой ---")
with DBTransaction(mock_db) as db:
    db["score"] = 200
    print("Пытаемся поделить на ноль...")
    x = 1 / 0  # Происходит ZeroDivisionError

print("Скрипт жив и продолжает работу!")

Вывод в консоли:

--- Успешный сценарий ---
>> Начинаем транзакцию...
Работаем с БД...
<< Всё отлично, делаем COMMIT!

--- Сценарий с ошибкой ---
>> Начинаем транзакцию...
Пытаемся поделить на ноль...
<< Ошибка ZeroDivisionError: division by zero. Делаем ROLLBACK!
Скрипт жив и продолжает работу!

Резюме: Контекстные менеджеры — это самый питоничий (pythonic) способ управления ресурсами. Если ваш объект захватывает что-то, что потом нужно обязательно отпустить### 8. Контекстные менеджеры (Оператор with)

Помните, мы говорили, что методу __del__ нельзя доверять закрытие файлов и соединений с БД? Так вот, оператор with был придуман именно для того, чтобы решать эту проблему элегантно и на 100% надежно.

Протокол контекстного менеджера состоит всего из двух методов: __enter__ и __exit__. Если ваш класс реализует их, он автоматически получает способность управлять ресурсами (файлами, сокетами, блокировками потоков или транзакциями).

__enter__(self): Вход в контекст

Этот метод выполняется в самом начале, как только интерпретатор встречает блок with. Его главная задача — подготовить ресурс к работе.

Самое важное: то, что вернет метод __enter__, будет записано в переменную после ключевого слова as. Если вы не возвращаете ничего (или возвращаете None), то и в переменной будет None. Обычно здесь возвращают self или какой-то вспомогательный объект-обертку.

__exit__(self, exc_type, exc_val, exc_tb): Безопасный выход

Этот метод — бронежилет вашего кода. Он гарантированно выполнится при выходе из блока with, **даже если внутри блока произошел сбой, исключение или был вызван return / break**.

Метод принимает три аргумента:

  1. exc_type — класс исключения (например, ValueError).

  2. exc_val — само значение исключения (сообщение об ошибке).

  3. exc_tb — traceback (объект с историей вызовов).

Если внутри блока with всё прошло гладко, все три аргумента будут равны None.

Как правильно обрабатывать исключения внутри __exit__?

Здесь есть одно неочевидное правило, на котором часто спотыкаются:

  • Если метод __exit__ возвращает True, Python считает, что вы обработали ошибку внутри контекстного менеджера. Исключение будет «проглочено», и программа продолжит работу со следующей строки после блока with.

  • Если метод __exit__ возвращает False (или None, что происходит по умолчанию, если вы не написали return), исключение полетит дальше вверх по стеку вызовов, и программа упадет (если ошибку не поймает внешний try/except).

Давайте напишем имитацию транзакции базы данных, которая автоматически делает ROLLBACK при ошибке:

class DBTransaction:
    def __init__(self, db_name: str):
        self.db_name = db_name

    def __enter__(self):
        print(f"[{self.db_name}] Открываем соединение, начинаем транзакцию...")
        return self  # Этот объект попадет в переменную после 'as'

    def query(self, sql: str):
        print(f"Выполняем: {sql}")

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is not None:
            # Если внутри блока with произошла ошибка
            print(f"[{self.db_name}] ОШИБКА: {exc_val}. Делаем ROLLBACK!")
            # Возвращаем False, чтобы разработчик увидел ошибку в логах
            return False 
        
        # Если ошибок не было
        print(f"[{self.db_name}] Транзакция успешна. Делаем COMMIT!")
        print(f"[{self.db_name}] Закрываем соединение.\n")
        return True # (Хотя здесь можно вернуть и None, ошибки-то нет)

# Сценарий 1: Успех
with DBTransaction("PostgreSQL") as db:
    db.query("UPDATE users SET balance = balance - 100 WHERE id = 1")
    db.query("UPDATE users SET balance = balance + 100 WHERE id = 2")
# Вывод:
# [PostgreSQL] Открываем соединение, начинаем транзакцию...
# Выполняем: UPDATE users ...
# Выполняем: UPDATE users ...
# [PostgreSQL] Транзакция успешна. Делаем COMMIT!
# [PostgreSQL] Закрываем соединение.

# Сценарий 2: Провал
try:
    with DBTransaction("MySQL") as db:
        db.query("INSERT INTO orders (id, item) VALUES (1, 'Ноутбук')")
        raise RuntimeError("Сетевой кабель выдернули!")
except RuntimeError as e:
    print(f"Поймали ошибку снаружи: {e}")

# Вывод:
# [MySQL] Открываем соединение, начинаем транзакцию...
# Выполняем: INSERT INTO orders ...
# [MySQL] ОШИБКА: Сетевой кабель выдернули!. Делаем ROLLBACK!
# Поймали ошибку снаружи: Сетевой кабель выдернули!

Резюме: Контекстные менеджеры — это паттерн, который спасает вас от утечек памяти, зависших коннектов к БД и заблокированных файлов. Всегда используйте with там, где есть открытие и закрытие ресурса.

9. Вызываемые объекты: __call__

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

Если вы когда-нибудь проверяли объект с помощью встроенной функции callable(obj) и получали True, это значит лишь одно — под капотом у этого класса реализован метод __call__.

Зачем вообще нужно «вызывать» экземпляры классов? Есть два классических сценария, где __call__ справляется намного лучше обычных функций.

Сценарий 1: Замыкания с сохранением состояния

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

Можно сделать это через замыкания (closures) и ключевое слово nonlocal, но такой код быстро становится нечитаемым. Класс с __call__ решает эту задачу изящно: состояние хранится в атрибутах self, а действие происходит при вызове.

class RequestCounter:
    """Счетчик запросов, который помнит, сколько раз его вызывали."""
    def __init__(self, limit: int):
        self.limit = limit
        self.count = 0

    def __call__(self, url: str):
        if self.count >= self.limit:
            raise PermissionError("Превышен лимит запросов!")
        
        self.count += 1
        print(f"[{self.count}/{self.limit}] Отправка запроса на {url}...")
        # Здесь могла быть логика requests.get(url)

# Создаем экземпляр (состояние инициализировано)
fetcher = RequestCounter(limit=2)

# Вызываем ОБЪЕКТ как обычную функцию
fetcher("https://api.github.com")  # Вывод: [1/2] Отправка запроса...
fetcher("https://habr.com")        # Вывод: [2/2] Отправка запроса...
# fetcher("https://google.com")    # Выбросит PermissionError

Сценарий 2: Декораторы на основе классов

Написание сложных декораторов (особенно тех, которые принимают аргументы) с помощью обычных функций — это боль. Вам приходится писать «матрешку» из трех вложенных def, чтобы прокинуть параметры, саму функцию и её аргументы *args, kwargs.

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

Давайте напишем полезный декоратор Retry, который будет автоматически перезапускать функцию, если она упала с ошибкой:

import time
from functools import wraps

class Retry:
    """Декоратор для повторного выполнения функции при ошибке."""
    def __init__(self, attempts: int = 3, delay: float = 1.0):
        self.attempts = attempts
        self.delay = delay

    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, self.attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    print(f"Попытка {attempt} провалилась: {e}")
                    if attempt == self.attempts:
                        print("Лимит исчерпан. Падаем.")
                        raise
                    time.sleep(self.delay)
        return wrapper

# Используем наш класс как декоратор!
@Retry(attempts=3, delay=0.5)
def connect_to_unstable_api():
    print("Пытаемся подключиться...")
    raise ConnectionError("Сервер не отвечает")

# connect_to_unstable_api()

Резюме: Метод __call__ стирает грань между функциями и объектами. Используйте его всякий раз, когда вашей функции нужно «много памяти» (сложное состояние) или когда вы пишете продвинутые декораторы. Архитектура скажет вам спасибо за избавление от глобальных переменных и многоэтажных замыканий.

Уровень 4: Black Magic (Бонус для хардкорщиков)

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

10. Метапрограммирование и хуки классов

Долгое время в Python для изменения поведения классов на этапе их создания использовались метаклассы. Но метаклассы — это сложно, громоздко и чревато конфликтами (metaclass conflict). Начиная с Python 3.6 появилась изящная альтернатива.

__init_subclass__: Хук создания наследников

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

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

class PluginBase:
    # Глобальный реестр всех активных плагинов
    registry = {}

    def __init_subclass__(cls, plugin_name: str, **kwargs):
        # Обязательно вызываем super()
        super().__init_subclass__(**kwargs)
        
        # Регистрируем новый класс в словаре под его именем
        cls.registry[plugin_name] = cls
        print(f"Зарегистрирован новый плагин: {plugin_name} ({cls.__name__})")

# Разработчику плагина достаточно просто унаследоваться
class TelegramNotifier(PluginBase, plugin_name="telegram"):
    def send(self, msg):
        pass

class SlackNotifier(PluginBase, plugin_name="slack"):
    def send(self, msg):
        pass

# Проверяем реестр (он заполнился АВТОМАТИЧЕСКИ на этапе загрузки модуля)
print(PluginBase.registry)
# Вывод: {'telegram': <class '__main__.TelegramNotifier'>, 'slack': <class '__main__.SlackNotifier'>}

__hash__: Билет в словари и множества

Словари (dict) и множества (set) в Python работают на основе хеш-таблиц. Чтобы положить туда объект, он должен быть хешируемым.

Главное правило: **если вы переопределяете __eq__, Python автоматически устанавливает __hash__ = None**, делая объект нехешируемым. Почему? Потому что фундаментальный контракт языка гласит: если объекты равны (a == b), то их хеши тоже должны быть равны (hash(a) == hash(b)).

Если ваш класс изменяемый (mutable, как список), его нельзя хешировать. Если у него изменятся атрибуты, изменится и хеш, и объект навсегда “потеряется” в корзине хеш-таблицы. Но если ваш объект концептуально неизменяемый (например, точка координат), вы можете вернуть ему способность лежать в set.

class Point:
    def __init__(self, x: int, y: int):
        self._x = x
        self._y = y

    # Переопределили равенство
    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return self._x == other._x and self._y == other._y

    # Восстанавливаем хешируемость
    def __hash__(self):
        # Хешируем кортеж неизменяемых атрибутов
        return hash((self._x, self._y))

p1 = Point(1, 2)
p2 = Point(1, 2)

# Благодаря __eq__ они равны
print(p1 == p2) # True

# Благодаря __hash__ мы можем положить их в множество
unique_points = {p1, p2}
print(len(unique_points)) # Вывод: 1 (дубликат p2 отсеялся)

__slots__: Убийца __dict__ и спаситель памяти

Строго говоря, это не dunder-метод, а dunder-атрибут, но не упомянуть его в разговоре о магии нельзя.

По умолчанию Python хранит все атрибуты экземпляра класса в скрытом словаре __dict__. Словари в Python работают быстро, но жрут очень много оперативной памяти (из-за выделения памяти под пустые ячейки хеш-таблицы). Если у вас есть класс User и вы создаете 1 000 000 таких объектов, программа легко сожрет гигабайт RAM.

Если вы заранее знаете структуру класса и не собираетесь добавлять атрибуты “на лету” (user.new_attr = 10), вы можете запретить создание __dict__ с помощью __slots__.

import sys

class DefaultPoint:
    def __init__(self, x, y):
        self.x = x
        self.y = y

class SlottedPoint:
    # Явно указываем, какие атрибуты будут у класса
    __slots__ = ('x', 'y')

    def __init__(self, x, y):
        self.x = x
        self.y = y

p_default = DefaultPoint(10, 20)
p_slotted = SlottedPoint(10, 20)

# Сравниваем размер объектов в байтах
# Обратите внимание: sys.getsizeof не учитывает размер самого словаря __dict__, 
# поэтому реальная разница в памяти будет ЕЩЕ больше!
print(f"С __dict__: {sys.getsizeof(p_default)} байт + {sys.getsizeof(p_default.__dict__)} байт словарь")
print(f"С __slots__: {sys.getsizeof(p_slotted)} байт")

# Попытка добавить новый атрибут на лету
try:
    p_slotted.z = 30
except AttributeError as e:
    print(f"Ошибка: {e}") 
    # Выведет: Ошибка: 'SlottedPoint' object has no attribute 'z'

Резюме: Используйте __slots__, только если у вас создаются десятки и сотни тысяч объектов одного класса и вам критична оптимизация памяти. В повседневной разработке (например, для сервисов и контроллеров) гибкость __dict__ важнее сэкономленных килобайтов.

Заключение

Реализуя dunder-методы, вы начинаете писать по-настоящему элегантный, pythonic код.

Главное табу: никогда не придумывайте свои собственные методы с двойным подчеркиванием (например, __my_method__). Это пространство имен зарезервировано разработчиками языка. Если в новых версиях Python появится стандартный метод с таким же именем, ваш продакшен непредсказуемо сломается. Оставьте дандеры питону, а для своих приватных методов используйте одно подчеркивание _.

Анонсы новых статей, полезные материалы, а так же если в процессе у вас возникнут сложности, обсудить их или задать вопрос по этой статье можно в моём Telegram-сообществе. Смело заходите, если что-то пойдет не так, — постараемся разобраться вместе.