Python Core

Python Core #

1. Что такое Дескрипторы #

Дескриптор — это объект, который управляет доступом к атрибуту другого объекта. Проще говоря, дескриптор позволяет перехватить операции:

obj.attr        # чтение
obj.attr = 10   # запись
del obj.attr    # удаление

Для этого объект должен реализовать хотя бы один из специальных методов:

__get__()
__set__()
__delete__()

В официальной документации Python дескриптор описывается как объект-атрибут, у которого поведение доступа переопределено через методы дескрипторного протокола: __get__, __set__, __delete__.

Пример:

class AgeDescriptor:
    def __get__(self, instance, owner):
        return instance._age

    def __set__(self, instance, value):
        if value < 0:
            raise ValueError("Возраст не может быть отрицательным")
        instance._age = value


class User:
    age = AgeDescriptor()

    def __init__(self, age):
        self.age = age


user = User(20)

print(user.age)  # вызывается __get__

user.age = 25    # вызывается __set__

user.age = -5    # ValueError

Что здесь происходит:

user.age

Python видит, что age в классе User — это объект с методом __get__, поэтому вызывает:

AgeDescriptor.__get__(...)

А когда выполняется:

user.age = 25

Python вызывает:

AgeDescriptor.__set__(...)

Главное правило #

Дескриптор должен лежать в классе, а не в конкретном объекте. Правильно:

class User:
    age = AgeDescriptor()

Неправильно для обычного применения дескриптора:

user.age = AgeDescriptor()

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

Методы дескриптора #

def __get__(self, instance, owner):
    ...

Вызывается при чтении атрибута:

user.age

instance — объект, через который обращаются к атрибуту.
owner — класс владельца, например User.

def __set__(self, instance, value):
    ...

Вызывается при записи:

user.age = 30

value — новое значение.

def __delete__(self, instance):
    ...

Вызывается при удалении:

del user.age

Data descriptor и non-data descriptor #

В Python есть два основных типа дескрипторов.

1. Data descriptor #

Это дескриптор, у которого есть __set__ или __delete__.

class Descriptor:
    def __get__(self, instance, owner):
        ...

    def __set__(self, instance, value):
        ...

Такой дескриптор имеет высокий приоритет. Даже если в __dict__ объекта есть атрибут с таким же именем, data descriptor будет главнее. В документации Python указано, что data descriptors переопределяют значения в словаре экземпляра.

2. Non-data descriptor #

Это дескриптор, у которого есть только __get__.

class Descriptor:
    def __get__(self, instance, owner):
        ...

Такой дескриптор может быть перекрыт атрибутом самого объекта. В документации Python указано, что non-data descriptors могут быть переопределены экземплярами.

property — это тоже дескриптор #

Когда ты пишешь:

class User:
    def __init__(self, age):
        self._age = age

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if value < 0:
            raise ValueError("Возраст не может быть отрицательным")
        self._age = value

Это работает через механизм дескрипторов.

То есть:

user.age

вызывает логику чтения, а:

user.age = 30

вызывает логику записи.

Официальная документация Python прямо указывает, что property() реализован как data descriptor.

Методы класса тоже работают через дескрипторы #

Обычные методы в Python тоже связаны с дескрипторами.

Пример:

class User:
    def say_hello(self):
        print("Hello")


user = User()
user.say_hello()

Когда ты обращаешься к:

user.say_hello

работает как:

User.say_hello(user)

Документация Python указывает, что методы, включая staticmethod и classmethod, реализованы как non-data descriptors.

Где дескрипторы используются на практике #

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

- @property
- @classmethod
- @staticmethod
- обычные методы класса
- ORM-поля в Django / SQLAlchemy
- валидация атрибутов
- ленивые вычисления
- контроль доступа к данным

Например, ORM-поле условно можно представить так:

class User:
    name = StringField()
    age = IntegerField()

StringField и IntegerField могут быть объектами, которые управляют чтением, записью, валидацией и преобразованием значений.

Минимальный пример своего дескриптора #

class PositiveNumber:
    def __set_name__(self, owner, name):
        self.private_name = "_" + name

    def __get__(self, instance, owner):
        return getattr(instance, self.private_name)

    def __set__(self, instance, value):
        if value <= 0:
            raise ValueError("Значение должно быть положительным")
        setattr(instance, self.private_name, value)


class Product:
    price = PositiveNumber()

    def __init__(self, price):
        self.price = price


product = Product(100)

print(product.price)  # 100

product.price = 200
print(product.price)  # 200

product.price = -50   # ValueError

Здесь PositiveNumber контролирует атрибут price.

Когда создаётся класс Product, метод:

__set_name__(self, owner, name)

позволяет дескриптору узнать, под каким именем он был добавлен в класс. В документации Python этот механизм называется automatic name notification: при создании класса дескриптор может получить имя переменной, к которой он привязан.


2. Когда и при каких операциях с атрибутом срабатывает дескриптор #

Когда срабатывает дескриптор #

Дескриптор срабатывает, когда Python работает с атрибутом через точку:

obj.attr
obj.attr = value
del obj.attr

Но важно: дескриптор должен быть найден в классе, а не просто лежать в __dict__ конкретного объекта. Официальная документация Python описывает дескрипторный протокол через методы __get__, __set__, __delete__; объект, определяющий один из этих методов, может переопределять стандартное поведение доступа к атрибуту.

1. Чтение атрибута: obj.attr #

При чтении атрибута вызывается:

descriptor.__get__(instance, owner)

Пример:

class Descriptor:
    def __get__(self, instance, owner):
        print("__get__ вызван")
        return 100


class User:
    age = Descriptor()


user = User()

print(user.age)

Результат:

__get__ вызван
100

То есть при операции:

user.age

Python находит age в классе User, видит, что это объект с методом __get__, и вызывает дескриптор.

2. Запись атрибута: obj.attr = value #

При записи вызывается:

descriptor.__set__(instance, value)

Пример:

class Descriptor:
    def __get__(self, instance, owner):
        return instance._age

    def __set__(self, instance, value):
        print("__set__ вызван")
        instance._age = value


class User:
    age = Descriptor()


user = User()

user.age = 20
print(user.age)

Результат:

__set__ вызван
20

Операция:

user.age = 20

вызывает:

Descriptor.__set__(...)

3. Удаление атрибута: del obj.attr #

При удалении вызывается:

descriptor.__delete__(instance)

Пример:

class Descriptor:
    def __get__(self, instance, owner):
        return instance._age

    def __set__(self, instance, value):
        instance._age = value

    def __delete__(self, instance):
        print("__delete__ вызван")
        del instance._age


class User:
    age = Descriptor()


user = User()
user.age = 20

del user.age

Результат:

__delete__ вызван

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

Работает:

class User:
    age = Descriptor()

Потому что age лежит в классе User.

А вот так дескрипторный механизм не сработает как обычный дескриптор:

user = User()
user.age = Descriptor()

Здесь age просто станет обычным атрибутом объекта user.

Приоритеты при чтении атрибута #

При операции:

user.age

Python примерно проверяет атрибуты в таком порядке:

  1. Data descriptor в классе
  2. Атрибут в user.__dict__
  3. Non-data descriptor в классе
  4. Обычный атрибут класса
  5. __getattr__, если атрибут не найден

Обращение через класс: Class.attr #

Дескриптор может сработать не только при обращении через объект:

user.age

но и при обращении через класс:

User.age

Пример:

class Descriptor:
    def __get__(self, instance, owner):
        print(instance)
        print(owner)
        return 100


class User:
    age = Descriptor()


print(User.age)

Здесь instance будет None, потому что обращение идёт через класс, а не через конкретный объект.

Пример с логом всех операций #

class LoggedAttribute:
    def __get__(self, instance, owner):
        print("Чтение атрибута")
        return instance._value

    def __set__(self, instance, value):
        print("Запись атрибута")
        instance._value = value

    def __delete__(self, instance):
        print("Удаление атрибута")
        del instance._value


class Example:
    value = LoggedAttribute()


obj = Example()

obj.value = 10      # Запись атрибута
print(obj.value)    # Чтение атрибута
del obj.value       # Удаление атрибута


3. Магический метод __get__ #

__get__ — это метод дескриптора, который срабатывает при чтении атрибута.

Формат:

def __get__(self, instance, owner):
    ...

Он используется, когда Python видит обращение вида:

obj.attr

или:

Class.attr

Официальная документация Python описывает __get__ как часть descriptor protocol вместе с __set__ и __delete__. Дескриптор может быть вызван напрямую, но обычно он вызывается автоматически при доступе к атрибуту через точку.

Параметры __get__ #

def __get__(self, instance, owner):
    ...
ПараметрЧто означает
selfсам объект-дескриптор
instanceобъект, через который обратились к атрибуту
ownerкласс, которому принадлежит дескриптор
Пример:
class Descriptor:
    def __get__(self, instance, owner):
        print("self:", self)
        print("instance:", instance)
        print("owner:", owner)
        return "value"


class User:
    name = Descriptor()


user = User()

print(user.name)

При вызове:

user.name

Python фактически делает примерно следующее:

User.__dict__["name"].__get__(user, User)

То есть:

self      -> Descriptor()
instance  -> user
owner     -> User

Обращение через объект #

class Descriptor:
    def __get__(self, instance, owner):
        return "Чтение через объект"


class User:
    name = Descriptor()


user = User()

print(user.name)

Результат:

Чтение через объект

Здесь instance — это объект user.

Обращение через класс #

class Descriptor:
    def __get__(self, instance, owner):
        print(instance)
        print(owner)
        return "Чтение через класс"


class User:
    name = Descriptor()


print(User.name)

Результат будет таким:

None
<class '__main__.User'>
Чтение через класс

Когда обращение идёт через класс, instance будет None, потому что конкретного объекта нет.

То есть Python вызывает:

User.__dict__["name"].__get__(None, User)

Зачем проверять instance is None #

Часто в __get__ пишут такую проверку:

class Descriptor:
    def __get__(self, instance, owner):
        if instance is None:
            return self

        return instance.__dict__["value"]

Это нужно, чтобы при обращении через класс вернуть сам дескриптор, а при обращении через объект вернуть значение

Пример:

class Field:
    def __get__(self, instance, owner):
        if instance is None:
            return self

        return instance.__dict__["value"]


class User:
    name = Field()


user = User()
user.__dict__["value"] = "Alex"

print(user.name)  # Alex
print(User.name)  # <__main__.Field object ...>

__get__ отвечает только за чтение #

Метод __get__ срабатывает при чтении, для записи и удаления используются другие методы

Пример с хранением значения #

class NameDescriptor:
    def __get__(self, instance, owner):
        if instance is None:
            return self

        return instance.__dict__["name"]

    def __set__(self, instance, value):
        instance.__dict__["name"] = value


class User:
    name = NameDescriptor()


user = User()

user.name = "Alex"

print(user.name)

Что происходит:

user.name = "Alex"

Вызывает:

NameDescriptor.__set__(user, "Alex")

А потом:

print(user.name)

Вызывает:

NameDescriptor.__get__(user, User)

Почему значение лучше хранить в instance.__dict__ #

Дескриптор обычно один на весь класс. Например:

class User:
    name = NameDescriptor()

Объект NameDescriptor() создаётся один раз и лежит в классе User.

Если хранить значение внутри самого дескриптора:

class BadDescriptor:
    def __get__(self, instance, owner):
        return self.value

    def __set__(self, instance, value):
        self.value = value

то все объекты будут делить одно значение:

class User:
    name = BadDescriptor()


user1 = User()
user2 = User()

user1.name = "Alex"
user2.name = "Bob"

print(user1.name)  # Bob
print(user2.name)  # Bob

Потому что name = BadDescriptor() один общий для всего класса.

Правильнее хранить значение в конкретном объекте:

class GoodDescriptor:
    def __get__(self, instance, owner):
        return instance.__dict__["name"]

    def __set__(self, instance, value):
        instance.__dict__["name"] = value

Тогда у каждого объекта будет своё значение.

Улучшенный вариант через __set_name__ #

Чтобы не писать имя атрибута вручную, используют __set_name__.

class Field:
    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, instance, owner):
        if instance is None:
            return self

        return instance.__dict__[self.name]

    def __set__(self, instance, value):
        instance.__dict__[self.name] = value


class User:
    name = Field()
    age = Field()


user = User()

user.name = "Alex"
user.age = 20

print(user.name)  # Alex
print(user.age)   # 20

Метод __set_name__ вызывается при создании класса и позволяет дескриптору узнать имя атрибута, к которому он привязан. В Python Descriptor HOWTO этот механизм описан как автоматическое уведомление дескриптора о его имени.

Пример с валидацией #

class PositiveNumber:
    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, instance, owner):
        if instance is None:
            return self

        return instance.__dict__[self.name]

    def __set__(self, instance, value):
        if value <= 0:
            raise ValueError("Значение должно быть положительным")

        instance.__dict__[self.name] = value


class Product:
    price = PositiveNumber()

    def __init__(self, price):
        self.price = price


product = Product(100)

print(product.price)  # 100

product.price = -10   # ValueError

Здесь __get__ отвечает за чтение:

product.price

А __set__ отвечает за проверку и запись:

product.price = 100

Связь с методами класса #

Обычные методы класса тоже работают через дескрипторы.

class User:
    def say_hello(self):
        print("Hello")


user = User()

user.say_hello()

Когда ты пишешь:

user.say_hello

Python достаёт функцию say_hello из класса User. Функция является дескриптором, и её __get__ превращает обычную функцию в bound method, то есть метод, уже связанный с объектом user.

Поэтому:

user.say_hello()

по смыслу близко к:

User.say_hello(user)


4. Магический метод __del__ #

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

Его часто называют деструктором, но в Python это не совсем такой же деструктор, как в C++.

Синтаксис:

class SomeClass:
    def __del__(self):
        ...

Официальная документация Python описывает object.__del__(self) как метод, вызываемый, когда экземпляр должен быть уничтожен. Также документация предупреждает, что вызов __del__ не всегда гарантирован при завершении интерпретатора.

Простой пример

class User:
    def __init__(self, name):
        self.name = name
        print(f"Создан объект {self.name}")

    def __del__(self):
        print(f"Удаляется объект {self.name}")


user = User("Alex")

del user

Примерный результат:

Создан объект Alex
Удаляется объект Alex

Когда выполняется:

del user

удаляется имя user, которое ссылалось на объект. Если других ссылок на этот объект больше нет, объект может быть уничтожен, и тогда вызывается __del__.

Важный момент: del obj не всегда сразу вызывает __del__ #

Команда:

class User:
    def __del__(self):
        print("Объект удалён")


user1 = User()
user2 = user1

del user1

print("После del user1")

del user2

print("После del user2")

Результат будет примерно таким:

После del user1
Объект удалён
После del user2

Почему так:

user1 = User()
user2 = user1

Здесь один объект, но две ссылки на него.

После:

del user1

объект ещё жив, потому что на него всё ещё ссылается user2.

Только после:

del user2

ссылок больше нет, и объект может быть уничтожен.

__del__ не предназначен для обычного управления ресурсами #

Плохая идея:

class FileWrapper:
    def __init__(self, path):
        self.file = open(path)

    def __del__(self):
        self.file.close()

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

Лучше использовать контекстный менеджер:

with open("file.txt") as file:
    data = file.read()

Или свой класс с __enter__ / __exit__:

class FileWrapper:
    def __init__(self, path):
        self.path = path
        self.file = None

    def __enter__(self):
        self.file = open(self.path)
        return self.file

    def __exit__(self, exc_type, exc_value, traceback):
        self.file.close()


with FileWrapper("file.txt") as file:
    data = file.read()

Так ресурс закрывается предсказуемо.

Когда __del__ реально может сработать #

Обычно __del__ вызывается, когда:

  1. На объект больше нет ссылок.
  2. Сборщик мусора решил удалить объект.
  3. Интерпретатор завершает работу и уничтожает оставшиеся объекты.

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

Проблема с циклическими ссылками #

Пример:

class Node:
    def __init__(self):
        self.other = None

    def __del__(self):
        print("Node удалён")


a = Node()
b = Node()

a.other = b
b.other = a

del a
del b

Здесь объекты ссылаются друг на друга:

a -> b
b -> a

Даже после del a и del b внутри объектов остаётся цикл ссылок. Современный Python умеет собирать такие циклы, но всё равно __del__ остаётся механизмом, с которым нужно быть осторожным. В документации вместо сложной логики в __del__ часто рекомендуют использовать weakref.finalize как более управляемый механизм финализации.

Отличие __del__ от __delete__ #

Их легко перепутать. __del__ — метод объекта, вызываемый при уничтожении объекта:

class User:
    def __del__(self):
        print("Объект уничтожается")

__delete__ — метод дескриптора, вызываемый при удалении атрибута:

class Descriptor:
    def __delete__(self, instance):
        print("Атрибут удаляется")

Сравнение: #

МетодКогда вызывается
__del__Когда объект уничтожается
__delete__Когда выполняется del obj.attr для дескриптора
__delattr__Когда удаляется любой атрибут объекта через del obj.attr

Отличие __del__ от __delattr__ #

__delattr__ вызывается при удалении атрибута:

class User:
    def __delattr__(self, name):
        print(f"Удаляется атрибут {name}")
        super().__delattr__(name)


user = User()
user.name = "Alex"

del user.name

Результат:

Удаляется атрибут name

А __del__ относится не к атрибутам, а ко всему объекту:

class User:
    def __del__(self):
        print("Удаляется объект")

Что нельзя делать в __del__ #

Не стоит:

  1. Закрывать критически важные ресурсы только через __del__.
  2. Полагаться на порядок вызова __del__.
  3. Выполнять сложную бизнес-логику.
  4. Обращаться к глобальным объектам, которые могут быть уже уничтожены.
  5. Рассчитывать, что __del__ всегда вызовется.

Пример рискованного кода:

logger = get_logger()

class Service:
    def __del__(self):
        logger.info("Service destroyed")

При завершении интерпретатора объект logger уже может быть недоступен.

Где __del__ допустим #

__del__ можно использовать как дополнительную страховку, например:

class Connection:
    def close(self):
        print("Соединение закрыто")

    def __del__(self):
        try:
            self.close()
        except Exception:
            pass

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

connection.close()

или через контекстный менеджер:

with connection:
    ...


5. Магический метод __set__ #

__set__ — это метод дескриптора, который срабатывает при записи значения в атрибут:

obj.attr = value

Формат метода:

def __set__(self, instance, value):
    ...

Официальный descriptor protocol в Python состоит из методов __get__, __set__ и __delete__; __set__ отвечает именно за установку значения атрибута.

Аргументы __set__ #

def __set__(self, instance, value):
    ...
АргументЗначение
selfсам объект-дескриптор
instanceобъект, у которого меняют атрибут
valueновое значение атрибута

Пример:

class AgeDescriptor:
    def __set__(self, instance, value):
        print("__set__ вызван")
        instance.__dict__["age"] = value


class User:
    age = AgeDescriptor()


user = User()

user.age = 20

При выполнении:

user.age = 20

Python фактически вызывает:

User.__dict__["age"].__set__(user, 20)

Результат:

__set__ вызван

Важное условие #

Дескриптор должен лежать в классе, а не в объекте.

Правильно:

class User:
    age = AgeDescriptor()

Неправильно для дескрипторного механизма:

user.age = AgeDescriptor()

Во втором случае AgeDescriptor() просто станет обычным значением атрибута age, а не дескриптором.

Пример с валидацией #

class PositiveNumber:
    def __set__(self, instance, value):
        if value <= 0:
            raise ValueError("Значение должно быть положительным")

        instance.__dict__["number"] = value


class Product:
    price = PositiveNumber()


product = Product()

product.price = 100
print(product.__dict__)

product.price = -50

Результат:

{'number': 100}ValueError: Значение должно быть положительным

Здесь __set__ перехватывает запись:

product.price = 100

и решает, можно ли сохранить значение.

Более правильный вариант через __set_name__ #

В предыдущем примере имя "number" было зашито вручную. Лучше использовать __set_name__, чтобы дескриптор сам узнал имя атрибута.

class PositiveNumber:
    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, instance, owner):
        if instance is None:
            return self

        return instance.__dict__[self.name]

    def __set__(self, instance, value):
        if value <= 0:
            raise ValueError(f"{self.name} должно быть положительным")

        instance.__dict__[self.name] = value


class Product:
    price = PositiveNumber()
    quantity = PositiveNumber()


product = Product()

product.price = 100
product.quantity = 5

print(product.price)     # 100
print(product.quantity)  # 5

Метод __set_name__ вызывается при создании класса и позволяет дескриптору узнать имя атрибута, к которому он привязан.

__set__ делает дескриптор data descriptor #

Если у дескриптора есть __set__ или __delete__, он считается data descriptor. Такие дескрипторы имеют более высокий приоритет, чем атрибуты в instance.__dict__.

Пример:

class Descriptor:
    def __get__(self, instance, owner):
        return "значение из дескриптора"

    def __set__(self, instance, value):
        instance.__dict__["attr"] = value


class Example:
    attr = Descriptor()


obj = Example()

obj.__dict__["attr"] = "значение из __dict__"

print(obj.attr)

Результат:

значение из дескриптора

Хотя в объекте есть:

obj.__dict__["attr"]

при чтении всё равно срабатывает data descriptor, потому что Descriptor содержит __set__.

__set__ может быть без __get__ #

Дескриптор может определить только __set__:

class WriteOnly:
    def __set__(self, instance, value):
        print(f"Записано: {value}")


class User:
    name = WriteOnly()


user = User()

user.name = "Alex"

Результат:

Записано: Alex

Но при чтении:

print(user.name)

поведение будет уже не таким удобным: Python вернёт сам объект-дескриптор, потому что __get__ не определён. Поэтому на практике обычно пишут вместе:

__get__
__set__

Отличие __set__ от __setattr__ #

Их часто путают. __set__ — это метод дескриптора:

class Descriptor:
    def __set__(self, instance, value):
        ...

Он срабатывает при записи в конкретный атрибут, который связан с этим дескриптором:

user.age = 20

__setattr__ — это метод самого объекта:

class User:
    def __setattr__(self, name, value):
        ...

Он срабатывает почти при любой записи атрибута:

user.name = "Alex"
user.age = 20
user.email = "a@example.com"

Сравнение:

МетодГде находитсяКогда вызывается
__set__в классе-дескрипторепри записи в атрибут-дескриптор
__setattr__в классе самого объектапри записи почти любого атрибута объекта

Пример с __setattr__:

class User:
    def __setattr__(self, name, value):
        print(f"Записывается атрибут {name}")
        super().__setattr__(name, value)


user = User()
user.name = "Alex"
user.age = 20

Результат:

Записывается атрибут name
Записывается атрибут age

Где используется __set__ #

__set__ используют, когда нужно контролировать запись значения:

  • валидация данных
  • логирование изменений
  • ORM-поля
  • ограничение доступа к атрибутам
  • приведение типов
  • проверка диапазонов

Например:

class StringField:
    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, instance, owner):
        if instance is None:
            return self

        return instance.__dict__[self.name]

    def __set__(self, instance, value):
        if not isinstance(value, str):
            raise TypeError(f"{self.name} должно быть строкой")

        instance.__dict__[self.name] = value


class User:
    username = StringField()


user = User()

user.username = "user"
print(user.username)

user.username = 123

Результат:

user
TypeError: username должно быть строкой


6. Магические методы __init__ и __new__ #

__new__ и __init__ #

При создании объекта Python вызывает два метода:

obj = MyClass(...)

Фактически процесс такой:

obj = MyClass.__new__(MyClass, ...)
MyClass.__init__(obj, ...)

Упрощённо:

__new__  → создаёт объект
__init__ → настраивает уже созданный объект

Официальная документация Python прямо разделяет их роли: __new__() вызывается для создания нового экземпляра класса, а __init__() вызывается после создания экземпляра, но до возврата объекта вызывающему коду.

__init__ #

__init__ — это инициализатор объекта. Он не создаёт объект, а заполняет его данными.

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


user = User("Alex", 20)

print(user.username)  # Alex
print(user.age)       # 20

Здесь объект User уже создан, а __init__ просто добавляет ему атрибуты username и age.

Важно: __init__ не должен возвращать значение. Он обязан возвращать None, иначе будет TypeError. Это также указано в документации Python.

Неправильно:

class User:
    def __init__(self):
        return "created"

Будет ошибка:

TypeError: __init__() should return None

__new__ #

__new__ — это метод, который отвечает именно за создание объекта.

class User:
    def __new__(cls, *args, **kwargs):
        print("__new__: создаём объект")
        obj = super().__new__(cls)
        return obj

    def __init__(self, username):
        print("__init__: инициализируем объект")
        self.username = username


user = User("Alex")

Вывод:

__new__: создаём объект
__init__: инициализируем объект

Первый аргумент у __new__ — это cls, то есть сам класс, объект которого нужно создать.

def __new__(cls, *args, **kwargs):
    ...

А первый аргумент у __init__ — это self, то есть уже созданный объект.

def __init__(self, ...):
    ...

Главное отличие #

МетодЧто делаетПервый аргументЧто возвращает
__new__Создаёт объектclsНовый объект
__init__Инициализирует объектselfТолько None

Почему обычно используют __init__, а не __new__ #

В обычных классах почти всегда достаточно __init__.

class Product:
    def __init__(self, title, price):
        self.title = title
        self.price = price

__new__ нужен редко. Его используют, когда нужно вмешаться именно в создание объекта, например:

class Singleton:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)

        return cls._instance


a = Singleton()
b = Singleton()

print(a is b)  # True

Здесь __new__ контролирует создание объекта: если объект уже был создан, новый не создаётся, а возвращается старый.

Важный момент #

__init__ вызывается только если __new__ вернул экземпляр этого же класса или его подкласса. Если __new__ вернул объект другого типа, __init__ вызван не будет. Это поведение описано в документации Python.

Пример:

class Test:
    def __new__(cls):
        print("__new__")
        return "not Test object"

    def __init__(self):
        print("__init__")


obj = Test()

print(obj)

Вывод:

__new__
not Test object

__init__ не вызвался, потому что __new__ вернул строку, а не объект Test.

Когда реально нужен __new__ #

Чаще всего __new__ нужен при:

  1. Создании singleton-объектов.
  2. Наследовании от неизменяемых типов: int, str, tuple.
  3. Кэшировании объектов.
  4. Контроле создания экземпляров.
  5. Метаклассах.

Пример с str:

class UpperStr(str):
    def __new__(cls, value):
        return super().__new__(cls, value.upper())


text = UpperStr("hello")

print(text)        # HELLO
print(type(text))  # <class '__main__.UpperStr'>

Почему тут нужен именно __new__?

Потому что str — неизменяемый тип. После создания строки её значение уже нельзя изменить. Поэтому значение нужно задать на этапе создания объекта, то есть в __new__, а не в __init__. Документация Python отдельно указывает, что __new__ в основном нужен для настройки создания экземпляров неизменяемых типов вроде int, str и tuple.


7. Какие магические методы (dunder-методы) класса вы знаете и за какие группы поведения они отвечают? #

Dunder-методы — это специальные методы вида __method__, через которые объект подключается к синтаксису Python: +, len(), for, with, [], сравнения, вызов как функции и так далее. В документации Python они называются special method names; именно они позволяют классу определять поведение для операторов и встроенных функций.

1. Создание и жизненный цикл объекта #

МетодЗа что отвечает
__new__(cls, ...)Создание нового объекта
__init__(self, ...)Инициализация уже созданного объекта
__del__(self)Действие перед уничтожением объекта сборщиком мусора
Пример:
class User:
    def __new__(cls, *args, **kwargs):
        print("Создание объекта")
        return super().__new__(cls)

    def __init__(self, name):
        print("Инициализация объекта")
        self.name = name

__new__ создаёт объект, а __init__ вызывается после создания экземпляра и настраивает его состояние.

2. Строковое представление объекта #

МетодЗа что отвечает
__str__(self)Человекочитаемое представление объекта, используется в print() и str()
__repr__(self)Техническое представление объекта, используется в отладке и repr()
__format__(self, format_spec)Поведение при format() и f-строках с форматированием
__bytes__(self)Преобразование объекта в bytes
Пример:
class User:
    def __init__(self, name):
        self.name = name

    def __str__(self):
        return f"User: {self.name}"

    def __repr__(self):
        return f"User(name={self.name!r})"


user = User("Alex")

print(str(user))   # User: Alex
print(repr(user))  # User(name='Alex')

Обычно __str__ делают удобным для пользователя, а __repr__ — полезным для разработчика.

3. Сравнение объектов #

МетодОператор
__eq__(self, other)==
__ne__(self, other)!=
__lt__(self, other)<
__le__(self, other)<=
__gt__(self, other)>
__ge__(self, other)>=
__hash__(self)hash(obj), использование в set и ключах dict
Пример:
class Product:
    def __init__(self, price):
        self.price = price

    def __lt__(self, other):
        return self.price < other.price

    def __eq__(self, other):
        return self.price == other.price


a = Product(100)
b = Product(200)

print(a < b)   # True
print(a == b)  # False

Эти методы отвечают за rich comparison — расширенное сравнение объектов.

4. Арифметические операции #

МетодОператор
__add__(self, other)+
__sub__(self, other)-
__mul__(self, other)*
__truediv__(self, other)/
__floordiv__(self, other)//
__mod__(self, other)%
__pow__(self, other)**
Пример:
class Money:
    def __init__(self, amount):
        self.amount = amount

    def __add__(self, other):
        return Money(self.amount + other.amount)


a = Money(100)
b = Money(50)

result = a + b

print(result.amount)  # 150

То есть выражение:

a + b

примерно превращается в:

type(a).__add__(a, b)

Именно так Python реализует перегрузку операторов через специальные методы.

5. Отражённые арифметические операции #

МетодКогда вызывается
__radd__(self, other)Когда левый объект не умеет +
__rsub__(self, other)Отражённый -
__rmul__(self, other)Отражённый *
__rtruediv__(self, other)Отражённый /
Пример:
class Number:
    def __init__(self, value):
        self.value = value

    def __radd__(self, other):
        return self.value + other


x = Number(10)

print(5 + x)  # 15

Здесь int.__add__ не знает, как сложить 5 и Number, поэтому Python пробует Number.__radd__.

6. Операции с присваиванием #

МетодОператор
__iadd__(self, other)+=
__isub__(self, other)-=
__imul__(self, other)*=
__itruediv__(self, other)/=
Пример:
class Counter:
    def __init__(self, value):
        self.value = value

    def __iadd__(self, other):
        self.value += other
        return self


counter = Counter(10)
counter += 5

print(counter.value)  # 15

7. Унарные операции #

МетодОператор
__neg__(self)-obj
__pos__(self)+obj
__abs__(self)abs(obj)
__invert__(self)~obj
Пример:
class Balance:
    def __init__(self, amount):
        self.amount = amount

    def __neg__(self):
        return Balance(-self.amount)


balance = Balance(100)
negative = -balance

print(negative.amount)  # -100

8. Приведение типов #

МетодЗа что отвечает
__bool__(self)bool(obj), проверки в if
__int__(self)int(obj)
__float__(self)float(obj)
__complex__(self)complex(obj)
__index__(self)Использование объекта как целого индекса
Пример:
class Cart:
    def __init__(self, items):
        self.items = items

    def __bool__(self):
        return len(self.items) > 0


cart = Cart([])

if cart:
    print("Корзина не пустая")
else:
    print("Корзина пустая")

9. Поведение контейнера #

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

МетодЗа что отвечает
__len__(self)len(obj)
__getitem__(self, key)obj[key]
__setitem__(self, key, value)obj[key] = value
__delitem__(self, key)del obj[key]
__contains__(self, item)item in obj
Пример:
class Users:
    def __init__(self):
        self._users = ["Alex", "Bob", "John"]

    def __len__(self):
        return len(self._users)

    def __getitem__(self, index):
        return self._users[index]

    def __contains__(self, user):
        return user in self._users


users = Users()

print(len(users))      # 3
print(users[0])        # Alex
print("Bob" in users)  # True

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

10. Итерация #

МетодЗа что отвечает
__iter__(self)Возвращает итератор
__next__(self)Возвращает следующий элемент
__reversed__(self)Поведение reversed(obj)
Пример:
class Countdown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current <= 0:
            raise StopIteration

        value = self.current
        self.current -= 1
        return value


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

Вывод:

3
2
1

11. Вызов объекта как функции #

МетодЗа что отвечает
__call__(self, ...)Позволяет вызывать объект как функцию
Пример:
class Multiplier:
    def __init__(self, factor):
        self.factor = factor

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


double = Multiplier(2)

print(double(10))  # 20

Объект double здесь ведёт себя как функция.

12. Работа с атрибутами #

МетодЗа что отвечает
__getattribute__(self, name)Вызывается при любом обращении к атрибуту
__getattr__(self, name)Вызывается, если обычный поиск атрибута не нашёл значение
__setattr__(self, name, value)Вызывается при присваивании атрибута
__delattr__(self, name)Вызывается при удалении атрибута
__dir__(self)Настраивает результат dir(obj)
Пример:
class User:
    def __init__(self, name):
        self.name = name

    def __getattr__(self, name):
        return f"Атрибут {name} не найден"


user = User("Alex")

print(user.name)     # Alex
print(user.age)      # Атрибут age не найден

Важно различать:

__getattribute__ вызывается всегда
__getattr__ вызывается только если атрибут не найден

В документации также указано, что __setattr__ и __delattr__ перехватывают присваивание и удаление атрибутов.

13. Дескрипторы #

МетодЗа что отвечает
__get__(self, instance, owner)Получение значения
__set__(self, instance, value)Установка значения
__delete__(self, instance)Удаление значения
__set_name__(self, owner, name)Получение имени атрибута при создании класса
Пример:
class PositiveNumber:
    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, instance, owner):
        return instance.__dict__[self.name]

    def __set__(self, instance, value):
        if value <= 0:
            raise ValueError("Значение должно быть положительным")

        instance.__dict__[self.name] = value


class Product:
    price = PositiveNumber()


product = Product()
product.price = 100

print(product.price)  # 100

Дескрипторы лежат в основе property, методов класса, staticmethod, classmethod и многих ORM-механизмов.

14. Контекстные менеджеры #

МетодЗа что отвечает
__enter__(self)Вход в блок with
__exit__(self, exc_type, exc_value, traceback)Выход из блока with
Пример:
class FileManager:
    def __enter__(self):
        print("Открываем ресурс")
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        print("Закрываем ресурс")


with FileManager():
    print("Работаем с ресурсом")

Вывод:

Открываем ресурс
Работаем с ресурсом
Закрываем ресурс

Такие методы используются для файлов, соединений с БД, транзакций, lock-объектов и других ресурсов, которые нужно гарантированно освободить.

15. Асинхронное поведение #

МетодЗа что отвечает
__await__(self)Позволяет использовать объект в await
__aiter__(self)Асинхронный итератор
__anext__(self)Следующее значение в async for
__aenter__(self)Вход в async with
__aexit__(self, exc_type, exc, tb)Выход из async with
Пример:
class AsyncContext:
    async def __aenter__(self):
        print("Вход")
        return self

    async def __aexit__(self, exc_type, exc, tb):
        print("Выход")


async with AsyncContext():
    print("Работа внутри async with")

Это используется, например, в асинхронных клиентах БД, HTTP-клиентах, транзакциях и соединениях.

16. Управление экземплярами класса #

МетодЗа что отвечает
__slots__Ограничивает набор атрибутов объекта и может уменьшить расход памяти
__dict__Словарь атрибутов объекта
__class__Ссылка на класс объекта

__slots__ — не метод, а специальный атрибут класса, но его часто упоминают рядом с dunder-механизмами.

Пример:

class Point:
    __slots__ = ("x", "y")

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


point = Point(1, 2)

point.z = 3  # AttributeError

17. Работа с классами и наследованием #

МетодЗа что отвечает
__init_subclass__(cls, ...)Вызывается при создании подкласса
__class_getitem__(cls, item)Позволяет писать MyClass[int]
__mro_entries__(self, bases)Участие в построении MRO
Пример:
class Base:
    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        print(f"Создан подкласс: {cls.__name__}")


class Child(Base):
    pass

Вывод:

Создан подкласс: Child

18. Управление памятью и слабые ссылки #

Метод / атрибутЗа что отвечает
__sizeof__(self)Размер объекта в памяти
__weakref__Поддержка weak references, если используются __slots__

На практике эти методы используют редко, обычно в низкоуровневой оптимизации или библиотеках.

Краткая сводка по группам #

Группа поведенияОсновные методы
Создание объекта__new__, __init__, __del__
Строковое представление__str__, __repr__, __format__, __bytes__
Сравнение__eq__, __ne__, __lt__, __le__, __gt__, __ge__
Хеширование__hash__
Арифметика__add__, __sub__, __mul__, __truediv__, __pow__
Отражённые операции__radd__, __rsub__, __rmul__
Операции с присваиванием__iadd__, __isub__, __imul__
Приведение типов__bool__, __int__, __float__, __index__
Контейнеры__len__, __getitem__, __setitem__, __delitem__, __contains__
Итерация__iter__, __next__, __reversed__
Вызов объекта__call__
Атрибуты__getattribute__, __getattr__, __setattr__, __delattr__
Дескрипторы__get__, __set__, __delete__, __set_name__
Контекстный менеджер__enter__, __exit__
Асинхронные объекты__await__, __aiter__, __anext__, __aenter__, __aexit__
Наследование и generics__init_subclass__, __class_getitem__, __mro_entries__

Главное #

Dunder-методы отвечают не за “обычные методы класса”, а за интеграцию объекта в механизмы языка Python.

Например:

len(obj)      # вызывает __len__
obj[key]      # вызывает __getitem__
obj + other   # вызывает __add__
for x in obj  # вызывает __iter__ и __next__
with obj:     # вызывает __enter__ и __exit__
obj()         # вызывает __call__

В реальной разработке чаще всего встречаются:

__init__
__str__
__repr__
__eq__
__hash__
__len__
__getitem__
__setitem__
__iter__
__next__
__enter__
__exit__
__getattr__
__setattr__
__call__

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


8. Какие магические методы определены в базовом метаклассе? #

Базовый метакласс — это type. Обычный класс в Python сам является объектом, а его типом обычно является type:

class User:
    pass


print(type(User))  # <class 'type'>

Документация Python указывает, что по умолчанию классы создаются через type, а результатом выполнения тела класса становится вызов примерно вида type(name, bases, namespace).

Основные магические методы type #

Ключевые dunder-методы базового метакласса:

МетодЗа что отвечает
__prepare__Подготавливает namespace класса перед выполнением тела класса
__new__Создаёт объект класса
__init__Инициализирует уже созданный объект класса
__call__Вызывается, когда класс вызывают как функцию: User(...)
__instancecheck__Настраивает поведение isinstance(obj, Class)
__subclasscheck__Настраивает поведение issubclass(SubClass, Class)
__getattribute__Отвечает за доступ к атрибутам класса
__setattr__Отвечает за присваивание атрибутов класса
__delattr__Отвечает за удаление атрибутов класса
__repr__Строковое представление класса
__dir__Поведение dir(Class)
__sizeof__Размер объекта класса в памяти
__subclasses__Получение списка прямых подклассов
__or__Поддержка `A
__ror__Отражённая операция `

Самые важные для понимания метаклассов #

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

__prepare__
__new__
__init__
__call__

Их порядок при создании класса примерно такой:

__prepare__ → тело класса → __new__ → __init__

Документация описывает процесс создания класса так: определяется метакласс, подготавливается namespace, выполняется тело класса, затем создаётся объект класса.

type.__prepare__ #

__prepare__ вызывается до выполнения тела класса. Он создаёт namespace, куда попадут атрибуты и методы класса:

class Meta(type):
    @classmethod
    def __prepare__(mcls, name, bases, **kwargs):
        print("__prepare__")
        return {}

    def __new__(mcls, name, bases, namespace, **kwargs):
        print("__new__")
        return super().__new__(mcls, name, bases, namespace)

    def __init__(cls, name, bases, namespace, **kwargs):
        print("__init__")
        super().__init__(name, bases, namespace)


class User(metaclass=Meta):
    age = 20

Вывод:

__prepare__
__new__
__init__

__prepare__ должен быть реализован как classmethod; возвращённый им namespace передаётся в __new__.

type.__new__ #

__new__ метакласса создаёт сам класс. Не экземпляр класса, а именно объект класса:

class User:
    pass

Упрощённо превращается в:

User = type("User", (), {})

Во время создания класса через type.__new__ Python также обрабатывает дескрипторы с __set_name__ и вызывает __init_subclass__ у родительского класса.

type.__init__ #

__init__ метакласса инициализирует уже созданный класс. Пример:

class Meta(type):
    def __new__(mcls, name, bases, namespace):
        print("Создаём класс")
        return super().__new__(mcls, name, bases, namespace)

    def __init__(cls, name, bases, namespace):
        print("Инициализируем класс")
        super().__init__(name, bases, namespace)


class User(metaclass=Meta):
    pass

__new__ создаёт класс, __init__ настраивает созданный класс.

type.__call__ #

__call__ метакласса срабатывает, когда ты вызываешь класс:

user = User()

Упрощённо это работает так:

user = type.__call__(User)

А внутри type.__call__ обычно происходит:

User.__new__(User, ...)
User.__init__(obj, ...)

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

Пример:

class Meta(type):
    def __call__(cls, *args, **kwargs):
        print("Вызвали класс как функцию")
        return super().__call__(*args, **kwargs)


class User(metaclass=Meta):
    def __init__(self, name):
        self.name = name


user = User("Alex")

Вывод:

Вызвали класс как функцию

type.__instancecheck__ и type.__subclasscheck__ #

Эти методы отвечают за проверки:

isinstance(obj, SomeClass)
issubclass(Child, Parent)
МетодИспользуется для
__instancecheck__isinstance(instance, class)
__subclasscheck__issubclass(subclass, class)

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

Пример:

class Meta(type):
    def __instancecheck__(cls, instance):
        print("__instancecheck__")
        return True


class User(metaclass=Meta):
    pass


print(isinstance(123, User))

Вывод:

__instancecheck__True

Проверка через Python #

Можно посмотреть dunder-имена, которые есть прямо в type.__dict__:

for name in sorted(type.__dict__):
    if name.startswith("__") and name.endswith("__"):
        print(name)

В актуальном Python среди них будут примерно такие:

__abstractmethods__
__annotations__
__base__
__bases__
__basicsize__
__call__
__delattr__
__dict__
__dictoffset__
__dir__
__doc__
__flags__
__getattribute__
__init__
__instancecheck__
__itemsize__
__module__
__mro__
__name__
__new__
__or__
__prepare__
__qualname__
__repr__
__ror__
__setattr__
__sizeof__
__subclasscheck__
__subclasses__
__text_signature__
__type_params__
__weakrefoffset__

Но важно: не всё из этого — методы. Часть — это специальные атрибуты/дескрипторы класса, например:

__name__
__qualname__
__bases__
__mro__
__dict__
__doc__
__module__


9. Как связаны магические методы и метаклассы в объектной модели? #

Связь простая: магические методы ищутся на типе объекта #

В Python каждый объект имеет тип. Тип обычного объекта — это его класс. Тип класса — это его метакласс.

class User:
    pass


user = User()

print(type(user))  # <class '__main__.User'>
print(type(User))  # <class 'type'>

То есть:

user → объект
User → класс user
type → метакласс класса User

Документация Python описывает это так: каждый объект имеет identity, type и value; тип определяет операции, которые объект поддерживает.

Магические методы управляют поведением объекта через его тип #

Когда ты пишешь:

len(obj)
obj[key]
obj + other
obj()

Python не просто ищет метод в самом объекте. Он смотрит на тип объекта.

Например:

obj[key]

примерно превращается в:

type(obj).__getitem__(obj, key)

В документации Python прямо сказано: если класс определяет __getitem__(), то x[i] для экземпляра x примерно эквивалентен type(x).__getitem__(x, i).

Отсюда главное правило:

магические методы объекта ищутся на его классе
магические методы класса ищутся на его метаклассе

Обычный случай: dunder-методы в классе управляют экземплярами #

class User:
    def __init__(self, name):
        self.name = name

    def __str__(self):
        return f"User: {self.name}"

    def __call__(self):
        return f"Called {self.name}"


user = User("Alex")

print(user)     # User: Alex
print(user())   # Called Alex

Здесь:

str(user)  User.__str__(user)
user()     User.__call__(user)

То есть User.__str__ и User.__call__ управляют поведением экземпляра user.

Метакласс: dunder-методы метакласса управляют классом #

Класс User сам является объектом. Его тип — type, либо пользовательский метакласс. Поэтому когда ты делаешь:

user = User()

Python вызывает не User.__call__, а type(User).__call__. Обычно это:

type.__call__(User, ...)

А внутри уже вызываются:

User.__new__(User, ...)
User.__init__(obj, ...)

Пример:

class Meta(type):
    def __call__(cls, *args, **kwargs):
        print("Метакласс перехватил вызов класса")
        return super().__call__(*args, **kwargs)


class User(metaclass=Meta):
    def __init__(self, name):
        self.name = name


user = User("Alex")

Вывод:

Метакласс перехватил вызов класса

Здесь User("Alex") перехватывает не сам User, а его метакласс Meta.

Важное различие #

class User:
    def __call__(self):
        print("Вызвали экземпляр")


user = User()

user()  # работает
User()  # просто создаёт объект

User.__call__ делает вызываемым экземпляр user.

Но чтобы изменить поведение самого вызова класса User(), нужен __call__ в метаклассе:

class Meta(type):
    def __call__(cls, *args, **kwargs):
        print("Вызвали класс")
        return super().__call__(*args, **kwargs)


class User(metaclass=Meta):
    pass


User()

То есть:

Где определён __call__Что становится вызываемым
В обычном классеЭкземпляр
В метаклассеСам класс

Как это связано с object и type #

print(isinstance(user, User))    # True
print(isinstance(User, type))    # True
print(issubclass(User, object))  # True
print(issubclass(type, object))  # True

object даёт базовое поведение обычным объектам: __repr__, __str__, __eq__, __hash__, __getattribute__, __setattr__ и так далее.

type даёт базовое поведение классам: создание класса, вызов класса, работа с атрибутами класса, проверка isinstance / issubclass.

Создание класса тоже идёт через dunder-методы метакласса #

Когда Python выполняет:

class User:
    name = "Alex"

Упрощённо происходит:

User = type("User", (object,), {"name": "Alex"})

Процесс создания класса включает несколько этапов:

выбор метакласса
подготовка namespace через __prepare__
выполнение тела класса
создание объекта класса через метакласс

Документация Python описывает порядок создания класса так: разрешаются MRO entries, определяется подходящий метакласс, подготавливается namespace, выполняется тело класса, затем создаётся объект класса. Например:

class Meta(type):
    @classmethod
    def __prepare__(mcls, name, bases):
        print("__prepare__")
        return {}

    def __new__(mcls, name, bases, namespace):
        print("__new__")
        return super().__new__(mcls, name, bases, namespace)

    def __init__(cls, name, bases, namespace):
        print("__init__")
        super().__init__(name, bases, namespace)


class User(metaclass=Meta):
    pass

Вывод:

__prepare__
__new__
__init__

То есть:

Meta.__prepare__ → готовит пространство имён класса
Meta.__new__     → создаёт объект класса
Meta.__init__    → инициализирует объект класса

Документация указывает, что __prepare__ вызывается у метакласса для подготовки namespace, а после выполнения тела класса объект класса создаётся вызовом metaclass(name, bases, namespace, **kwds).

Метакласс может влиять на isinstance и issubclass #

Некоторые dunder-методы работают именно на уровне метакласса:

class Meta(type):
    def __instancecheck__(cls, instance):
        print("__instancecheck__")
        return True


class User(metaclass=Meta):
    pass


print(isinstance(123, User))

Вывод:

__instancecheck__
True

Документация Python указывает, что __instancecheck__ и __subclasscheck__ используются для переопределения поведения isinstance() и issubclass(), причём эти методы ищутся именно на типе класса, то есть на метаклассе.

Почему нельзя просто записать dunder-метод в экземпляр #

Специальные методы для синтаксиса обычно ищутся не в конкретном объекте, а на его типе.

Например:

class Box:
    pass


box = Box()

box.__len__ = lambda: 10

print(box.__len__())  # 10
print(len(box))       # TypeError

Почему? Потому что len(box) ищет __len__ не в box.__dict__, а на уровне класса:

type(box).__len__

То есть нужно так:

class Box:
    def __len__(self):
        return 10


box = Box()

print(len(box))  # 10

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

Сводная схема #

Экземпляр:
    user.__str__?      — нет, Python смотрит в User.__str__
    user + other       — User.__add__
    len(user)          — User.__len__
    user()             — User.__call__

Класс:
    User()             — type(User).__call__
    isinstance(x, User) — type(User).__instancecheck__
    issubclass(A, User) — type(User).__subclasscheck__
    User.attr          — type(User).__getattribute__

Итог #

Магические методы — это механизм, через который Python подключает объекты к синтаксису языка.

Метаклассы — это классы для классов.

Связь такая:

dunder-методы обычного класса управляют экземплярами
dunder-методы метакласса управляют самими классами


10. Что такое и зачем нужны метаклассы? #

Что такое метакласс #

Метакласс — это класс, который создаёт другие классы. Обычный объект создаётся классом:

user = User()

А сам класс User создаётся метаклассом:

class User:
    pass

Упрощённо это можно представить так:

User = type("User", (object,), {})

То есть type — стандартный метакласс в Python. Официальная документация описывает создание класса как процесс, где выбирается метакласс, подготавливается namespace, выполняется тело класса, а затем создаётся объект класса. ( Python documentation)

Главная схема #

экземпляр → создаётся классом
класс     → создаётся метаклассом

Пример:

class User:
    pass


user = User()

print(type(user))  # <class '__main__.User'>
print(type(User))  # <class 'type'>

Здесь:

user — экземпляр класса User
User — экземпляр метакласса type

Зачем нужны метаклассы #

Метаклассы нужны, когда нужно управлять не отдельными объектами, а самими классами в момент их создания или вызова.

Они позволяют:

  1. Проверять структуру класса при его создании.
  2. Автоматически добавлять методы или атрибуты в класс.
  3. Регистрировать классы в общем реестре.
  4. Изменять поведение вызова класса Class(...).
  5. Управлять isinstance() и issubclass().
  6. Создавать фреймворки, ORM, сериализаторы, декларативные API.

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

Пример без метакласса #

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

class Handler:
    pass


class UserHandler(Handler):
    pass


class OrderHandler(Handler):
    pass

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

Можно сделать вручную:

registry = {}


class UserHandler:
    pass


registry["UserHandler"] = UserHandler

Но это легко забыть.

Пример с метаклассом #

class RegistryMeta(type):
    registry = {}

    def __new__(mcls, name, bases, namespace):
        cls = super().__new__(mcls, name, bases, namespace)

        if name != "BaseHandler":
            mcls.registry[name] = cls

        return cls


class BaseHandler(metaclass=RegistryMeta):
    pass


class UserHandler(BaseHandler):
    pass


class OrderHandler(BaseHandler):
    pass


print(RegistryMeta.registry)

Результат:

{
    'UserHandler': <class '__main__.UserHandler'>,
    'OrderHandler': <class '__main__.OrderHandler'>
}

Что произошло:

создался класс UserHandler  → метакласс автоматически добавил его в registry
создался класс OrderHandler → метакласс автоматически добавил его в registry

То есть метакласс сработал не при создании объекта UserHandler(), а при создании самого класса UserHandler.

Где метакласс вмешивается #

При создании класса Python может вызвать методы метакласса:

Meta.__prepare__ → подготовка namespace класса
Meta.__new__     → создание объекта класса
Meta.__init__    → инициализация объекта класса

Документация Python указывает, что __prepare__ метакласса вызывается для подготовки пространства имён класса перед выполнением тела класса. ( Python documentation)

Пример:

class Meta(type):
    @classmethod
    def __prepare__(mcls, name, bases):
        print("__prepare__")
        return {}

    def __new__(mcls, name, bases, namespace):
        print("__new__")
        return super().__new__(mcls, name, bases, namespace)

    def __init__(cls, name, bases, namespace):
        print("__init__")
        super().__init__(name, bases, namespace)


class User(metaclass=Meta):
    pass

Вывод:

__prepare__
__new__
__init__

Метакласс и вызов класса #

Когда ты пишешь:

user = User()

вызывается не User.__call__, а type(User).__call__. То есть:

user = User()

примерно означает:

user = type(User).__call__(User)

А внутри стандартного type.__call__ обычно вызываются:

User.__new__(User, ...)
User.__init__(obj, ...)

Метакласс может перехватить этот процесс:

class Meta(type):
    def __call__(cls, *args, **kwargs):
        print("Перед созданием объекта")
        obj = super().__call__(*args, **kwargs)
        print("После создания объекта")
        return obj


class User(metaclass=Meta):
    def __init__(self, name):
        self.name = name


user = User("Alex")

Вывод:

Перед созданием объекта
После создания объекта

Это можно использовать, например, для singleton, кеширования, логирования создания объектов, контроля аргументов.

Пример: запретить создавать класс без нужного метода #

class ServiceMeta(type):
    def __new__(mcls, name, bases, namespace):
        if name != "BaseService" and "execute" not in namespace:
            raise TypeError(f"Класс {name} должен определить метод execute")

        return super().__new__(mcls, name, bases, namespace)


class BaseService(metaclass=ServiceMeta):
    pass


class UserService(BaseService):
    def execute(self):
        print("Работаем")


class BadService(BaseService):
    pass

При создании BadService будет ошибка:

TypeError: Класс BadService должен определить метод execute

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

Метаклассы и ABCMeta #

Один из реальных примеров метакласса в стандартной библиотеке — ABCMeta из модуля abc. Он нужен для абстрактных базовых классов:

from abc import ABC, abstractmethod


class BaseRepository(ABC):
    @abstractmethod
    def get(self, id):
        pass

ABC использует механизм абстрактных базовых классов, а инфраструктура находится в модуле abc; документация прямо описывает этот модуль как инфраструктуру для определения abstract base classes. ( Python documentation)

Без реализации абстрактного метода объект создать нельзя:

class UserRepository(BaseRepository):
    pass


repo = UserRepository()  # TypeError

То есть метакласс здесь помогает контролировать правила создания экземпляров.

Метаклассы и дескрипторы #

При создании нового класса стандартный метакласс type сканирует словарь класса и вызывает __set_name__() у дескрипторов, если он определён. Это описано в официальном Descriptor HowTo Guide.

Пример:

class Field:
    def __set_name__(self, owner, name):
        self.name = name


class User:
    username = Field()

Когда создаётся класс User, type вызывает:

Field.__set_name__(field, User, "username")

Это активно используется в ORM, валидаторах, serializers, моделях и декларативных API.

Когда метаклассы действительно нужны #

Метакласс оправдан, если нужно влиять на сам класс:

класс должен автоматически регистрироваться
класс должен проверяться при создании
классу нужно автоматически добавлять методы/атрибуты
нужно изменить поведение Class(...)
нужно изменить isinstance()/issubclass()
строится фреймворк или декларативный API

Например, ORM-модель:

class User(Model):
    id = IntegerField()
    username = StringField()

В таких системах фреймворк должен на этапе создания класса понять:

какие поля объявлены
какие у них имена
какая таблица соответствует модели
какие ограничения есть у полей

Это можно делать через метаклассы, __init_subclass__ и дескрипторы.

Когда метаклассы не нужны #

Во многих случаях вместо метакласса лучше использовать более простые механизмы:

ЗадачаЧасто лучше использовать
Добавить поведение объектуобычное наследование
Обернуть методдекоратор
Проверить данные__init__, Pydantic, dataclass
Зарегистрировать подклассы__init_subclass__
Управлять атрибутомdescriptor или property
Создать объект особым способомфабрика

Например, регистрацию подклассов можно сделать без метакласса:

class BaseHandler:
    registry = {}

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        BaseHandler.registry[cls.__name__] = cls


class UserHandler(BaseHandler):
    pass


class OrderHandler(BaseHandler):
    pass


print(BaseHandler.registry)

__init_subclass__ проще, чем метакласс, и часто его достаточно.


11. Какой объект является результатом работы метакласса? #

Результат работы метакласса — это класс #

Метакласс создаёт не обычный экземпляр, а объект класса.

Пример:

class User:
    pass

Упрощённо это эквивалентно:

User = type("User", (object,), {})

Здесь результат работы type(...) — это класс User.

print(User)        # <class '__main__.User'>
print(type(User))  # <class 'type'>

То есть:

type создал объект класса User
User потом может создавать обычные объекты user

Документация Python описывает создание класса как процесс, где выбирается метакласс, подготавливается namespace, выполняется тело класса, а затем объект класса создаётся вызовом метакласса.

Схема #

метакласс → создаёт класс
класс     → создаёт экземпляр

Пример:

class User:
    pass


user = User()

Связи:

print(type(user))  # <class '__main__.User'>
print(type(User))  # <class 'type'>

То есть:

user — результат работы класса User
User — результат работы метакласса type

Пример с пользовательским метаклассом #

class Meta(type):
    def __new__(mcls, name, bases, namespace):
        print("Метакласс создаёт класс:", name)
        return super().__new__(mcls, name, bases, namespace)


class User(metaclass=Meta):
    pass

Вывод:

Метакласс создаёт класс: User

Здесь Meta.__new__ вернул объект класса User.

Проверка:

print(type(User))  # <class '__main__.Meta'>

То есть User — это экземпляр метакласса Meta.

Важно различать #

class User:
    pass

Результат работы метакласса:

User

А вот это:

user = User()

Результат работы класса:

user


12. Менеджер контекста with #

Контекстный менеджер — это объект, который управляет входом в некоторый контекст и выходом из него. Обычно он нужен для гарантированного освобождения ресурсов: закрыть файл, соединение с БД, транзакцию, lock, временно изменить состояние и потом вернуть обратно. В Python это реализуется через методы __enter__() и __exit__(), которые вызываются при входе и выходе из блока with.

Пример с файлом:

with open("data.txt", "r", encoding="utf-8") as file:
    content = file.read()

Здесь with гарантирует, что файл будет закрыт после выхода из блока, даже если внутри блока произойдёт исключение.

Как работает with #

Код:

with manager as value:
    body

упрощённо работает так:

manager = manager_expression

value = manager.__enter__()

try:
    body
except Exception as exc:
    suppress = manager.__exit__(type(exc), exc, exc.__traceback__)
    if not suppress:
        raise
else:
    manager.__exit__(None, None, None)

То есть:

__enter__ → вызывается перед входом в блок with
__exit__  → вызывается при выходе из блока with

__enter__() возвращает значение, которое попадает в переменную после as, а __exit__() получает информацию об исключении, если оно произошло.

Пример своего контекстного менеджера #

class FileManager:
    def __init__(self, filename, mode):
        self.filename = filename
        self.mode = mode
        self.file = None

    def __enter__(self):
        print("Открываем файл")
        self.file = open(self.filename, self.mode, encoding="utf-8")
        return self.file

    def __exit__(self, exc_type, exc_value, traceback):
        print("Закрываем файл")
        self.file.close()

Использование:

with FileManager("data.txt", "w") as file:
    file.write("Hello")

Порядок будет такой:

1. Создаётся FileManager
2. Вызывается __enter__
3. Выполняется тело with
4. Вызывается __exit__

Аргументы __exit__ #

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

def __exit__(self, exc_type, exc_value, traceback):
    ...
АргументЧто означает
exc_typeТип исключения
exc_valueСам объект исключения
tracebackTraceback исключения
Если исключения не было, все три значения будут None.

Пример:

class Manager:
    def __enter__(self):
        print("Вход")
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type is None:
            print("Вышли без ошибки")
        else:
            print(f"Произошла ошибка: {exc_value}")

        print("Выход")


with Manager():
    print("Работа внутри with")

Вывод:

Вход
Работа внутри with
Вышли без ошибки
Выход

Подавление исключений #

__exit__ может подавить исключение, если вернёт True.

class SuppressZeroDivision:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type is ZeroDivisionError:
            print("ZeroDivisionError подавлен")
            return True

        return False


with SuppressZeroDivision():
    result = 10 / 0

print("Программа продолжила работу")

Вывод:

ZeroDivisionError подавлен
Программа продолжила работу

Если __exit__ вернёт False, None или другое ложное значение, исключение пойдёт дальше.

Аналог через try/finally #

with часто заменяет такую конструкцию:

file = open("data.txt", "r", encoding="utf-8")

try:
    content = file.read()
finally:
    file.close()

Через with короче и безопаснее:

with open("data.txt", "r", encoding="utf-8") as file:
    content = file.read()

PEP 343, который ввёл with, описывает его как способ вынести типичные случаи использования try/finally в отдельный переиспользуемый механизм.

Контекстный менеджер через contextlib #

Не всегда нужно писать класс. Можно использовать @contextmanager.

from contextlib import contextmanager


@contextmanager
def managed_resource():
    print("Открываем ресурс")

    try:
        yield "resource"
    finally:
        print("Закрываем ресурс")


with managed_resource() as resource:
    print(f"Работаем с {resource}")

Вывод:

Открываем ресурс
Работаем с resource
Закрываем ресурс

contextlib.contextmanager позволяет создать контекстный менеджер из генераторной функции без отдельного класса с __enter__ и __exit__.

Где используют with #

Частые случаи:

# Файлы
with open("file.txt") as file:
    data = file.read()

# Блокировки
with lock:
    shared_resource += 1

# Транзакции
with transaction.atomic():
    ...

# Временное подавление исключений
from contextlib import suppress

with suppress(FileNotFoundError):
    remove_file()

В backend-разработке with часто встречается при работе с файлами, транзакциями БД, lock-объектами, временными настройками, моками в тестах и управлением сетевыми/IO-ресурсами.

Асинхронный контекстный менеджер #

Для async with используются другие методы:

class AsyncManager:
    async def __aenter__(self):
        print("Асинхронный вход")
        return self

    async def __aexit__(self, exc_type, exc_value, traceback):
        print("Асинхронный выход")

Использование:

async with AsyncManager():
    print("Работа внутри async with")

Это часто используется в асинхронных HTTP-клиентах, соединениях с БД и транзакциях.

Кратко #

Контекстный менеджер — это объект, который реализует протокол:

__enter__
__exit__

with нужен, чтобы гарантированно выполнить код выхода:

открыть ресурс → поработать → гарантированно закрыть ресурс

Главная идея:

with resource:
    work()

лучше, чем вручную писать:

try:
    work()
finally:
    cleanup()


13. Какие параметры передаются в __exit__ у контекстного менеджера? #

Параметры __exit__ #

Сигнатура обычного контекстного менеджера:

def __exit__(self, exc_type, exc_value, traceback):
    ...

В __exit__ передаются три параметра, описывающие исключение, из-за которого произошёл выход из блока with. Если исключения не было, все три параметра равны None. Это указано в Python Data Model. ( Python documentation)

ПараметрЧто означает
exc_typeТип исключения, например ZeroDivisionError
exc_valueСам объект исключения, например объект с сообщением division by zero
tracebackОбъект traceback, то есть информация о стеке вызовов

Пример:

class Manager:
    def __enter__(self):
        print("Вход в with")
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        print("exc_type:", exc_type)
        print("exc_value:", exc_value)
        print("traceback:", traceback)

Если исключения не было #

with Manager():
    print("Работа без ошибки")

Примерный вывод:

Вход в with
Работа без ошибки
exc_type: None
exc_value: None
traceback: None

То есть при нормальном выходе Python вызывает:

__exit__(None, None, None)

Если исключение было #

with Manager():
    10 / 0

Примерный вывод:

Вход в with
exc_type: <class 'ZeroDivisionError'>
exc_value: division by zero
traceback: <traceback object at 0x...>

То есть Python вызывает примерно:

__exit__(ZeroDivisionError, ZeroDivisionError("division by zero"), traceback)

PEP 343 описывает этот механизм как передачу в __exit__ типа исключения, значения исключения и traceback.

Возвращаемое значение __exit__ #

__exit__ может управлять тем, будет ли исключение подавлено. Если вернуть True, исключение подавляется:

class SuppressZeroDivision:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type is ZeroDivisionError:
            print("ZeroDivisionError подавлен")
            return True

        return False


with SuppressZeroDivision():
    10 / 0

print("Код продолжил работу")

Если вернуть False, None или любое ложное значение, исключение не подавляется и пробрасывается дальше. Это поведение также описано в документации Python для with и contextlib: истинное значение из __exit__ может подавить исключение.

Кратко #

def __exit__(self, exc_type, exc_value, traceback):
    ...

Где:

exc_type   → класс исключения
exc_value  → объект исключения
traceback  → traceback исключения

При нормальном выходе:

__exit__(None, None, None)

При ошибке:

__exit__(type(error), error, error.__traceback__)


14. С какими типами ресурсов обычно используют контекстные менеджеры и почему это удобно? #

Где обычно используют контекстные менеджеры #

Контекстные менеджеры используют там, где есть ресурс или состояние, которое нужно гарантированно освободить, закрыть или вернуть в исходное состояние. В Python with вызывает __enter__() при входе в блок и __exit__() при выходе из него; PEP 343 описывает with как способ вынести типичные случаи try/finally в переиспользуемый механизм.

1. Файлы #

Самый частый пример:

with open("data.txt", "r", encoding="utf-8") as file:
    data = file.read()

Почему удобно:

файл открылся → файл прочитали → файл гарантированно закрылся

Без with пришлось бы писать так:

file = open("data.txt", "r", encoding="utf-8")

try:
    data = file.read()
finally:
    file.close()

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

2. Соединения с базой данных #

Например, соединение или транзакция:

with connection:
    cursor.execute("INSERT INTO users VALUES (...)")

Идея:

открыть соединение / начать транзакцию
выполнить операции
закрыть соединение / зафиксировать или откатить транзакцию

Это удобно, потому что при ошибке можно автоматически сделать rollback, а при успешном завершении — commit.

Условно:

class Transaction:
    def __enter__(self):
        print("BEGIN")
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type is None:
            print("COMMIT")
        else:
            print("ROLLBACK")

3. Lock-и и синхронизация #

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

with lock:
    shared_counter += 1

Идея:

захватить lock
безопасно выполнить код
гарантированно отпустить lock

Это важно, потому что забытый lock.release() может привести к зависанию программы. Без with пришлось бы писать:

lock.acquire()

try:
    shared_counter += 1
finally:
    lock.release()

4. Сетевые соединения и HTTP-клиенты #

Например:

with client:
    response = client.get("https://example.com")

Идея:

открыть клиент / соединение
сделать запросы
закрыть соединения и освободить ресурсы

В async-коде часто используется async with:

async with session:
    ...

Для async with используются методы __aenter__() и __aexit__(). Python Data Model отдельно описывает асинхронные контекстные менеджеры как объектный протокол для async with.

5. Временные файлы и временные директории #

Пример:

from tempfile import TemporaryDirectory

with TemporaryDirectory() as tmp_dir:
    print(tmp_dir)

Идея:

создать временную директорию
использовать её
автоматически удалить после выхода из блока

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

6. Подавление ожидаемых исключений #

Через contextlib.suppress можно явно сказать: “эту ошибку можно проигнорировать”.

from contextlib import suppress

with suppress(FileNotFoundError):
    remove_file("old.log")

Это читается лучше, чем пустой except. Модуль contextlib в стандартной библиотеке предоставляет утилиты для работы с контекстными менеджерами, включая suppress, closing, contextmanager и ExitStack. ( Python documentation)

7. Временное изменение состояния #

Например:

with change_settings(DEBUG=True):
    run_test()

Идея:

сохранить старое состояние
временно изменить
после блока вернуть обратно

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

Почему это удобно #

Главные преимущества:

1. Гарантированная очистка ресурсов.
2. Меньше ручного try/finally.
3. Код становится короче и читаемее.
4. Легче не забыть закрыть файл, соединение или lock.
5. Исключения обрабатываются централизованно в __exit__.

Ключевой смысл:

with resource:
    work()

означает:

подготовить ресурс
выполнить работу
гарантированно завершить работу с ресурсом


15. Чем можно заменить контекстный менеджер? #

Главная замена контекстного менеджера — try/finally.

with как раз создан для того, чтобы удобно инкапсулировать типичные шаблоны try/finally: подготовить ресурс, выполнить код, затем гарантированно выполнить очистку. Это описано в документации Python по оператору with. ( Python documentation)

1. try/finally #

Было с with:

with open("data.txt", "r", encoding="utf-8") as file:
    data = file.read()

Аналог без контекстного менеджера:

file = open("data.txt", "r", encoding="utf-8")

try:
    data = file.read()
finally:
    file.close()

Смысл одинаковый:

открыли файл
поработали с ним
гарантированно закрыли файл

Даже если внутри try будет ошибка, блок finally всё равно выполнится.

2. Ручной вызов open/close, acquire/release, connect/disconnect #

Например, без with:

lock.acquire()

try:
    # работа с защищённым ресурсом
    ...
finally:
    lock.release()

Или с соединением:

connection = connect()

try:
    # работа с БД
    ...
finally:
    connection.close()

Это рабочий вариант, но он более многословный и легче ошибиться: можно забыть close(), release() или rollback().

3. Декоратор #

Иногда контекстный менеджер можно заменить декоратором, если нужно обернуть всю функцию целиком.

Например:

def with_logging(func):
    def wrapper(*args, **kwargs):
        print("До выполнения")

        try:
            return func(*args, **kwargs)
        finally:
            print("После выполнения")

    return wrapper


@with_logging
def process():
    print("Работа функции")


process()

Это похоже на with, но область действия другая:

with       → управляет конкретным блоком кода
decorator  → управляет всей функцией

4. contextlib.contextmanager #

Это не замена with, а замена написанию класса с __enter__ и __exit__. Вместо класса:

class Manager:
    def __enter__(self):
        print("Вход")
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        print("Выход")

можно написать функцию:

from contextlib import contextmanager


@contextmanager
def manager():
    print("Вход")

    try:
        yield
    finally:
        print("Выход")

Использование всё равно через with:

with manager():
    print("Работа")

contextlib.contextmanager позволяет создавать контекстный менеджер из генераторной функции без отдельного класса. ( Python documentation)

5. ExitStack #

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

from contextlib import ExitStack

files = ["a.txt", "b.txt", "c.txt"]

with ExitStack() as stack:
    opened_files = [
        stack.enter_context(open(filename, "r", encoding="utf-8"))
        for filename in files
    ]

    # работа со всеми файлами

Это не совсем замена with, а более гибкий инструмент для сложных случаев. contextlib.ExitStack позволяет программно управлять стеком callbacks и контекстных менеджеров. ( Python documentation)

Главное #

Контекстный менеджер можно заменить:

ЧемКогда подходит
try/finallyБазовая ручная замена with
Ручным close() / release()Можно, но хуже по надёжности
ДекораторомКогда нужно обернуть всю функцию
contextlib.contextmanagerКогда не хочется писать класс
ExitStackКогда ресурсов много или их количество динамическое


16. Какие знаешь способы реализации контекстного менеджера? #

Основные способы:

1. Класс с __enter__ и __exit__
2. Функция с @contextmanager
3. Асинхронный класс с __aenter__ и __aexit__
4. Асинхронная функция с @asynccontextmanager
5. Через готовые инструменты contextlib: closing, suppress, ExitStack

Контекстный менеджер — это объект, который поддерживает протокол with: при входе вызывается __enter__, при выходе — __exit__. Это базовый механизм, описанный в Python Data Model и PEP 343. ( Python documentation)

1. Через класс: __enter__ и __exit__ #

Самый явный способ.

class FileManager:
    def __init__(self, filename: str, mode: str):
        self.filename = filename
        self.mode = mode
        self.file = None

    def __enter__(self):
        self.file = open(self.filename, self.mode, encoding="utf-8")
        return self.file

    def __exit__(self, exc_type, exc_value, traceback):
        self.file.close()
        return False

Использование:

with FileManager("data.txt", "w") as file:
    file.write("Hello")

Порядок работы:

__enter__ → тело with → __exit__

Этот способ лучше использовать, когда у контекстного менеджера есть состояние: файл, соединение, транзакция, lock, клиент и так далее.

2. Через contextlib.contextmanager #

Можно не писать класс, а сделать контекстный менеджер из генераторной функции.

from contextlib import contextmanager


@contextmanager
def file_manager(filename: str, mode: str):
    file = open(filename, mode, encoding="utf-8")

    try:
        yield file
    finally:
        file.close()

Использование:

with file_manager("data.txt", "w") as file:
    file.write("Hello")

Здесь:

код до yield     → аналог __enter__
yield value      → значение после as
код после yield  → аналог __exit__

contextlib.contextmanager официально предназначен для создания контекстных менеджеров из генераторных функций без отдельного класса. ( Python documentation)

3. Асинхронный контекстный менеджер: __aenter__ и __aexit__ #

Для async with используются методы:

__aenter__
__aexit__

Пример:

class AsyncConnection:
    async def __aenter__(self):
        print("Открываем async-соединение")
        return self

    async def __aexit__(self, exc_type, exc_value, traceback):
        print("Закрываем async-соединение")
        return False

Использование:

async with AsyncConnection() as connection:
    print("Работаем с соединением")

Это нужно для асинхронных ресурсов: HTTP-клиентов, async DB-соединений, async-транзакций, Redis-клиентов и подобных объектов. Python Data Model отдельно описывает асинхронный протокол контекстного менеджера для async with. ( Python documentation)

4. Через contextlib.asynccontextmanager #

Асинхронный аналог @contextmanager.

from contextlib import asynccontextmanager


@asynccontextmanager
async def async_connection():
    print("Открываем соединение")

    try:
        yield "connection"
    finally:
        print("Закрываем соединение")

Использование:

async with async_connection() as connection:
    print(connection)

Этот вариант удобен, когда не хочется писать отдельный класс с __aenter__ и __aexit__. В contextlib есть утилиты для обычных и асинхронных контекстных менеджеров.

5. Через готовые контекстные менеджеры из contextlib #

В стандартной библиотеке есть готовые инструменты. Например, suppress:

from contextlib import suppress
from pathlib import Path


with suppress(FileNotFoundError):
    Path("old.log").unlink()

Он подавляет указанные исключения. closing:

from contextlib import closing


with closing(get_resource()) as resource:
    resource.do_work()

Он вызывает resource.close() при выходе из блока. ExitStack:

from contextlib import ExitStack


files = ["a.txt", "b.txt", "c.txt"]

with ExitStack() as stack:
    opened_files = [
        stack.enter_context(open(name, "r", encoding="utf-8"))
        for name in files
    ]

    # работа со всеми файлами

ExitStack удобен, когда количество контекстных менеджеров заранее неизвестно или ресурсы открываются динамически. Модуль contextlib как раз предоставляет утилиты для типовых задач, связанных с with. ( Python documentation)

Что выбрать #

СпособКогда использовать
Класс с __enter__ / __exit__Когда нужен объект с состоянием
@contextmanagerКогда логика простая и удобно описать её через try/finally
Класс с __aenter__ / __aexit__Для async-ресурсов со своим состоянием
@asynccontextmanagerДля простой async-логики
ExitStackКогда ресурсов много или их количество динамическое


17. Чем отличаются операторы сравнения == и is #

== проверяет, одинаковые ли значения у переменных (проверка равенства значений), используя метод __eq__ (Когда вы используете a == b, Python вызывает метод a.__eq__(b))

is проверяет, указывают ли переменные на один и тот же объект (проверка идентичности), сравнивая адреса в памяти (Python сравнивает идентификаторы объектов: id(a) == id(b))

a = [1, 2]
b = [1, 2]

print(a == b) #True
print(a is b) #False

Копия и глубокая копия #

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

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

import copy

# создание копии объекта
    new_list = old_list.copy()

# создание глубокой копии объекта
    new_list = copy.deepcopy(old_list)
где old_list - исходный список, а new_list - его копия.

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

Управление копированием через __copy__ и __deepcopy__

Метод __copy__

Позволяет кастомизировать поверхностное копирование. Когда вы определяете этот метод в классе, copy.copy() будет использовать вашу реализацию вместо стандартной.

Что можно контролировать:

  • Какие атрибуты копировать, а какие нет
  • Как именно создавать копии отдельных полей
  • Какие данные сбрасывать/обновлять в копии

Метод __deepcopy__

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

Что можно контролировать:

  • Глубину копирования для разных атрибутов
  • Обработку циклических ссылок
  • Оптимизацию процесса (например, не копировать неизменяемые объекты)

Слабые стороны Python #

У языка программирования Python есть несколько слабых сторон, которые связаны с особенностями производительности, потребления памяти, ограничений в области видимости и ограниченности стандартных библиотек для решения некоторых задач.

Производительность

  • Интерпретируемая природа языка — код выполняется построчно интерпретатором, что добавляет накладные расходы по сравнению с компилируемыми языками.
  • Динамическая типизация — проверка типов происходит во время выполнения, что требует дополнительных вычислительных ресурсов.
  • Глобальная блокировка интерпретатора (GIL) — ограничивает выполнение только одной инструкции Python в любой момент времени на одном процессоре, что может стать узким местом в многопоточных приложениях. С версии 3.14 есть обход GIL.
  • Медленные операции ввода-вывода — если программа выполняет много операций ввода-вывода (например, чтение/запись файлов, работа с сетью), производительность может снизиться из-за ожидания завершения этих операций.

Память

  • Python использует больше памяти по сравнению с жёстко типизированными языками, такими как C или C++. Это может стать ограничением при работе с большими данными или в средах с ограниченной памятью.
  • Утечки памяти — память выделяется для конкретной задачи, но не освобождается после завершения процесса. Это особенно вредно, если утечка находится в часто выполняемом участке кода.

Область видимости

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

Это означает, что:

  • Имена, определяемые внутри инструкции def, видны только программному коду внутри этой инструкции, к ним нельзя обратиться за пределами функции.
  • Область видимости переменной определяется местом, где ей было присвоено значение, а не местом, откуда была вызвана функция.

Библиотеки

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

  1. Библиотеки с агрессивным кэшированием (для производительности)
    TensorFlow / PyTorch — преднамеренно кэшируют память на GPU/CPU между операциями для ускорения вычислений. Это может выглядеть как «утечка», но на самом деле — оптимизация.
    Решение: torch.cuda.empty_cache(), tf.keras.backend.clear_session().
    NumPy — при работе с большими массивами и срезами (views) может удерживать ссылки на исходные данные дольше ожидаемого.
    Решение: использовать .copy() при необходимости.
  2. Библиотеки с глобальным состоянием
    Matplotlib — сохраняет все созданные фигуры в памяти до явного вызова plt.close() или plt.clf(). При генерации тысяч графиков в цикле это приводит к утечкам.
    Решение: всегда вызывать plt.close(fig) после сохранения.
    OpenCV (cv2) — при работе с видео (cv2.VideoCapture) требует явного освобождения ресурсов через .release().
  3. ORM и кэши сессий
    SQLAlchemy — identity map и кэш сессии могут удерживать объекты в памяти при долгоживущих сессиях.
    Решение: использовать session.expunge_all(), session.close() или паттерн «сессия на запрос».

Конфликты имён — например, в модуле math есть функция sqrt(), но и в библиотеке cmath (для комплексных чисел) есть такая же функция. Это может привести к ошибкам, так как не ясно, из какого модуля используется функция.

Кто или что создает объект в Python? #

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

Что такое “слабая” ссылка? #

“Слабая ссылка” (англ. weak reference) — это специальный тип ссылки на объект в программировании, который не препятствует удалению этого объекта сборщиком мусора.

Обычная (сильная) ссылка “удерживает” объект в памяти: пока на него есть хотя бы одна такая ссылка — он не будет удалён. А слабая ссылка не удерживает объект. Если все обычные ссылки на объект исчезли, то даже если есть слабые — объект будет удалён из памяти.

Где применяется:

  • Кэширование
  • Системы наблюдения (observer pattern)
  • Деревья с родительскими ссылками (например, DOM)
  • Оптимизация памяти в длительных процессах

Модуль weakref — это встроенный модуль Python, который позволяет создавать слабые ссылки (weak references) на объекты.

import weakref

class Well:
    def __init__(self, name):
        self.name = name

well = Well("WELL-101")
weak_ref = weakref.ref(well)  # слабая ссылка

print(weak_ref())  # <__main__.Well object> — объект жив

del well          # последняя сильная ссылка удалена
print(weak_ref())  # None — объект уничтожен сборщиком мусора


18. Чем отличается @classmethod от @staticmethod? #

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

classmethod:

  • Декоратор преобразует обычный метод класса в тот, который принимает первым аргументом ссылку на класс (обычно называемый cls).
  • Это означает, что метод classmethod может обращаться к атрибутам и вызывать другие методы класса через ссылку на сам класс, а не через экземпляр класса.
  • Может использоваться, например, для создания альтернативных конструкторов класса или для работы с классовыми переменными.
class MyClass:
    class_attribute = 123

    @classmethod
    def class_method(cls):
        return cls.class_attribute

print(MyClass.class_method())  # Выведет: 123

staticmethod:

  • Декоратор создает метод класса, который не принимает ссылку на сам класс (неявно или явно), и не принимает ссылку на экземпляр класса (обычно называемый self).
  • Это означает, что staticmethod является статическим методом и может быть вызван как из класса, так и из экземпляра класса, но не имеет доступа к атрибутам и методам класса или экземпляра.
  • Статические методы могут быть полезны для группировки связанных функций внутри класса или для создания методов, которые не требуют доступа к состоянию класса или экземпляра.
class MyClass:
    @staticmethod
    def static_method():
        return "This is a static method"

print(MyClass.static_method())  # Выведет: This is a static method

Основное отличие между classmethod и staticmethod заключается в том, что classmethod принимает ссылку на класс, а staticmethod - нет.


19. Чем отличается первый аргумент classmethod от метода экземпляра? #

Метод экземпляраClassmethod
Первый аргументselfcls
Что получаетСам объект (экземпляр)Сам класс (не экземпляр!)
ДоступК атрибутам конкретного объектаК атрибутам класса
Когда использоватьКогда работаем с данными объектаКогда работаем с классом в целом
class Student:
    school = "Школа №1"  # Атрибут класса

    def __init__(self, name):
        self.name = name  # Атрибут экземпляра

    # Метод экземпляра — получает self (конкретного студента)
    def show_name(self):
        print(f"Студент: {self.name}")  # Данные конкретного объекта

    # Classmethod — получает cls (класс Student)
    @classmethod
    def show_school(cls):
        print(f"Учебное заведение: {cls.school}")  # Данные класса

    @classmethod
    def create_anonymous(cls):
        return cls("Аноним")  # Создает НОВЫЙ экземпляр класса

# Использование
s = Student("Маша")
s.show_name()        # self = s (объект Маша)
Student.show_school() # cls = Student (сам класс)
anon = Student.create_anonymous()  # cls = Student, создает нового студента

Что получают методы?

class Demo:
    class_attr = "общее"

    def __init__(self, value):
        self.instance_attr = value

    def instance_method(self):
        print(f"self = {self}")              # Конкретный объект
        print(f"Через self: {self.instance_attr}")  # Данные объекта
        print(f"Через self: {self.class_attr}")      # Тоже работает

    @classmethod
    def class_method(cls):
        print(f"cls = {cls}")                 # Сам класс
        print(f"Через cls: {cls.class_attr}")  # Данные класса
        # cls.instance_attr — ОШИБКА! у класса нет атрибутов экземпляра

obj = Demo("индивидуальное")
obj.instance_method()  # self = <__main__.Demo object at 0x...>
Demo.class_method()    # cls = <class '__main__.Demo'>

Таблица сравнения

Аспектself (метод экземпляра)cls (classmethod)
Что этоЭкземпляр классаСам класс
Когда вызываетсяobj.method()Class.method()
Может менятьАтрибуты объектаАтрибуты класса
Может создаватьНовые атрибуты объектаНовые экземпляры класса
Доступ к атрибутам классаДа (через self.__class__)Да (прямо через cls)
Доступ к атрибутам объектаДаНет

self — это “конкретный объект”, а cls — это “тип объекта” (сам класс).


20. Что такое self в методах класса? #

self — это ссылка на текущий экземпляр класса (на самого себя).

Как работает

class Person:
    def __init__(self, name):  # self — новый объект
        self.name = name        # сохраняем имя В ЭТОТ объект

    def hello(self):            # self — конкретный объект
        print(f"Привет, я {self.name}")

p = Person("Анна")  # self внутри = p
p.hello()          # self внутри = p → "Привет, я Анна"

Что происходит на самом деле

p.hello()          # Python превращает в:
Person.hello(p)    # self = p (объект передается первым аргументом)

Главные правила

  1. Первый параметр любого метода экземпляра — self
  2. Не нужно передавать при вызове — Python подставляет автоматически
  3. Через self обращаемся к атрибутам и методам объекта

Итоги

  • self — это сам объект, от которого вызван метод
  • Нужен для доступа к атрибутам и другим методам
  • Без self метод не знает, с каким именно объектом работать
  • Можно назвать по-другому, но все используют self


21. Является ли класс объектом в модели данных? #

Да, класс в Python — это объект #

В модели данных Python всё является объектом: числа, строки, функции, модули, классы и даже сам код. Официальная документация Python прямо говорит: все данные в программе представлены объектами или отношениями между объектами. ( Python documentation)

Пример:

class User:
    pass

print(type(User))

Результат:

<class 'type'>

Что это означает #

Когда ты пишешь:

class User:
    pass

Python создаёт объект класса User.

То есть User — не просто “описание” для будущих объектов. Это полноценный объект, у которого есть:

User.__name__
User.__dict__
User.__bases__
User.__mro__

Например:

class User:
    role = "admin"

print(User.__name__)   # User
print(User.role)       # admin

Связь с экземплярами #

user = User()

Здесь:

User

— объект класса.

А:

user

— объект-экземпляр этого класса.

Проверка:

print(type(user))  # <class '__main__.User'>
print(type(User))  # <class 'type'>

То есть:

user -> экземпляр класса User
User -> экземпляр метакласса type

Итоговая схема #

user является объектом класса User
User является объектом класса type
type тоже является объектом

Коротко:

isinstance(User, object)  # True
isinstance(User, type)    # True

Вывод #

Да, класс является объектом в модели данных Python.

Более точно: класс — это объект, который обычно является экземпляром метакласса type. Через это и работают classmethod, метаклассы, динамическое создание классов и доступ к атрибутам самого класса.


22. Какой метод вызывается при удалении объекта? #

Основной метод: __del__()

class MyClass:
    def __del__(self):
        print("Объект удаляется")

obj = MyClass()
del obj  # Явное удаление → вызов __del__

Важные особенности

  1. __del__ — деструктор, но работает не так, как в других языках
  2. del не гарантирует немедленный вызов __del__
  3. Вызывается сборщиком мусора, когда объект действительно удаляется
  4. При циклических ссылках может не вызваться вообще
  5. Исключения внутри __del__ подавляются

Главный недостаток

obj = MyClass()
del obj  # __del__ может вызваться сейчас... а может через секунду

Нет гарантии, когда именно выполнится __del__

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

class Resource:
    def __enter__(self): ...
    def __exit__(self, *args): ...  # ← Гарантированно выполнится

with Resource() as r:  # __exit__ вызовется сразу после блока
    pass

Итоги

  • __del__ существует, но ненадежен для критической очистки
  • Используйте with и контекстные менеджеры для гарантированного освобождения ресурсов
  • __del__ лучше вообще не использовать в реальном коде


23. Как определяется хэшируемость объектов и их связь с магическими методами __hash__ и __eq__ #

Что такое хэшируемость #

Объект считается хэшируемым, если:

  1. У него есть хэш-значение, которое не меняется в течение жизни объекта.
  2. Он может сравниваться с другими объектами через __eq__.
  3. Если два объекта равны через __eq__, то их хэши обязаны быть одинаковыми. Это правило прямо указано в документации Python. ( Python documentation)

Хэш нужен для использования объекта как ключа в dict или элемента в set / frozenset. Встроенная функция hash() возвращает целочисленный хэш объекта, который используется, например, при поиске ключей в словаре. ( Python documentation)

Пример:

x = "hello"

print(hash(x))

Строка хэшируема, потому что она неизменяемая.


Связь с __hash__ #

Метод:

__hash__(self)

должен возвращать целое число:

class User:
    def __init__(self, user_id):
        self.user_id = user_id

    def __hash__(self):
        return hash(self.user_id)

Когда ты вызываешь:

hash(user)

Python фактически обращается к:

user.__hash__()

Также __hash__ используется внутри dict и set.


Связь с __eq__ #

Метод:

__eq__(self, other)

отвечает за сравнение объектов через ==.

Пример:

class User:
    def __init__(self, user_id):
        self.user_id = user_id

    def __eq__(self, other):
        if not isinstance(other, User):
            return NotImplemented
        return self.user_id == other.user_id

Теперь:

u1 = User(1)
u2 = User(1)

print(u1 == u2)  # True

Но если объекты равны, то их хэши тоже должны быть равны:

hash(u1) == hash(u2)

Иначе объект нельзя корректно использовать в dict и set.


Правильная реализация __eq__ и __hash__ #

class User:
    def __init__(self, user_id):
        self.user_id = user_id

    def __eq__(self, other):
        if not isinstance(other, User):
            return NotImplemented
        return self.user_id == other.user_id

    def __hash__(self):
        return hash(self.user_id)

Теперь:

u1 = User(1)
u2 = User(1)

print(u1 == u2)              # True
print(hash(u1) == hash(u2))  # True

users = {u1, u2}
print(len(users))            # 1

Почему len(users) == 1?

Потому что для set эти объекты считаются одинаковыми:

u1 == u2

и имеют одинаковый хэш.


Что будет, если определить __eq__, но не определить __hash__ #

В Python есть важное правило: если класс переопределяет __eq__, но не определяет __hash__, то Python делает класс нехэшируемым. В таком случае __hash__ становится None. ( Python documentation)

Пример:

class User:
    def __init__(self, user_id):
        self.user_id = user_id

    def __eq__(self, other):
        if not isinstance(other, User):
            return NotImplemented
        return self.user_id == other.user_id


u = User(1)

print(hash(u))

Будет ошибка:

TypeError: unhashable type: 'User'

Проверка:

print(User.__hash__)  # None

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


Почему нельзя делать хэш от изменяемых данных #

Плохой пример:

class User:
    def __init__(self, name):
        self.name = name

    def __eq__(self, other):
        if not isinstance(other, User):
            return NotImplemented
        return self.name == other.name

    def __hash__(self):
        return hash(self.name)

Проблема:

u = User("Alex")

users = {u}

u.name = "Bob"

print(u in users)  # может дать неожиданное поведение

Объект уже лежит в set по старому хэшу, но после изменения name его хэш изменился. Поэтому хэшируемые объекты должны опираться на данные, которые не меняются во время жизни объекта.


Поведение по умолчанию #

Если ты вообще не определяешь __eq__ и __hash__, класс наследует поведение от object.

Пример:

class User:
    pass

u1 = User()
u2 = User()

print(u1 == u2)        # False
print(hash(u1))        # работает
print(hash(u2))        # работает

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

То есть:

u1 == u2

будет False, потому что это два разных объекта.


Краткая таблица #

СитуацияХэшируемость
Не определены ни __eq__, ни __hash__объект хэшируем
Определён __eq__, но не определён __hash__объект нехэшируем
Определены и __eq__, и корректный __hash__объект хэшируем
__hash__ = Noneобъект явно нехэшируем
Хэш зависит от изменяемых полейтехнически может работать, но это ошибка проектирования

Главное правило #

Если переопределяешь __eq__, почти всегда нужно подумать о __hash__.

Правильный контракт:

a == b  =>  hash(a) == hash(b)

Но обратное не обязано быть верным:

hash(a) == hash(b)  не значит, что  a == b

Одинаковые хэши у разных объектов возможны. Это называется коллизия хэшей.


24. Какие объекты и сущности участвуют в создании класса #

В создании класса участвуют не только class и type, а целая цепочка сущностей:

class User(Base, metaclass=Meta):
    x = 10

    def method(self):
        pass

При выполнении этого кода Python создаёт объект класса User.

Официальная документация описывает этапы так: разрешаются MRO-базы, определяется метакласс, подготавливается namespace, выполняется тело класса, затем создаётся объект класса.


Основные участники создания класса #

УчастникРоль
class statementсинтаксическая инструкция создания класса
имя классанапример User
базовые классынапример (Base,)
метаклассобычно type, либо явно заданный Meta
namespace классасловарь/маппинг, куда попадают атрибуты класса
тело классакод внутри class, который заполняет namespace
функции внутри классабудущие методы
дескрипторыобъекты с __get__, __set__, __set_name__
type.__new__реально создаёт объект класса
__init_subclass__хук родителя при создании подкласса
декораторы классамогут заменить уже созданный класс другим объектом

Пошагово #

1. Python встречает инструкцию class #

class User:
    pass

class — это не просто объявление, а выполняемая инструкция. В документации Python определения функций и классов относятся к compound statements, то есть составным инструкциям.


2. Определяются базовые классы #

class User(Base):
    pass

Здесь Base участвует в создании нового класса. Python использует базовые классы для построения наследования и MRO.

Если в базах есть специальные объекты с __mro_entries__, они могут быть заменены на другие базы перед созданием класса. Это используется, например, в механизмах typing/generic-классов.


3. Определяется метакласс #

По умолчанию классы создаются через type. Документация прямо говорит: по умолчанию классы конструируются с помощью type(), тело класса выполняется в новом namespace, а имя класса связывается с результатом type(name, bases, namespace).

То есть примерно:

class User:
    pass

похоже на:

User = type("User", (), {})

Если указан свой метакласс:

class User(metaclass=Meta):
    pass

то объект класса будет создавать Meta, а не напрямую обычный type.


4. Готовится namespace класса #

Перед выполнением тела класса Python создаёт пространство имён класса.

Обычно это обычный словарь:

{
    "__module__": "...",
    "__qualname__": "User",
    "x": 10,
    "method": <function method>
}

Но метакласс может управлять этим через:

__prepare__

Пример:

class Meta(type):
    @classmethod
    def __prepare__(mcls, name, bases):
        print("__prepare__")
        return {}

class User(metaclass=Meta):
    x = 10

Если у метакласса есть __prepare__, Python вызывает его как metaclass.__prepare__(name, bases, **kwds). Полученный namespace затем передаётся в __new__.


5. Выполняется тело класса #

class User:
    x = 10

    def method(self):
        pass

Тело класса выполняется примерно как:

exec(body, globals(), namespace)

Это значит, что x, method, __module__, __qualname__ и другие имена попадают в namespace класса.

Важно: функция method на этом этапе — ещё просто объект функции, лежащий в namespace класса. Методом она станет позже, когда будет получена через экземпляр:

u = User()
u.method()

6. Метакласс создаёт объект класса #

После выполнения тела класса Python вызывает:

metaclass(name, bases, namespace, **kwds)

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

Для обычного класса это фактически:

User = type("User", (Base,), namespace)

Внутри этого процесса обычно участвуют:

Meta.__new__
Meta.__init__

Пример:

class Meta(type):
    def __new__(mcls, name, bases, namespace):
        print("Создание класса:", name)
        return super().__new__(mcls, name, bases, namespace)

    def __init__(cls, name, bases, namespace):
        print("Инициализация класса:", name)
        super().__init__(name, bases, namespace)

class User(metaclass=Meta):
    pass

7. Вызывается __set_name__ у дескрипторов #

Если в классе есть объект с методом:

__set_name__(self, owner, name)

то Python вызывает его при создании класса.

Пример:

class Field:
    def __set_name__(self, owner, name):
        print(owner, name)

class User:
    name = Field()

При создании User будет вызвано:

name.__set_name__(User, "name")

Документация указывает, что type.__new__() сканирует переменные класса и вызывает __set_name__ у тех объектов, где этот хук определён.


8. Вызывается __init_subclass__ у родителя #

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

__init_subclass__

Пример:

class Base:
    def __init_subclass__(cls):
        print("Создан подкласс:", cls.__name__)

class User(Base):
    pass

При создании User Python вызовет хук родителя Base.__init_subclass__. В документации сказано, что при наследовании __init_subclass__() вызывается на родительском классе, а cls — это новый подкласс.


10. Применяются декораторы класса #

def decorator(cls):
    cls.extra = 123
    return cls

@decorator
class User:
    pass

Сначала создаётся класс User, потом он передаётся в декоратор. Результат декоратора связывается с именем User. Документация указывает, что после создания объект класса передаётся декораторам класса, если они есть, и результат связывается с именем класса.


Полная схема #

class User(Base, metaclass=Meta):
    x = 10
    def method(self): ...

Примерная внутренняя логика:

1. взять имя класса: "User"
2. взять базовые классы: (Base,)
3. определить метакласс: Meta или type
4. вызвать Meta.__prepare__("User", (Base,))
5. выполнить тело класса в namespace
6. вызвать Meta("User", (Base,), namespace)
7. внутри создать объект класса
8. вызвать __set_name__ у дескрипторов
9. вызвать __init_subclass__ у родителя
10. применить декораторы класса
11. связать имя User с созданным объектом

Итог #

В создании класса участвуют:

class statement
имя класса
базовые классы
метакласс
namespace класса
тело класса
функции/атрибуты/дескрипторы внутри тела
type.__new__ / метакласс.__new__
метакласс.__init__
__set_name__
__init_subclass__
декораторы класса

Главный объект, который реально создаёт класс, — это метакласс. По умолчанию это type.


25. Что такое исключение (exception) и какую роль оно играет в обработке ошибок? #

Что такое исключение #

Исключение — это объект, который сообщает о нештатной ситуации во время выполнения программы.

В Python все исключения являются экземплярами классов, наследующихся от BaseException. Для обычных ошибок в пользовательском коде обычно используют Exception и его потомков: ValueError, TypeError, KeyError, RuntimeError и другие.

Пример:

int("abc")

Результат:

ValueError: invalid literal for int() with base 10: 'abc'

Здесь ValueError — это исключение. Оно говорит: программа пыталась преобразовать строку в число, но значение некорректное.

Обработка исключений в Python реализуется с помощью конструкции try—except—finally:

try:
    # Python пробует выполнить эту часть кода
except:
    # к этому блоку переходит, если не получилось выполнить try
finally:
    # этот блок выполняется всегда

Ветка else в конструкции _try…except…else_ будет выполнена только в том случае, если исключения не было возбуждено в блоке try. Если в блоке try произошло исключение, то выполнение программы переходит к соответствующему блоку except, и ветка else пропускается. Если блок except не указан, то исключение будет возбуждено дальше, а программа завершится с сообщением об ошибке.

Пример, в котором будет выполнена ветка else

try:
   # some code here
except:
   # code to handle the exception
else:
   # code to execute if there is no exception

Если в блоке try не возникает исключений, то выполняется код в блоке else.

Можно перечислить несколько блоков except для разных типов ошибок — каждый будет срабатывать только на свой тип исключения. Python проверяет их по порядку и выполняет первый подходящий блок. Порядок блоков имеет значение — сначала нужно обрабатывать конкретные типы ошибок, потом более общие.

Когда несколько разных ошибок требуют одинаковой обработки, можно объединить их в один блок except.

try:
    num = float(input("Введите число: "))
    result = 100 / num
except (ValueError, TypeError, ZeroDivisionError) as e:
    print("Ошибка ввода или вычисления!")
    print(f"Детали: {e}")

Статья на хабре:  Полное руководство по обработке ошибок в Python

Роль исключений в обработке ошибок #

Исключения позволяют отделить обычную логику программы от логики обработки ошибок.

Без исключений пришлось бы постоянно вручную проверять результат каждой операции:

result = divide(a, b)

if result is None:
    print("Ошибка")
else:
    print(result)

С исключениями можно писать нормальный сценарий отдельно, а обработку ошибки — отдельно:

try:
    result = 10 / 0
except ZeroDivisionError:
    print("Деление на ноль невозможно")

Если исключение возникает внутри блока try, Python ищет подходящий блок except. Если подходящий обработчик найден, ошибка обрабатывается. Если нет — исключение поднимается выше по стеку вызовов.

Как это работает по шагам #

try:
    value = int("abc")
except ValueError:
    print("Некорректное число")

Логика такая:

1. Python выполняет код внутри try.
2. int("abc") вызывает ValueError.
3. Выполнение try прерывается.
4. Python ищет except ValueError.
5. Если находит — выполняет его.
6. Программа может продолжить работу.

Исключение — это объект #

Исключение можно поймать в переменную:

try:
    int("abc")
except ValueError as error:
    print(type(error))
    print(error)

Вывод будет примерно такой:

<class 'ValueError'>
invalid literal for int() with base 10: 'abc'

То есть error — это объект исключения.

Зачем нужны разные типы исключений #

Разные исключения позволяют точнее понять, что именно сломалось:

try:
    data = {"name": "Alex"}
    print(data["age"])
except KeyError:
    print("Нет такого ключа")
except TypeError:
    print("Неверный тип данных")

KeyError означает проблему с отсутствующим ключом. TypeError означает операцию с неподходящим типом. ValueError означает, что тип подходящий, но само значение некорректное.

raise #

Исключения можно не только ловить, но и создавать самому:

def set_age(age):
    if age < 0:
        raise ValueError("Возраст не может быть отрицательным")
    return age

Здесь raise явно сообщает: дальше выполнять функцию нельзя, потому что данные нарушают правило.

Итог #

Исключение — это объект ошибки, который прерывает обычный ход выполнения программы и передаёт управление механизму обработки ошибок.

Главная роль исключений:

сообщить об ошибке
прервать некорректное выполнение
передать ошибку выше по стеку
дать возможность обработать ошибку в нужном месте
отделить обычную бизнес-логику от обработки нештатных ситуаций

Коротко:

try:
    # обычная логика
except SomeException:
    # обработка ошибки

Исключения — это основной механизм обработки ошибок в Python.


26. Какие дополнительные блоки, помимо try и except, существуют в конструкции обработки исключений (else, finally) и когда они выполняются? #

Дополнительные блоки: else и finally #

В конструкции обработки исключений кроме try и except могут использоваться:

try:
    ...
except SomeError:
    ...
else:
    ...
finally:
    ...

Официальная документация Python описывает else как блок, который выполняется, если в try не возникло исключение, а finally — как блок, который выполняется перед завершением конструкции try независимо от того, было исключение или нет.

else #

Блок else выполняется только если в блоке try не возникло исключение.

Пример:

try:
    number = int("123")
except ValueError:
    print("Ошибка преобразования")
else:
    print("Преобразование прошло успешно")

Результат:

Преобразование прошло успешно

Логика:

try выполнился без ошибки
except пропущен
else выполнен

Если ошибка возникнет:

try:
    number = int("abc")
except ValueError:
    print("Ошибка преобразования")
else:
    print("Преобразование прошло успешно")

Результат:

Ошибка преобразования

else уже не выполнится.

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

else используют, когда нужно выполнить код только после успешного выполнения try.

Например:

try:
    user_id = int(raw_user_id)
except ValueError:
    print("Некорректный id")
else:
    user = get_user_from_db(user_id)

Так код читается лучше: в try находится только потенциально опасная операция, а успешная логика вынесена в else.

finally #

Блок finally выполняется в любом случае:

try:
    ...
except SomeError:
    ...
finally:
    ...

Он выполнится:

если ошибки не было
если ошибка была и её обработал except
если ошибка была, но её не обработали
если внутри try был return
если внутри try был break или continue

Документация Python отдельно указывает, что finally выполняется перед фактическим выходом из конструкции при return, break или continue. ( Python documentation)

Пример:

try:
    number = int("abc")
except ValueError:
    print("Ошибка")
finally:
    print("Завершение")

Результат:

Ошибка
Завершение

Пример без ошибки:

try:
    number = int("123")
except ValueError:
    print("Ошибка")
finally:
    print("Завершение")

Результат:

Завершение

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

finally используют для гарантированной очистки ресурсов:

file = open("data.txt")

try:
    data = file.read()
except OSError:
    print("Ошибка чтения файла")
finally:
    file.close()

Даже если при чтении файла возникнет ошибка, file.close() всё равно выполнится.

На практике для файлов чаще используют контекстный менеджер:

with open("data.txt") as file:
    data = file.read()

Но смысл тот же: гарантированно освободить ресурс.

Полный пример с else и finally #

try:
    number = int("10")
except ValueError:
    print("Ошибка: нельзя преобразовать в число")
else:
    print("Ошибки не было")
finally:
    print("Этот блок выполнится всегда")

Результат:

Ошибки не было
Этот блок выполнится всегда

Если заменить "10" на "abc":

try:
    number = int("abc")
except ValueError:
    print("Ошибка: нельзя преобразовать в число")
else:
    print("Ошибки не было")
finally:
    print("Этот блок выполнится всегда")

Результат:

Ошибка: нельзя преобразовать в число
Этот блок выполнится всегда

Итоговая схема #

БлокКогда выполняется
tryвсегда первым
exceptесли в try возникло подходящее исключение
elseесли в try не было исключения
finallyвсегда, перед завершением конструкции

Коротко:

try     — пробуем выполнить код
except  — обрабатываем ошибку
else    — выполняем код при успешном try
finally — выполняем завершающие действия всегда


27. Как работает обработчик исключений (exception) #

Что такое обработчик исключений #

Обработчик исключений — это блок except, который перехватывает ошибку, возникшую в try, и решает, что делать дальше.

Базовая форма:

try:
    # код, где может возникнуть ошибка
except SomeException:
    # обработчик ошибки

По документации Python: если исключение возникает во время выполнения блока try, оставшаяся часть try пропускается, затем Python ищет подходящий except. Если тип исключения совпадает с указанным в except, выполняется этот обработчик.

Как работает по шагам #

try:
    number = int("abc")
    print("Эта строка не выполнится")
except ValueError:
    print("Ошибка: нельзя преобразовать строку в число")

print("Программа продолжила работу")

Что происходит:

1. Python входит в блок try.
2. Выполняет int("abc").
3. Возникает исключение ValueError.
4. Остальная часть try пропускается.
5. Python ищет except, который подходит для ValueError.
6. Находит except ValueError.
7. Выполняет код внутри except.
8. После этого программа продолжает работу дальше.

Результат:

Ошибка: нельзя преобразовать строку в число
Программа продолжила работу

Если обработчик не подходит #

try:
    number = int("abc")
except TypeError:
    print("Ошибка типа")

Здесь возникнет ValueError, но обработчик указан для TypeError.

Поэтому except TypeError не сработает. Исключение пойдёт выше по стеку вызовов. Если подходящий обработчик нигде не найден, программа завершится с traceback. Документация Python описывает это как передачу исключения внешним try-блокам; если обработчик не найден, исключение становится необработанным.

Обработчик можно выбрать по типу исключения #

try:
    data = {"name": "Alex"}
    print(data["age"])
except KeyError:
    print("Нет такого ключа")
except TypeError:
    print("Неверный тип объекта")
except ValueError:
    print("Некорректное значение")

Python проверяет except сверху вниз и выполняет первый подходящий обработчик. Поэтому порядок важен.

Плохо:

try:
    ...
except Exception:
    print("Общая ошибка")
except ValueError:
    print("Ошибка значения")

except ValueError здесь фактически бесполезен, потому что ValueError наследуется от Exception, а Exception стоит выше и перехватит его раньше.

Правильно:

try:
    ...
except ValueError:
    print("Ошибка значения")
except Exception:
    print("Общая ошибка")

Объект исключения можно получить через as #

try:
    number = int("abc")
except ValueError as error:
    print(type(error))
    print(error)

Результат:

<class 'ValueError'>
invalid literal for int() with base 10: 'abc'

Здесь error — это объект исключения.

Обычно это используют, чтобы залогировать ошибку или передать её дальше:

try:
    number = int("abc")
except ValueError as error:
    print(f"Ошибка преобразования: {error}")

Несколько типов в одном обработчике #

Можно обработать несколько исключений одинаково:

try:
    value = data["age"]
    number = int(value)
except (KeyError, ValueError):
    print("Не удалось получить корректный возраст")

Этот обработчик сработает и при отсутствии ключа, и при неправильном значении.

Голый except лучше не использовать #

Плохо:

try:
    ...
except:
    print("Ошибка")

Такой обработчик ловит слишком многое, включая исключения, которые обычно не стоит перехватывать.

Лучше указывать конкретный тип:

try:
    ...
except ValueError:
    print("Некорректное значение")

Или, если нужен общий обработчик обычных ошибок:

try:
    ...
except Exception as error:
    print(error)

Обычные пользовательские ошибки обычно наследуются от Exception, а Exception сам наследуется от BaseException.

Обработчик может не только обработать ошибку, но и пробросить её дальше #

try:
    number = int("abc")
except ValueError as error:
    print("Логируем ошибку")
    raise

raise без аргументов внутри except повторно поднимает то же самое исключение.

Это используют, когда нужно:

1. Зафиксировать ошибку.
2. Не скрывать её.
3. Передать выше по стеку.

Общая схема #

try:
    # опасный код
except SpecificError as error:
    # обработка конкретной ошибки
except AnotherError:
    # обработка другой ошибки
else:
    # выполнится, если ошибок в try не было
finally:
    # выполнится всегда

Итог #

Обработчик исключений работает так:

try запускает потенциально опасный код
если ошибки нет — except пропускается
если ошибка есть — выполнение try прерывается
Python ищет подходящий except по типу исключения
если нашёл — выполняет обработчик
если не нашёл — исключение передаётся выше
finally, если есть, выполняется в любом случае

Коротко:

try:
    risky_operation()
except SomeException:
    handle_error()

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


28. Когда используют try-finally без except #

Когда используют try-finally без except #

try-finally используют, когда ошибку не нужно обрабатывать в этом месте, но нужно гарантированно выполнить завершающее действие.

То есть:

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

finally выполнится независимо от результата:

ошибки не было           -> finally выполнится
ошибка была              -> finally выполнится, потом ошибка пойдёт выше
был return               -> finally выполнится перед return
был break / continue     -> finally выполнится перед выходом

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

Главная идея #

try-finally без except означает:

я не ловлю ошибку здесь,
но перед выходом обязан выполнить cleanup

Например: закрыть файл, соединение, lock, временно изменить состояние и потом вернуть обратно.

Пример с файлом #

file = open("data.txt")

try:
    data = file.read()
finally:
    file.close()

Если file.read() завершится успешно, файл закроется. Если file.read() выбросит ошибку, файл всё равно закроется, а ошибка пойдёт выше.

Сейчас для файлов обычно используют with, но try-finally показывает сам механизм:

with open("data.txt") as file:
    data = file.read()

Документация Python прямо рекомендует явно освобождать внешние ресурсы и указывает try-finally и with как удобные способы для этого. ( Python documentation)

Пример с lock #

lock.acquire()

try:
    update_shared_data()
finally:
    lock.release()

Здесь важно не потерять release() даже при ошибке.

Без finally можно случайно оставить lock захваченным:

lock.acquire()

update_shared_data()  # если тут ошибка, release не выполнится

lock.release()

Это может привести к зависанию других частей программы.

Пример с временным изменением состояния #

old_value = config.debug
config.debug = True

try:
    run_task()
finally:
    config.debug = old_value

Даже если run_task() упадёт с ошибкой, старое значение config.debug будет восстановлено.

Отличие от try-except-finally #

С except:

try:
    risky_operation()
except SomeError:
    handle_error()
finally:
    cleanup()

Здесь ошибка обрабатывается.

Без except:

try:
    risky_operation()
finally:
    cleanup()

Здесь ошибка не обрабатывается, но cleanup всё равно выполняется.

Важное предупреждение #

Не стоит делать return внутри finally, если есть риск исключения:

def func():
    try:
        1 / 0
    finally:
        return 42

Такой return подавит исходную ошибку ZeroDivisionError. Это может скрыть реальную проблему. В Python 3.14 компилятор даже выдаёт SyntaxWarning для случаев, где return, break или continue выходят из finally.

Итог #

try-finally без except используют, когда нужно:

гарантированно освободить ресурс
закрыть файл / соединение / сокет
освободить lock
откатить временное изменение состояния
выполнить cleanup
не скрывать ошибку, а передать её выше

Коротко:

try:
    do_work()
finally:
    cleanup()

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


29. В каких случаях несколько объектов могут ссылаться на один и тот же объект? #

Кратко #

Несколько объектов или имён могут ссылаться на один и тот же объект, когда в Python создаётся несколько ссылок на один объект, а не несколько копий объекта.

В Python объект имеет идентичность, тип и значение. Идентичность можно проверить через is или id(): is сравнивает, один и тот же это объект или нет.


1. Простое присваивание #

a = [1, 2, 3]
b = a

print(a is b)  # True

Здесь a и b ссылаются на один и тот же список.

Схема:

a ─┐
   ├──> [1, 2, 3]
b ─┘

Поэтому изменение через b видно через a:

b.append(4)

print(a)  # [1, 2, 3, 4]
print(b)  # [1, 2, 3, 4]

Присваивание в Python не копирует объект, а связывает имя с объектом. Это прямо указано в официальном tutorial: assignments do not copy data, they bind names to objects.


2. Передача объекта в функцию #

def add_item(items):
    items.append(4)

numbers = [1, 2, 3]
add_item(numbers)

print(numbers)  # [1, 2, 3, 4]

items внутри функции и numbers снаружи ссылаются на один список.

Схема:

numbers ─┐
         ├──> [1, 2, 3]
items  ──┘

Поэтому функция может изменить объект, если он изменяемый.


3. Один объект лежит в нескольких контейнерах #

user = {"name": "Alex"}

list1 = [user]
list2 = [user]

list1[0]["name"] = "Bob"

print(list2[0]["name"])  # Bob

list1 и list2 — разные списки, но внутри них лежит ссылка на один и тот же словарь.

Проверка:

print(list1 is list2)        # False
print(list1[0] is list2[0])  # True

4. Несколько атрибутов ссылаются на один объект #

class User:
    pass

shared_data = []

u1 = User()
u2 = User()

u1.data = shared_data
u2.data = shared_data

u1.data.append("test")

print(u2.data)  # ['test']

Здесь u1.data и u2.data ссылаются на один список.


5. Общий изменяемый атрибут класса #

class User:
    tags = []

u1 = User()
u2 = User()

u1.tags.append("admin")

print(u2.tags)  # ['admin']

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

Проверка:

print(u1.tags is u2.tags)  # True

Правильнее для индивидуального состояния делать так:

class User:
    def __init__(self):
        self.tags = []

Теперь:

u1 = User()
u2 = User()

print(u1.tags is u2.tags)  # False

6. Поверхностное копирование #

При shallow copy создаётся новый внешний контейнер, но вложенные объекты остаются теми же самыми. Документация copy описывает это так: shallow copy создаёт новый составной объект и вставляет в него ссылки на объекты из оригинала.

import copy

a = [[1, 2], [3, 4]]
b = copy.copy(a)

print(a is b)        # False
print(a[0] is b[0])  # True

Схема:

a ──> [  ref  ,  ref  ]
          │       │
          v       v
        [1,2]   [3,4]
          ^       ^
          │       │
b ──> [  ref  ,  ref  ]

Поэтому:

b[0].append(99)

print(a)  # [[1, 2, 99], [3, 4]]
print(b)  # [[1, 2, 99], [3, 4]]

7. Несколько имён для одного класса или функции #

Классы и функции тоже объекты.

class User:
    pass

Account = User

print(Account is User)  # True

Теперь User и Account — два имени одного и того же объекта класса.

Официальный Python FAQ показывает похожий пример: B = A, затем экземпляры и переменные могут ссылаться на один и тот же объект.


8. Неизменяемые объекты и оптимизации Python #

Иногда несколько переменных могут ссылаться на один и тот же неизменяемый объект:

a = 10
b = 10

print(a is b)  # часто True

Но для чисел, строк и других immutable-объектов не стоит строить бизнес-логику на is.

Правильно сравнивать значения через ==:

a == b

А is использовать, когда нужно проверить именно идентичность объекта:

x is None

Главное отличие: один объект или равные объекты #

a = [1, 2]
b = [1, 2]

print(a == b)  # True
print(a is b)  # False

Здесь списки равны по содержимому, но это два разных объекта.

А здесь:

a = [1, 2]
b = a

print(a == b)  # True
print(a is b)  # True

Это один и тот же объект.


Итог #

Несколько имён, атрибутов, элементов контейнеров или параметров функций могут ссылаться на один объект в случаях:

СлучайПример
Простое присваиваниеb = a
Передача в функциюfunc(obj)
Один объект в нескольких списках/словаряхlist1 = [obj], list2 = [obj]
Общий атрибут классаclass A: items = []
Поверхностное копированиеcopy.copy(obj)
Алиас класса или функцииB = A
Оптимизации immutable-объектовмаленькие числа, некоторые строки

Ключевая мысль:

переменная в Python — это имя, связанное с объектом,
а не коробка, в которой лежит сам объект


30. Оператор del - для каких целей используется #

Для чего используется del #

del в Python используется для удаления привязки имени, элемента, среза или атрибута.

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


1. Удаление переменной #

x = 10

del x

print(x)

Результат:

NameError: name 'x' is not defined

После del x имя x больше не связано с объектом 10.


2. Удаление элемента списка #

numbers = [10, 20, 30, 40]

del numbers[1]

print(numbers)

Результат:

[10, 30, 40]

Удалился элемент по индексу 1, то есть 20.


3. Удаление среза списка #

numbers = [10, 20, 30, 40, 50]

del numbers[1:4]

print(numbers)

Результат:

[10, 50]

У mutable sequences, например списков, срезы могут быть целью del-операции. Это описано в документации по модели данных Python.


4. Удаление ключа из словаря #

user = {
    "name": "Alex",
    "age": 20
}

del user["age"]

print(user)

Результат:

{'name': 'Alex'}

Для mapping-типов, например dict, обращение по ключу может использоваться как цель del.


5. Удаление атрибута объекта #

class User:
    pass

user = User()
user.name = "Alex"

del user.name

print(user.name)

Результат:

AttributeError: 'User' object has no attribute 'name'

Здесь удаляется атрибут name у конкретного объекта user.


6. Удаление имени класса или функции #

Классы и функции — тоже объекты. Их имена тоже можно удалить:

class User:
    pass

del User

print(User)

Результат:

NameError: name 'User' is not defined

Но это не значит, что “сам класс физически стёрт из памяти”. Удалена привязка имени User. Если где-то ещё есть ссылка на этот класс, объект класса продолжит существовать.

Пример:

class User:
    pass

Account = User

del User

obj = Account()
print(type(obj))

Account всё ещё ссылается на тот же объект класса.


Главное отличие: del удаляет ссылку, а не обязательно объект #

a = [1, 2, 3]
b = a

del a

print(b)

Результат:

[1, 2, 3]

Список не исчез, потому что на него всё ещё ссылается b.

Схема до del a:

a ─┐
   ├──> [1, 2, 3]
b ─┘

После del a:

b ───> [1, 2, 3]

Когда del может привести к уничтожению объекта #

a = [1, 2, 3]

del a

Если a была единственной ссылкой на список, то после del a объект становится недоступным. Тогда Python может освободить память.

Но напрямую вызывать удаление объекта через del нельзя. del работает с именами, атрибутами, индексами и срезами, а не с памятью напрямую.


Связь с __del__ #

Не путать:

del obj

и магический метод:

__del__(self)

del obj удаляет имя obj.

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

Пример:

class User:
    def __del__(self):
        print("Объект уничтожается")

u = User()

del u

Если других ссылок на объект нет, в CPython __del__ обычно будет вызван сразу. Но полагаться на это как на основной способ освобождения ресурсов не стоит.


Итог #

del используют для:

ЦельПример
Удалить имя переменнойdel x
Удалить элемент спискаdel items[0]
Удалить срез спискаdel items[1:3]
Удалить ключ словаряdel data["key"]
Удалить атрибут объектаdel user.name
Удалить имя класса или функцииdel User, del func

Коротко:

del удаляет привязку или элемент из контейнера.
Он не гарантирует немедленное физическое удаление объекта из памяти.


31. Что такое __slots__ и как он работает? #

__slots__ — это атрибут класса, который явно определяет фиксированный набор атрибутов для его экземпляров. Это механизм оптимизации, который заменяет стандартный словарь __dict__ на статическое размещение атрибутов, что приводит к уменьшению потребления памяти и увеличению скорости доступа к атрибутам.

Ключевые аспекты:

  1. Ограничение атрибутов
    • Экземпляры класса могут содержать только те атрибуты, которые перечислены в __slots__
    • Попытка добавить атрибут, не указанный в __slots__, вызывает AttributeError
  2. Оптимизация памяти
    • Основная экономия памяти достигается за счет отсутствия __dict__ у каждого экземпляра
    • Вместо динамического словаря Python использует компактный массив с фиксированным размером
    • Эффект особенно заметен при создании тысяч экземпляров
  3. Ускорение доступа к атрибутам
    • Скорость увеличивается благодаря использованию дескрипторов
    • Атрибуты из __slots__ становятся дескрипторами данных в классе
    • При поиске атрибута алгоритм MRO находит их сразу на уровне класса, минуя поиск в __dict__ экземпляра
  4. Ограничение добавления новых атрибутов
    • Атрибуты, объявленные в __slots__, доступны сразу после создания экземпляра
    • Место для них резервируется при создании экземпляра, подобно атрибутам в __init__
    • Это отличается от динамического добавления атрибутов в обычных классах, где атрибуты могут добавляться в любой момент

Пример:

class PointSlots:
    __slots__ = ('x', 'y')  # Фиксированный набор атрибутов

    def __init__(self, x, y):
        self.x = x  # ✓ Разрешено
        self.y = y  # ✓ Разрешено

p = PointSlots(1, 2)
p.z = 3  # ❌ AttributeError: 'PointSlots' object has no attribute 'z'

__slots__ — это компромисс между гибкостью (возможность добавлять любые атрибуты) и производительностью (память + скорость).

Объяснение работы __slots__ при наследовании в Python 3.x:

  1. Если родитель имеет __slots__:
    Потомок наследует эти слоты автоматически, даже если определяет свой собственный __slots__.

  2. Если потомок определяет свой __slots__:
    Он не теряет слоты родителя. Python объединяет их автоматически при одиночном наследовании.

  3. Если потомок НЕ определяет свой __slots__:
    Он наследует слоты родителя И получает __dict__, что позволяет динамически добавлять новые атрибуты.

  4. Множественное наследование:
    Python автоматически объединяет __slots__ разных родителей, если их структуры совместимы. При конфликте возникает ошибка.

Полная таблица наследования __slots__

СитуацияУ экземпляра есть __dict__?Можем создавать новые атрибуты?Что наследуется?
1. Родитель БЕЗ __slots__ Потомок БЕЗ __slots__✅ Да✅ ДаТолько __dict__
2. Родитель БЕЗ __slots__ Потомок СО __slots__❌ Нет❌ Только слоты потомкаСлоты потомка (__dict__ родителя не наследуется)
3. Родитель СО __slots__ Потомок БЕЗ __slots__✅ Да✅ ДаСлоты родителя + __dict__
4. Родитель СО __slots__ Потомок СО __slots__❌ Нет❌ Только все слотыСлоты родителя и потомка объединяются
5. Класс с '__dict__' в __slots__✅ Да✅ ДаЯвное разрешение __dict__ наряду со слотами

Примеры:

# Случай 3 из таблицы: родитель со slots, потомок без slots
class Parent:
    __slots__ = ['x']

    def __init__(self):
        self.x = 10

class Child(Parent):
    pass  # Не определяет свой __slots__

c = Child()
c.x = 10  # ✅ Работает — унаследованный слот
print(hasattr(c, '__dict__'))  # ✅ True — словарь есть!
c.y = 20  # ✅ Работает — через __dict__
c.z = 30  # ✅ Работает — через __dict__
print(c.__dict__)  # {'y': 20, 'z': 30}
# Случай 4 из таблицы: оба имеют slots
class Parent:
    __slots__ = ['x']

class Child(Parent):
    __slots__ = ['y']  # ✅ Слот 'x' наследуется автоматически

c = Child()
c.x = 10  # ✅ Работает
c.y = 20  # ✅ Работает
# c.z = 30  # ❌ Ошибка: нет слота 'z'
print(hasattr(c, '__dict__'))  # ❌ False
# Множественное наследование
class A:
    __slots__ = ['a']

class B:
    __slots__ = ['b']

class C(A, B):
    __slots__ = ['c']  # ✅ Автоматически получаем слоты 'a' и 'b'

obj = C()
obj.a = 1  # ✅
obj.b = 2  # ✅
obj.c = 3  # ✅
# obj.d = 4  # ❌ AttributeError
# Явное объединение слотов (рекомендуется для ясности)
class Parent:
    __slots__ = ['x']

class ExplicitChild(Parent):
    __slots__ = Parent.__slots__ + ['y']  # ✅ Понятно, что слоты объединены
# Случай с __dict__ в слотах
class DynamicSlots:
    __slots__ = ['x', '__dict__']  # Явно разрешаем __dict__

obj = DynamicSlots()
obj.x = 10     # ✅ Через слот
obj.y = 20     # ✅ Через __dict__
obj.z = 30     # ✅ Через __dict__
print(obj.__dict__)  # {'y': 20, 'z': 30}
print(obj.x)   # 10

Ключевые выводы:

  1. Слоты родителей автоматически наследуются в Python 3.x
  2. Если потомок не определяет __slots__, он получает и слоты родителя, и __dict__ (в отличие от распространенного заблуждения)
  3. Если потомок определяет __slots____dict__ отсутствует (если не добавлен явно)
  4. Для восстановления динамических атрибутов в классе со слотами добавьте '__dict__' в __slots__
  5. При множественном наследовании все слоты родителей объединяются, если нет конфликтов структуры

Что изменилось в __slots__ в новых версиях Python

  1. Снижение эффективности экономии памяти

    • Раньше (версии 3.9-3.10): экономия 80-216 байт на объект
    • Сейчас (версии 3.11-3.13): экономия 32-64 байта на объект
    • Причина: стандартные словари (__dict__) стали компактнее
  2. Почти исчезло ускорение доступа к атрибутам

    • Раньше (версия 3.9): доступ через __slots__ быстрее на 62%
    • Сейчас (версия 3.13): быстрее всего на 3%
    • В оптимизированных сборках разница стремится к 0%
  3. Исправление в Python 3.12

    • Исправлена ошибка с наследованием классов, использующих property и __slots__ одновременно

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

  • Python ≤ 3.10__slots__ даёт огромный выигрыш (используйте активно)
  • Python ≥ 3.11: выигрыш минимальный, используйте только если нужно запретить динамические атрибуты, а не ради производительности


32. Ограничивает ли __slots__ создание новых атрибутов объекта? #

Да, __slots__ ограничивает создание новых атрибутов объекта #

__slots__ позволяет заранее объявить, какие атрибуты могут быть у экземпляра класса. Если у объекта нет __dict__, то добавить произвольный новый атрибут уже нельзя. В документации Python указано, что __slots__ явно объявляет data members и запрещает создание __dict__ и __weakref__, если они не объявлены явно или не доступны от родителя.

Пример:

class User:
    __slots__ = ("name", "age")

    def __init__(self, name, age):
        self.name = name
        self.age = age


user = User("Alex", 20)

user.name = "Bob"      # можно
user.age = 21          # можно

user.email = "a@mail"  # AttributeError

Ошибка:

AttributeError: 'User' object has no attribute 'email'

Потому что email не указан в __slots__.

Почему так происходит #

Обычно экземпляры классов хранят свои атрибуты в специальном словаре:

obj.__dict__

Пример без __slots__:

class User:
    pass


user = User()
user.name = "Alex"
user.age = 20

print(user.__dict__)

Результат:

{'name': 'Alex', 'age': 20}

А при использовании __slots__ Python не создаёт обычный __dict__ для каждого экземпляра, если только ты явно не добавил "__dict__" в __slots__. Поэтому набор допустимых атрибутов становится фиксированным.

class User:
    __slots__ = ("name", "age")


user = User()
user.name = "Alex"

print(user.__dict__)

Будет ошибка:

AttributeError: 'User' object has no attribute '__dict__'

Но ограничение можно отключить через "__dict__" #

class User:
    __slots__ = ("name", "__dict__")


user = User()
user.name = "Alex"
user.age = 20

print(user.__dict__)

Результат:

{'age': 20}

Здесь name хранится в slot, а произвольные новые атрибуты, например age, могут храниться в __dict__.

Важный момент про наследование #

Ограничение работает только для класса, где объявлены __slots__, и зависит от родителей/наследников.

class Base:
    __slots__ = ("name",)


class Child(Base):
    pass

У Child снова появится __dict__, потому что подкласс не объявил свои __slots__.

child = Child()
child.name = "Alex"
child.age = 20  # можно

Чтобы подкласс тоже был ограничен, нужно указать __slots__ и в нём:

class Base:
    __slots__ = ("name",)


class Child(Base):
    __slots__ = ("age",)

Теперь:

child = Child()
child.name = "Alex"
child.age = 20
child.email = "a@mail"  # AttributeError

Для чего используют __slots__ #

Главные цели:

ЦельСмысл
Ограничить набор атрибутовнельзя случайно добавить obj.emial вместо obj.email
Сэкономить памятьне создаётся __dict__ на каждый объект
Немного ускорить доступ к атрибутаматрибуты хранятся в фиксированных slot-ах

Итог #

Да, __slots__ ограничивает создание новых атрибутов объекта, но только если у экземпляра нет __dict__.

Коротко:

class User:
    __slots__ = ("name",)

Значит:

user.name = "Alex"  # можно
user.age = 20       # нельзя

А вот так ограничение снимается:

class User:
    __slots__ = ("name", "__dict__")

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


33. В какой структуре данных хранятся атрибуты объекта #

Обычно атрибуты объекта хранятся в __dict__ #

У обычного э

obj.__dict__

Пример:

class User:
    pass


user = User()
user.name = "Alex"
user.age = 20

print(user.__dict__)

Результат:

{'name': 'Alex', 'age': 20}

То есть после:

user.name = "Alex"

Python фактически сохраняет значение примерно так:

user.__dict__["name"] = "Alex"

Официальная документация Python указывает, что без __slots__ у экземпляров доступен __dict__, а __slots__ как раз может запретить автоматическое создание __dict__.

У класса тоже есть __dict__ #

Атрибуты класса хранятся в namespace класса:

class User:
    role = "admin"

print(User.__dict__)

Там будут лежать:

{
    "__module__": "__main__",
    "role": "admin",
    ...
}

Но User.__dict__ обычно отображается как mappingproxy, то есть read-only обёртка над словарём класса:

print(type(User.__dict__))

Примерный результат:

<class 'mappingproxy'>

Менять напрямую так нельзя:

User.__dict__["role"] = "guest"  # TypeError

Но можно через обычное присваивание:

User.role = "guest"

Разница между атрибутами объекта и класса #

class User:
    role = "guest"      # атрибут класса

    def __init__(self, name):
        self.name = name  # атрибут объекта

Использование:

user = User("Alex")

print(user.__dict__)  # {'name': 'Alex'}
print(User.__dict__)  # содержит role, __init__, __module__ и т.д.

self.name хранится в словаре конкретного объекта:

user.__dict__

role хранится в словаре класса:

User.__dict__

Что меняется при __slots__ #

Если класс использует __slots__, экземпляр может не иметь __dict__:

class User:
    __slots__ = ("name", "age")


user = User()
user.name = "Alex"
user.age = 20

print(user.__dict__)

Будет ошибка:

AttributeError: 'User' object has no attribute '__dict__'

В этом случае атрибуты хранятся не в обычном словаре экземпляра, а в заранее выделенных слотах. Документация Python описывает __slots__ как механизм, который объявляет допустимые поля экземпляра и предотвращает автоматическое создание __dict__, если он явно не добавлен. ( Python documentation)

Можно вернуть __dict__ явно:

class User:
    __slots__ = ("name", "__dict__")


user = User()
user.name = "Alex"
user.age = 20

print(user.__dict__)

Результат:

{'age': 20}

Итог #

Обычно:

атрибуты экземпляра -> obj.__dict__
атрибуты класса     -> Class.__dict__

Но если используется __slots__, то экземпляр может не иметь __dict__, и его атрибуты будут храниться в slot-структуре, а не в обычном словаре.


34. Можно ли применить декоратор к классу? #

Да, декоратор можно применить к классу #

В Python декоратор можно применять не только к функции или методу, но и к классу.

Пример:

def add_repr(cls):
    def __repr__(self):
        return f"{cls.__name__}({self.__dict__})"

    cls.__repr__ = __repr__
    return cls


@add_repr
class User:
    def __init__(self, name):
        self.name = name


user = User("Alex")
print(user)

Результат:

User({'name': 'Alex'})

Что происходит внутри #

Запись:

@add_repr
class User:
    ...

примерно равна такой записи:

class User:
    ...

User = add_repr(User)

То есть порядок такой:

1. Python создаёт объект класса User.
2. Передаёт этот объект в декоратор add_repr.
3. Декоратор возвращает класс или другой объект.
4. Имя User связывается с результатом декоратора.

Официальная документация Python описывает, что после создания объекта класса он передаётся декораторам класса, а результат связывается с именем класса.

Декоратор класса может изменить класс #

Например, можно добавить атрибут:

def add_role(cls):
    cls.role = "admin"
    return cls


@add_role
class User:
    pass


print(User.role)  # admin

Здесь декоратор получил класс User, добавил ему атрибут role и вернул тот же класс.

Декоратор класса может заменить класс #

Декоратор не обязан возвращать тот же самый класс:

def replace_class(cls):
    class Wrapper:
        pass

    return Wrapper


@replace_class
class User:
    pass


print(User)

Здесь имя User после декоратора будет ссылаться уже не на исходный класс, а на Wrapper.

Реальный пример: @dataclass #

Один из самых популярных примеров декоратора класса — @dataclass:

from dataclasses import dataclass


@dataclass
class User:
    name: str
    age: int

@dataclass обрабатывает класс и автоматически добавляет специальные методы, например __init__, __repr__, __eq__. Документация dataclasses прямо описывает этот модуль как предоставляющий декоратор для автоматического добавления сгенерированных специальных методов к пользовательским классам.

Несколько декораторов класса #

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

@decorator1
@decorator2
class User:
    pass

Это примерно то же самое:

class User:
    pass

User = decorator1(decorator2(User))

То есть ближайший к классу декоратор применяется первым.

Итог #

Да, к классу можно применить декоратор. Декоратор класса:

получает объект класса
может изменить его
может вернуть тот же класс
может вернуть другой объект
результат связывается с именем класса


35. Что происходит с __slots__ в наследниках класса? #

Кратко #

__slots__ в наследовании работает так:

слоты родителя доступны наследнику,
но наследник по умолчанию снова получает __dict__,
если сам не объявит __slots__

Официальная документация Python указывает: __slots__ запрещает автоматическое создание __dict__ и __weakref__, но только при соблюдении условий наследования. Слоты родителей доступны в дочерних классах, однако экземпляры подкласса получат __dict__, если подкласс сам не объявит __slots__.

1. Родитель со __slots__, наследник без __slots__ #

class Base:
    __slots__ = ("name",)


class Child(Base):
    pass


obj = Child()
obj.name = "Alex"
obj.age = 20

print(obj.name)  # Alex
print(obj.age)   # 20
print(obj.__dict__)  # {'age': 20}

Здесь name хранится в слоте родителя, но у Child появился обычный __dict__, потому что Child не объявил свои __slots__.

То есть ограничение на новые атрибуты сломалось:

obj.age = 20  # можно

2. Родитель со __slots__, наследник тоже со __slots__ #

class Base:
    __slots__ = ("name",)


class Child(Base):
    __slots__ = ("age",)


obj = Child()
obj.name = "Alex"
obj.age = 20
obj.email = "a@mail"  # AttributeError

Здесь у объекта есть только:

name — слот родителя
age  — слот наследника

А произвольный атрибут email создать нельзя, потому что у экземпляра нет __dict__. Документация прямо указывает: без __dict__ нельзя присваивать имена, не перечисленные в __slots__; попытка даст AttributeError. ( Python documentation)

3. Наследник может объявить пустые __slots__ #

Если наследнику не нужны новые поля, но нужно сохранить ограничение, пишут так:

class Base:
    __slots__ = ("name",)


class Child(Base):
    __slots__ = ()

Теперь:

obj = Child()
obj.name = "Alex"
obj.age = 20  # AttributeError

__slots__ = () означает: наследник не добавляет новых слотов и не создаёт __dict__.

4. Если родитель без __slots__, наследник со __slots__ #

class Base:
    pass


class Child(Base):
    __slots__ = ("name",)


obj = Child()
obj.name = "Alex"
obj.age = 20

print(obj.__dict__)  # {'age': 20}

Так как родитель обычный и имеет __dict__, у наследника он всё равно будет доступен. Документация отдельно указывает: при наследовании от класса без __slots__ атрибуты __dict__ и __weakref__ у экземпляров всегда доступны.

То есть __slots__ в наследнике добавит слот name, но не запретит произвольные новые атрибуты.

5. Нельзя дублировать имя слота из родителя #

Плохо:

class Base:
    __slots__ = ("name",)


class Child(Base):
    __slots__ = ("name",)  # плохо

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

Правильно:

class Base:
    __slots__ = ("name",)


class Child(Base):
    __slots__ = ("age",)

6. Множественное наследование со слотами ограничено #

class A:
    __slots__ = ("a",)


class B:
    __slots__ = ("b",)


class C(A, B):
    __slots__ = ()

Такой вариант может привести к TypeError, потому что при множественном наследовании только один родитель может иметь непустую slot-разметку. Остальные базовые классы должны иметь пустую slot-разметку.

Итоговая таблица #

СитуацияЧто будет
Родитель со __slots__, наследник без __slots__слоты родителя есть, но у наследника появляется __dict__
Родитель со __slots__, наследник со своими __slots__слоты родителя + слоты наследника, произвольные атрибуты запрещены
Родитель со __slots__, наследник __slots__ = ()новые поля не добавляются, __dict__ не появляется
Родитель без __slots__, наследник со __slots____dict__ всё равно доступен через родителя
Наследник дублирует имя слота родителяплохая практика, поведение считается неопределённым
Множественное наследование от нескольких slotted-классовесть ограничения, иначе возможен TypeError

Главное правило:

class Base:
    __slots__ = ("name",)


class Child(Base):
    __slots__ = ("age",)

Так наследник сохраняет ограничение и добавляет только свои разрешённые атрибуты.


36. Магический метод __getattr__ #

Что такое __getattr__ #

__getattr__ — это магический метод, который вызывается только тогда, когда обычный поиск атрибута не нашёл атрибут.

Сигнатура:

def __getattr__(self, name):
    ...

Где:

self — объект
name — имя атрибута, который пытались получить

Официальная документация Python формулирует это так: __getattr__ вызывается, когда обычный доступ к атрибуту завершился ошибкой AttributeError; метод должен вернуть значение атрибута или снова выбросить AttributeError.

Пример #

class User:
    def __init__(self, name):
        self.name = name

    def __getattr__(self, attr_name):
        return f"Атрибут {attr_name!r} не найден"


user = User("Alex")

print(user.name)
print(user.age)

Результат:

Alex
Атрибут 'age' не найден

Что произошло:

user.name найден в user.__dict__ -> __getattr__ не вызывается
user.age не найден              -> вызывается __getattr__("age")

Важный момент #

__getattr__ не перехватывает вообще все обращения к атрибутам. Он вызывается только после неудачного обычного поиска:

class User:
    def __init__(self):
        self.name = "Alex"

    def __getattr__(self, name):
        print("__getattr__ вызван")
        return None


user = User()

print(user.name)  # __getattr__ не вызван
print(user.age)   # __getattr__ вызван

Отличие __getattr__ от __getattribute__ #

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

__getattribute__ — перехватывает ВСЁ Вызывается при любом обращении к любому атрибуту — существующему или нет.

class Test:
    def __init__(self):
        self.name = "Анна"

    def __getattribute__(self, name):
        print(f"Перехвачен доступ к: {name}")
        # Обязательно вызываем родительский метод, иначе всё сломается
        return super().__getattribute__(name)

obj = Test()
print(obj.name)  # Перехвачен доступ к: name → Анна
print(obj.age)   # Перехвачен доступ к: age → AttributeError

Особенности:

  • Срабатывает всегда при обращении к атрибуту
  • Должен вызывать super().__getattribute__() для реального доступа
  • Легко создать бесконечную рекурсию

__getattr__ — перехватывает только отсутствующее Вызывается только если атрибут не найден обычным способом.

class Test:
    def __init__(self):
        self.name = "Анна"

    def __getattr__(self, name):
        print(f"Атрибут {name} не найден, создаём...")
        return f"Значение для {name}"

obj = Test()
print(obj.name)     # Анна (__getattr__ НЕ вызван)
print(obj.age)      # Атрибут age не найден → "Значение для age"

Особенности:

  • Срабатывает только для отсутствующих атрибутов
  • Не нужно вызывать super()
  • Безопаснее, нет риска рекурсии

Сравнение в таблице

__getattribute____getattr__
Когда вызываетсяПри любом доступе к атрибутуТолько если атрибут не найден
Для существующих атрибутов✅ Да❌ Нет
Для несуществующих✅ Да✅ Да
Риск рекурсииВысокийНизкий
Нужен super()ОбязательноНет
Типичное использованиеЛогирование, контроль доступаЗначения по умолчанию, динамические атрибуты

Главное правило

  • __getattribute__ — всегда первый
  • Если атрибут есть — он возвращает значение и всё
  • Если атрибута нет — после __getattribute__ вызывается __getattr__

Для чего используют __getattr__ #

Типичные случаи:

динамические атрибуты
ленивая загрузка данных
проксирование обращения к другому объекту
обратная совместимость старых имён атрибутов
удобная обработка отсутствующих атрибутов

Пример динамического атрибута:

class Config:
    def __init__(self):
        self.values = {
            "host": "localhost",
            "port": 5432,
        }

    def __getattr__(self, name):
        try:
            return self.values[name]
        except KeyError:
            raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}")


config = Config()

print(config.host)  # localhost
print(config.port)  # 5432

Здесь host и port физически не лежат как обычные атрибуты объекта, но __getattr__ достаёт их из словаря values.

Почему важно выбрасывать AttributeError #

Если атрибут реально не найден, нужно выбрасывать именно AttributeError, а не возвращать что угодно.

Плохо:

class User:
    def __getattr__(self, name):
        return None

Проблема:

user = User()

print(user.anything)  # None

Так можно скрыть реальные ошибки и опечатки:

user.emial  # опечатка, но ошибки нет

Лучше:

class User:
    def __getattr__(self, name):
        raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}")

Итог #

__getattr__ — это резервный механизм доступа к атрибутам.

Коротко:

obj.some_attr

Python делает примерно так:

1. Ищет some_attr обычным способом.
2. Если нашёл — возвращает значение.
3. Если не нашёл — вызывает obj.__getattr__("some_attr").
4. Если __getattr__ вернул значение — оно используется.
5. Если __getattr__ выбросил AttributeError — атрибут считается отсутствующим.

Главное отличие:

__getattr__

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


37. Магический метод __getattribute__ #

Что такое __getattribute__ #

__getattribute__ — это магический метод, который вызывается при любом обращении к атрибуту объекта.

Сигнатура:

def __getattribute__(self, name):
    ...

Где:

self — объект
name — имя атрибута, к которому обращаются

Пример:

class User:
    def __init__(self, name):
        self.name = name

    def __getattribute__(self, attr_name):
        print(f"Обращение к атрибуту: {attr_name}")
        return object.__getattribute__(self, attr_name)


user = User("Alex")

print(user.name)

Результат:

Обращение к атрибуту: name
Alex

Официальная документация Python указывает, что __getattribute__ вызывается безусловно при попытке доступа к атрибуту экземпляра класса. ( Python documentation)

Чем отличается от __getattr__ #

Главное отличие:

МетодКогда вызывается
__getattribute__всегда при обращении к атрибуту
__getattr__только если обычный поиск атрибута не нашёл атрибут

Пример:

class User:
    def __init__(self):
        self.name = "Alex"

    def __getattribute__(self, attr_name):
        print("__getattribute__:", attr_name)
        return object.__getattribute__(self, attr_name)

    def __getattr__(self, attr_name):
        print("__getattr__:", attr_name)
        return "default"


user = User()

print(user.name)
print(user.age)

Результат:

__getattribute__: name
Alex
__getattribute__: age
__getattr__: age
default

Что произошло:

user.name:
1. вызвался __getattribute__("name")
2. атрибут найден
3. __getattr__ не вызывается

user.age:
1. вызвался __getattribute__("age")
2. атрибут не найден
3. возник AttributeError
4. Python вызвал __getattr__("age")

Почему внутри нужно вызывать object.__getattribute__ #

Если внутри __getattribute__ обратиться к атрибуту обычным способом, можно получить бесконечную рекурсию.

Плохо:

class User:
    def __init__(self):
        self.name = "Alex"

    def __getattribute__(self, attr_name):
        return self.name

Проблема:

user.name
-> __getattribute__("name")
-> self.name
-> __getattribute__("name")
-> self.name
-> ...

В итоге будет:

RecursionError: maximum recursion depth exceeded

Правильно:

class User:
    def __getattribute__(self, attr_name):
        return object.__getattribute__(self, attr_name)

Так мы явно вызываем базовый механизм получения атрибута.

Пример контроля доступа #

class User:
    def __init__(self, name, password):
        self.name = name
        self.password = password

    def __getattribute__(self, attr_name):
        if attr_name == "password":
            raise AttributeError("Доступ к password запрещён")

        return object.__getattribute__(self, attr_name)


user = User("Alex", "12345")

print(user.name)      # Alex
print(user.password)  # AttributeError

Здесь __getattribute__ перехватывает любое обращение к атрибутам и запрещает чтение password.

Пример логирования доступа к атрибутам #

class Config:
    def __init__(self):
        self.host = "localhost"
        self.port = 5432

    def __getattribute__(self, attr_name):
        print(f"Чтение атрибута: {attr_name}")
        return object.__getattribute__(self, attr_name)


config = Config()

print(config.host)
print(config.port)

Результат:

Чтение атрибута: host
localhost
Чтение атрибута: port
5432

Связь с дескрипторами #

Механизм дескрипторов (property, методы, staticmethod, classmethod, объекты с __get__) встроен в __getattribute__.

Например:

class User:
    @property
    def name(self):
        return "Alex"


user = User()

print(user.name)

Когда выполняется:

user.name

Python идёт через __getattribute__, а внутри этого механизма срабатывает дескриптор property.

В официальном Descriptor HOWTO указано, что дескрипторы вызываются именно механизмом __getattribute__, и переопределение __getattribute__ может нарушить автоматический вызов дескрипторов.

Упрощённый порядок поиска атрибута #

Когда пишем:

obj.attr

Python примерно делает:

1. вызывает obj.__getattribute__("attr")
2. внутри обычного механизма ищет data descriptor в классе
3. ищет attr в obj.__dict__
4. ищет non-data descriptor или обычный атрибут в классе
5. ищет по MRO в родителях
6. если ничего не найдено — возникает AttributeError
7. если есть __getattr__, вызывается __getattr__("attr")

__getattribute__ стоит в самом начале этой цепочки.

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

Его используют редко, потому что он очень низкоуровневый. Типичные случаи:

проксирование доступа к другому объекту
логирование всех обращений к атрибутам
ленивая загрузка атрибутов
контроль доступа
создание ORM-подобного поведения
создание специальных proxy/wrapper-объектов

Во многих обычных задачах лучше использовать:

@property
__getattr__
дескрипторы

__getattribute__ нужен, когда требуется перехватывать вообще все обращения к атрибутам.

Итог #

__getattribute__ — это главный метод перехвата доступа к атрибутам. Коротко:

obj.attr

почти всегда проходит через:

obj.__getattribute__("attr")

Главное отличие от __getattr__:

__getattribute__ вызывается всегда
__getattr__ вызывается только если атрибут не найден

И главное правило при переопределении:

return object.__getattribute__(self, attr_name)

Иначе очень легко получить бесконечную рекурсию или сломать работу дескрипторов.


38. Есть ли разница между list() и [ ]? #

Да, разница есть #

[] и list() оба могут создать список, но используются немного по-разному.

1. Пустой список #

a = []
b = list()

print(a)  # []
print(b)  # []

Результат одинаковый: создаётся пустой список.

Но на практике чаще пишут:

items = []

Почему:

[] короче
[] читается проще
[] немного быстрее

Документация Python прямо указывает, что список можно создать несколькими способами, включая [] для пустого списка и list() через конструктор.

2. Список с элементами #

Через квадратные скобки:

numbers = [1, 2, 3]

Через list() так напрямую нельзя:

numbers = list(1, 2, 3)  # TypeError

Правильно:

numbers = list((1, 2, 3))

То есть list() принимает один итерируемый объект, например tuple, строку, range, set, dict.

3. list() используют для преобразования iterable в список #

chars = list("abc")
print(chars)

Результат:

['a', 'b', 'c']

Ещё пример:

numbers = list(range(5))
print(numbers)

Результат:

[0, 1, 2, 3, 4]

Здесь list() удобен, потому что он превращает итерируемый объект в список.

4. [] используют для литерального создания списка #

users = ["Alex", "Bob", "John"]

Это обычный способ создать список с известными элементами.

Документация Python описывает list display как выражение со значениями внутри квадратных скобок.

5. Разница в скорости #

Для пустого списка:

a = []

обычно быстрее, чем:

a = list()

Причина: [] — это синтаксис языка, а list() — вызов встроенного класса list.

Но в обычном коде разница минимальная. Выбирать лучше по смыслу:

[]      — создать новый список
list(x) — преобразовать x в список

6. Важный пример с копированием #

a = [1, 2, 3]

b = list(a)
c = a[:]
d = a.copy()

Все три варианта создают поверхностную копию списка:

print(a is b)  # False
print(a == b)  # True

Но если внутри вложенные объекты, они не копируются глубоко:

a = [[1], [2]]
b = list(a)

print(a is b)        # False
print(a[0] is b[0])  # True

Итог #

ЗаписьЧто делает
[]создаёт новый пустой список
[1, 2, 3]создаёт список с элементами
list()создаёт пустой список через конструктор
list(iterable)преобразует iterable в список

Главное правило:

items = []

используют для создания пустого списка.

items = list(some_iterable)

используют для преобразования другого итерируемого объекта в список.


39. Что такое слабая ссылка #

“Слабая ссылка” (англ. weak reference) — это специальный тип ссылки на объект в программировании, который не препятствует удалению этого объекта сборщиком мусора.

Обычная (сильная) ссылка “удерживает” объект в памяти: пока на него есть хотя бы одна такая ссылка — он не будет удалён. А слабая ссылка не удерживает объект. Если все обычные ссылки на объект исчезли, то даже если есть слабые — объект будет удалён из памяти.

Где применяется:

  • Кэширование
  • Системы наблюдения (observer pattern)
  • Деревья с родительскими ссылками (например, DOM)
  • Оптимизация памяти в длительных процессах

Модуль weakref — это встроенный модуль Python, который позволяет создавать слабые ссылки (weak references) на объекты.

import weakref

class Well:
    def __init__(self, name):
        self.name = name

well = Well("WELL-101")
weak_ref = weakref.ref(well)  # слабая ссылка

print(weak_ref())  # <__main__.Well object> — объект жив

del well          # последняя сильная ссылка удалена
print(weak_ref())  # None — объект уничтожен сборщиком мусора


40. Какие слабые стороны (недостатки | минусы) есть у Python? #

У языка программирования Python есть несколько слабых сторон, которые связаны с особенностями производительности, потребления памяти, ограничений в области видимости и ограниченности стандартных библиотек для решения некоторых задач.

Производительность

  • Интерпретируемая природа языка — код выполняется построчно интерпретатором, что добавляет накладные расходы по сравнению с компилируемыми языками.
  • Динамическая типизация — проверка типов происходит во время выполнения, что требует дополнительных вычислительных ресурсов.
  • Глобальная блокировка интерпретатора (GIL) — ограничивает выполнение только одной инструкции Python в любой момент времени на одном процессоре, что может стать узким местом в многопоточных приложениях. С версии 3.14 есть обход GIL.
  • Медленные операции ввода-вывода — если программа выполняет много операций ввода-вывода (например, чтение/запись файлов, работа с сетью), производительность может снизиться из-за ожидания завершения этих операций.

Память

  • Python использует больше памяти по сравнению с жёстко типизированными языками, такими как C или C++. Это может стать ограничением при работе с большими данными или в средах с ограниченной памятью.
  • Утечки памяти — память выделяется для конкретной задачи, но не освобождается после завершения процесса. Это особенно вредно, если утечка находится в часто выполняемом участке кода.

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

Это означает, что:

  • Имена, определяемые внутри инструкции def, видны только программному коду внутри этой инструкции, к ним нельзя обратиться за пределами функции.
  • Область видимости переменной определяется местом, где ей было присвоено значение, а не местом, откуда была вызвана функция.

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

  1. Библиотеки с агрессивным кэшированием (для производительности)
    TensorFlow / PyTorch — преднамеренно кэшируют память на GPU/CPU между операциями для ускорения вычислений. Это может выглядеть как «утечка», но на самом деле — оптимизация.
    Решение: torch.cuda.empty_cache(), tf.keras.backend.clear_session().
    NumPy — при работе с большими массивами и срезами (views) может удерживать ссылки на исходные данные дольше ожидаемого.
    Решение: использовать .copy() при необходимости.
  2. Библиотеки с глобальным состоянием
    Matplotlib — сохраняет все созданные фигуры в памяти до явного вызова plt.close() или plt.clf(). При генерации тысяч графиков в цикле это приводит к утечкам.
    Решение: всегда вызывать plt.close(fig) после сохранения.
    OpenCV (cv2) — при работе с видео (cv2.VideoCapture) требует явного освобождения ресурсов через .release().
  3. ORM и кэши сессий
    SQLAlchemy — identity map и кэш сессии могут удерживать объекты в памяти при долгоживущих сессиях.
    Решение: использовать session.expunge_all(), session.close() или паттерн «сессия на запрос».

Конфликты имён — например, в модуле math есть функция sqrt(), но и в библиотеке cmath (для комплексных чисел) есть такая же функция. Это может привести к ошибкам, так как не ясно, из какого модуля используется функция.


41. Пишут ли на Python высокопроизводительные приложения и почему? #

Да, на Python пишут высокопроизводительные приложения #

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

Python часто используют как:

координирующий слой
API/backend слой
язык glue-кода
язык бизнес-логики
обёртку над быстрыми C/C++/Rust/Fortran-библиотеками

Почему это возможно #

1. Узкое место не всегда в Python #

В backend-приложениях большая часть времени часто уходит не на выполнение Python-кода, а на:

запросы в БД
сетевые вызовы
Redis/cache
очереди сообщений
файловый ввод-вывод
HTTP-запросы к другим сервисам

То есть приложение может быть высоконагруженным, даже если сам Python не самый быстрый язык по CPU. Для I/O-bound задач важнее правильно работать с конкурентностью: async, worker-процессы, connection pooling, кеширование, очереди.

Пример:

async def get_user(user_id: int):
    user = await db.fetch_user(user_id)
    return user

Здесь основное ожидание — не вычисления Python, а ответ базы данных.

2. Для CPU-bound задач используют нативные библиотеки #

Python медленный в чистых циклах:

total = 0

for i in range(100_000_000):
    total += i

Но в научных вычислениях, ML, обработке массивов и математики Python часто вызывает быстрый код внутри библиотек.

Например, NumPy использует векторизацию: операции над массивами выполняются так, что цикл происходит в C, а не в Python. Документация NumPy прямо описывает broadcasting как способ векторизовать операции, где looping occurs in C instead of Python.

Пример:

import numpy as np

a = np.arange(100_000_000)
result = a.sum()

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

3. Python легко расширяется C/C++-кодом #

Python официально поддерживает расширение интерпретатора модулями на C/C++. Такие модули могут определять новые функции, типы и вызывать C-библиотеки.

Поэтому часто делают так:

удобный API на Python
горячие участки на C/C++/Rust/Cython

Примерная схема:

Python код:
    принимает запрос
    валидирует данные
    вызывает быструю функцию

C/Rust/C++ код:
    выполняет тяжёлые вычисления

Так работают многие производительные Python-библиотеки.

4. Можно использовать несколько процессов #

В CPython GIL ограничивает параллельное выполнение Python bytecode в потоках, но multiprocessing обходит GIL через процессы. Документация Python прямо говорит, что multiprocessing side-stepping the Global Interpreter Lock by using subprocesses and allows fully leverage multiple processors.

Пример:

from multiprocessing import Pool

def calculate(x):
    return x * x

with Pool() as pool:
    result = pool.map(calculate, range(1_000_000))

Для backend это обычно выглядит как несколько worker-процессов:

gunicorn/uvicorn workers
celery workers
несколько контейнеров
горизонтальное масштабирование

5. Async даёт хорошую производительность для I/O-bound backend #

Для API-сервисов Python может быть быстрым, если задача не CPU-bound. Например, FastAPI/aiohttp/asyncio позволяют одному процессу обслуживать много сетевых операций конкурентно, пока код ожидает БД, Redis или внешние API.

Но это работает только если внутри async-кода нет блокирующих вызовов:

# плохо
time.sleep(5)

# правильно
await asyncio.sleep(5)

Где Python реально используют для производительных систем #

Python подходит для высокопроизводительных приложений, когда:

основная нагрузка I/O-bound
есть масштабирование процессами/контейнерами
тяжёлые вычисления вынесены в нативные библиотеки
используются NumPy/PyTorch/TensorFlow/Pandas/Polars
горячие участки оптимизированы отдельно
есть кеширование, очереди, батчинг, профилирование

Типичные области:

backend API
ML/AI inference orchestration
data pipelines
ETL
автоматизация инфраструктуры
аналитические сервисы
микросервисы
scientific computing
нагруженные web-сервисы

Где Python будет плохим выбором #

Python обычно не лучший выбор, если нужно:

максимально быстро выполнять чистые CPU-bound циклы
писать игровой движок
писать драйверы
делать real-time систему
жёстко контролировать память
использовать много потоков для CPU-bound вычислений в обычном CPython

Например, такой код лучше писать не на чистом Python:

for pixel in pixels:
    process_pixel(pixel)

Лучше:

NumPy/OpenCV
Cython
Rust/C++ extension
GPU
Numba

Короткий вывод #

Да, на Python пишут высокопроизводительные приложения, потому что Python часто используют не как “самый быстрый исполнитель инструкций”, а как удобный управляющий слой.

Главная идея:

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

Но чистый Python-код в тяжёлых CPU-циклах обычно проигрывает C/C++/Rust/Go. Поэтому профессиональная Python-производительность — это не “писать всё на Python”, а грамотно сочетать Python с профилированием, нативными библиотеками, кешированием, async, multiprocessing и масштабированием.


42. Магический метод __call__ #

Что такое __call__ #

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

Сигнатура:

def __call__(self, *args, **kwargs):
    ...

Если у объекта есть метод __call__, то можно писать:

obj()

Python фактически выполнит:

obj.__call__()

Официальная документация Python указывает: object.__call__(self[, args...]) вызывается, когда экземпляр класса вызывается как функция.

Пример

class Greeter:
    def __call__(self, name):
        return f"Hello, {name}"


greeter = Greeter()

print(greeter("Alex"))

Результат:

Hello, Alex

Здесь greeter — это объект, но его можно вызвать как функцию:

greeter("Alex")

Потому что внутри класса определён:

def __call__(self, name):

Проверка через callable #

Есть встроенная функция:

callable(obj)

Она проверяет, можно ли объект вызвать.

class A:
    pass


class B:
    def __call__(self):
        pass


a = A()
b = B()

print(callable(a))  # False
print(callable(b))  # True

b вызываемый, потому что у него есть __call__.

Для чего используют __call__ #

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

Пример счётчика:

class Counter:
    def __init__(self):
        self.count = 0

    def __call__(self):
        self.count += 1
        return self.count


counter = Counter()

print(counter())  # 1
print(counter())  # 2
print(counter())  # 3

Обычная функция без замыкания не хранила бы состояние так явно. А объект с __call__ может хранить данные в атрибутах.

Пример валидатора #

class MinLengthValidator:
    def __init__(self, min_length):
        self.min_length = min_length

    def __call__(self, value):
        return len(value) >= self.min_length


validate_password = MinLengthValidator(8)

print(validate_password("abc"))       # False
print(validate_password("password"))  # True

Здесь объект validate_password работает как функция, но внутри хранит настройку:

self.min_length

Пример декоратора через класс #

__call__ часто используют для создания декораторов-классов:

class Logger:
    def __init__(self, func):
        self.func = func

    def __call__(self, *args, **kwargs):
        print(f"Вызов функции {self.func.__name__}")
        return self.func(*args, **kwargs)


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


print(add(2, 3))

Примерно это равно:

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

add = Logger(add)

Теперь add — это объект Logger, но его можно вызвать как функцию, потому что у Logger есть __call__.

__call__ у класса и создание экземпляра #

Когда ты вызываешь класс:

user = User()

это тоже вызов объекта. Сам класс User является объектом, и его вызов обрабатывается через метакласс type.

Упрощённо:

User()

означает:

type.__call__(User)

А внутри обычно происходят этапы:

1. вызывается User.__new__
2. затем User.__init__
3. возвращается готовый экземпляр

То есть создание объекта класса тоже связано с механизмом __call__.

Отличие от обычного метода #

Обычный метод вызывается явно по имени:

obj.run()

А __call__ позволяет вызывать сам объект:

obj()

Пример:

class Task:
    def run(self):
        print("run")

    def __call__(self):
        print("call")


task = Task()

task.run()  # обычный метод
task()      # вызов __call__

Итог #

__call__ делает экземпляр класса вызываемым объектом.

Коротко:

class MyCallable:
    def __call__(self, *args, **kwargs):
        ...

После этого:

obj = MyCallable()
obj()

становится валидным вызовом.

Используют __call__, когда нужно совместить:

поведение функции
+
состояние объекта

Типичные случаи:

валидаторы
фабрики
декораторы-классы
callback-объекты
объекты-команды
стратегии
ленивые вычисления


43. __call__ работает с атрибутами класса или экземпляра? | Обязательно ли писать __init__, если есть __call__? #

__call__ работает с атрибутами класса или экземпляра? #

__call__ — это метод класса, но при вызове объекта он получает экземпляр как первый аргумент self.

class Counter:
    step = 1  # атрибут класса

    def __init__(self):
        self.value = 0  # атрибут экземпляра

    def __call__(self):
        self.value += self.step
        return self.value


counter = Counter()

print(counter())  # 1
print(counter())  # 2

Здесь:

self.value

— атрибут экземпляра.

А:

self.step

— атрибут класса, найденный через экземпляр.

То есть внутри __call__ можно работать и с атрибутами экземпляра, и с атрибутами класса.

Важный нюанс #

__call__ обычно должен быть определён в классе, а не просто добавлен в конкретный объект.

class User:
    pass


user = User()

user.__call__ = lambda: "hello"

print(user.__call__())  # hello
print(user())           # TypeError

Почему user() не работает?

Потому что специальные методы вроде __call__, __len__, __iter__, __add__ Python ищет не как обычные атрибуты экземпляра, а через тип объекта, то есть через класс. В документации Python указано, что специальные методы вызываются специальным синтаксисом и ищутся на типе объекта.

Правильно:

class User:
    def __call__(self):
        return "hello"


user = User()

print(user())  # hello

Обязательно ли писать __init__, если есть __call__? #

Нет, не обязательно. __init__ нужен только тогда, когда объекту нужно начальное состояние.

Пример без __init__:

class Hello:
    def __call__(self, name):
        return f"Hello, {name}"


hello = Hello()

print(hello("Alex"))  # Hello, Alex

Здесь __init__ не нужен, потому что объект не хранит состояние.

Пример, где __init__ нужен:

class Multiplier:
    def __init__(self, factor):
        self.factor = factor

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


double = Multiplier(2)

print(double(10))  # 20

Здесь __init__ нужен, потому что объект должен запомнить:

self.factor

Документация Python описывает __init__ как метод, который вызывается после создания экземпляра для его инициализации. __call__ же вызывается, когда экземпляр вызывают как функцию.

Разница между __init__ и __call__ #

МетодКогда вызываетсяДля чего нужен
__init__при создании объекта: obj = Class(...)инициализировать состояние
__call__при вызове объекта: obj(...)сделать объект вызываемым

Пример с обоими:

class Power:
    def __init__(self, exponent):
        self.exponent = exponent

    def __call__(self, number):
        return number ** self.exponent


square = Power(2)

print(square(5))  # 25

Что происходит:

Power(2)   -> создаётся объект, вызывается __init__
square(5) -> вызывается __call__

Итог #

__call__:

определяется в классе
вызывается на экземпляре
получает self
может работать с атрибутами экземпляра
может читать атрибуты класса
делает объект вызываемым

__init__ писать не обязательно. Он нужен только если перед использованием __call__ объект должен сохранить какое-то состояние.


44. Влияет ли typing на выполнение программы в runtime? #

Обычный typing не заставляет программу соблюдать типы в runtime.

def add(a: int, b: int) -> int:
    return a + b


print(add("10", "20"))

Результат:

1020

Хотя написано:

a: int
b: int

Python не остановит выполнение. Аннотации типов в основном нужны для статических анализаторов, IDE и линтеров. Официальная документация прямо говорит, что Python runtime не enforce-ит аннотации типов функций и переменных.

Что тогда делает typing #

typing даёт синтаксис и инструменты для описания типов:

from typing import Optional, Callable, Iterable


def get_name(user_id: int) -> Optional[str]:
    ...

Это помогает:

mypy / pyright проверять код до запуска
IDE подсказывать методы и поля
линтерам находить ошибки
разработчикам лучше понимать контракты функций

Но сам Python во время выполнения обычно не проверяет, что user_id действительно int.

Аннотации всё же существуют в runtime #

Аннотации могут сохраняться в объекте:

def add(a: int, b: int) -> int:
    return a + b


print(add.__annotations__)

Результат:

{'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}

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

Также можно использовать:

from typing import get_type_hints


print(get_type_hints(add))

Но это просто получение информации о типах, а не автоматическая проверка аргументов.

Когда typing всё-таки влияет на runtime #

Есть несколько случаев, где типы могут косвенно влиять на выполнение:

1. Когда библиотека сама читает аннотации #

Например, Pydantic, FastAPI, SQLAlchemy ORM, dataclasses и другие инструменты могут читать аннотации и на их основе строить валидацию, схемы, сериализацию или поля модели.

Пример с Pydantic:

from pydantic import BaseModel


class User(BaseModel):
    id: int
    name: str

Здесь уже не сам Python проверяет типы, а библиотека.

2. Когда используется dataclass #

from dataclasses import dataclass


@dataclass
class User:
    name: str
    age: int

Но даже dataclass сам по себе не проверяет типы:

user = User(name=123, age="wrong")
print(user)

Это выполнится, если не подключена дополнительная валидация.

3. Когда код явно проверяет типы #

def add(a: int, b: int) -> int:
    if not isinstance(a, int):
        raise TypeError("a должен быть int")

    if not isinstance(b, int):
        raise TypeError("b должен быть int")

    return a + b

Здесь проверка работает, но не из-за typing, а из-за ручного isinstance.

Важное отличие #

def func(value: int):
    ...

Это не значит:

Python запретит передать str

Это значит:

разработчик ожидает int
type checker может проверить int
IDE может подсказать int
runtime сам не обязан проверять int

Итог #

typing напрямую почти не влияет на выполнение программы в runtime. Он влияет на:

статическую проверку
подсказки IDE
читаемость кода
документацию контракта
работу библиотек, которые читают аннотации

Но не делает Python статически типизированным языком.


45. Какие области видимости есть в Python? | Что такое LEGB в Python? #

Использование LEGB #

Концепция области видимости в Python обычно представлена c использованием правила, известного как правило LEGB. Буквы в аббревиатуре LEGB обозначают локальную, вложенную, глобальную и встроенную (Local, Enclosing, Global и Built-in Scope) области.

  • Локальная (или функция) область видимости (Local) — это блок кода или тело любой функции Python или лямбда-выражения. Эта область Python содержит имена, которые вы определяете внутри функции. Эти имена будут видны только из кода функции. Он создается при вызове функции, а не при ее определении, поэтому у вас будет столько же различных локальных областей, сколько и при вызовах функций. Это верно, даже если вы вызываете одну и ту же функцию несколько раз или рекурсивно.Каждый вызов приведет к созданию новой локальной области.
  • Вложенная (или нелокальная) область видимости (Enclosing) — это особая область видимости, которая существует только для вложенных функций. Если локальная область видимости является внутренней или вложенной функцией, тогда вложенная область видимости является областью внешней или вложенной функции. Эта область содержит имена, которые вы определяете во вложенной функции. Имена в охватывающей области видны из кода внутренних и включающих функций.
  • Глобальная (или модульная) область видимости (Global) — это самая верхняя область в программе, скрипте или модуле Python. Эта область Python содержит все имена, которые вы определяете на верхнем уровне программы или модуля.Имена в этой области Python видны повсюду в вашем коде.
  • Встроенная область видимости (Built-in) — это специальная область видимости Python, которая создается или загружается всякий раз, когда вы запускаете скрипт или открываете интерактивный сеанс. Эта область содержит имена, такие как ключевые слова, функции, исключения и другие атрибуты, встроенные в Python. Имена в этой области Python также доступны повсюду в вашем коде. Он автоматически загружается Python при запуске программы или сценария.

Правило LEGB — это своего рода процедура поиска имени, которая определяет порядок, в котором Python ищет имена. Например, если вы ссылаетесь на данное имя, тогда Python будет искать это имя последовательно в локальной, вложенной, глобальной и встроенной области видимости. Если имя существует, вы получите первое его появление. В противном случае вы получите ошибку.

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

Функции: локальная область видимости #

Локальная область видимости или область функции создаётся при вызове функции. Каждый раз, при вызове функцию, создаётся новая локальная область видимости. С другой стороны, можно рассматривать каждый оператор def и лямбда-выражение как образец для новых локальных областей видимости. Эти локальные области будут появляться всякий раз при вызове функций под рукой.

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

Вот как это работает:

>>> def square(base):
...     result = base ** 2
...     print(f'The square of {base} is: {result}')
...
>>> square(10)
The square of 10 is: 100
>>> result  # Недоступен снаружи square()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    result
NameError: name 'result' is not defined
>>> base  # Недоступен снаружи square()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    base
NameError: name 'base' is not defined
>>> square(20)
The square of 20 is: 400
</module></stdin></module></stdin>

square() — это функция, которая вычисляет квадрат заданного числа по основанию. Когда вы вызываете функцию, Python создает локальную область видимости, содержащую базу имен (аргумент) и результат (локальную переменную). После первого вызова square() base содержит значение 10, а result — значение 100. Во второй разлокальные имена не будут запоминать значения, которые были сохранены в них при первом вызове функции. Обратите внимание, что base теперь содержит значение 20, а result — 400.

Если вы попытаетесь получить доступ к результату или базе после вызова функции, вы получите NameError, потому что они существуют только в локальной области, созданной вызовом square(). Всякий раз, когда вы пытаетесь получить доступ к имени, которое не определено ни в одной области Python, вы получаете ошибку NameError. В сообщении об ошибке будет указано имя, которое не удалось найти.

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

>>> def cube(base):
...     result = base ** 3
...     print(f'The cube of {base} is: {result}')
...
>>> cube(30)
The cube of 30 is: 27000

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

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

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

>>> square.__code__.co_varnames
('base', 'result')
>>> square.__code__.co_argcount
1
>>> square.__code__.co_consts
(None, 2, 'The square of ', ' is: ')
>>> square.__code__.co_name
'square'

В этом примере кода вы проверяете .__code__ на square(). Это специальный атрибут, содержащий информацию о коде функции Python. В этом случае вы видите, что .co_varnames содержит кортеж, содержащий имена, которые вы определяете внутри square().

Вложенные функции: вложенные данные #

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

>>> def outer_func():
...     # Этот блок является локальной областью outer_func()
...     var = 100  # A nonlocal var
...     # Это также область видимости inner_func()
...     def inner_func():
...         # Этот блок является локальной областью inner_func()
...         print(f"Printing var from inner_func(): {var}")
...
...     inner_func()
...     print(f"Printing var from outer_func(): {var}")
...
>>> outer_func()
Printing var from inner_func(): 100
Printing var from outer_func(): 100
>>> inner_func()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'inner_func' is not defined
</module></stdin>

Когда вы вызываете external_func(), вы также создаете локальную область видимости. Локальная область видимости outer_func() в то же время является охватывающей областью inner_func(). Изнутри inner_func() эта область не является ни глобальной, ни локальной. Это особая область видимости, которая находится между этими двумя областями и известна как вложенная область. В некотором смысле inner_func() — это временная функция, которая оживает только во время выполнения своей функции-оболочки external_func(). Обратите внимание, что inner_func() виден только коду в outer_func().

Все имена, которые вы создаете во вложенной области видимости, видны изнутри inner_func(), кроме созданных после вызова inner_func(). Вот новая версия outer_fun(), которая показывает это:

>>> def outer_func():
...     var = 100
...     def inner_func():
...         print(f"Printing var from inner_func(): {var}")
...         print(f"Printing another_var from inner_func(): {another_var}")
...
...     inner_func()
...     another_var = 200  # This is defined after calling inner_func()
...     print(f"Printing var from outer_func(): {var}")
...
>>> outer_func()
Printing var from inner_func(): 100
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    outer_func()
  File "<stdin>", line 7, in outer_func
    inner_func()
  File "<stdin>", line 5, in inner_func
    print(f"Printing another_var from inner_func(): {another_var}")
NameError: free variable 'another_var' referenced before assignment in enclosing
 scope
</stdin></stdin></module></stdin>

Когда вы вызываете external_func(), код работает до точки, в которой вы вызываете inner_func(). Последний оператор inner_func() пытается получить доступ к another_var. На данный момент another_var еще не определено, поэтому Python вызывает NameError, потому что не может найти имя, которое вы пытаетесь использовать.

Последний, но тем не менее важный момент, нельзя изменять имена во вложенной области из вложенной функции, если не объявите их nonlocal во вложенной функции.

Модули: глобальные данные #

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

В Python понятия глобальной области видимости и глобальных имен тесно связаны с файлами модулей. Например, если вы определяете имя на верхнем уровне любого модуля Python, то это имя считается глобальным для модуля. Вот почему такая область видимости также называется областью модуля. Всякий раз, когда вы запускаете программу Python или интерактивный сеанс, интерпретатор выполняет код в модуле или скрипте, который служит точкой входа в вашу программу. Этот модуль или скрипт загружается со специальным именем __main__. С этого момента вы можете сказать, что ваша основная глобальная область видимости — это область видимости main. Для проверки имен в вашей основной глобальной области видимости, можно использовать dir(). Если вы вызовете dir() без аргументов, то получите список имен, которые находятся в текущей глобальной области видимости. Взгляните на этот код:

>>> dir()
['__annotations__', '__builtins__',..., '__package__', '__spec__']
>>> var = 100  # Назначьте var на верхнем уровне __main__
>>> dir()
['__annotations__', '__builtins__',..., '__package__', '__spec__', 'var']

Когда вы вызываете dir() без аргументов, то получаете список имен, доступных в вашей основной глобальной области Python. Обратите внимание, что если вы назначите новое имя (например, здесь var) на верхнем уровне модуля (здесь main), то это имя будет добавлено в список, возвращаемый dir().

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

>>> var = 100
>>> def func():
...     return var  # Вы можете получить доступ к var из func()
...
>>> func()
100
>>> var  # Остается неизменной
100

Внутри func() вы можете свободно обращаться к значению var или ссылаться на него. Это не влияет на ваше глобальное имя var, но показывает, что к var можно получить свободный доступ из func(). С другой стороны, вы не можете назначать глобальные имена внутри функций, если вы явно не объявите их как глобальные имена с помощью глобального оператора, который вы увидите позже.

Каждый раз, когда вы присваиваете значение имени в Python, может произойти одно из двух:

  • Вы создаете новое имя
  • Вы обновляете существующее имя

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

Если вы будете следовать этой логике, то поймете, что следующий код не будет работать так, как вы могли ожидать:

>>> var = 100  # Глобальная переменная
>>> def increment():
...     var = var + 1  # Попробуйте обновить глобальную переменную
...
>>> increment()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    increment()
  File "<stdin>", line 2, in increment
    var = var + 1
UnboundLocalError: local variable 'var' referenced before assignment
</stdin></module></stdin>

Внутри increment() вы пытаетесь увеличить глобальную переменную var. Поскольку var не объявлен глобальным внутри функции increment(), Python создает новую локальную переменную с тем же именем var внутри функции. В процессе Python понимает, что вы пытаетесь использовать локальную переменную до ее первого присвоения (var + 1), поэтому вызывает UnboundLocalError. Вот еще один пример:

>>> var = 100  # Глобальная переменная
>>> def func():
...     print(var)  # Ссылка на глобальную переменную var
...     var = 200   # Определить новую локальную переменную с тем же именем var
...
>>> func()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    func()
  File "<stdin>", line 2, in func
    print(var)
UnboundLocalError: local variable 'var' referenced before assignment
</stdin></module></stdin>

Вы, вероятно, ожидаете, что сможете распечатать глобальную переменную и обновить ее позже, но снова вы получите UnboundLocalError. Здесь происходит следующее: когда вы запускаете тело функции func(), Python решает, что var является локальной переменной, поскольку она назначается в области видимости функции. Это не ошибка, а выбор дизайна. Python предполагает, что имена, присвоенные в теле функции, являются локальными для этой функции.

Глобальные имена можно обновлять или изменять из любого места в вашей глобальной области Python. Помимо этого, оператор global можно использовать для изменения глобальных имен практически из любого места в вашем коде.

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

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

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

  • Напишите автономные функции, которые полагаются на локальные имена, а не на глобальные.
  • Старайтесь использовать уникальные имена объектов, независимо от того, в какой области вы находитесь.
  • Избегайте глобальных модификаций имен в ваших программах.
  • Избегайте изменения имени кросс-модуля.
  • Используйте глобальные имена как константы, которые не меняются во время выполнения вашей программы.

В следующем примере показано, где расположены области видимости в вашем коде и как Python ищет имена с их помощью:

>>> # Эта область является глобальной или модульной областью
>>> number = 100
>>> def outer_func():
...     # Этот блок является локальной областью outer_func()
...     # Это также область видимости inner_func()
...     def inner_func():
...         # Этот блок является локальной областью inner_func()
...         print(number)
...
...     inner_func()
...
>>> outer_func()
100

Когда вы вызываете outer_func(), вы получаете 100, напечатанное на вашем экране. Но как в этом случае Python ищет номер имени? Следуя правилу LEGB, вы будете искать числа в следующих местах:

Внутри inner_func(): это локальная область видимости, но числа там не существует.

Внутри outer_func(): это вложенная область,но и там число не определено.

В области видимости модуля: Это глобальная область видимости, и вы найдете там номер, чтобы вы могли вывести его на экран.

Если число не определено внутри глобальной области видимости, Python продолжает поиск, просматривая встроенную область видимости. Это последний компонент правила LEGB, как вы увидите далее.

Встроенные функции: встроенные данные #

Встроенная область видимости — это специальная область видимости Python, которая реализована в виде стандартного библиотечного модуля с именем builtins в Python 3.x. Все встроенные объекты Python находятся в этом модуле. Они автоматически загружаются во встроенную область видимости, когда вы запускаете интерпретатор Python. Python выполняет поиск встроенных функций последними в поиске LEGB, так что вы получите все имена, которые он определяет, бесплатно. Это означает, что вы можете использовать их без импорта какого-либо модуля.

Обратите внимание, что имена во встроенных командах всегда загружаются в вашу глобальную область Python со специальным именем __builtins__, как вы можете видеть в следующем коде:

>>> dir()
['__annotations__', '__builtins__',..., '__package__', '__spec__']
>>> dir(__builtins__)
['ArithmeticError', 'AssertionError',..., 'tuple', 'type', 'vars', 'zip']

В выводе первого вызова dir() вы можете видеть, что __builtins__ всегда присутствует в глобальной области Python. Если вы проверите сам __builtins__ с помощью dir(), вы получите полный список встроенных имен Python.

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

>>> len(dir(__builtins__))
152

С помощью вызова len() вы получаете количество элементов в списке, возвращаемое функцией dir(). Это возвращает 152 имени, которые включают исключения, функции, типы, специальные атрибуты и другие встроенные объекты Python.

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

>>> import builtins  # Импортировать встроенные функции как обычный модуль
>>> dir(builtins)
['ArithmeticError', 'AssertionError',..., 'tuple', 'type', 'vars', 'zip']
>>> builtins.sum([1, 2, 3, 4, 5])
15
>>> builtins.max([1, 5, 8, 7, 3])
8
>>> builtins.sorted([1, 5, 8, 7, 3])
[1, 3, 5, 7, 8]
>>> builtins.pow(10, 2)
100

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

>>> abs(-15)  # Стандартное использование встроенной функции
15
>>> abs = 20  # Переопределить встроенное имя в глобальной области видимости
>>> abs(-15)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'int' object is not callable
</module></stdin>

Если вы переопределите или повторно назначите abs, то исходный встроенный abs() повлияет на весь ваш код. Теперь предположим, что вам нужно вызвать исходный abs(), но вы забыли, что переназначили имя. В этом случае, когда вы снова вызываете abs(), вы получите ошибку TypeError, потому что abs теперь содержит ссылку на целое число, которое нельзя вызвать.


46. Как передаются переменные в Python: по ссылке или по значению? #

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

def modify_list(my_list):
    my_list.append(4)        # изменяем список, на который ссылается аргумент
    my_list = [1, 2, 3]      # присваиваем новый список самому аргументу

original_list = [0]
modify_list(original_list)

print(original_list)  # Выведет: [0, 4], изменения внутри функции отражены на оригинальном объекте

В этом примере список original_list изменяется внутри функции modify_list(), добавляя в него элемент 4. Эти изменения отражаются на самом объекте original_list, так как мы работаем с одним и тем же списком. Однако, когда мы присваиваем новый список переменной my_list внутри функции, это не изменяет объект original_list, а только изменяет то, на что ссылается аргумент функции.

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


47. Что такое и для чего используется @property #

Что такое @property #

@property — это встроенный декоратор Python, который позволяет обращаться к методу как к обычному атрибуту.

То есть вместо:

user.get_name()

можно писать:

user.name

Пример:

class User:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    @property
    def full_name(self):
        return f"{self.first_name} {self.last_name}"


user = User("Alex", "Smith")

print(user.full_name)

Результат:

Alex Smith

Хотя full_name — это метод, вызываем мы его как атрибут:

user.full_name

А не так:

user.full_name()

Официальная документация описывает property как встроенный класс, который возвращает property-атрибут и позволяет определить getter, setter и deleter.


Зачем используется @property #

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

Например, есть вычисляемое значение:

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    @property
    def area(self):
        return self.width * self.height


rect = Rectangle(10, 5)

print(rect.area)  # 50

area не хранится как отдельный атрибут. Оно вычисляется каждый раз при обращении.


@property как getter #

class User:
    def __init__(self, age):
        self._age = age

    @property
    def age(self):
        return self._age

Использование:

user = User(20)

print(user.age)

Здесь:

user.age

вызывает метод:

def age(self):
    return self._age

Но внешне выглядит как обычный атрибут.


@property с setter #

Можно контролировать запись значения:

class User:
    def __init__(self, age):
        self.age = age

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if value < 0:
            raise ValueError("Возраст не может быть отрицательным")

        self._age = value

Использование:

user = User(20)

user.age = 25
print(user.age)  # 25

user.age = -5    # ValueError

Здесь @property отвечает за чтение:

user.age

А @age.setter отвечает за запись:

user.age = 25

@property без setter делает атрибут read-only #

class User:
    def __init__(self, name):
        self._name = name

    @property
    def name(self):
        return self._name


user = User("Alex")

print(user.name)  # Alex
user.name = "Bob" # AttributeError

Если setter не определён, атрибут нельзя изменить обычным присваиванием.


@property с deleter #

Можно также управлять удалением атрибута:

class User:
    def __init__(self, name):
        self._name = name

    @property
    def name(self):
        return self._name

    @name.deleter
    def name(self):
        del self._name

Использование:

user = User("Alex")

del user.name

При этом вызывается:

@name.deleter
def name(self):
    del self._name

Почему часто используют _name #

В примерах часто пишут так:

self._age = age

А не так:

self.age = age

Причина: если внутри getter обратиться к self.age, получится рекурсия.

Плохо:

class User:
    @property
    def age(self):
        return self.age

Что произойдёт:

user.age
-> вызывается property age
-> внутри return self.age
-> снова вызывается property age
-> снова return self.age
-> RecursionError

Правильно хранить реальное значение в другом атрибуте:

self._age

А наружу отдавать через property:

user.age

Чем @property отличается от обычного метода #

Обычный метод:

class Rectangle:
    def get_area(self):
        return self.width * self.height

Вызов:

rect.get_area()

@property:

class Rectangle:
    @property
    def area(self):
        return self.width * self.height

Вызов:

rect.area

Разница по смыслу:

ПодходКогда использовать
обычный методдействие, операция, команда
@propertyзначение, характеристика объекта

Например:

user.save()

Это метод, потому что это действие.

user.full_name

Это property, потому что это характеристика объекта.


Связь с дескрипторами #

property — это встроенный дескриптор. Он работает через механизм __get__, __set__ и __delete__.

Упрощённо:

user.age

вызывает getter.

user.age = 20

вызывает setter.

del user.age

вызывает deleter.

Это часть стандартного механизма доступа к атрибутам в Python.


Когда использовать @property #

@property используют, когда нужно:

сделать вычисляемый атрибут
скрыть внутреннее хранение данных
добавить валидацию при записи
сделать read-only атрибут
сохранить старый интерфейс класса
управлять доступом к данным без изменения синтаксиса вызова

Пример сохранения интерфейса:

class User:
    def __init__(self, birth_year):
        self.birth_year = birth_year

    @property
    def age(self):
        return 2026 - self.birth_year

Снаружи удобно:

user.age

Хотя внутри значение вычисляется.


48. Как динамически создать класс в runtime #

Основной способ — через type() #

В Python класс можно создать динамически в runtime через type.

Обычное объявление:

class User:
    role = "admin"

    def say_hello(self):
        return "Hello"

Примерно эквивалентно:

def say_hello(self):
    return "Hello"


User = type(
    "User",          # имя класса
    (),              # базовые классы
    {
        "role": "admin",
        "say_hello": say_hello,
    }
)

Использование:

user = User()

print(user.role)          # admin
print(user.say_hello())   # Hello
print(type(User))         # <class 'type'>

type(name, bases, dict) с тремя аргументами создаёт новый объект типа, то есть новый класс. Это описано в официальной документации Python по встроенной функции type.


Что означают аргументы type #

User = type("User", (), {})
АргументЗначение
"User"имя создаваемого класса
()кортеж базовых классов
{}namespace класса: атрибуты и методы

Пример с наследованием:

class Base:
    def base_method(self):
        return "base"


def user_method(self):
    return "user"


User = type(
    "User",
    (Base,),
    {
        "role": "admin",
        "user_method": user_method,
    }
)

obj = User()

print(obj.base_method())  # base
print(obj.user_method())  # user
print(obj.role)           # admin

Здесь динамически созданный User наследуется от Base.


Добавление __init__ #

def __init__(self, name):
    self.name = name


def greet(self):
    return f"Hello, {self.name}"


User = type(
    "User",
    (),
    {
        "__init__": __init__,
        "greet": greet,
    }
)

user = User("Alex")

print(user.name)     # Alex
print(user.greet())  # Hello, Alex

Важно: функции, переданные в словарь класса, становятся методами.


Динамическое создание класса с @property #

def __init__(self, first_name, last_name):
    self.first_name = first_name
    self.last_name = last_name


def get_full_name(self):
    return f"{self.first_name} {self.last_name}"


User = type(
    "User",
    (),
    {
        "__init__": __init__,
        "full_name": property(get_full_name),
    }
)

user = User("Alex", "Smith")

print(user.full_name)  # Alex Smith

Здесь property(get_full_name) вручную создаёт property-атрибут.


Динамическое создание класса с метаклассом #

Так как type — это стандартный метакласс, можно создать свой метакласс:

class Meta(type):
    def __new__(mcls, name, bases, namespace):
        namespace["created_by_meta"] = True
        return super().__new__(mcls, name, bases, namespace)


User = Meta(
    "User",
    (),
    {
        "role": "admin",
    }
)

print(User.created_by_meta)  # True
print(User.role)             # admin

Здесь класс User создаётся не обычным type, а метаклассом Meta.


Более правильный низкоуровневый способ — types.new_class #

В стандартной библиотеке есть модуль types, который содержит утилиты для динамического создания новых типов. Документация прямо описывает types как модуль для помощи в динамическом создании новых типов. ( Python documentation)

Пример:

import types


def exec_body(namespace):
    namespace["role"] = "admin"

    def say_hello(self):
        return "Hello"

    namespace["say_hello"] = say_hello


User = types.new_class(
    "User",
    (),
    {},
    exec_body
)

user = User()

print(user.role)         # admin
print(user.say_hello())  # Hello

types.new_class полезен, когда нужно создать класс ближе к обычному механизму class, особенно если важны метаклассы, __prepare__ и корректная обработка namespace.


Когда реально используют динамическое создание классов #

Обычно это нужно не в прикладном коде, а во фреймворках и библиотеках:

ORM
сериализаторы
валидаторы
генерация DTO / schema-классов
динамические proxy-классы
тестовые mock-классы
плагины
метапрограммирование

Например, ORM может динамически создавать классы моделей по схеме БД, а библиотека валидации — создавать классы схем на основе конфигурации.


Когда лучше не использовать #

Если класс известен заранее, лучше писать обычный class:

class User:
    pass

Динамическое создание через type() хуже читается и сложнее для IDE, type checker-ов и других разработчиков.


Итог #

Класс в runtime можно создать так:

User = type("User", (), {"role": "admin"})

Это создаёт полноценный класс:

user = User()
print(user.role)

Главная формула:

ClassName = type(
    "ClassName",
    (BaseClass,),
    {
        "attr": value,
        "method": function,
    }
)

Для простых случаев достаточно type(). Для более сложного и корректного метапрограммирования лучше смотреть в сторону types.new_class().


49. Как выполняется Python-код интерпретатором? #

В CPython Python-код обычно проходит такую цепочку:

.py файл
лексический и синтаксический разбор
AST
bytecode
code object
выполнение виртуальной машиной CPython

Python не выполняет .py файл как текст построчно в прямом смысле. Сначала исходный код компилируется во внутреннее представление — байткод, а уже байткод выполняется интерпретатором.

1. Python читает исходный код #

Например, есть файл:

x = 10
y = 20
print(x + y)

Когда запускаешь:

python main.py

интерпретатор читает исходный код из файла main.py.

2. Код разбирается на токены и синтаксическую структуру #

Python сначала анализирует текст программы: ключевые слова, имена, операторы, отступы, строки, числа и другие элементы синтаксиса.

Например:

x = 10

разбирается примерно как:

имя x
оператор =
число 10

На этом этапе Python может обнаружить синтаксические ошибки:

if True
    print("Hello")

Ошибка:

SyntaxError: expected ':'

3. Создаётся AST #

После синтаксического разбора Python строит AST — abstract syntax tree, абстрактное синтаксическое дерево.

Пример:

x = 1 + 2

логически превращается в дерево:

Assign
├── target: x
└── value: BinOp
    ├── left: 1
    ├── op: +
    └── right: 2

Модуль ast в стандартной библиотеке Python позволяет работать с такими деревьями; документация указывает, что AST можно получить через ast.parse() или compile(..., flags=ast.PyCF_ONLY_AST), а затем скомпилировать в code object через compile().

4. AST компилируется в байткод #

Дальше Python компилирует AST в байткод.

Байткод — это набор низкоуровневых инструкций для виртуальной машины Python, а не машинный код процессора.

Пример:

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

Можно посмотреть байткод через dis:

import dis

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

dis.dis(add)

Модуль dis предназначен для анализа байткода Python; он умеет показывать инструкции, соответствующие функции, строке исходного кода или code object.

5. Создаётся code object #

Результатом компиляции становится code object.

Например:

code = compile("x = 10\nprint(x)", "<string>", "exec")

compile() компилирует исходный код в code object или AST object. Документация указывает, что code object может быть выполнен через exec() или eval().

Пример:

code = compile("x = 10\nprint(x)", "<string>", "exec")

exec(code)

Результат:

10

6. Байткод выполняется виртуальной машиной CPython #

Виртуальная машина CPython выполняет инструкции байткода одну за другой.

Упрощённо:

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

Например, для выражения:

x + y

байткод может делать примерно следующее:

загрузить x
загрузить y
выполнить сложение
вернуть результат

Это не точный байткод для всех версий Python, потому что инструкции меняются между версиями.

7. При импорте модулей байткод кешируется в .pyc #

Когда Python импортирует модуль, он может сохранить скомпилированный байткод в файл .pyc внутри папки __pycache__.

Например:

__pycache__/module.cpython-312.pyc

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

Важно: .pyc не делает Python “скомпилированным в машинный код”. Это всё ещё байткод для виртуальной машины Python.

Важное уточнение: Python компилируется, но не как C++ #

Python часто называют интерпретируемым языком, но в CPython есть этап компиляции:

исходный код -> байткод

Однако это не такая компиляция, как в C/C++/Rust:

исходный код -> машинный код процессора

В обычном CPython:

исходный код Python -> байткод Python -> выполнение виртуальной машиной

Пример полного процесса #

Код:

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

print(add(2, 3))

Что происходит:

1. Python читает исходный код.
2. Проверяет синтаксис.
3. Строит AST.
4. Компилирует AST в байткод.
5. Создаёт code object.
6. Выполняет байткод.
7. Создаёт объект функции add.
8. Вызывает print.
9. Вызывает add(2, 3).
10. Выполняет байткод функции add.
11. Возвращает 5.
12. print выводит 5.

Что происходит при определении функции #

Когда Python видит:

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

он не выполняет тело функции сразу. Он создаёт объект функции add, внутри которого лежит code object тела функции.

Тело выполнится только при вызове:

add(2, 3)

То есть:

def создаёт функцию
вызов функции выполняет её тело

Что происходит при определении класса #

Когда Python видит:

class User:
    name = "Alex"

он выполняет тело класса и создаёт объект класса User.

Упрощённо:

1. создать namespace класса
2. выполнить тело класса в этом namespace
3. передать имя, bases и namespace метаклассу
4. получить объект класса
5. связать имя User с этим объектом

Это связано с тем, что класс в Python — тоже объект.

Итоговая схема #

main.py
исходный текст
токены
синтаксический разбор
AST
байткод
code object
виртуальная машина CPython
выполнение программы


50. Что такое и зачем нужен pip #

PIP (Pip Installs Packages) — это стандартная система управления пакетами в Python. Это инструмент командной строки, который позволяет устанавливать, удалять и управлять дополнительными библиотеками и зависимостями, не входящими в стандартную библиотеку Python. Он взаимодействует с Python Package Index (PyPI), чтобы находить, загружать и устанавливать пакеты.

🛠 Основные возможности #

Установка пакетов

# Установка последней версии
pip install requests

# Установка конкретной версии
pip install django==4.2.0

# Установка из файла требований
pip install -r requirements.txt

Поиск и информация

# Поиск пакетов в репозитории
pip search "web framework"

# Показать информацию о пакете
pip show django

# Список установленных пакетов
pip list

Управление зависимостями

# Генерация файла зависимостей
pip freeze > requirements.txt

# Установка из файла зависимостей
pip install -r requirements.txt

Обновление и удаление

# Обновление пакета
pip install --upgrade requests

# Удаление пакета
pip uninstall django

# Проверка устаревших пакетов
pip list --outdated

📁 Файл requirements.txt Файл requirements.txt — это стандартный способ хранения списка зависимостей проекта:

# requirements.txt
Django==4.2.0
requests>=2.25.0
numpy<1.22.0
celery[redis]

Генерирация список всех установленных пакетов

pip freeze > requirements.txt

Установка всех зависимостей

pip install -r requirements.txt

Установка в режиме разработки

pip install -r requirements/dev.txt

Обновить все пакеты до последних версий

pip install --upgrade -r requirements.txt

⚡ Ключевые особенности #

Работа с репозиторием PyPI PIP по умолчанию работает с PyPI (Python Package Index) — официальным репозиторием пакетов Python, содержащим тысячи библиотек.

Разрешение зависимостей PIP автоматически определяет и устанавливает все необходимые зависимости пакетов, обеспечивая корректную работу устанавливаемых библиотек.

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

🚀 Практическое использование #

Типичный рабочий процесс #

# Создание виртуального окружения
python -m venv myproject_env

# Активация окружения
source myproject_env/bin/activate  # Linux/Mac
myproject_env\Scripts\activate     # Windows

# Установка зависимостей проекта
pip install -r requirements.txt

# Добавление нового пакета
pip install fastapi
pip freeze > requirements.txt

Совместимость с системами #

  • Работает на всех основных ОС (Windows, Linux, macOS)
  • Поддерживает все версии Python (начиная с Python 3.4)
  • Интегрируется с инструментами CI/CD

Безопасность #

  • Проверка цифровых подписей пакетов
  • Поддержка HTTPS для загрузки
  • Возможность использования приватных репозиториев


51. Что такое пакет и модуль, в чем их разница? #

Модуль — это файл, содержащий код Python, который может быть повторно использован в других программах.

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

Например, если у нас есть пакет mypackage, в нем может находится несколько модулей, таких как module1.py, module2.py. В файле __init__.py определяются функции и переменные, которые могут использоваться внутри module1 и module2. Некоторые примеры импорта:

import mymodule # импортируем модуль

from mypackage import mymodule # импортируем модуль из пакета

from mypackage.mymodule import myfunction # импортируем функцию из модуля в пакете

Пример со структурой файлов

project/
    main.py
    shapes/           # пакет
        __init__.py   # обязательный файл для пакета
        circle.py     # модуль в пакете
        rectangle.py  # модуль в пакете

# Импорт конкретного модуля из пакета
from shapes import circle

result = circle.area(5)
print(result)  # 78.53975

# Импорт функции напрямую из модуля пакета
from shapes.rectangle import area

result = area(4, 6)
print(result)  # 24

# Импорт всего пакета (требует настройки __init__.py)
import shapes

# После настройки __init__.py можно использовать:
# result = shapes.circle.circumference(3)

Настройка __init__.py необходима для корректного импорта всего пакета. Без неё команда import shapes загрузит пакет, но не предоставит прямого доступа к его модулям и функциям. Основная настройка включает определение списка __all__для контроля импорта через звёздочку и явный реэкспорт ключевых компонентов на верхний уровень пакета. Также в __init__.py обычно указывают метаданные пакета, такие как версия и автор, и могут размещать код инициализации, который выполняется при первом импорте.

Статья на хабре  Python модули и пакеты


52. Глубокое и поверхностное копирование | copy - deepcopy #

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

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

import copy

# создание копии объекта
    new_list = old_list.copy()

# создание глубокой копии объекта
    new_list = copy.deepcopy(old_list)
где old_list - исходный список, а new_list - его копия.

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

Управление копированием через __copy__ и __deepcopy__

Метод __copy__

Позволяет кастомизировать поверхностное копирование. Когда вы определяете этот метод в классе, copy.copy() будет использовать вашу реализацию вместо стандартной.

Что можно контролировать:

  • Какие атрибуты копировать, а какие нет
  • Как именно создавать копии отдельных полей
  • Какие данные сбрасывать/обновлять в копии

Метод __deepcopy__

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

Что можно контролировать:

  • Глубину копирования для разных атрибутов
  • Обработку циклических ссылок
  • Оптимизацию процесса (например, не копировать неизменяемые объекты)


53. Что такое магичекие (dunder) методы #

Магические методы (иногда также называемые специальными методами или dunder-методами от “double underscore methods” — то есть “методы с двойным подчеркиванием”, то есть начинаются и заканчиваются двумя подчеркиваниями (например, __init__()). Это специальные методы, которые необходимы для реализации базовых операций над объектами. Благодаря этим методам объекты могут реализовывать, поддерживать и взаимодействовать с базовыми конструкциями языка, а именно:

  • итерирование
  • коллекции
  • доступ к атрибутам
  • перегрузка операторов
  • контекстные менеджеры
  • вызов методов
  • создание и уничтожение объектов

Магические они потому, что почти никогда не вызываются явно. Их вызывают встроенные функции или синтаксические конструкции. Например, функция len() вызывает метод __len__() переданного объекта. Метод __add__(self, other) вызывается автоматически при сложении оператором +.

Какие есть магические методы и для чего они используются?

  1. __init__(self, ...): Вызывается при создании нового экземпляра класса и используется для инициализации его атрибутов.

  2. __repr__(self): Возвращает формальное строковое представление объекта, которое должно быть максимально точным и однозначным. В идеале эта строка должна быть валидным Python-кодом, который можно использовать для воссоздания объекта. Используется в первую очередь для отладки разработчиками.

  3. __str__(self): Возвращает неформальное строковое представление объекта, ориентированное на удобочитаемость. Это то, что видят пользователи при вызове print() или str(). Если __str__ не определён, Python использует __repr__ как запасной вариант.

  4. __len__(self): Возвращает длину объекта. Это используется, когда объект поддерживает операцию получения длины, например, для строк, списков и т. д.

  5. __getitem__(self, key): Позволяет объекту поддерживать доступ к элементам по индексу, как это делают списки и словари.

  6. __setitem__(self, key, value): Позволяет объекту устанавливать значение элемента по ключу.

  7. __delitem__(self, key): Позволяет объекту удалять элемент по ключу.

  8. __iter__(self): Возвращает итератор для объекта, позволяя его использовать в циклах for.

  9. __next__(self): Вызывается итератором для получения следующего элемента в последовательности.

  10. __enter__(self)__exit__(self, exc_type, exc_value, traceback): Позволяют объекту быть контекстным менеджером, что позволяет определять действия, которые должны выполняться при входе и выходе из контекста.

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

Статья на хабре  Руководство по магическим методам в Питоне


54. Что такое Monkey Patching #

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

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

class MyBaseClass:
    def my_method(self):
        print('Hello from MyBaseClass')

def monkey_patch():
    def new_method(self):
        print('Hello from new_method')
    MyBaseClass.my_method = new_method

monkey_patch()
obj = MyBaseClass()
obj.my_method()  # выведет "Hello from new_method"

В этом примере мы добавляем новый метод new_method() в класс MyBaseClass, используя функцию monkey_patch(). После этого, вызов метода obj.my_method() выведет строку

Hello from new_method

Важно учитывать, что использование monkey patching может усложнить отладку и поддержку в будущем, поэтому следует использовать эту технику с осторожностью и только при необходимости.


55. Работали ли вы с оператором * (звёздочка)? Что означает и для чего используется в Python? #

Оператор * в Python используется в нескольких разных смыслах. Основные варианты:

*      — умножение
**     — возведение в степень
*args  — собрать позиционные аргументы
**kwargs — собрать именованные аргументы
*iterable — распаковать iterable
**dict — распаковать словарь в keyword-аргументы
*      — ограничитель keyword-only параметров

1. Умножение #

Самое простое использование:

x = 5 * 3

print(x)  # 15

Для чисел * означает умножение. Также * работает с некоторыми последовательностями:

print("ha" * 3)
print([1, 2] * 3)

Результат:

hahaha
[1, 2, 1, 2, 1, 2]

То есть для строк, списков, кортежей * может означать повторение.

2. Возведение в степень через ** #

Двойная звёздочка ** используется для возведения в степень:

x = 2 ** 3

print(x)  # 8

То же самое можно сделать через pow:

print(pow(2, 3))  # 8

3. *args — сбор позиционных аргументов #

В параметрах функции *args собирает лишние позиционные аргументы в кортеж:

def func(*args):
    print(args)


func(1, 2, 3)

Результат:

(1, 2, 3)

То есть:

func(1, 2, 3)

превращается внутри функции в:

args = (1, 2, 3)

Название args не обязательно. Важна именно звёздочка:

def func(*values):
    print(values)

Но по соглашению обычно пишут *args.

4. **kwargs — сбор именованных аргументов #

**kwargs собирает лишние именованные аргументы в словарь:

def func(**kwargs):
    print(kwargs)


func(name="Alex", age=20)

Результат:

{'name': 'Alex', 'age': 20}

То есть:

func(name="Alex", age=20)

превращается внутри функции в:

kwargs = {
    "name": "Alex",
    "age": 20
}

Название kwargs тоже не обязательно, но это стандартное соглашение.

5. Распаковка iterable через * #

Звёздочка может распаковывать список, кортеж, строку, range и другие iterable-объекты.

Пример:

numbers = [1, 2, 3]

print(*numbers)

Результат:

1 2 3

Без звёздочки:

print(numbers)

Результат:

[1, 2, 3]

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

Пример с функцией:

def add(a, b, c):
    return a + b + c


values = [1, 2, 3]

print(add(*values))  # 6

Фактически:

add(*values)

превращается в:

add(1, 2, 3)

6. Распаковка словаря через ** #

Двойная звёздочка распаковывает словарь в именованные аргументы:

def create_user(name, age):
    print(name, age)


data = {
    "name": "Alex",
    "age": 20
}

create_user(**data)

Результат:

Alex 20

Фактически:

create_user(**data)

превращается в:

create_user(name="Alex", age=20)

Ключи словаря должны совпадать с именами параметров функции.

7. * при распаковке присваивания #

Звёздочка может собрать “остаток” значений при распаковке:

a, *middle, b = [1, 2, 3, 4, 5]

print(a)
print(middle)
print(b)

Результат:

1
[2, 3, 4]
5

Здесь:

a      -> первый элемент
middle -> всё между первым и последним
b      -> последний элемент

Ещё пример:

first, *rest = [10, 20, 30]

print(first)  # 10
print(rest)   # [20, 30]

8. * для keyword-only параметров #

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

def create_user(name, *, age, city):
    print(name, age, city)


create_user("Alex", age=20, city="Baku")

Так можно.

А так нельзя:

create_user("Alex", 20, "Baku")

Будет ошибка:

TypeError

Потому что age и city находятся после *, значит они keyword-only.

Это часто используют, чтобы сделать вызов функции более явным:

def connect(host, *, timeout, retries):
    ...

Вызов:

connect("localhost", timeout=5, retries=3)

Так читается лучше, чем:

connect("localhost", 5, 3)

9. Комбинация обычных аргументов, *args и **kwargs #

Частый вариант:

def func(a, b, *args, **kwargs):
    print(a)
    print(b)
    print(args)
    print(kwargs)


func(1, 2, 3, 4, name="Alex", age=20)

Результат:

1
2
(3, 4)
{'name': 'Alex', 'age': 20}

Разбор:

a      -> 1
b      -> 2
args   -> (3, 4)
kwargs -> {'name': 'Alex', 'age': 20}

10. Связь с магическими методами #

Оператор * связан с магическими методами:

a * b   -> __mul__
b * a   -> __rmul__
a *= b  -> __imul__
a ** b  -> __pow__

Пример:

class Number:
    def __init__(self, value):
        self.value = value

    def __mul__(self, other):
        return Number(self.value * other.value)


a = Number(2)
b = Number(3)

result = a * b

print(result.value)  # 6

Когда Python видит:

a * b

он пытается вызвать:

a.__mul__(b)

Итоговая схема #

* в Python может означать:

1. умножение:
   2 * 3

2. повторение последовательности:
   "a" * 3

3. распаковку iterable:
   func(*values)

4. сбор позиционных аргументов:
   def func(*args)

5. сбор keyword-аргументов:
   def func(**kwargs)

6. распаковку словаря:
   func(**data)

7. сбор остатка при распаковке:
   first, *rest = values

8. разделитель keyword-only параметров:
   def func(a, *, b)

9. часть магических методов:
   __mul__, __rmul__, __imul__, __pow__

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


56. Что такое formatter в Python? #

В Python слово formatter может означать разные вещи. Чаще всего под ним понимают одну из трёх сущностей:

1. formatter строк
   объект или механизм, который форматирует значения в строку

2. formatter логов
   объект logging.Formatter, который задаёт внешний вид сообщений логирования

3. formatter кода
   инструмент, который автоматически приводит Python-код к единому стилю

То есть formatter — это не один конкретный оператор Python, а общее название для сущности, которая что-то форматирует.

1. Formatter строк #

В контексте строк formatter отвечает за подстановку и оформление значений внутри строки.

Пример через f-string:

name = "Alex"
age = 20

text = f"User: {name}, age: {age}"

print(text)

Результат:

User: Alex, age: 20

Здесь Python форматирует значения name и age в строку.

Можно задавать правила форматирования:

value = 3.14159

print(f"{value:.2f}")

Результат:

3.14

Здесь:

:.2f

означает: вывести число с двумя знаками после запятой.

Официальная документация Python описывает f-strings, str.format() и format string syntax как механизмы форматирования строк.

2. string.Formatter #

В стандартной библиотеке есть класс:

string.Formatter

Он нужен для более низкоуровневого и настраиваемого форматирования строк.

Пример:

from string import Formatter

formatter = Formatter()

result = formatter.format("Hello, {name}", name="Alex")

print(result)

Результат:

Hello, Alex

На практике string.Formatter используют редко. Чаще используют:

f-strings
str.format()

Например:

name = "Alex"

print(f"Hello, {name}")
print("Hello, {}".format(name))

Документация Python указывает, что str.format() и класс Formatter используют общий синтаксис format strings.

3. Formatter в logging #

В модуле logging formatter — это объект, который определяет, как будет выглядеть сообщение лога.

Пример:

import logging

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

handler = logging.StreamHandler()

formatter = logging.Formatter(
    "%(asctime)s - %(levelname)s - %(message)s"
)

handler.setFormatter(formatter)
logger.addHandler(handler)

logger.info("User created")

Вывод будет примерно такой:

2026-06-18 12:30:00,000 - INFO - User created

Здесь logging.Formatter задаёт формат сообщения:

%(asctime)s   -> время
%(levelname)s -> уровень лога
%(message)s   -> текст сообщения

То есть formatter не пишет лог сам. Он только говорит handler-у, как оформить LogRecord перед выводом. Документация logging описывает Formatter как часть системы логирования, отвечающую за форматирование записей логов.

4. Formatter кода #

Также formatter может означать инструмент для автоматического форматирования Python-кода.

Например:

Black
autopep8
yapf
ruff format

Такие инструменты меняют внешний вид кода, но не должны менять его смысл.

Было:

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

После форматирования:

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

Black в документации описан как opinionated code formatter: он автоматически приводит код к единому стилю, чтобы уменьшить споры о форматировании и упростить code review.


57. Магический метод __add__ #

__add__ — это магический метод, который вызывается при использовании оператора +.

То есть выражение:

a + b

Python пытается выполнить через:

a.__add__(b)

Официальная документация Python описывает __add__ как специальный метод для реализации бинарной арифметической операции +

Простой пример #

class Number:
    def __init__(self, value):
        self.value = value

    def __add__(self, other):
        return Number(self.value + other.value)


a = Number(10)
b = Number(20)

result = a + b

print(result.value)

Результат:

30

Когда Python видит:

a + b

он вызывает:

a.__add__(b)

Внутри метода мы сами определяем, что значит “сложить” два объекта Number.

Как это работает по шагам #

Код:

result = a + b

Упрощённо превращается в:

1. Python видит оператор +
2. Проверяет левый объект a
3. Ищет у его класса метод __add__
4. Вызывает a.__add__(b)
5. Возвращает результат

То есть:

a + b

примерно равно:

a.__add__(b)

Пример с классом Vector #

Более практичный пример:

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

    def __add__(self, other):
        return Vector(
            self.x + other.x,
            self.y + other.y
        )

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


v1 = Vector(2, 3)
v2 = Vector(4, 5)

print(v1 + v2)

Результат:

Vector(6, 8)

Здесь мы определили, что сложение двух векторов означает:

x складывается с x
y складывается с y

То есть:

Vector(2, 3) + Vector(4, 5)
Vector(2 + 4, 3 + 5)
Vector(6, 8)

Проверка типа второго объекта #

В хорошем коде обычно проверяют тип other.

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

    def __add__(self, other):
        if not isinstance(other, Vector):
            return NotImplemented

        return Vector(
            self.x + other.x,
            self.y + other.y
        )

NotImplemented означает: этот класс не знает, как складываться с таким типом объекта.

Это лучше, чем сразу выбрасывать ошибку, потому что Python после этого может попробовать обратную операцию через __radd__.

Связь с __radd__ #

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

b.__radd__(a)

Пример:

class Number:
    def __init__(self, value):
        self.value = value

    def __add__(self, other):
        if isinstance(other, Number):
            return Number(self.value + other.value)

        return NotImplemented

    def __radd__(self, other):
        if isinstance(other, int):
            return Number(other + self.value)

        return NotImplemented


x = Number(10)

result = 5 + x

print(result.value)

Результат:

15

Что происходит:

1. Python видит 5 + x
2. Сначала пробует int.__add__(5, x)
3. int не знает, как сложить int и Number
4. Python пробует x.__radd__(5)
5. Получает результат

Связь с __iadd__ #

Для операции:

a += b

Python может использовать:

a.__iadd__(b)

Это in-place сложение.

Пример:

class Basket:
    def __init__(self, items):
        self.items = items

    def __iadd__(self, other):
        self.items.extend(other.items)
        return self


b1 = Basket(["apple"])
b2 = Basket(["banana"])

b1 += b2

print(b1.items)

Результат:

['apple', 'banana']

Разница:

__add__  -> a + b
__radd__ -> b + a, если левый объект не смог обработать операцию
__iadd__ -> a += b

Важный момент про неизменяемость #

Обычно __add__ должен возвращать новый объект, а не менять текущий.

Например:

class Vector:
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

Это ожидаемое поведение для +.

А изменение текущего объекта больше подходит для __iadd__:

a += b

Пример плохой реализации #

Плохо:

class Vector:
    def __add__(self, other):
        self.x += other.x
        self.y += other.y
        return self

Почему плохо:

v1 + v2 неожиданно меняет v1

Оператор + обычно ожидается как операция, которая создаёт результат, а не изменяет левый объект.

Итоговая схема #

a + b
a.__add__(b)

если __add__ вернул NotImplemented:
b.__radd__(a)

a += b
a.__iadd__(b), если он определён


58. Decimal vs Float #

float и Decimal оба используются для работы с дробными числами, но решают разные задачи.

float   -> быстрые бинарные числа с плавающей точкой
Decimal -> десятичные числа с контролируемой точностью и округлением

Главное отличие:

float хорошо подходит для инженерных, научных, игровых, статистических расчётов

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

1. float #

float — это встроенный тип Python для чисел с плавающей точкой.

Пример:

x = 1.5
y = 2.3

print(x + y)

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

Классический пример:

print(0.1 + 0.2)

Результат:

0.30000000000000004

Почему так:

0.1 и 0.2 выглядят простыми в десятичной системе,
но в бинарной системе они представляются неточно

То есть float не хранит ровно 0.1. Он хранит ближайшее представимое бинарное значение.

2. Decimal #

Decimal — это тип из модуля decimal, который работает с десятичной арифметикой.

Пример:

from decimal import Decimal

x = Decimal("0.1")
y = Decimal("0.2")

print(x + y)

Результат:

0.3

Здесь Decimal("0.1") хранит именно десятичное значение 0.1.

Важно создавать Decimal из строки:

Decimal("0.1")  # правильно

А не из float:

Decimal(0.1)  # обычно плохо

Почему:

from decimal import Decimal

print(Decimal("0.1"))
print(Decimal(0.1))

Результат будет примерно такой:

0.1
0.1000000000000000055511151231257827021181583404541015625

Потому что во втором случае Decimal получает уже неточное float-значение.

3. Основная разница на примере денег #

Плохо для денег:

price = 0.1
total = price + price + price

print(total)

Результат:

0.30000000000000004

Лучше:

from decimal import Decimal

price = Decimal("0.10")
total = price + price + price

print(total)

Результат:

0.30

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

4. Decimal умеет управлять округлением #

В Decimal можно явно задавать правила округления.

Пример:

from decimal import Decimal, ROUND_HALF_UP

value = Decimal("10.235")

rounded = value.quantize(
    Decimal("0.01"),
    rounding=ROUND_HALF_UP
)

print(rounded)

Результат:

10.24

Здесь:

Decimal("0.01")

означает: округлить до двух знаков после запятой.

Это часто нужно для:

денег
налогов
банковских расчётов
процентов
цены товаров
финансовой отчётности

5. Decimal не означает бесконечную точность #

Важный момент: Decimal точнее для десятичных вычислений, но он не бесконечно точный.

У него есть контекст точности:

from decimal import Decimal, getcontext

getcontext().prec = 5

x = Decimal("1") / Decimal("7")

print(x)

Результат:

0.14286

Здесь точность ограничена 5 значащими цифрами.

То есть:

Decimal даёт контролируемую десятичную точность,
но не магическую бесконечную точность

6. Производительность #

float обычно быстрее, чем Decimal.

Причина:

float поддерживается на уровне процессора
Decimal реализует более сложную десятичную арифметику

Поэтому для больших массивов чисел, графики, физики, статистики, ML и игровых расчётов обычно используют:

float
NumPy float64
PyTorch / TensorFlow float32, float64

А Decimal используют там, где важнее корректное десятичное округление, чем скорость.

7. Нельзя бездумно смешивать Decimal и float #

Так делать не стоит:

from decimal import Decimal

x = Decimal("10.5")
y = 2.5

print(x + y)

Будет ошибка:

TypeError

Нужно привести значения к одному типу:

from decimal import Decimal

x = Decimal("10.5")
y = Decimal("2.5")

print(x + y)

Результат:

13.0

Это сделано специально, чтобы случайно не смешивать точную десятичную арифметику с неточным бинарным float.

8. Когда использовать float #

float подходит, когда небольшие погрешности допустимы:

математика общего назначения
физика
игры
графика
статистика
машинное обучение
научные вычисления
координаты
измерения
вероятности

Пример:

speed = 12.5
time = 3.2

distance = speed * time

print(distance)

Для таких задач микроскопическая ошибка представления обычно не критична.

9. Когда использовать Decimal #

Decimal подходит, когда важна десятичная точность:

деньги
цены
налоги
скидки
проценты
банковские операции
финансовые отчёты
расчёты с юридически важным округлением

Пример:

from decimal import Decimal

price = Decimal("199.99")
discount = Decimal("0.15")

final_price = price * (Decimal("1") - discount)

print(final_price)

Для финального вывода обычно добавляют округление:

final_price = final_price.quantize(Decimal("0.01"))

print(final_price)

10. Таблица сравнения #

float
быстрый
встроенный тип
бинарное представление
может давать 0.30000000000000004
подходит для научных и обычных расчётов
не лучший выбор для денег

Decimal
медленнее
из модуля decimal
десятичное представление
даёт контролируемую точность и округление
подходит для денег и финансов
лучше создавать из строк

Итоговая схема #

float:
0.1 + 0.2
0.30000000000000004

Decimal:
Decimal("0.1") + Decimal("0.2")
Decimal("0.3")

Короткий вывод #

float используй для быстрых обычных численных расчётов.

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

Главное правило:

Decimal("0.1")  # правильно
Decimal(0.1)    # нежелательно

Потому что Decimal должен получать точное десятичное значение, а не уже искажённое бинарное float.


59. Что представляет собой функциональный подход к программированию? #

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

Главная идея:
данные подаются на вход функции → функция возвращает новый результат → старые данные не изменяются.

Пример обычного императивного подхода:

numbers = [1, 2, 3, 4]
result = []

for number in numbers:
    result.append(number * 2)

print(result)

Функциональный стиль:

numbers = [1, 2, 3, 4]

result = list(map(lambda number: number * 2, numbers))

print(result)

Основные идеи #

  1. Чистые функции Чистая функция зависит только от входных данных и не меняет внешний мир.
def add(a, b):
    return a + b

Это чистая функция: передали 2 и 3, всегда получили 5.

Нечистая функция:

total = 0

def add_to_total(x):
    global total
    total += x

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

  1. Неизменяемость данных В функциональном стиле стараются не менять существующие данные, а создавать новые.
numbers = [1, 2, 3]

new_numbers = numbers + [4]

А не так:

numbers.append(4)

То есть меньше мутаций — меньше неожиданных побочных эффектов.

  1. Функции как значения Функцию можно передавать в другую функцию, сохранять в переменную и возвращать из функции. Это называется функциями первого класса. Например, MDN описывает функции первого класса как функции, которые можно передавать как аргументы, возвращать из других функций и присваивать переменным.
def apply_operation(x, operation):
    return operation(x)

def square(n):
    return n * n

print(apply_operation(5, square))  # 25
  1. Функции высшего порядка Это функции, которые принимают другие функции или возвращают функции. Примеры в Python:
map()
filter()
functools.reduce()

Официальная документация Python отдельно описывает функциональный стиль и такие инструменты, как map, filter, reduce, генераторы и итераторы.

Зачем это нужно #

Функциональный подход помогает писать код, который:

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

Например:

users = [
    {"name": "Alex", "age": 17},
    {"name": "Bob", "age": 20},
    {"name": "John", "age": 15},
]

adult_names = [
    user["name"]
    for user in users
    if user["age"] >= 18
]

print(adult_names)  # ['Bob']

Здесь мы не меняем исходный список users, а получаем новый результат.

В чём отличие от ООП #

В ООП основной акцент на объектах:

user.activate()
order.calculate_total()

В функциональном подходе основной акцент на функциях и данных:

activate_user(user)
calculate_total(order)

В ООП объект часто хранит состояние внутри себя.
В функциональном подходе состояние стараются явно передавать в функцию и получать новое состояние как результат.

Где используется #

Функциональный стиль есть в Python, JavaScript, Scala, Kotlin, F#, Clojure, Haskell и других языках. Haskell, например, официально описывается как чисто функциональный язык с неизменяемостью и ссылочной прозрачностью.

Python не является чисто функциональным языком, но поддерживает функциональные элементы: функции первого класса, lambda, map, filter, reduce, генераторные выражения и comprehension-синтаксис.


60. Сравните императивную (процедурную) и функциональную парадигмы программирования. #

Императивная / процедурная парадигма описывает программу как последовательность команд: что сделать сначала, что потом, какие переменные изменить, какие процедуры вызвать. Britannica сравнивает процедурные программы с «рецептами»: есть пошаговые инструкции и управляющие конструкции вроде условий и циклов.

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

Основные различия #

КритерийИмперативная / процедурнаяФункциональная
Главный вопросКак выполнить задачу?Что получить из данных?
Основа программыКоманды, процедуры, изменения состоянияФункции, выражения, преобразования данных
СостояниеЧасто изменяется напрямуюСтараются избегать изменения состояния
ДанныеМогут изменяться на местеЧаще создаются новые данные
Побочные эффектыНормальная часть программыСтараются изолировать или минимизировать
ЧитаемостьХороша для пошаговой логикиХороша для обработки данных и цепочек преобразований
Типичный кодfor, while, присваивания, изменение переменныхmap, filter, reduce, рекурсия, композиция функций
Примеры языковC, Pascal, procedural-style PythonHaskell, Lisp, Clojure, F#, functional-style Python

Пример на Python #

Императивный / процедурный стиль:

numbers = [1, 2, 3, 4, 5, 6]

result = []

for number in numbers:
    if number % 2 == 0:
        result.append(number * 2)

print(result)  # [4, 8, 12]

Здесь мы явно описываем шаги:

создай список
пройди циклом
проверь условие
измени result
выведи результат

Функциональный стиль:

numbers = [1, 2, 3, 4, 5, 6]

result = list(
    map(
        lambda number: number * 2,
        filter(lambda number: number % 2 == 0, numbers)
    )
)

print(result)  # [4, 8, 12]

Здесь акцент другой:

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

В Python чаще пишут так:

numbers = [1, 2, 3, 4, 5, 6]

result = [
    number * 2
    for number in numbers
    if number % 2 == 0
]

print(result)  # [4, 8, 12]

Это не «чистое» функциональное программирование, но стиль близок к функциональному: исходный список numbers не изменяется, а создаётся новый список result.

Главное отличие по состоянию #

В процедурном коде нормально делать так:

balance = 100

def withdraw(amount):
    global balance
    balance -= amount

Функция меняет внешнюю переменную balance. Это побочный эффект.

В функциональном стиле лучше так:

def withdraw(balance, amount):
    return balance - amount

new_balance = withdraw(100, 30)

Функция не меняет внешний мир. Она получает данные и возвращает новый результат.

Где что удобнее #

Императивный / процедурный подход удобен, когда нужно описать конкретный процесс:

открыть файл
прочитать строки
записать лог
отправить запрос
сохранить результат в базу

То есть он хорошо подходит для кода с явной последовательностью действий и взаимодействием с внешним миром.

Функциональный подход удобен, когда нужно преобразовывать данные:

отфильтровать пользователей
отсортировать записи
посчитать сумму
преобразовать список объектов
собрать новый результат

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

Важный момент #

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

Например, в реальном backend-коде часто делают так:

def calculate_total(items):
    return sum(item.price * item.quantity for item in items)


def create_order(user, items):
    total = calculate_total(items)
    order = Order.objects.create(user=user, total=total)
    return order

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

Итог #

Императивная / процедурная парадигма — это стиль пошаговых инструкций. Функциональная парадигма — это стиль преобразования данных через функции.