Сильный ответ по Python объясняет модель объектов под синтаксисом: что создаётся или разделяется, какой протокол запускается и какую production-ошибку предотвращает это понимание.
Вопросы и ответы
100 подробных ответов
01Чем отличаются изменяемые (mutable) и неизменяемые (immutable) типы?
junior
Короткий ответ: Изменяемые объекты можно менять «на месте» без создания нового объекта (list, dict, set, bytearray), неизменяемые — нет (int, float, str, bytes, tuple, frozenset, bool, None). Любое «изменение» неизменяемого создаёт новый объект.
Подробно: Изменяемость — это про то, может ли объект поменять своё содержимое, сохранив тот же id() (адрес в памяти).
# Неизменяемый: операция создаёт НОВЫЙ объект
s = "hello"
print(id(s))
s += " world" # создаётся новая строка
print(id(s)) # id поменялся — это другой объект
# Изменяемый: меняется тот же объект
lst = [1, 2, 3]
print(id(lst))
lst.append(4) # меняем на месте
print(id(lst)) # id тот же
Почему это важно:
- Хешируемость. Только неизменяемые (по содержимому) объекты можно класть в
dict-ключи иset. Если бы ключ менялся, его хеш «уехал» бы, и его нельзя было бы найти. - Безопасность при шаринге. Неизменяемый объект можно безопасно расшарить между потоками/функциями — никто его не испортит.
- Аргументы функций. Python передаёт ссылку на тот же объект: функция может изменить содержимое изменяемого аргумента, но локальное переназначение параметра не меняет переменную вызывающего кода.
⚠️ Ловушка: tuple неизменяем, но если в нём лежит list, этот список можно поменять:
t = ([1, 2], 3)
t[0].append(99) # OK! список внутри мутабелен
print(t) # ([1, 2, 99], 3)
# t[0] = [...] # вот ЭТО — TypeError, переприсвоить элемент нельзя
А ещё hash(([1,2], 3)) упадёт — кортеж нехешируем, если внутри есть нехешируемый элемент.
02Чем отличается list от tuple?
junior
Короткий ответ: list изменяемый, tuple — нет. tuple хешируем (если все элементы хешируемы), занимает меньше памяти, чуть быстрее создаётся. list используют для однородных изменяемых коллекций, tuple — для фиксированных гетерогенных записей.
Подробно:
| Признак | list | tuple |
|---|---|---|
| Изменяемость | да | нет |
| Хешируемость | нет | да (если элементы хешируемы) |
| Память | больше (есть запас под рост) | меньше |
| Создание | медленнее | быстрее (литерал может кешироваться) |
| Семантика | коллекция однородных элементов | запись фиксированной структуры |
import sys
print(sys.getsizeof([1, 2, 3])) # ~88 байт
print(sys.getsizeof((1, 2, 3))) # ~64 байта
Почему list больше: он держит переаллокацию (over-allocation) — заранее выделяет место под будущие append, чтобы амортизировать стоимость роста до O(1). tuple фиксирован, ему запас не нужен.
Когда что выбирать:
tuple— когда количество и смысл элементов фиксированы: координаты(x, y), возврат нескольких значений из функции, ключ словаря.list— когда коллекция растёт/меняется/сортируется.
⚠️ Ловушка: «tuple быстрее» — правда лишь для создания литерала и доступа, но не магически во всём. И помни про вложенный мутабельный объект: tuple гарантирует неизменность ссылок, а не объектов, на которые они указывают.
03Что такое хешируемость и зачем она нужна?
junior
Короткий ответ: Объект хешируем, если у него есть __hash__(), возвращающий стабильное значение за время жизни, и __eq__(). Хешируемость нужна, чтобы быть ключом dict или элементом set.
Подробно: Контракт: если a == b, то hash(a) == hash(b). Обратное не обязано выполняться (коллизии разрешены). По умолчанию пользовательские объекты хешируются по id().
hash((1, 2)) # ок
hash(frozenset())) # ок
# hash([1, 2]) # TypeError: unhashable type: 'list'
Если переопределяешь __eq__, то __hash__ автоматически становится None (объект перестаёт быть хешируемым) — Python защищает контракт. Нужно задать __hash__ явно:
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
return (self.x, self.y) == (other.x, other.y)
__hash__ = lambda self: hash((self.x, self.y))
⚠️ Ловушка: Нельзя делать __hash__ по изменяемому полю. Если объект-ключ изменится после вставки, его хеш «уедет», и d[key] его не найдёт — он застрянет в словаре навсегда.
04Как устроен dict внутри?
middle
Короткий ответ: dict — это хеш-таблица. Ключ хешируется, по хешу вычисляется индекс в массиве слотов. Коллизии разрешаются открытой адресацией (open addressing) с пробированием. Начиная с 3.6 реализация «компактная»: данные лежат в плотном массиве записей, а массив индексов отдельно — это и даёт порядок вставки и экономию памяти.
Подробно: Алгоритм поиска/вставки:
- Считается
hash(key). - Младшие биты хеша дают индекс слота.
- Если слот пуст — кладём. Если занят другим ключом (коллизия) — пробируем следующие слоты по специальной последовательности (perturbation), пока не найдём пустой или искомый.
- Сравнение по
==: два ключа равны, только если совпали и хеш, и__eq__.
Компактная схема (CPython 3.6+):
indices: [_, 1, _, 0, _, 2] # массив индексов в entries
entries: [(hash, key0, val0), # плотный, в порядке вставки
(hash, key1, val1),
(hash, key2, val2)]
entries хранится в порядке вставки → итерация идёт в этом порядке. indices — разреженный, по нему ищем.
При заполнении таблицы примерно на 2/3 происходит resize (обычно увеличение в ~2-4 раза) с перехешированием — амортизированно вставка остаётся O(1).
⚠️ Ловушка: Не путай «порядок вставки гарантирован» с «отсортировано». Словарь не сортирует ключи. Для сортированного обхода — sorted(d).
05Гарантирует ли dict порядок вставки?
middle
Короткий ответ: Да. В CPython 3.6 это деталь реализации, а с Python 3.7 — официальная гарантия языка: dict сохраняет порядок вставки ключей.
Подробно:
d = {}
d["b"] = 1
d["a"] = 2
d["c"] = 3
list(d) # ['b', 'a', 'c'] — порядок вставки, не алфавит
Перезапись значения по существующему ключу не меняет позицию ключа. Удаление и повторная вставка — ключ уходит в конец.
До 3.7 для гарантированного порядка использовали collections.OrderedDict. Сейчас OrderedDict всё ещё полезен: у него есть move_to_end(), popitem(last=False) и его == чувствителен к порядку (у обычного dict — нет).
{"a": 1, "b": 2} == {"b": 2, "a": 1} # True — порядок не влияет на равенство
06Когда доступ к dict — это O(1), а когда O(n)?
middle
Короткий ответ: В среднем все операции (get/set/del/in) — O(1). В худшем случае при массовых коллизиях — O(n), потому что приходится перебирать цепочку пробирования.
Подробно: O(1) опирается на равномерное распределение хешей. Если у многих ключей хеши попадают в одни и те же слоты, пробирование вырождается, и каждая операция начинает обходить много занятых слотов.
Когда это реально происходит:
- Плохой
__hash__у пользовательского класса (например, всегда возвращает0). - Hash-flooding атака — злоумышленник подбирает ключи с одинаковым хешем. От этого CPython защищается рандомизацией хеша строк (
PYTHONHASHSEED).
class Bad:
def __hash__(self): return 0 # все в один слот
def __eq__(self, o): return self is o
# Вставка N таких объектов выродится в O(n) на операцию → O(n^2) суммарно
⚠️ Ловушка: Амортизированная стоимость append/вставки тоже O(1), но отдельная вставка, вызвавшая resize, дороже — это нормально и усредняется.
07Чем отличается set от frozenset, и какие есть операции над множествами?
middle
Короткий ответ: Оба — неупорядоченные коллекции уникальных хешируемых элементов на базе хеш-таблицы. set изменяемый, frozenset — нет (поэтому frozenset сам хешируем и может быть элементом другого множества или ключом словаря).
Подробно:
a = {1, 2, 3}
b = {2, 3, 4}
a | b # {1, 2, 3, 4} объединение (union)
a & b # {2, 3} пересечение (intersection)
a - b # {1} разность (difference)
a ^ b # {1, 4} симметричная разность
a <= b # False подмножество (issubset)
a >= b # False надмножество (issuperset)
a.isdisjoint(b) # False нет общих элементов?
Изменяющие методы только у set: add, discard, remove, pop, update (|=), intersection_update (&=) и т.д.
Зачем frozenset: когда нужно множество как ключ словаря или элемент другого множества.
graph = {frozenset({"a", "b"}): 5} # неориентированное ребро как ключ
Проверка x in s — O(1) в среднем, в отличие от x in list (O(n)). Это главная причина выбирать set для тестов на принадлежность.
⚠️ Ловушка: Литерал {} — это пустой dict, не set! Пустое множество — set(). И порядок в множестве не определён, не полагайся на него.
08Почему опасен аргумент по умолчанию `def f(x=[])`?
middle
Короткий ответ: Значение по умолчанию вычисляется один раз в момент определения функции, а не при каждом вызове. Изменяемый дефолт (список, словарь) разделяется между всеми вызовами и копит изменения.
Подробно:
def add(item, target=[]):
target.append(item)
return target
add(1) # [1]
add(2) # [1, 2] — НЕ [2]! тот же список
add(3) # [1, 2, 3]
Список создан один раз и живёт в add.__defaults__. Каждый вызов без target мутирует один и тот же объект.
Как чинить — сентинел None:
def add(item, target=None):
if target is None:
target = [] # свежий список на каждый вызов
target.append(item)
return target
Почему именно None, а не if not target: пустой переданный список [] тоже falsy, и if not target ошибочно его заменит. is None отличает «не передали» от «передали пустое».
⚠️ Ловушка: Это же касается dict, set и вызовов вроде def f(t=time.time()) — время «замёрзнет» на моменте определения функции. Иногда мутабельный дефолт используют намеренно как кеш — но это сознательный трюк, не случайность.
09Как Python передаёт аргументы в функцию?
junior
Короткий ответ: «Pass by object reference» (передача по ссылке на объект, она же «call by sharing»). В функцию передаётся ссылка на тот же объект. Мутабельный объект можно изменить изнутри, переприсваивание имени внутри — нет.
Подробно: Имя в Python — это ярлык на объект. При вызове параметр становится новым ярлыком на тот же объект.
def mutate(lst):
lst.append(99) # меняем сам объект → видно снаружи
def rebind(lst):
lst = [0, 0, 0] # переприсваиваем ЛОКАЛЬНОЕ имя → снаружи не видно
x = [1, 2]
mutate(x); print(x) # [1, 2, 99]
rebind(x); print(x) # [1, 2, 99] — не изменилось
То есть это не «pass by value» (копии нет) и не классический «pass by reference» (нельзя переприсвоить переменную вызывающего). Передаётся значение ссылки.
⚠️ Ловушка: Неизменяемые объекты создают иллюзию «pass by value»:
def inc(n): n += 1 # n = n + 1 создаёт новый int, имя локально
a = 5; inc(a); print(a) # 5 — не изменилось
Чтобы «вернуть» изменение неизменяемого — возвращай значение через return.
10В чём разница между `is` и `==`?
junior
Короткий ответ: == сравнивает значения (вызывает __eq__). is сравнивает идентичность — это один и тот же объект в памяти (id(a) == id(b)).
Подробно:
a = [1, 2, 3]
b = [1, 2, 3]
a == b # True — значения равны
a is b # False — разные объекты
c = a
a is c # True — один объект
is используют для сравнения с синглтонами: None, True, False.
if x is None: ... # правильно
if x == None: ... # работает, но плохой стиль и медленнее
⚠️ Ловушка: is иногда «случайно» совпадает с ==, потому что CPython переиспользует некоторые маленькие целые числа и интернирует часть строк. Поэтому if x is 256 может сработать в одном контексте и сломаться в другом. Значения чисел и строк всегда сравнивайте через ==.
11Что такое кэширование малых int и интернирование строк?
middle
Короткий ответ: CPython заранее создаёт и переиспользует объекты int в диапазоне −5..256, поэтому они идентичны (is). Короткие строки-«идентификаторы» интернируются (хранится один экземпляр). Это оптимизация, на которую нельзя полагаться логически.
Подробно:
a = 256; b = 256
a is b # True — из кеша малых int
a = 257; b = 257
a is b # False (обычно) — отдельные объекты
Диапазон −5..256 выбран как «самые частые» числа. Это деталь реализации CPython.
Интернирование строк: строки, похожие на идентификаторы (буквы, цифры, _), и строковые литералы часто интернируются компилятором, поэтому is для них может давать True. Принудительно — sys.intern():
import sys
a = sys.intern("hello world")
b = sys.intern("hello world")
a is b # True — гарантированно один объект
Зачем интернировать: ускоряет сравнение (сначала проверяется is, а это мгновенно) и экономит память при множестве одинаковых строк (например, ключи при парсинге).
⚠️ Ловушка: Поведение is для int/str зависит от того, литералы это или результат вычислений, от версии Python и контекста (REPL vs модуль). Никогда не сравнивай числа и строки через is.
12В чём разница между поверхностным и глубоким копированием?
middle
Короткий ответ: Поверхностная копия (copy.copy) создаёт новый внешний объект, но вкладывает те же ссылки на внутренние объекты. Глубокая (copy.deepcopy) рекурсивно копирует всё дерево объектов.
Подробно:
import copy
original = [[1, 2], [3, 4]]
shallow = copy.copy(original) # или original[:] / list(original)
shallow[0].append(99)
print(original) # [[1, 2, 99], [3, 4]] — внутренний список общий!
deep = copy.deepcopy(original)
deep[0].append(77)
print(original) # без изменений — всё скопировано
Способы поверхностного копирования: lst[:], list(lst), dict(d), set(s), copy.copy().
deepcopy дороже и умеет обрабатывать циклические ссылки (запоминает уже скопированные объекты в memo), чтобы не зациклиться. Поведение копирования своих классов настраивается через __copy__ и __deepcopy__.
⚠️ Ловушка: b = a — это вообще не копия, а второй ярлык на тот же объект. И поверхностная копия списка словарей — частый источник «почему изменился оригинал».
13Что такое *args и **kwargs, и какие бывают виды аргументов?
junior
Короткий ответ: *args собирает лишние позиционные аргументы в кортеж, **kwargs — лишние именованные в словарь. В сигнатуре / отделяет positional-only, * — keyword-only аргументы.
Подробно:
def f(a, b, /, c, d, *args, e, f=10, **kwargs):
...
# ^^^^ ^^^^^ ^^^^^^^^^
# positional- обычные keyword-only (после *)
# only (до /)
- До
/— только позиционные (нельзя передать по имени). - После
*или*args— только по имени (keyword-only). *args→tuple,**kwargs→dict.
Распаковка при вызове:
def add(a, b, c): return a + b + c
nums = [1, 2, 3]
add(*nums) # распаковка списка в позиционные
d = {"a": 1, "b": 2, "c": 3}
add(**d) # распаковка словаря в именованные
Зачем positional-only (/): чтобы имена параметров не стали частью публичного API и их можно было переименовать. Зачем keyword-only (*): заставить вызывающего писать func(verbose=True) для читаемости и защиты от перепутанного порядка.
⚠️ Ловушка: Имена args/kwargs — соглашение, важны именно * и **. И порядок при распаковке нескольких источников должен быть валидным: f(*a, *b, **c, **d) допустимо, но ключи в **c/**d не должны конфликтовать.
14Что такое comprehensions и почему они быстрее цикла?
middle
Короткий ответ: Это компактный синтаксис построения коллекций. Бывают list [...], set {...}, dict {k: v ...} и генераторное выражение (...). Они быстрее ручного цикла с append, потому что итерация и построение идут на уровне C, без повторного поиска метода append.
Подробно:
squares = [x*x for x in range(10)] # list
even_set = {x for x in range(10) if x % 2 == 0} # set
sq_map = {x: x*x for x in range(5)} # dict
gen = (x*x for x in range(10)) # generator (ленивый!)
Почему быстрее цикла: в цикле for ... : result.append(x) на каждой итерации интерпретатор ищет атрибут append и делает вызов функции Python. List comprehension использует специализированный байткод и не платит за повторный lookup метода.
Генераторное выражение (...) не строит всю коллекцию сразу — отдаёт элементы по одному. Это экономит память на больших данных:
sum(x*x for x in range(10**7)) # не создаёт список на 10M элементов
⚠️ Ловушка: Не злоупотребляй вложенными comprehension — [[... for ...] for ...] быстро становится нечитаемым. И переменная цикла в comprehension не утекает в окружающую область (в отличие от Python 2): после [x for x in ...] имени x снаружи нет.
15Что такое замыкание (closure) и что такое late binding?
senior
Короткий ответ: Замыкание — это вложенная функция, которая «запоминает» переменные окружающей функции, даже после её завершения. Late binding означает, что значение свободной переменной берётся в момент вызова замыкания, а не его создания — отсюда классическая ловушка в циклах.
Подробно:
def make_counter():
count = 0
def inc():
nonlocal count # без этого count был бы локальным
count += 1
return count
return inc
c = make_counter()
c(); c() # 1, затем 2 — count сохраняется в замыкании
Захваченные переменные лежат в c.__closure__ (ячейки cell).
Классическая ловушка — late binding в цикле:
funcs = [lambda: i for i in range(3)]
[f() for f in funcs] # [2, 2, 2] — НЕ [0, 1, 2]!
Все лямбды захватывают одну и ту же переменную i, а к моменту вызова цикл уже закончился и i == 2.
Чинят аргументом по умолчанию (захват значения сейчас) или фабрикой:
funcs = [lambda i=i: i for i in range(3)] # i=i связывается при создании
[f() for f in funcs] # [0, 1, 2]
⚠️ Ловушка: То же самое случается в циклах с for, не только в comprehension. Если внутри обработчиков событий/коллбеков используешь переменную цикла — всегда фиксируй её через дефолтный аргумент или functools.partial.
16Как работают декораторы?
middle
Короткий ответ: Декоратор — это функция, которая принимает функцию и возвращает новую функцию (обычно обёртку). @dec над def f — это синтаксический сахар для f = dec(f).
Подробно:
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"вызов {func.__name__}")
result = func(*args, **kwargs)
print("готово")
return result
return wrapper
@log_calls
def greet(name):
return f"Привет, {name}"
# Эквивалент: greet = log_calls(greet)
greet("Аня")
wrapper принимает *args, **kwargs, чтобы подходить к любой сигнатуре. Внутри он может: что-то делать до/после, менять аргументы/результат, не вызывать оригинал вовсе (например, кеш-хит), ловить исключения.
Декораторы используют для сквозной функциональности (cross-cutting concerns): логирование, кеширование (functools.lru_cache), измерение времени, проверка прав, ретраи, регистрация хендлеров.
⚠️ Ловушка: без functools.wraps обёртка «съедает» метаданные оригинала (__name__, __doc__, аннотации и ссылку __wrapped__). Это мешает introspection, документации и инструментам тестирования, поэтому обёртку почти всегда помечают @functools.wraps(func).
17Как написать декоратор с аргументами и зачем functools.wraps?
senior
Короткий ответ: Декоратор с аргументами — это функция, возвращающая декоратор (три уровня вложенности). functools.wraps копирует метаданные оригинальной функции на обёртку, чтобы интроспекция, документация и отладка не ломались.
Подробно:
import functools
def repeat(times): # 1) фабрика декораторов
def decorator(func): # 2) собственно декоратор
@functools.wraps(func) # сохраняем __name__, __doc__ и т.д.
def wrapper(*args, **kwargs): # 3) обёртка
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def ping():
"""Шлёт ping."""
print("ping")
# Эквивалент: ping = repeat(times=3)(ping)
Зачем wraps:
print(ping.__name__) # 'ping' (с wraps) вместо 'wrapper'
print(ping.__doc__) # 'Шлёт ping.' вместо None
Без него help(ping), логи, сериализация и фреймворки (которые читают __name__) увидят wrapper — это ломает отладку и иногда логику. wraps также проставляет __wrapped__, позволяя добраться до оригинала.
⚠️ Ловушка: Легко запутаться в уровнях: @repeat (без скобок) передаст саму функцию как times. Декоратор с аргументами всегда вызывается со скобками: @repeat(3).
18Что такое генераторы и как работает yield?
middle
Короткий ответ: Генератор — функция с yield, которая возвращает объект-генератор. На каждом next() она выполняется до следующего yield, отдаёт значение и «замораживает» своё состояние (локальные переменные, точку выполнения) до следующего вызова. Это ленивые, одноразовые итераторы.
Подробно:
def countdown(n):
while n > 0:
yield n # отдаём значение и приостанавливаемся
n -= 1
gen = countdown(3)
next(gen) # 3
next(gen) # 2
for x in gen: # продолжит с того места: 1
print(x)
Ключевое отличие от обычной функции: yield не уничтожает кадр стека, а замораживает его. Локальные переменные сохраняются между вызовами.
Выгода — память и ленивость. Бесконечные/огромные последовательности без хранения в памяти:
def naturals():
n = 0
while True:
yield n
n += 1 # бесконечный поток, памяти O(1)
Генераторы поддерживают .send(value) (передать значение внутрь, оно станет результатом yield), .throw() (бросить исключение внутрь) и .close().
⚠️ Ловушка: Генератор одноразовый — после полного обхода он исчерпан, повторный for ничего не даст. Для повторного прохода либо пересоздавай генератор, либо собирай в список (но тогда теряешь экономию памяти).
19Зачем нужен yield from?
senior
Короткий ответ: yield from iterable делегирует итерацию подгенератору/итерируемому: пробрасывает все его значения наружу, а также корректно проксирует send, throw и значение из return. Это короче и правильнее ручного цикла.
Подробно:
def chain(*iterables):
for it in iterables:
yield from it # вместо: for x in it: yield x
list(chain([1, 2], (3, 4), "ab")) # [1, 2, 3, 4, 'a', 'b']
yield from важен не только как сахар: он устанавливает прозрачный канал между внешним потребителем и внутренним генератором — send()/throw() доходят до подгенератора, а его return value становится результатом выражения yield from:
def inner():
yield 1
return 99 # это значение «вернётся» наверх
def outer():
result = yield from inner()
print("получено:", result) # получено: 99
Применяется в рекурсивном обходе деревьев, композиции генераторов и (исторически) был основой корутин до async/await.
⚠️ Ловушка: yield from работает с любым итерируемым, но всю мощь делегирования (send/return) даёт именно с генераторами.
20Что такое итератор и протокол итерации?
middle
Короткий ответ: Итерируемый объект (iterable) умеет отдать итератор через __iter__(). Итератор реализует __next__(), который возвращает следующий элемент или бросает StopIteration. Цикл for под капотом вызывает iter(), затем next() в петле, ловя StopIteration.
Подробно:
class Squares:
def __init__(self, n):
self.n = n
self.i = 0
def __iter__(self): # делает объект итератором
return self
def __next__(self):
if self.i >= self.n:
raise StopIteration # сигнал «элементы кончились»
val = self.i ** 2
self.i += 1
return val
for x in Squares(4):
print(x) # 0 1 4 9
Что делает for x in obj:
it = iter(obj) # obj.__iter__()
while True:
try:
x = next(it) # it.__next__()
except StopIteration:
break
... # тело цикла
Разница iterable vs iterator: list — iterable, но не итератор (нет __next__); каждый iter(list) даёт свежий итератор. Генератор — и iterable, и итератор одновременно (его __iter__ возвращает себя), и потому одноразовый.
⚠️ Ловушка: Если в классе __iter__ возвращает self, объект становится одноразовым: два for подряд по нему не сработают. Чтобы был многоразовым — __iter__ должен возвращать новый итератор.
21Что такое контекстный менеджер и как работает with?
middle
Короткий ответ: Контекстный менеджер гарантирует выполнение «входа» и «выхода» вокруг блока — типично для управления ресурсами (файлы, блокировки, соединения). with вызывает __enter__() на входе и __exit__() на выходе, в том числе при исключении.
Подробно:
class Timer:
def __enter__(self):
import time
self.start = time.perf_counter()
return self # это значение попадёт в `as`
def __exit__(self, exc_type, exc_val, exc_tb):
import time
self.elapsed = time.perf_counter() - self.start
print(f"заняло {self.elapsed:.4f}s")
return False # False → исключение НЕ подавляется
with Timer() as t:
sum(range(10**6))
__exit__ получает информацию об исключении (или три None, если его не было). Если __exit__ вернёт «истинное» значение — исключение подавляется (обычно так не делают без причины).
Через contextlib короче:
from contextlib import contextmanager
@contextmanager
def open_file(path):
f = open(path)
try:
yield f # всё до yield = __enter__, после = __exit__
finally:
f.close() # выполнится даже при исключении в блоке
Зачем: гарантировать освобождение ресурса. with open(...) закроет файл даже если внутри блока вылетит исключение — в отличие от ручного f.close(), который можно пропустить.
⚠️ Ловушка: В @contextmanager нужен try/finally вокруг yield, иначе при исключении в теле with код после yield (очистка) не выполнится.
22Как устроена обработка исключений (try/except/else/finally)?
middle
Короткий ответ: try — защищаемый код, except — обработка конкретных исключений, else — выполняется, если исключения не было, finally — выполняется всегда (для гарантированной очистки). Исключения образуют иерархию классов с корнем BaseException.
Подробно:
try:
x = int(input())
except ValueError as e: # конкретный тип — хорошо
print("не число:", e)
except (KeyError, IndexError): # несколько типов сразу
print("проблема доступа")
else:
print("успех:", x) # только если try прошёл без исключения
finally:
print("всегда выполнится") # очистка
Иерархия (упрощённо):
BaseException
├── SystemExit, KeyboardInterrupt, GeneratorExit # НЕ ловить общим except
└── Exception
├── ArithmeticError → ZeroDivisionError
├── LookupError → KeyError, IndexError
├── ValueError, TypeError, OSError, ...
except Exception ловит «обычные» ошибки, но не KeyboardInterrupt/SystemExit — это правильно. except: (голый) ловит всё, включая Ctrl-C, — почти всегда плохо.
Кастомные исключения:
class ValidationError(Exception):
"""Доменная ошибка валидации."""
raise ValidationError("поле обязательно")
Свои исключения наследуют от Exception (не от BaseException) и образуют доменную иерархию, чтобы вызывающий мог ловить как конкретику, так и базовый класс пакета.
⚠️ Ловушка: finally с return/break перекрывает исключение и значение из try — легко проглотить ошибку незаметно. И else нужен, чтобы код «при успехе» не попадал случайно под обработку except (если бы он стоял в конце try).
23Что такое EAFP и LBYL?
concept
Короткий ответ: LBYL («Look Before You Leap») — сначала проверь условие, потом действуй. EAFP («Easier to Ask Forgiveness than Permission») — действуй и лови исключение. Python тяготеет к EAFP.
Подробно:
# LBYL
if "key" in d:
value = d["key"]
else:
value = default
# EAFP — питоничнее
try:
value = d["key"]
except KeyError:
value = default
Почему EAFP предпочитают:
- Нет гонки (race condition). Между проверкой
if os.path.exists(p)иopen(p)файл может исчезнуть. EAFP проверяет и действует атомарно. - Меньше дублирования логики проверки и действия.
- В Python исключения дёшевы на «счастливом пути» (когда их нет), хотя дороги при выбрасывании.
Когда LBYL уместен: проверка дешева, исключение вероятно и не является ошибкой (тогда постоянные исключения дороги), либо проверка яснее выражает намерение.
⚠️ Ловушка: Не лови слишком широко в EAFP: except Exception вокруг d[key] спрячет и баги в коде вычисления значения. Лови конкретный тип.
24Как устроены строки, юникод и байты?
middle
Короткий ответ: str — неизменяемая последовательность Unicode-символов (code points). bytes — неизменяемая последовательность байтов (0–255). Между ними переходят через encode()/decode() с указанием кодировки (обычно UTF-8). f-strings — удобный способ форматирования.
Подробно:
s = "café"
b = s.encode("utf-8") # b'caf\xc3\xa9' — 5 байт (é = 2 байта в UTF-8)
b.decode("utf-8") # 'café'
len(s) # 4 символа
len(b) # 5 байт
str хранит символы, bytes — сырые байты. Их нельзя смешивать ("a" + b"b" → TypeError). Из сети/файла приходят bytes — их декодируют в str; в файл/сеть пишут bytes — str кодируют.
f-strings:
name, score = "Аня", 0.857
f"{name}: {score:.1%}" # 'Аня: 85.7%' форматирование на лету
f"{score=}" # 'score=0.857' отладочный вывод (3.8+)
f"{name!r}" # вызов repr()
f-strings быстрее %-форматирования и .format(), потому что компилируются в эффективный байткод.
Неизменяемость строк означает, что конкатенация в цикле s += x создаёт новые объекты каждый раз → O(n²). Правильно — "".join(parts):
result = "".join(str(x) for x in range(1000)) # быстро, один проход
⚠️ Ловушка: Один Unicode-символ ≠ один байт (UTF-8 переменной длины) и даже ≠ один «видимый знак» (эмодзи с модификаторами, комбинируемые диакритики). Для работы с кодировками никогда не угадывай — указывай явно.
25Что такое truthiness, и что возвращают or и and?
junior
Короткий ответ: В булевом контексте объекты сами определяют свою «истинность» через __bool__ (или __len__). Falsy: False, None, 0, 0.0, "", [], {}, (), set(). and/or — это short-circuit операторы, возвращающие операнд (не обязательно bool).
Подробно:
bool([]) # False — пустые коллекции falsy
bool([0]) # True — непустая, хоть и с нулём внутри
bool("0") # True — непустая строка!
or возвращает первый truthy операнд (или последний, если все falsy); and — первый falsy (или последний, если все truthy):
"" or "default" # 'default'
"value" or "default" # 'value'
0 and 5 # 0 — and остановился на falsy
2 and 5 # 5
Это используют для дефолтов и короткого замыкания:
name = user_input or "Аноним" # дефолт, если пусто
config and config.get("debug") # безопасный доступ, не упадёт на None
⚠️ Ловушка: x or default подменит любое falsy значение, включая 0, "", False. Если 0 — валидное значение, используй x if x is not None else default. Классическая бага: count = get_count() or 10 превратит легитимный 0 в 10.
26Как работает распаковка (unpacking)?
junior
Короткий ответ: Можно присвоить нескольким именам сразу из итерируемого. * собирает «остаток» в список. Это даёт элегантный swap и разбор структур.
Подробно:
a, b, c = [1, 2, 3] # a=1, b=2, c=3
a, b = b, a # swap без временной переменной
first, *rest = [1, 2, 3, 4] # first=1, rest=[2, 3, 4]
first, *mid, last = [1,2,3,4] # first=1, mid=[2,3], last=4
*init, last = [1, 2, 3] # init=[1,2], last=3
(a, b), c = (1, 2), 3 # вложенная распаковка
Swap a, b = b, a работает так: правая часть сначала собирается в кортеж (b, a), потом распаковывается — поэтому временная переменная не нужна и порядок корректен.
* в распаковке захватывает всегда список (даже из кортежа), и может быть только один на уровень.
⚠️ Ловушка: Количество имён должно совпадать с количеством элементов (если нет *), иначе ValueError: too many values to unpack или not enough values. И распаковка генератора исчерпывает его.
27Как работают срезы (slicing)?
junior
Короткий ответ: seq[start:stop:step] возвращает новую подпоследовательность. start включается, stop — нет. Отрицательные индексы считаются с конца. Срез не выходит за границы (не бросает IndexError).
Подробно:
s = [0, 1, 2, 3, 4, 5]
s[1:4] # [1, 2, 3] stop не включён
s[:3] # [0, 1, 2] от начала
s[3:] # [3, 4, 5] до конца
s[::2] # [0, 2, 4] каждый второй
s[::-1] # [5,4,3,2,1,0] разворот
s[-2:] # [4, 5] последние два
s[10:20] # [] за границей — пустой, без ошибки
Срез создаёт поверхностную копию (для list/str/tuple). s[:] — идиома копирования списка. Можно присваивать срезу у изменяемых:
lst = [1, 2, 3, 4]
lst[1:3] = [20, 30, 40] # [1, 20, 30, 40, 4] — замена с изменением длины
del lst[::2] # удалить каждый второй
Под капотом s[1:4] — это s[slice(1, 4)]; объект slice можно передать явно.
⚠️ Ловушка: s[::-1] разворачивает, но это копия — для разворота на месте у списка используй .reverse(). И отрицательный шаг меняет смысл start/stop: s[5:1:-1] идёт от индекса 5 к 2.
28Что такое LEGB, и зачем global и nonlocal?
senior
Короткий ответ: Поиск имени идёт по правилу LEGB: Local → Enclosing → Global → Built-in. global позволяет присваивать переменной модульного уровня изнутри функции, nonlocal — переменной охватывающей (не глобальной) функции.
Подробно:
x = "global" # G
def outer():
x = "enclosing" # E (для inner)
def inner():
x = "local" # L
print(x) # 'local' — берётся ближайший уровень
inner()
# Built-in (B): len, print, range, ... — последний уровень поиска
Чтение идёт снизу вверх по LEGB. Но присваивание делает имя локальным по умолчанию, поэтому:
counter = 0
def bump():
counter += 1 # UnboundLocalError! counter считается локальным,
# а читается до присваивания
Чинят объявлением:
def bump():
global counter # «работай с глобальной»
counter += 1
def make():
n = 0
def inc():
nonlocal n # «работай с n из make», не глобальной и не локальной
n += 1
return n
return inc
⚠️ Ловушка: Даже одно присваивание где-то в функции делает имя локальным во всём теле функции — отсюда UnboundLocalError ещё до строки присваивания. И злоупотребление global — признак плохой архитектуры; обычно лучше вернуть значение или использовать класс.
29Почему Python медленный?
concept
Короткий ответ: CPython интерпретирует байткод, типы определяются в рантайме (динамическая диспетчеризация), всё — объекты в куче (overhead на боксинг и подсчёт ссылок), а GIL не даёт CPU-bound потокам исполнять байткод параллельно. Это плата за гибкость и скорость разработки.
Подробно: Основные причины:
- Интерпретация байткода. Исходник компилируется в байткод, который выполняет виртуальная машина — нет нативной компиляции под процессор (как в C/Rust).
- Динамическая типизация.
a + bтребует в рантайме узнать типы и найти нужный__add__. Компилируемые языки знают типы заранее и генерируют прямую инструкцию. - Всё — объект в куче. Даже
int— объект с заголовком, счётчиком ссылок и типом. Простаяx + 1создаёт новый объект, а не меняет регистр. - GIL (Global Interpreter Lock). В один момент байткод исполняет только один поток → для CPU-bound задач многопоточность не даёт ускорения (нужны процессы или нативные расширения).
Что с этим делают: PyPy (JIT-компиляция), C-расширения и numpy (тяжёлые вычисления уходят в C), Cython, numba, многопроцессность (multiprocessing), асинхронность для I/O-bound. С Python 3.11+ интерпретатор ощутимо ускорили (specializing adaptive interpreter), а 3.12+/3.13 движутся к опциональному отключению GIL.
⚠️ Ловушка: «Python медленный» — про CPU-bound. Для I/O-bound (сеть, диск, БД) скорость интерпретатора почти не важна — узкое место в ожидании, и тут Python вполне эффективен (особенно с asyncio).
30Что значит, что Python интерпретируемый и динамический язык?
concept
Короткий ответ: «Интерпретируемый» — код исполняется виртуальной машиной построчно/по байткоду без отдельной фазы компиляции в машинный код. «Динамический» — типы связаны со значениями, а не с переменными, и проверяются/разрешаются в рантайме; структуру объектов можно менять во время выполнения.
Подробно: Что значит интерпретируемый (точнее — байткод-компилируемый + интерпретируемый):
# Исходник → компилируется в байткод (.pyc) → исполняется CPython VM
import dis
dis.dis("a + b") # покажет байткод: LOAD_NAME, BINARY_OP, ...
Нет отдельного шага сборки в исполняемый файл; запуск = компиляция в байткод + интерпретация.
Что значит динамический:
x = 5 # x ссылается на int
x = "теперь строка" # то же имя — другой тип, это нормально
# Duck typing: важно поведение, а не класс
def total(items):
return sum(items) # работает с любым, что итерируемо и складывается
# Объекты можно менять в рантайме
class A: pass
a = A()
a.new_attr = 123 # добавили атрибут на лету
Имя — это лишь ярлык, который может указывать на объект любого типа. Тип проверяется при операции (duck typing: «если крякает как утка...»).
Плюсы: гибкость, лаконичность, быстрая разработка, метапрограммирование. Минусы: ошибки типов всплывают в рантайме (отсюда полезность type hints + mypy), и накладные расходы на динамику (см. «почему медленный»).
⚠️ Ловушка: Type hints (def f(x: int) -> str) не проверяются интерпретатором в рантайме — это подсказки для людей и статических анализаторов. Python остаётся динамическим: f("строка") спокойно выполнится.
31Зачем вообще нужны генераторы?
concept
Короткий ответ: Чтобы обрабатывать большие или бесконечные потоки данных лениво, с константной памятью, и строить композируемые конвейеры (pipelines) обработки.
Подробно: Три главные причины:
- Память. Список на 100 млн чисел не влезет; генератор отдаёт по одному:
def read_lines(path):
with open(path) as f:
for line in f: # файл читается построчно, не целиком в память
yield line.rstrip()
- Ленивость / бесконечность. Можно описать бесконечную последовательность и брать только нужное:
import itertools
firsts = list(itertools.islice(naturals(), 5)) # [0,1,2,3,4]
- Композиция конвейеров. Генераторы соединяются без промежуточных списков:
nums = (int(x) for x in read_lines("data.txt"))
evens = (x for x in nums if x % 2 == 0)
total = sum(evens) # данные «текут» по одному, память O(1)
Это меняет модель с «загрузить всё → обработать» на «обрабатывать потоково по мере поступления» — критично для больших данных и стримов.
⚠️ Ловушка: За ленивость платишь одноразовостью и невозможностью индексирования/len(). И отложенное вычисление прячет ошибки до момента итерации, что усложняет отладку.
32Зачем вообще нужны декораторы?
concept
Короткий ответ: Чтобы добавлять поведение к функциям/методам/классам, не трогая их код — реализация принципа DRY для сквозной функциональности (логирование, кеш, авторизация, ретраи, тайминги, регистрация).
Подробно: Декораторы выносят повторяющуюся «обвязку» из множества функций в одно место:
import functools
def cache(func):
store = {}
@functools.wraps(func)
def wrapper(*args):
if args not in store:
store[args] = func(*args) # считаем один раз
return store[args]
return wrapper
@cache
def fib(n):
return n if n < 2 else fib(n-1) + fib(n-2)
Без декоратора пришлось бы вписывать логику кеша внутрь каждой функции. Декоратор отделяет «что делает функция» от «как её обернуть».
Где видно на практике:
@functools.lru_cache— мемоизация.- Flask/FastAPI
@app.route(...)/@app.get(...)— регистрация хендлеров. @property,@staticmethod,@classmethod— встроенные декораторы.@dataclass— генерация__init__,__repr__и т.д.- Декораторы прав/ретраев/тайм-аутов в продакшене.
Это соответствует принципу «открыт для расширения, закрыт для изменения»: поведение наращивается обёртками, исходная функция не меняется.
⚠️ Ловушка: Декоратор меняет объект, на который ссылается имя; без functools.wraps теряется интроспекция, а несколько декораторов применяются снизу вверх (@a над @b = a(b(f))) — порядок важен.
33Что значит «в Python всё объект»?
concept
Короткий ответ: Любая сущность в Python — число, строка, функция, класс, модуль, сам тип — это объект: у неё есть идентичность (id), тип (type) и значение. Всё имеет ссылку, всё лежит в куче и всё можно передать как аргумент, положить в список, присвоить переменной.
Подробно: На уровне CPython каждый объект — это структура PyObject с двумя обязательными полями: счётчиком ссылок (ob_refcnt) и указателем на тип (ob_type). Не существует «примитивов» как в Java: даже int 5 — это полноценный объект в куче.
def f(): pass
# функции — объекты, у них есть атрибуты
f.custom = 42
print(f.custom) # 42
print(type(f)) # <class 'function'>
# классы — тоже объекты (экземпляры метакласса type)
class A: pass
print(type(A)) # <class 'type'>
B = A # класс можно присвоить переменной
print(isinstance(A, object)) # True
# даже type — объект
print(type(type)) # <class 'type'>
Из этого следует единообразие: классы можно создавать в рантайме (type('X', (), {})), функции хранить в словарях, типы передавать как значения. Это фундамент динамизма Python.
⚠️ Ловушка: «Всё объект» не значит «всё изменяемо». int, str, tuple, frozenset — объекты, но immutable. И «всё объект» не отменяет того, что переменная — это лишь имя-ссылка, а не контейнер: присваивание копирует ссылку, а не значение.
34Чем отличается `type()` от `isinstance()`?
junior
Короткий ответ: type(x) возвращает точный тип объекта без учёта наследования; isinstance(x, T) проверяет, является ли x экземпляром T или его подкласса. Для проверок типа почти всегда нужен isinstance.
Подробно:
class Animal: pass
class Dog(Animal): pass
d = Dog()
print(type(d) is Dog) # True
print(type(d) is Animal) # False — точный тип, без наследования
print(isinstance(d, Animal)) # True — учитывает иерархию
print(isinstance(d, (Dog, int))) # True — можно кортеж типов
isinstance дополнительно учитывает виртуальное наследование через ABC (__instancecheck__ метакласса), а type() is — нет:
from collections.abc import Sequence
print(isinstance([], Sequence)) # True (list зарегистрирован как Sequence)
print(type([]) is Sequence) # False
⚠️ Ловушка: type(x) == bool против isinstance(x, int): bool — подкласс int, поэтому isinstance(True, int) → True. Если нужно отличать bool от чисел, проверяйте type(x) is int. Также не используйте type(x) == SomeType — предпочитайте is, т.к. сравнение типов идёт по идентичности.
35Чем отличается `__new__` от `__init__`?
middle
Короткий ответ: __new__ — статический метод (фактически), он создаёт и возвращает новый экземпляр; __init__ — инициализирует уже созданный экземпляр и ничего не возвращает. Сначала вызывается __new__, потом __init__ (только если __new__ вернул экземпляр этого класса).
Подробно: При вызове C(*args) метакласс выполняет примерно obj = C.__new__(C, *args); if isinstance(obj, C): C.__init__(obj, *args).
class C:
def __new__(cls, *args, **kwargs):
print("new", cls)
instance = super().__new__(cls) # object.__new__ создаёт пустой объект
return instance
def __init__(self, value):
print("init", value)
self.value = value
c = C(10) # new <class 'C'> ; init 10
__new__ нужен для immutable-типов (нельзя менять в __init__, объект уже зафиксирован), синглтонов, фабрик и наследования от int/str/tuple:
class PositiveInt(int):
def __new__(cls, value):
if value < 0:
raise ValueError("must be >= 0")
return super().__new__(cls, value) # значение задаётся здесь
⚠️ Ловушка: Если __new__ вернёт объект другого класса (не подкласс cls), __init__ вызван не будет. Частая ошибка — забыть return в __new__: тогда возвращается None и конструктор отдаёт None. И __new__ неявно статический: первый аргумент — cls, а не self.
36Чем отличается `__repr__` от `__str__`?
junior
Короткий ответ: __repr__ — однозначное «техническое» представление для разработчика (в идеале валидный Python-код для воссоздания объекта); __str__ — человекочитаемое представление для пользователя. str()/print используют __str__, а если его нет — падают на __repr__.
Подробно:
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __repr__(self):
return f"Point(x={self.x!r}, y={self.y!r})"
def __str__(self):
return f"({self.x}, {self.y})"
p = Point(1, 2)
print(str(p)) # (1, 2) -> __str__
print(repr(p)) # Point(x=1, y=2) -> __repr__
print(p) # (1, 2)
print([p]) # [Point(x=1, y=2)] — контейнеры зовут __repr__ элементов
Правило: __str__ опционален, __repr__ стоит определять всегда — это то, что видно в отладчике, логах и REPL. Обратное не работает: определение только __str__ не даёт нормального repr.
⚠️ Ловушка: Контейнеры (list, dict) при печати всегда вызывают __repr__ элементов, а не __str__. Поэтому print([p]) покажет repr, даже если у вас красивый __str__. Ещё: f"{p}" → __str__, а f"{p!r}" → __repr__.
37Почему при переопределении `__eq__` нужно переопределять и `__hash__`?
senior
Короткий ответ: Контракт: если a == b, то hash(a) == hash(b). Хеш-контейнеры (dict, set) сначала ищут по хешу, затем сравнивают через __eq__. Если вы переопределили __eq__, но не __hash__, объекты с разными хешами никогда не сравнятся, и логика «равны → один ключ» сломается. Поэтому Python при определении __eq__ автоматически делает класс нехешируемым (__hash__ = None).
Подробно:
class P:
def __init__(self, x): self.x = x
def __eq__(self, other):
return isinstance(other, P) and self.x == other.x
# __hash__ автоматически = None!
a = P(1)
{a} # TypeError: unhashable type: 'P'
Чтобы объект остался хешируемым и согласованным:
class P:
def __init__(self, x): self.x = x
def __eq__(self, other):
return isinstance(other, P) and self.x == other.x
def __hash__(self):
return hash(self.x) # равные объекты -> одинаковый хеш
Правила контракта:
a == b⇒hash(a) == hash(b)(обязательно).- Обратное не требуется: разные объекты могут иметь одинаковый хеш (коллизия — это нормально).
- Хеш должен быть неизменным на протяжении жизни объекта ⇒ хешировать только по immutable-полям.
⚠️ Ловушка: Если хешировать по изменяемому полю и потом его поменять, объект «потеряется» в set/dict — окажется не в той корзине, и x in s вернёт False, хотя объект там. Также: явно прописать __hash__ = None, чтобы запретить хеширование, или унаследовать __hash__ родителя через __hash__ = SomeBase.__hash__.
38Как работает `__call__` и что такое callable?
middle
Короткий ответ: __call__ делает экземпляр класса вызываемым как функция: obj() транслируется в type(obj).__call__(obj). Объект «callable», если у его типа определён __call__. Сами функции, классы и методы callable именно потому, что их типы реализуют __call__.
Подробно:
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, x):
return x * self.factor
double = Multiplier(2)
print(double(10)) # 20
print(callable(double)) # True
Когда вы пишете C() для создания экземпляра — это вызов type(C).__call__(C), то есть type.__call__, который внутри дёргает __new__ и __init__. Это объясняет, как метакласс может вмешаться в создание объектов.
Применения: объекты-функции с состоянием (счётчики, мемоизация), декораторы на классах, фабрики, частичное применение.
⚠️ Ловушка: __call__ ищется на типе, а не на экземпляре. Присвоение obj.__call__ = lambda: ... в обычном случае не сделает obj() рабочим — специальные методы для неявных операций берутся из класса, минуя __dict__ экземпляра.
39Чем отличается `__getattr__` от `__getattribute__`?
senior
Короткий ответ: __getattribute__ вызывается при каждом доступе к атрибуту (obj.x) и реализует весь обычный механизм поиска. __getattr__ — резервный, вызывается только если обычный поиск не нашёл атрибут (поднялся бы AttributeError). Переопределять обычно нужно __getattr__ — это безопасно; __getattribute__ — опасно и редко.
Подробно:
class Proxy:
def __init__(self, data):
self._data = data
def __getattr__(self, name):
# вызовется только если name не найден обычным путём
print("fallback for", name)
return self._data.get(name)
p = Proxy({"a": 1})
print(p._data) # обычный поиск, __getattr__ НЕ вызывается
print(p.a) # fallback for a ; 1 — нет атрибута, идём в __getattr__
__getattribute__ перехватывает абсолютно всё:
class Loud:
def __getattribute__(self, name):
print("access", name)
return super().__getattribute__(name) # обязательно делегировать!
⚠️ Ловушка: В __getattribute__ нельзя писать self.attr или self.__dict__[...] напрямую — это вызовет рекурсию (снова __getattribute__). Используйте super().__getattribute__(name) или object.__getattribute__(self, name). В __getattr__ обращение к настоящим атрибутам безопасно, но обращение к несуществующему атрибуту внутри __getattr__ приведёт к бесконечной рекурсии.
40Как в деталях работает поиск атрибута (attribute lookup)?
senior
Короткий ответ: При obj.x срабатывает type(obj).__getattribute__. Порядок (упрощённо): сначала ищется data-дескриптор в MRO класса → затем obj.__dict__ → затем non-data-дескриптор или обычный атрибут класса по MRO → если нигде нет, вызывается __getattr__, иначе AttributeError.
Подробно: Точный алгоритм object.__getattribute__:
- Пройти по MRO типа, найти
x. Если это data-дескриптор (есть__set__/__delete__) — вернутьdescr.__get__(obj, type). Он имеет высший приоритет. - Иначе посмотреть в
obj.__dict__— если есть, вернуть как есть. - Иначе, если в классе найден non-data-дескриптор (только
__get__) — вернутьdescr.__get__(obj, type). - Иначе, если в классе найден обычный атрибут — вернуть его.
- Иначе вызвать
type(obj).__getattr__(obj, 'x'), если он есть; иначеAttributeError.
class D: # non-data дескриптор
def __get__(self, obj, owner): return "from descriptor"
class C:
attr = D()
c = C()
print(c.attr) # from descriptor (шаг 3)
c.__dict__['attr'] = "shadow"
print(c.attr) # shadow — экземпляр перебивает non-data (шаг 2 > 3)
Иерархия приоритетов: data-дескриптор класса > __dict__ экземпляра > non-data-дескриптор/атрибут класса > __getattr__.
⚠️ Ловушка: Поэтому property (data-дескриптор) нельзя «перебить» атрибутом экземпляра, а обычный метод (non-data-дескриптор) — можно затереть, положив одноимённый ключ в __dict__ экземпляра. Это же объясняет, почему __slots__ (data-дескрипторы) и property имеют приоритет над одноимёнными записями в __dict__.
41Как работают `__getitem__`, `__setitem__`, `__contains__`?
junior
Короткий ответ: Они реализуют синтаксис индексации и проверки вхождения: obj[k] → __getitem__, obj[k] = v → __setitem__, del obj[k] → __delitem__, k in obj → __contains__.
Подробно:
class Grid:
def __init__(self): self._d = {}
def __getitem__(self, key): return self._d[key]
def __setitem__(self, key, value): self._d[key] = value
def __delitem__(self, key): del self._d[key]
def __contains__(self, key): return key in self._d
g = Grid()
g[(0, 0)] = "X" # __setitem__
print(g[(0, 0)]) # __getitem__ -> X
print((0, 0) in g) # __contains__ -> True
Если __contains__ не определён, in падает на __iter__ (перебирает и сравнивает) или на __getitem__ с целыми индексами 0,1,2…. __getitem__ также делает объект итерируемым по старому протоколу, если нет __iter__.
class Squares:
def __getitem__(self, i):
if i > 3: raise IndexError
return i * i
print(list(Squares())) # [0, 1, 4, 9] — итерация через __getitem__
⚠️ Ловушка: Срезы obj[1:5] передают в __getitem__ объект slice(1, 5, None), а не int — нужно его обработать самому. И «old-style» итерация через __getitem__ остановится по IndexError; любое другое исключение прервёт цикл.
42Как реализуется перегрузка операторов (`__add__` и рефлективные версии)?
middle
Короткий ответ: Бинарные операторы маппятся на dunder-методы: + → __add__, - → __sub__, * → __mul__, и т.д. Для случая, когда левый операнд не умеет работать с правым, существуют рефлективные версии (__radd__, __rmul__), а для += — inplace (__iadd__).
Подробно:
class Vec:
def __init__(self, x, y): self.x, self.y = x, y
def __add__(self, other):
return Vec(self.x + other.x, self.y + other.y)
def __mul__(self, k): # Vec * число
return Vec(self.x * k, self.y * k)
def __rmul__(self, k): # число * Vec
return self.__mul__(k)
def __repr__(self):
return f"Vec({self.x}, {self.y})"
print(Vec(1, 2) + Vec(3, 4)) # Vec(4, 6) -> __add__
print(3 * Vec(1, 2)) # Vec(3, 6) -> __rmul__ (у int нет __mul__ для Vec)
Механика: для a + b Python пробует type(a).__add__(a, b); если он вернул NotImplemented, пробует type(b).__radd__(b, a). Возвращать нужно именно синглтон NotImplemented (не поднимать исключение), чтобы дать шанс рефлективному методу.
__iadd__ (для a += b) должен по возможности менять объект на месте и вернуть self; если его нет, a += b превращается в a = a + b.
⚠️ Ловушка: Если в __add__ для чужого типа поднять TypeError вместо return NotImplemented, вы заблокируете рефлективный метод второго операнда и сломаете симметрию операций. Также для подклассов: если правый операнд — подкласс левого и переопределяет __radd__, Python вызовет его первым.
43Как связаны `__len__` и `__bool__`?
junior
Короткий ответ: len(obj) вызывает __len__. Истинность объекта (if obj:, bool(obj)) определяется так: сначала __bool__, если его нет — __len__ (объект «ложный», если длина 0), если нет ни того ни другого — объект всегда истинный.
Подробно:
class Box:
def __init__(self, items): self.items = items
def __len__(self): return len(self.items)
b = Box([])
print(len(b)) # 0
print(bool(b)) # False — нет __bool__, падаем на __len__ == 0
print(bool(Box([1])))# True
class Always:
def __bool__(self): return False # __bool__ имеет приоритет
print(bool(Always())) # False
⚠️ Ловушка: __len__ обязан возвращать неотрицательный int; возврат float/строки или отрицательного числа — ошибка. Если у класса нет ни __bool__, ни __len__, любой экземпляр считается True — частый сюрприз, когда «пустой» кастомный объект ведёт себя как истинный в if.
44Как реализованы `__slots__` и зачем они нужны?
senior
Короткий ответ: __slots__ задаёт фиксированный набор атрибутов экземпляра и отключает создание __dict__ у объекта. Под капотом для каждого слота создаётся data-дескриптор (member_descriptor), который хранит значение в фиксированном offset внутри структуры объекта, а не в словаре. Это экономит память и слегка ускоряет доступ.
Подробно:
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x, self.y = x, y
p = Point(1, 2)
print(p.x) # 1
p.z = 3 # AttributeError: 'Point' object has no attribute 'z'
print(p.__dict__) # AttributeError: нет __dict__
print(type(Point.x)) # <class 'member_descriptor'>
Что даёт:
- Память: вместо словаря (десятки–сотни байт + рост) — компактный массив слотов в самом объекте. Критично при миллионах экземпляров.
- Скорость: доступ через дескриптор по offset немного быстрее поиска в dict.
- Защита от опечаток: нельзя случайно создать новый атрибут.
Ограничения и наследование:
- Нельзя присваивать атрибуты вне
__slots__(если не добавить'__dict__'в слоты — тогда экономия теряется). - Если хотя бы один класс в иерархии не объявил
__slots__, у экземпляров появится__dict__, и экономия исчезнет. - Слоты не наследуются «вычитанием»: в подклассе объявляйте только новые атрибуты; повторение слота родителя создаёт скрытый дубликат и тратит память.
- Не сочетается с переменными класса того же имени (слот занимает имя).
- Слоты несовместимы с некоторыми вещами без явного добавления
'__weakref__'(по умолчанию слоты убирают и поддержку слабых ссылок).
class Base:
__slots__ = ("a",)
class Child(Base):
__slots__ = ("b",) # только новое; a унаследован как слот
⚠️ Ловушка: Самая частая — добавить __slots__ в один класс, но забыть про родителя/потомка без слотов: появляется __dict__, и вся экономия пропадает молча. Также слоты ломают код, который рассчитывает на obj.__dict__ (некоторые сериализаторы, monkey-patching). pickle слотовых объектов работает, но требует аккуратности с __getstate__/__setstate__.
45Чем отличается `__dict__` объекта от `__dict__` класса?
middle
Короткий ответ: instance.__dict__ хранит атрибуты конкретного экземпляра (обычный изменяемый dict). Class.__dict__ хранит атрибуты класса — методы, переменные класса, дескрипторы — и это mappingproxy (только для чтения).
Подробно:
class C:
cls_var = 10
def method(self): pass
c = C()
c.inst_var = 5
print(c.__dict__) # {'inst_var': 5}
print('method' in C.__dict__) # True — методы лежат в классе
print(type(C.__dict__)) # <class 'mappingproxy'> (read-only)
C.new_attr = 1 # менять класс можно через атрибут, не через __dict__
Динамическое добавление атрибутов работает именно через instance.__dict__: присваивание c.x = 1 кладёт ключ в словарь экземпляра. Поиск же читает сначала экземпляр, потом класс по MRO (см. attribute lookup).
⚠️ Ловушка: Изменяемая переменная класса (например, items = [] на уровне класса) разделяется между всеми экземплярами — присваивание через self.items.append(...) модифицирует общий объект. А self.items = [...] создаёт атрибут экземпляра, затеняющий классовый. Также Class.__dict__ нельзя менять напрямую (mappingproxy), только через setattr/присваивание атрибута.
46Сколько реально экономят `__slots__` по памяти?
senior
Короткий ответ: Для объекта с несколькими атрибутами слоты убирают накладной __dict__ (в CPython это часто экономит ~100–200+ байт на экземпляр, зависит от версии и числа полей). На миллионах объектов это разница в сотни мегабайт.
Подробно: Сравнение «на пальцах» (порядок величин, CPython):
import sys
class WithDict:
def __init__(self, x, y): self.x, self.y = x, y
class WithSlots:
__slots__ = ("x", "y")
def __init__(self, x, y): self.x, self.y = x, y
a = WithDict(1, 2)
b = WithSlots(1, 2)
# сам объект + его __dict__
size_dict = sys.getsizeof(a) + sys.getsizeof(a.__dict__)
size_slots = sys.getsizeof(b)
print(size_dict, size_slots) # напр. ~152+ против ~56 байт (числа зависят от версии)
У dict-варианта память = размер объекта + отдельный словарь (который ещё и растёт с запасом). У слотового — всё в одной компактной структуре, словаря нет вовсе. Точные цифры менялись от версии к версии (в новых CPython словари экземпляров оптимизированы «key-sharing dict», что сокращает разрыв, но слоты всё равно компактнее).
⚠️ Ловушка: sys.getsizeof(obj) для dict-объекта не учитывает размер связанного __dict__ — надо складывать вручную, иначе экономия кажется меньше, чем есть. И не оптимизируйте слотами преждевременно: выигрыш заметен на больших количествах однотипных объектов, а ценой идёт потеря гибкости.
47Что такое дескриптор и чем отличаются data- и non-data-дескрипторы?
senior
Короткий ответ: Дескриптор — объект, который определяет поведение доступа к атрибуту через __get__/__set__/__delete__, будучи помещённым как атрибут класса. Data-дескриптор реализует __set__ или __delete__ (и обычно __get__); non-data-дескриптор — только __get__. Разница в приоритете: data-дескриптор перебивает __dict__ экземпляра, non-data — нет.
Подробно:
class LoggedAttr:
def __set_name__(self, owner, name): # узнаём имя атрибута
self.name = "_" + name
def __get__(self, obj, owner):
if obj is None: return self
return getattr(obj, self.name)
def __set__(self, obj, value): # наличие __set__ => data-дескриптор
print("set", value)
setattr(obj, self.name, value)
class C:
x = LoggedAttr()
c = C()
c.x = 5 # set 5 (вызван __set__)
print(c.x) # 5
Сигнатуры:
__get__(self, obj, objtype)—objэто экземпляр (илиNoneпри доступе через класс).__set__(self, obj, value).__delete__(self, obj).__set_name__(self, owner, name)— вызывается при создании класса, даёт имя атрибута.
Приоритет (из attribute lookup): data-дескриптор > __dict__ экземпляра > non-data-дескриптор.
⚠️ Ловушка: Хранить значение в самом дескрипторе (self.value = value) — ошибка: дескриптор один на класс, значение станет общим для всех экземпляров. Храните в obj.__dict__ (через __set_name__-имя) или в слоте. И помните: дескрипторы работают, только когда лежат в классе, а не в экземпляре.
48Как устроен `property`?
middle
Короткий ответ: property — это встроенный data-дескриптор. Его __get__ вызывает getter, __set__ — setter, __delete__ — deleter. Декораторы @x.setter и @x.deleter возвращают новый объект property с обновлёнными функциями.
Подробно:
class Temperature:
def __init__(self, celsius=0):
self._c = celsius
@property
def celsius(self): # getter
return self._c
@celsius.setter
def celsius(self, value): # setter
if value < -273.15:
raise ValueError("ниже абсолютного нуля")
self._c = value
@celsius.deleter
def celsius(self): # deleter
del self._c
@property
def fahrenheit(self): # вычисляемое свойство только на чтение
return self._c * 9 / 5 + 32
t = Temperature(25)
t.celsius = 30 # вызывает setter
print(t.fahrenheit) # 86.0
Поскольку property — data-дескриптор, он имеет приоритет над __dict__, поэтому obj.celsius всегда идёт через getter, даже если кто-то попытается положить celsius в словарь экземпляра.
⚠️ Ловушка: Без @celsius.setter свойство только для чтения — попытка t.celsius = 5 даст AttributeError. Частая ошибка — назвать backing-поле так же, как property (self.celsius = ... внутри __init__), что вызовет setter рекурсивно или бесконечную рекурсию, если setter сам обращается к property. Используйте отдельное имя (_c).
49Почему обычные методы, `classmethod` и `staticmethod` — это дескрипторы?
senior
Короткий ответ: Функция — non-data-дескриптор: её __get__ возвращает связанный метод (bound method), автоматически подставляя self. classmethod и staticmethod — отдельные дескрипторы, меняющие, что подставляется: classmethod биндит cls, staticmethod не биндит ничего.
Подробно:
class C:
def method(self): return self # function -> bound при доступе
@classmethod
def cm(cls): return cls
@staticmethod
def sm(): return "no binding"
c = C()
print(c.method) # <bound method C.method of <__main__.C ...>>
print(c.method()) # c — self подставлен через __get__
print(C.cm()) # <class 'C'> — cls подставлен
print(c.sm()) # no binding — staticmethod просто возвращает функцию
Как это работает: c.method → находит функцию в классе → она non-data-дескриптор → function.__get__(c, C) возвращает bound method, который при вызове передаёт c первым аргументом. Поэтому методы можно «затереть» атрибутом экземпляра (non-data приоритет ниже __dict__).
classmethod.__get__ возвращает метод, привязанный к классу; staticmethod.__get__ возвращает исходную функцию без привязки.
Сравнение трёх видов:
- instance method: получает
self; работает с конкретным объектом. - classmethod: получает
cls; альтернативные конструкторы, фабрики, работа с состоянием класса; корректно работает с наследованием (cls— реальный подкласс). - staticmethod: ничего не получает; логически связанная функция в неймспейсе класса.
⚠️ Ловушка: Если положить функцию прямо в obj.__dict__ (не в класс), она не станет методом — __get__ срабатывает только для атрибутов класса. И classmethod в фабриках лучше staticmethod, потому что cls указывает на реальный подкласс: SubClass.create() создаст SubClass, а не базовый класс.
50Зачем вообще нужны дескрипторы?
concept
Короткий ответ: Дескрипторы — это механизм, лежащий в основе property, методов, classmethod/staticmethod, __slots__, functools.cached_property и ORM-полей (Django/SQLAlchemy). Они позволяют один раз описать логику доступа к атрибуту (валидация, ленивое вычисление, типизация) и переиспользовать её декларативно на многих полях.
Подробно: Без дескрипторов под каждое валидируемое поле пришлось бы писать отдельный property с дублирующимся кодом. Дескриптор инкапсулирует логику и применяется многократно:
class Typed:
def __init__(self, expected): self.expected = expected
def __set_name__(self, owner, name): self.name = "_" + name
def __get__(self, obj, owner):
return self if obj is None else getattr(obj, self.name)
def __set__(self, obj, value):
if not isinstance(value, self.expected):
raise TypeError(f"ожидался {self.expected}")
setattr(obj, self.name, value)
class Person:
name = Typed(str) # одна логика — много полей
age = Typed(int)
Это и есть «инфраструктурный» приём фреймворков: декларативные модели, ленивые свойства, дескрипторы-валидаторы.
⚠️ Ловушка: Дескрипторы — мощный, но «неочевидный» механизм: код в __get__/__set__ срабатывает «магически» при обычном obj.x, что усложняет отладку. Часто property или __init_subclass__/dataclass решают задачу проще; полноценный дескриптор оправдан, когда логика реально переиспользуется на многих полях/классах.
51Что такое MRO и как работает C3-линеаризация?
senior
Короткий ответ: MRO (Method Resolution Order) — порядок, в котором Python ищет атрибуты/методы по базовым классам. Для new-style классов он вычисляется алгоритмом C3-линеаризации, который гарантирует: класс идёт раньше своих родителей, и относительный порядок родителей из объявления сохраняется. Смотреть через Cls.__mro__ или Cls.mro().
Подробно: C3 строит линеаризацию L[C] через слияние (merge) линеаризаций родителей и списка родителей:
L[C] = C + merge(L[B1], L[B2], ..., [B1, B2, ...])
merge на каждом шаге берёт «голову» первого списка, которая не встречается в «хвосте» ни одного другого списка, и удаляет её отовсюду.
Классический ромб (diamond):
class A:
def who(self): return "A"
class B(A):
def who(self): return "B"
class C(A):
def who(self): return "C"
class D(B, C):
pass
print([c.__name__ for c in D.__mro__])
# ['D', 'B', 'C', 'A', 'object']
print(D().who()) # "B" — B раньше C в MRO
A появляется в MRO один раз (после B и C), хотя достижим двумя путями — это и решает «проблему ромба»: общий предок вызывается единожды и после всех своих потомков.
⚠️ Ловушка: Если порядки несовместимы, C3 не сможет построить линеаризацию и Python бросит TypeError: Cannot create a consistent method resolution order. Пример: class X(A, B) и class Y(B, A), а потом class Z(X, Y) — противоречивый порядок A и B. Решение — согласовать порядок базовых классов во всей иерархии.
52Как на самом деле работает `super()`?
senior
Короткий ответ: super() не «вызывает родителя» — он делегирует следующему классу в MRO относительно текущего класса и текущего экземпляра. Без аргументов super() в методе превращается в super(__class__, self), используя скрытую ячейку __class__. Это позволяет кооперативному множественному наследованию работать корректно.
Подробно:
class A:
def __init__(self): print("A");
class B(A):
def __init__(self): print("B"); super().__init__()
class C(A):
def __init__(self): print("C"); super().__init__()
class D(B, C):
def __init__(self): print("D"); super().__init__()
D()
# D
# B
# C <- super() в B пошёл не в A, а в C — следующий по MRO D!
# A
Ключевой момент: super().__init__() внутри B вызвал C, а не A, потому что MRO у D это D → B → C → A → object. super() идёт по этому списку, поэтому A.__init__ выполнится ровно один раз. Это «кооперативное наследование» — каждый класс вызывает super(), и цепочка проходит весь MRO без дублей.
super(Type, obj) — явная форма: ищет следующий после Type в type(obj).__mro__.
⚠️ Ловушка: В кооперативной схеме все классы цепочки должны вызывать super().__init__() с совместимыми сигнатурами, иначе цепочка оборвётся или упадёт. Если думать про super() как «вызов прямого родителя», результат удивит при ромбовидном наследовании. Также super() без аргументов работает только внутри метода класса (нужна ячейка __class__); вне метода используйте явную форму.
53Что такое mixin и как его правильно использовать?
middle
Короткий ответ: Mixin — класс, добавляющий определённое поведение через множественное наследование, но не предназначенный для самостоятельного инстанцирования. Он не хранит собственное состояние «как сущность», а лишь привносит методы (например, сериализацию, логирование, сравнение).
Подробно:
class ReprMixin:
def __repr__(self):
attrs = ", ".join(f"{k}={v!r}" for k, v in vars(self).items())
return f"{type(self).__name__}({attrs})"
class ComparableMixin:
def __eq__(self, other):
return type(self) == type(other) and vars(self) == vars(other)
class User(ReprMixin, ComparableMixin):
def __init__(self, name): self.name = name
print(User("Ann")) # User(name='Ann')
Правила хорошего тона: миксины ставят левее основного класса (выше в MRO, чтобы перебивать поведение), у них узкая ответственность, они опираются на интерфейс «хост»-класса и часто используют super() для кооперативности.
⚠️ Ловушка: Множественное наследование с конфликтующими методами и __init__-сигнатурами — источник тонких багов через MRO. Не делайте «толстые» миксины с состоянием и зависимостями друг от друга. Часто композиция (агрегирование объекта) понятнее наследования миксинов.
54Что такое метакласс и зачем он нужен?
senior
Короткий ответ: Метакласс — это «класс класса»: то, экземпляром чего является сам класс. По умолчанию метакласс — type. Когда вы пишете class C: ..., Python вызывает type(name, bases, namespace) и создаёт объект-класс. Кастомный метакласс позволяет вмешаться в создание класса: менять/проверять его атрибуты, регистрировать, навязывать соглашения.
Подробно: type сам — класс и одновременно метакласс (type(type) is type). Объявление класса эквивалентно:
def make_class():
return type("C", (object,), {"x": 1}) # имя, базы, namespace
C = make_class()
print(C.x) # 1
Кастомный метакласс через __new__/__init__:
class Meta(type):
def __new__(mcls, name, bases, ns, **kwargs):
# ns — словарь тела класса; можно проверять/менять
ns["created_by"] = "Meta"
return super().__new__(mcls, name, bases, ns)
class A(metaclass=Meta):
pass
print(A.created_by) # Meta
print(type(A)) # <class 'Meta'>
Реальные применения: ORM (Django Model собирает поля из тела класса), регистрация плагинов, enum, ABCMeta (проверка абстрактных методов), enforcement API (запрет/требование методов), синглтоны.
Цепочка: экземпляр → класс (type(obj)) → метакласс (type(cls)). Метакласс наследника берётся как метакласс одного из базовых классов (он должен быть совместим — подклассом метаклассов всех баз).
⚠️ Ловушка: Метакласс класса обязан быть подклассом метаклассов всех его баз, иначе TypeError: metaclass conflict. Метаклассы — редко нужны: 95% задач решаются проще через __init_subclass__, декораторы классов или dataclass. «Если ты не уверен, нужен ли метакласс — он тебе не нужен» (Тим Питерс).
55Как `__init_subclass__` заменяет метаклассы?
middle
Короткий ответ: __init_subclass__ — хук (неявный classmethod), который вызывается на родителе каждый раз при создании подкласса. Он покрывает большинство «лёгких» сценариев, ради которых раньше писали метаклассы (регистрация, валидация, дефолты), без сложности метаклассов.
Подробно:
class Plugin:
registry = {}
def __init_subclass__(cls, /, key=None, **kwargs):
super().__init_subclass__(**kwargs)
if key:
Plugin.registry[key] = cls # авторегистрация подкласса
class JsonPlugin(Plugin, key="json"):
pass
print(Plugin.registry) # {'json': <class '__main__.JsonPlugin'>}
Аргументы из объявления класса (key="json") пробрасываются в __init_subclass__. Также есть __set_name__ для дескрипторов и декораторы классов — вместе они закрывают почти всё, что раньше требовало метакласса.
Когда метакласс всё же нужен: когда надо изменить сам процесс создания класса (порядок/тип namespace через __prepare__), переопределить __call__ метакласса (контроль инстанцирования) или __instancecheck__/__subclasscheck__.
⚠️ Ловушка: Не забудьте вызвать super().__init_subclass__(**kwargs) — иначе сломаете кооперативность с другими классами в иерархии. __init_subclass__ вызывается для подклассов, но не для самого класса, где он определён.
56Как работают абстрактные базовые классы (ABC)?
middle
Короткий ответ: abc.ABC (метакласс ABCMeta) позволяет объявить методы абстрактными через @abstractmethod. Класс с нереализованными абстрактными методами нельзя инстанцировать. ABC задаёт интерфейс-контракт и поддерживает виртуальную регистрацию (register) и кастомный __subclasshook__.
Подробно:
from abc import ABC, abstractmethod
class Storage(ABC):
@abstractmethod
def save(self, data): ...
@abstractmethod
def load(self): ...
Storage() # TypeError: Can't instantiate abstract class
class FileStorage(Storage):
def save(self, data): ...
def load(self): ... # все абстрактные реализованы
FileStorage() # OK
Виртуальное наследование без подкласса:
from collections.abc import Sequence
Sequence.register(MyType) # MyType теперь isinstance(x, Sequence) == True
collections.abc использует __subclasshook__, чтобы isinstance(obj, Iterable) возвращал True для любого объекта с __iter__, без явной регистрации.
⚠️ Ловушка: @abstractmethod нужно комбинировать с @property/@classmethod в правильном порядке (@property сверху). Регистрация (register) делает isinstance/issubclass истинными, но не проверяет, что методы реально реализованы — это «обещание», легко нарушить. Также абстрактность проверяется только при инстанцировании, а не при определении подкласса.
57Что такое Protocol и структурная типизация?
middle
Короткий ответ: typing.Protocol задаёт интерфейс по структуре («duck typing» в системе типов): класс считается совместимым, если у него есть нужные методы/атрибуты, без явного наследования. Это статическая типизация в стиле «если крякает как утка».
Подробно:
from typing import Protocol, runtime_checkable
class Readable(Protocol):
def read(self) -> str: ...
def consume(src: Readable) -> str:
return src.read()
class File: # НЕ наследует Readable
def read(self) -> str: return "data"
consume(File()) # OK для type checker — структурно подходит
Отличие от ABC:
- ABC (nominal): совместимость через явное наследование/регистрацию.
- Protocol (structural): совместимость через совпадение структуры; класс ничего не должен наследовать.
@runtime_checkable позволяет isinstance(obj, Readable) в рантайме (проверяет только наличие методов по имени, не сигнатуры).
⚠️ Ловушка: runtime_checkable isinstance проверяет лишь наличие атрибутов, а не их сигнатуры/типы — может дать ложноположительный результат. Protocol — в первую очередь инструмент статической проверки (mypy/pyright); в рантайме без @runtime_checkable isinstance с ним падает.
58Как работает подсчёт ссылок (reference counting)?
middle
Короткий ответ: CPython считает количество ссылок на каждый объект в поле ob_refcnt. Когда счётчик падает до нуля, объект немедленно освобождается. Это основной механизм управления памятью; счётчик растёт при присваивании/добавлении в контейнер и падает при del/выходе из области видимости.
Подробно:
import sys
x = []
print(sys.getrefcount(x)) # >=2: одна ссылка x + временная ссылка-аргумент getrefcount
y = x
print(sys.getrefcount(x)) # на 1 больше — y тоже ссылается
del y # счётчик уменьшается
Преимущество — детерминизм: объект обычно уничтожается сразу при потере последней ссылки. Недостаток — один refcount не может освободить цикл: два объекта продолжают ссылаться друг на друга, хотя снаружи они уже недостижимы. Для таких циклов CPython отдельно запускает поколенческий cyclic GC. Изменение счётчиков также добавляет накладные расходы и требует синхронизации между потоками.
⚠️ Ловушка: sys.getrefcount(x) всегда показывает на 1 больше «реального», потому что сам аргумент функции создаёт временную ссылку. Не полагайтесь на точные числа. И не пишите код, опирающийся на немедленное освобождение (__del__), — на PyPy/Jython refcounting нет, объекты собираются недетерминированно.
59Зачем нужен сборщик мусора, если есть подсчёт ссылок?
senior
Короткий ответ: Подсчёт ссылок не освобождает циклические ссылки (объекты, ссылающиеся друг на друга, у которых refcount никогда не упадёт до нуля). Для них существует отдельный циклический GC, работающий по поколениям и обнаруживающий недостижимые циклы.
Подробно: Пример утечки без GC:
import gc
a = {}
b = {}
a["b"] = b
b["a"] = a # цикл: refcount каждого >= 1 даже после del
del a, b # объекты недостижимы, но refcount != 0
gc.collect() # циклический GC находит и удаляет их
Поколения (generational GC): новые объекты — в поколении 0; пережившие сборку переходят в 1, затем в 2. Молодые поколения собираются чаще (гипотеза: большинство объектов умирают молодыми), старые — реже. Модуль gc:
import gc
gc.collect() # принудительная сборка, возвращает число собранных
gc.disable() # отключить циклический GC (refcount продолжает работать)
gc.get_count() # счётчики поколений
gc.get_threshold() # пороги срабатывания
gc.set_threshold(700, 10, 10)
Циклический GC отслеживает только объекты-контейнеры (которые могут содержать ссылки): list, dict, экземпляры классов и т.п. Простые объекты (int, str) в циклах не участвуют.
⚠️ Ловушка: Метод __del__ на объектах внутри цикла исторически мешал сборке (до Python 3.4 такие циклы попадали в gc.garbage и не освобождались). Сейчас собираются, но порядок вызова __del__ в цикле не определён. Утечки чаще всего из-за «живых» ссылок (глобальные кэши, замыкания, регистрация в коллекциях), а не из-за GC. Для отладки утечек: gc.get_objects(), gc.get_referrers(), tracemalloc, weakref для разрыва циклов.
60Что такое интернирование строк и кэш малых int?
middle
Короткий ответ: CPython кэширует часто используемые неизменяемые объекты, чтобы переиспользовать их: малые целые от -5 до 256 создаются один раз (singletons), а короткие строки-идентификаторы интернируются. Поэтому a is b может быть True для одинаковых значений — но это деталь реализации, не гарантия.
Подробно:
a = 256
b = 256
print(a is b) # True — в кэше малых int
c = 257
d = 257
print(c is d) # часто False — вне кэша (может отличаться в REPL/одной строке)
s1 = "hello"
s2 = "hello"
print(s1 is s2) # обычно True — строковые литералы интернируются
import sys
s3 = sys.intern("".join(["he", "llo"])) # явное интернирование
Зачем: экономия памяти и ускорение сравнения (можно сравнивать по is/указателю). Интернирование строк особенно помогает с ключами словарей и идентификаторами.
⚠️ Ловушка: Никогда не сравнивайте значения через is (if x is 256). Используйте ==. Поведение интернирования зависит от версии, контекста компиляции (один блок кода vs интерактивный ввод) и реализации. id() возвращает уникальный идентификатор объекта на время его жизни, но после смерти объекта id может быть переиспользован другим объектом — сравнивать id сохранённые во времени нельзя.
61Что такое bytecode, .pyc, и чем CPython отличается от PyPy?
senior
Короткий ответ: CPython компилирует исходник в промежуточный bytecode (инструкции для стековой виртуальной машины), который выполняет интерпретатор. Скомпилированный bytecode кэшируется в .pyc-файлах (__pycache__) для ускорения повторного импорта. CPython — референсная реализация; PyPy — альтернатива с JIT, Cython компилирует Python/расширенный синтаксис в C.
Подробно:
import dis
def f(x):
return x + 1
dis.dis(f)
# LOAD_FAST x
# LOAD_CONST 1
# BINARY_OP + (в новых версиях)
# RETURN_VALUE
Конвейер CPython: исходник → AST → bytecode (code object, доступен как f.__code__.co_code) → исполнение виртуальной машиной (цикл интерпретатора). .pyc содержит маршалленый bytecode плюс заголовок с хешем/таймстампом исходника для инвалидации кэша. .pyc создаётся при импорте модуля (не для запускаемого напрямую скрипта).
Реализации:
- CPython — эталон, написан на C, имеет GIL, интерпретирует bytecode (с недавними оптимизациями — специализирующий адаптивный интерпретатор).
- PyPy — JIT-компилятор, на «горячем» коде близок к нативной скорости; написан на RPython; не использует refcounting (другой GC).
- Cython — транслирует Python (с опциональной типизацией) в C для скорости и интеграции с C-кодом.
- Прочие: Jython (JVM), GraalPy, MicroPython.
⚠️ Ловушка: Bytecode не кросс-версионный — .pyc от другой версии Python несовместим (отсюда тег версии в имени файла). Не считайте bytecode «защитой» исходника — он легко дизассемблируется (dis) и декомпилируется. И помните, что обсуждаемые детали (GIL, refcounting, кэш малых int, интернирование) — это про CPython, а не про язык Python вообще.
62В чём разница между concurrency и parallelism?
junior
Короткий ответ: Конкурентность — это про структуру программы (управление несколькими задачами, которые прогрессируют, но не обязательно одновременно). Параллелизм — это про физическое одновременное выполнение на нескольких ядрах.
Подробно:
- Конкурентность (concurrency) — задачи переключаются и продвигаются «по очереди», создавая иллюзию одновременности. Один повар готовит 3 блюда, переключаясь между ними. Может быть и на одном ядре.
- Параллелизм (parallelism) — задачи реально выполняются в одну и ту же физическую секунду. Три повара, каждый готовит своё блюдо. Требует нескольких ядер/процессоров.
Конкурентность (1 ядро): Параллелизм (2 ядра):
CPU: A B A B A B CPU0: A A A A
CPU1: B B B B
В Python:
asyncioиthreading(из-за GIL) дают конкурентность, но не настоящий параллелизм для CPU-кода.multiprocessingдаёт настоящий параллелизм.
💡 «Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.» — Rob Pike.
⚠️ Ловушка: многие путают и говорят «потоки в Python работают параллельно». Для CPU-bound кода это не так из-за GIL.
63Что такое GIL и зачем он существует?
senior
Короткий ответ: GIL (Global Interpreter Lock) — это мьютекс уровня интерпретатора CPython, который гарантирует, что только один поток исполняет байт-код Python в любой момент времени. Нужен для упрощения управления памятью (reference counting) и потокобезопасности внутренних структур.
Подробно:
CPython управляет памятью через подсчёт ссылок (ob_refcnt у каждого объекта). Инкремент/декремент счётчика — не атомарная операция на уровне процессора. Без глобальной блокировки два потока, одновременно меняющие счётчик одного объекта, привели бы к гонке: счётчик «потеряет» изменение, объект освободится раньше времени (use-after-free) или утечёт.
Варианты решения:
- Делать каждый refcount атомарным (через atomic-инструкции) — дорого, замедляет однопоточный код.
- Один глобальный лок (GIL) — просто, быстро для однопоточного кода. CPython выбрал это.
import sys
a = []
print(sys.getrefcount(a)) # счётчик ссылок; GIL защищает такие операции
GIL — особенность CPython (референсной реализации). В Jython (JVM) и IronPython (.NET) GIL нет — там используется сборка мусора рантайма.
⚠️ Ловушка: GIL — это не часть языка Python, а деталь реализации CPython. Формулировка «в Python есть GIL» технически неточна.
64Почему многопоточность не ускоряет CPU-bound задачи, но ускоряет I/O-bound?
senior
Короткий ответ: GIL разрешает исполнять байт-код только одному потоку. CPU-bound потоки конкурируют за GIL и фактически работают по очереди (даже хуже из-за оверхеда переключений). I/O-bound потоки отпускают GIL на время блокирующего системного вызова, поэтому другие потоки в это время работают.
Подробно:
CPU-bound — поток постоянно исполняет байт-код, держит GIL. Два таких потока на 4 ядрах всё равно дают ~1 ядро полезной работы:
import threading, time
def cpu_task(n):
while n > 0:
n -= 1
N = 50_000_000
# Последовательно
t = time.perf_counter()
cpu_task(N); cpu_task(N)
print("seq:", time.perf_counter() - t)
# 2 потока — НЕ быстрее (часто медленнее из-за борьбы за GIL)
t = time.perf_counter()
threads = [threading.Thread(target=cpu_task, args=(N,)) for _ in range(2)]
for x in threads: x.start()
for x in threads: x.join()
print("threads:", time.perf_counter() - t)
I/O-bound — поток вызывает read()/recv()/time.sleep() и отдаёт GIL на время ожидания ОС. Пока он спит, GIL свободен — другой поток работает:
import threading, time
def io_task():
time.sleep(1) # блокировка ввода-вывода (имитация); GIL освобождён
t = time.perf_counter()
threads = [threading.Thread(target=io_task) for _ in range(10)]
for x in threads: x.start()
for x in threads: x.join()
print("10 потоков по 1с:", time.perf_counter() - t) # ~1с, а не 10с
⚠️ Ловушка: для CPU-bound нужен multiprocessing, а не threading. Это классический «обман» на собесе: «ускорь подсчёт хешей потоками» — ответ «потоками не получится из-за GIL».
65Когда именно освобождается GIL?
middle
Короткий ответ: GIL освобождается (1) при блокирующих I/O-операциях и системных вызовах, (2) в C-расширениях, которые явно его отпускают (Py_BEGIN_ALLOW_THREADS), (3) принудительно каждые ~5 мс по таймеру («switch interval»).
Подробно:
- I/O и syscalls.
socket.recv,file.read,time.sleep,os.system— CPython оборачивает блокирующий вызов вPy_BEGIN_ALLOW_THREADS ... Py_END_ALLOW_THREADS, отпуская GIL на время ожидания ОС. - Тяжёлые C-расширения (NumPy, lxml, сжатие) часто отпускают GIL на время вычислений в C — поэтому NumPy-операции могут реально параллелиться потоками.
- Switch interval — для долгого чистого Python-байт-кода интерпретатор периодически принуждает поток отдать GIL, чтобы дать шанс другим:
import sys
print(sys.getswitchinterval()) # 0.005 (5 мс) по умолчанию
sys.setswitchinterval(0.01)
В Python 2 переключение было «по числу байт-код инструкций» (каждые ~100 тиков), в Python 3 — по времени (5 мс). Это сделало планирование честнее.
⚠️ Ловушка: time.sleep() отпускает GIL (это блокирующий syscall), а вот «busy-wait» цикл while True: pass — нет, он держит GIL.
66Что меняется с GIL в новых версиях Python?
senior
Короткий ответ: В 3.13 появилась экспериментальная сборка free-threaded CPython (PEP 703) — интерпретатор без GIL, где потоки реально параллельны для CPU-кода. В 3.12 заложен фундамент: per-interpreter GIL (PEP 684) — каждый суб-интерпретатор может иметь свой GIL.
Подробно:
- Python 3.12, PEP 684 (per-interpreter GIL): изоляция состояния позволяет каждому суб-интерпретатору иметь собственный GIL. Через
interpreters(в 3.13 — модульconcurrent.interpreters, PEP 734) можно запускать несколько интерпретаторов в одном процессе с параллелизмом, но без оверхеда процессов. - Python 3.13, PEP 703 (free-threading): отдельная сборка
python3.13tбез GIL. Reference counting заменён на потокобезопасный (biased reference counting + atomic-операции), используется специализированная аллокация и блокировки на уровне объектов. CPU-bound потоки реально масштабируются по ядрам. - Цена: одиночный поток медленнее (накладные на атомарность); C-расширения нужно адаптировать (объявить
Py_mod_gil). - Python 3.14 двигает free-threading к официально поддерживаемому статусу (из «экспериментального»).
# Проверить, отключён ли GIL в free-threaded сборке
python3.13t -c "import sys; print(sys._is_gil_enabled())"
💡 Стратегически: к этому идёт весь экосистемный стек (NumPy, Cython уже добавляют поддержку), но на собесе важно знать, что на стандартной сборке GIL всё ещё есть.
67Когда стоит использовать threading?
junior
Короткий ответ: Для I/O-bound задач с блокирующими библиотеками (сетевые запросы, диск, БД-драйверы без async-API), где потоки большую часть времени ждут и отпускают GIL.
Подробно:
import threading
def worker(name):
print(f"работает {name}")
t = threading.Thread(target=worker, args=("A",))
t.start()
t.join() # ждём завершения
# Через наследование
class MyThread(threading.Thread):
def run(self):
print("в потоке")
MyThread().start()
Хорошие сценарии:
- Много блокирующих HTTP-запросов через
requests(нет async). - Параллельное чтение файлов.
- GUI: фоновый поток, чтобы не блокировать интерфейс.
Плохие сценарии:
- CPU-bound вычисления → бери
multiprocessing. - Тысячи одновременных соединений → бери
asyncio(потоки слишком дороги по памяти).
💡 Потоки — это «параллелизм ожидания», а не «параллелизм вычислений» в CPython.
68В чём разница между Lock и RLock?
middle
Короткий ответ: Lock — простой мьютекс: повторный acquire() тем же потоком приведёт к взаимоблокировке. RLock (reentrant lock) — реентерабельный: один поток может захватить его несколько раз (нужно столько же release()).
Подробно:
import threading
lock = threading.Lock()
counter = 0
def increment():
global counter
for _ in range(100_000):
with lock: # критическая секция
counter += 1
threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # 400000 — без lock было бы непредсказуемо
RLock — когда метод под локом вызывает другой метод, тоже берущий тот же лок:
rlock = threading.RLock()
def outer():
with rlock:
inner() # с обычным Lock здесь был бы deadlock
def inner():
with rlock:
pass
Другие примитивы: Semaphore (счётчик разрешений), Event (флаг-сигнал), Condition (ожидание условия), Barrier (синхронизация N потоков).
⚠️ Ловушка: Lock.acquire() дважды в одном потоке = вечный deadlock. Если код рекурсивный/реентерабельный — используй RLock.
69Что такое race condition и почему `x += 1` небезопасен?
middle
Короткий ответ: Race condition — когда результат зависит от непредсказуемого порядка выполнения потоков. x += 1 — это три байт-код операции (read, add, store); GIL может переключить поток между ними, и инкремент потеряется.
Подробно:
import dis
def f():
x = 0
x += 1
dis.dis(f)
# LOAD_FAST x / LOAD_CONST 1 / BINARY_OP += / STORE_FAST x
Поток A читает x=5, переключение, поток B читает x=5, оба пишут 6. Одно увеличение потеряно. GIL не спасает, потому что он может переключиться между байт-кодами.
Решение — лок или атомарные структуры (queue.Queue, itertools.count, операции под with lock).
⚠️ Ловушка: GIL гарантирует атомарность одной байт-код инструкции, но не последовательности. +=, if x: x = ..., check-then-act — гонки.
70Почему говорят, что `dict[key] = value` потокобезопасна, а проверка `if key not in d: d[key]=...` — нет?
senior
Короткий ответ: Отдельная операция вставки/чтения реализована в C и выполняется в рамках одной байт-код инструкции под GIL — она атомарна. Но составная операция (check-then-act) состоит из нескольких инструкций, между которыми возможно переключение → гонка.
Подробно:
Атомарны (одна C-операция, не прерывается GIL):
d[k] = v,d.get(k),list.append(x),x = d[k],L.pop().
НЕ атомарны (несколько шагов):
# Гонка: между проверкой и присваиванием другой поток мог вставить ключ
if key not in d:
d[key] = compute() # ❌
# Гонка: read-modify-write
d[key] += 1 # ❌ (LOAD, ADD, STORE)
Безопасно:
with lock:
if key not in d:
d[key] = compute() # ✅
# или
d.setdefault(key, []).append(x) # setdefault атомарен
⚠️ Ловушка: не полагайтесь на «атомарность благодаря GIL» как на дизайн — это деталь реализации CPython (в free-threaded сборке гарантии другие, там используются внутренние локи). Явный Lock надёжнее и переносимее.
71Что такое deadlock и как его избежать?
middle
Короткий ответ: Deadlock — взаимная блокировка: поток A держит лок 1 и ждёт лок 2, поток B держит лок 2 и ждёт лок 1. Никто не продвигается. Избегают единым порядком захвата локов, таймаутами, минимизацией удерживаемых локов.
Подробно:
import threading
lock1, lock2 = threading.Lock(), threading.Lock()
def thread_a():
with lock1:
with lock2: # ждёт lock2
...
def thread_b():
with lock2: # держит lock2
with lock1: # ждёт lock1 -> DEADLOCK
...
Условия Коффмана (нужны все 4): mutual exclusion, hold-and-wait, no preemption, circular wait. Убираем любое — нет deadlock.
Способы избежать:
- Единый порядок захвата (всегда сначала lock1, потом lock2).
- Таймауты:
lock.acquire(timeout=1). RLockпротив самоблокировки.- Уменьшать область удержания, использовать высокоуровневые
queue.Queue.
⚠️ Ловушка: забыть release() при исключении — всегда используй with lock:.
72Как multiprocessing обходит GIL?
junior
Короткий ответ: Каждый процесс — отдельный интерпретатор Python со своим GIL и своей памятью. Процессы выполняются на разных ядрах по-настоящему параллельно, поэтому подходят для CPU-bound.
Подробно:
from multiprocessing import Process
import os
def cpu_task(n):
s = sum(i*i for i in range(n))
print(os.getpid(), s)
if __name__ == "__main__": # важно на Windows/macOS (spawn)
procs = [Process(target=cpu_task, args=(10_000_000,)) for _ in range(4)]
for p in procs: p.start()
for p in procs: p.join()
Методы старта:
fork(Linux по умолчанию) — копирует процесс (copy-on-write), быстро.spawn(Windows, macOS по умолчанию с 3.8+) — запускает свежий интерпретатор, импортирует модуль заново → нуженif __name__ == "__main__".forkserver— отдельный процесс-сервер для форка.
⚠️ Ловушка: без if __name__ == "__main__" при spawn — бесконечное рекурсивное порождение процессов.
73Чем процессы дороже потоков?
middle
Короткий ответ: Процесс — отдельное адресное пространство: дороже создавать (особенно spawn), больше памяти, обмен данными требует сериализации (pickle) и IPC. Потоки делят память и дёшевы, но ограничены GIL.
Подробно:
| Аспект | Поток | Процесс |
|---|---|---|
| Память | общая | изолированная |
| Создание | дёшево (~КБ стека) | дорого (новый интерпретатор) |
| Параллелизм CPU | нет (GIL) | да |
| Обмен данными | напрямую (нужны локи) | pickle + IPC |
| Падение | тащит весь процесс | изолировано |
| Старт | микросекунды | миллисекунды–десятки мс |
💡 Правило: процессов имеет смысл порождать примерно по числу ядер (os.cpu_count()), а не тысячами. Потоков можно сотни, корутин — десятки/сотни тысяч.
74Как процессы обмениваются данными и что такое pickling-проблема?
middle
Короткий ответ: Через IPC: Queue, Pipe, Manager. Все передаваемые объекты сериализуются через pickle, поэтому несериализуемые объекты (lambda, локальные функции, открытые сокеты/файлы) передать нельзя.
Подробно:
from multiprocessing import Pool
def square(x):
return x * x
if __name__ == "__main__":
with Pool(processes=4) as pool:
print(pool.map(square, range(10))) # распараллелено по процессам
# apply_async / imap / starmap тоже доступны
IPC-механизмы:
Queue— потокобезопасная очередь, под капотом pipe + pickle.Pipe— двунаправленный канал между двумя процессами.Manager— прокси-объекты (list,dict), синхронизированные между процессами (медленнее, через сервер-процесс).
from multiprocessing import Process, Queue
def worker(q):
q.put("результат")
if __name__ == "__main__":
q = Queue()
p = Process(target=worker, args=(q,)); p.start(); p.join()
print(q.get())
⚠️ Ловушка: pool.map(lambda x: x*x, data) упадёт — lambda не пиклится. Нужна функция верхнего уровня модуля. Также накладные расходы на pickle могут «съесть» выигрыш, если данные большие, а работа маленькая.
75Как разделять память без копирования через pickle?
middle
Короткий ответ: multiprocessing.Value/Array для простых типов и multiprocessing.shared_memory.SharedMemory (с 3.8) для блоков байтов/NumPy-массивов — данные лежат в общей памяти, не сериализуются.
Подробно:
from multiprocessing import Process, Value, Array
def inc(val, arr):
with val.get_lock(): # есть встроенный лок
val.value += 1
arr[0] += 10
if __name__ == "__main__":
v = Value("i", 0) # int в общей памяти
a = Array("i", [1, 2, 3]) # массив
p = Process(target=inc, args=(v, a)); p.start(); p.join()
print(v.value, a[:])
SharedMemory — для больших массивов (например, поделить NumPy-массив между процессами без копий):
from multiprocessing import shared_memory
shm = shared_memory.SharedMemory(create=True, size=1024)
shm.buf[0] = 42
# другой процесс: SharedMemory(name=shm.name)
shm.close(); shm.unlink()
⚠️ Ловушка: общая память всё равно требует синхронизации (get_lock()), иначе гонки. И не забыть unlink() — иначе утечка сегмента памяти ОС.
76Когда что использовать?
junior
Короткий ответ: Единый высокоуровневый API. ThreadPoolExecutor — для I/O-bound (потоки, общий GIL), ProcessPoolExecutor — для CPU-bound (процессы, обход GIL). Переключение — заменой одного класса.
Подробно:
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completed
def fetch(url): ... # I/O
def crunch(n): ... # CPU
# I/O-bound
with ThreadPoolExecutor(max_workers=20) as ex:
futures = [ex.submit(fetch, u) for u in urls]
for fut in as_completed(futures):
print(fut.result())
# CPU-bound
with ProcessPoolExecutor() as ex: # по умолчанию = os.cpu_count()
results = list(ex.map(crunch, data))
Future даёт .result(), .done(), .add_done_callback(), .cancel(). as_completed — итерация по мере готовности; map — сохраняет порядок входа.
💡 На собесе: concurrent.futures — рекомендуемый высокоуровневый интерфейс вместо ручного создания потоков/процессов. Совместим с asyncio через loop.run_in_executor.
⚠️ Ловушка: ProcessPoolExecutor так же требует if __name__ == "__main__" и пиклящихся функций. Исключения в задаче всплывут при .result(), а не при .submit().
77Зачем вообще нужна асинхронность, если есть потоки?
senior
Короткий ответ: Чтобы эффективно обслуживать много одновременных I/O-операций одним потоком, без затрат памяти и переключений контекста на тысячи ОС-потоков. Это решение проблемы C10k — «как обслужить 10 000+ соединений».
Подробно:
Представь веб-сервер на 10 000 одновременных клиентов, каждый в основном ждёт (сеть, БД):
- Модель «поток на соединение»: 10 000 ОС-потоков. Каждый поток ~512 КБ–8 МБ стека → гигабайты RAM. Переключение контекста между ними делает ядро ОС — это дорогие syscalls. Не масштабируется.
- Асинхронная модель: 1 поток + event loop. Когда корутина ждёт I/O, она «отдаёт управление» (
await), loop переключается на другую готовую корутину. Переключение — это просто вызов функции в user-space, не syscall. Память на корутину — единицы КБ.
Ключевые идеи:
- Кооперативная многозадачность: задачи сами добровольно отдают управление в точках
await(в отличие от вытесняющей у потоков, где ОС прерывает в любой момент). - Экономия: нет тысяч стеков потоков, нет дорогих переключений ядра, нет локов на каждый shared-объект (внутри одного потока гонок «между инструкциями» нет... но есть «между await», см. ниже).
- Предсказуемость: код переключается только на
await, легче рассуждать о критических секциях.
import asyncio
async def handle(client_id):
await asyncio.sleep(1) # имитация ожидания сети; loop делает другое
return client_id
async def main():
# 10 000 «соединений» в одном потоке
results = await asyncio.gather(*(handle(i) for i in range(10_000)))
print(len(results)) # ~1 секунда, не 10000 потоков
asyncio.run(main())
💡 Async — это про масштабирование I/O-конкурентности, а не про скорость вычислений.
78Как один поток обслуживает тысячи соединений?
senior
Короткий ответ: Потому что соединения почти всё время ждут, а не вычисляют. Event loop через механизм ОС (epoll/kqueue/IOCP) одновременно следит за всеми сокетами и активирует только те корутины, чьи данные готовы. Один CPU успевает «обслужить» переключения между тысячами ожидающих задач.
Подробно:
Под капотом — мультиплексирование I/O (selectors модуль → epoll на Linux). Вместо «поток ждёт один сокет» — «один поток через epoll_wait ждёт сразу 10 000 сокетов и получает список тех, что готовы»:
loop: epoll_wait(все сокеты) -> [готовы сокеты 3, 17, 952]
-> разбудить корутины, привязанные к 3, 17, 952
-> исполнить их до следующего await
-> снова epoll_wait
Это работает, пока обработка одного события короткая и не блокирующая. Узкое место — не количество соединений, а суммарная CPU-работа на одном ядре.
⚠️ Ловушка: «один поток» = одно ядро. Для CPU-нагрузки или чтобы занять все ядра запускают несколько процессов с event loop (например, gunicorn/uvicorn с N воркерами).
79Из чего состоит asyncio?
junior
Короткий ответ: Event loop — планировщик, крутящий задачи. Корутина — функция async def, которую можно приостанавливать. await — точка приостановки/возобновления. Task — корутина, запланированная на исполнение в loop.
Подробно:
import asyncio
async def fetch(name, delay):
print(f"{name}: старт")
await asyncio.sleep(delay) # отдаём управление loop
print(f"{name}: готово")
return name
async def main():
# запускаем конкурентно
results = await asyncio.gather(
fetch("A", 2),
fetch("B", 1),
)
print(results)
asyncio.run(main()) # создаёт loop, гоняет main, закрывает loop
async defсоздаёт корутину-функцию; её вызов возвращает объект-корутину (ничего не выполняется до await/запуска в loop).asyncio.run(coro)— точка входа: создаёт event loop, выполняет, закрывает.
⚠️ Ловушка: вызов fetch("A", 2) без await/create_task ничего не запускает и выдаст RuntimeWarning: coroutine was never awaited.
80Что именно делает оператор `await`?
middle
Короткий ответ: await приостанавливает текущую корутину и возвращает управление event loop до тех пор, пока ожидаемый объект (awaitable) не завершится. Loop в это время исполняет другие задачи.
Подробно:
await X требует, чтобы X был awaitable: корутина, Task, Future или объект с __await__. Механика: корутина приостанавливается, её состояние сохраняется (как у генератора), loop планирует возобновление, когда awaitable готов.
async def main():
data = await fetch_data() # пауза тут, loop работает дальше
process(data) # продолжение после готовности
Важно: await не делает код параллельным сам по себе. await a(); await b() — последовательно. Для конкурентности нужны gather/create_task:
# последовательно (3 сек):
await sleep3(); await sleep2()
# конкурентно (3 сек = max):
await asyncio.gather(sleep3(), sleep2())
⚠️ Ловушка: await уступает управление, поэтому между чтением общего состояния и последующей записью после await другая корутина могла это состояние изменить. Так возникают гонки даже в однопоточном event loop; критическую последовательность защищают asyncio.Lock или перепроектируют так, чтобы один task владел изменяемым состоянием.
81В чём разница между корутиной, Task и Future?
middle
Короткий ответ: Корутина — описание работы, само по себе не выполняется. Future — низкоуровневый «контейнер для будущего результата». Task — подкласс Future, который оборачивает корутину и планирует её исполнение в event loop (запускается немедленно/конкурентно).
Подробно:
import asyncio
async def work():
await asyncio.sleep(1); return 42
async def main():
coro = work() # корутина — НЕ запущена
task = asyncio.create_task(coro) # Task — запланирована и уже бежит
print(isinstance(task, asyncio.Future)) # True (Task ⊂ Future)
result = await task # дожидаемся результата
print(result)
asyncio.run(main())
- Future — обещание результата, обычно создаёт библиотека/loop (
loop.create_future()), редко вручную. Имеетset_result/set_exception. - Task = Future + drive корутины. Создаётся
create_task/ensure_future. - Корутина начинает реально работать только когда обёрнута в Task или ожидается через
await/gather.
💡 Аналогия: корутина — рецепт; Task — повар, которому дали рецепт и сказали готовить сейчас; Future — пустая тарелка, куда придёт блюдо.
⚠️ Ловушка: create_task запускает корутину немедленно (на следующем шаге loop), а просто вызов work() — нет.
82Чем отличаются gather, create_task и run?
middle
Короткий ответ: run — точка входа (один раз на программу). create_task — запланировать корутину конкурентно и получить Task. gather — запустить несколько awaitable конкурентно и дождаться всех, собрав результаты.
Подробно:
import asyncio
async def task(n):
await asyncio.sleep(n)
return n
async def main():
# 1) create_task — запускаем сразу, можем await позже
t1 = asyncio.create_task(task(2))
t2 = asyncio.create_task(task(1))
await t1; await t2 # обе уже бежали конкурентно -> ~2с
# 2) gather — конкурентно + сбор результатов (по порядку аргументов)
results = await asyncio.gather(task(2), task(1)) # [2, 1]
# 3) gather с обработкой ошибок
res = await asyncio.gather(task(1), task(2), return_exceptions=True)
asyncio.run(main()) # 4) точка входа
gather(return_exceptions=False)(по умолчанию) — первое исключение всплывает наверх, остальные таски продолжают выполняться (но их результаты теряются).- В 3.11+ есть
asyncio.TaskGroup— современная заменаgatherсо структурной конкурентностью (автоотмена при ошибке):
async with asyncio.TaskGroup() as tg:
tg.create_task(task(1))
tg.create_task(task(2))
# при выходе из блока ждёт все; при ошибке отменяет остальные
⚠️ Ловушка: нельзя вызывать asyncio.run() внутри уже работающего loop (например, в Jupyter) — будет RuntimeError: asyncio.run() cannot be called from a running event loop.
83Как работает event loop изнутри?
senior
Короткий ответ: Это бесконечный цикл в одном потоке: берёт готовые к выполнению задачи (callbacks), исполняет их до следующего await, опрашивает ОС о готовности I/O (selector.select), планирует таймеры. Многозадачность кооперативная — задача сама уступает на await.
Подробно:
Упрощённая логика одной итерации loop:
while running:
1. now = time()
2. выполнить просроченные таймеры (call_later/sleep)
3. timeout = время до ближайшего таймера
4. events = selector.select(timeout) # epoll/kqueue: ждём готовности I/O
5. для каждого готового события -> запланировать его callback
6. выполнить все готовые callbacks (по одному, до их следующего await)
Ключевое:
- Однопоточность: в каждый момент исполняется ровно одна корутина → внутри отрезка между
awaitгонок нет, локи внутри loop часто не нужны. - Кооперативность: loop не может прервать корутину — он ждёт, пока та сама дойдёт до
await. Если корутина не дойдёт — loop встанет.
💡 Реализация loop: стандартная на чистом Python; uvloop — реализация на Cython поверх libuv, в 2–4 раза быстрее.
⚠️ Ловушка: долгий чисто-вычислительный код между await блокирует весь loop — все остальные соединения «замораживаются».
84Что произойдёт, если вызвать блокирующую функцию в корутине, и как это исправить?
senior
Короткий ответ: Блокирующий вызов (requests.get, time.sleep, тяжёлый CPU-цикл) занимает единственный поток loop и не отдаёт управление → все корутины замирают. Решение — вынести блокирующий код в пул потоков/процессов через loop.run_in_executor или asyncio.to_thread.
Подробно:
import asyncio, time, requests
# ❌ ПЛОХО: блокирует весь loop на 5 секунд
async def bad():
time.sleep(5) # синхронный sleep — НЕ отдаёт loop
requests.get("https://example.com") # блокирующий HTTP
# ✅ ХОРОШО
async def good():
await asyncio.sleep(5) # асинхронный sleep
# блокирующую либу — в поток:
data = await asyncio.to_thread(requests.get, "https://example.com") # 3.9+
# или старый способ:
loop = asyncio.get_running_loop()
data = await loop.run_in_executor(None, requests.get, "https://example.com")
asyncio.to_thread(fn, *args)— выполнить блокирующую функцию вThreadPoolExecutor, не блокируя loop. Для I/O-bound блокирующих либ.run_in_executor(executor, fn, ...)— то же, можно передатьProcessPoolExecutorдля CPU-bound.
⚠️ Ловушка: самая частая боль на проде — кто-то вставил синхронный ORM/requests/time.sleep в async-эндпоинт, и latency всего сервиса взлетела. Используйте async-драйверы (aiohttp, asyncpg) или to_thread.
85Почему asyncio бесполезен для CPU-bound задач?
senior
Короткий ответ: asyncio — однопоточный. Он переключает задачи только в точках await, которые есть у I/O. Чистые вычисления await-ов не содержат, выполняются в одном потоке на одном ядре — конкуренции нет, ускорения нет (а оверхед есть).
Подробно:
CPU-bound код не «ждёт» — ему нечего ждать, он считает. Между вычислениями нет естественных точек уступки. Даже если расставить await asyncio.sleep(0), это лишь добавит накладные на переключения, но всё равно один поток = одно ядро.
# Это НЕ станет быстрее с asyncio:
async def crunch(n):
return sum(i*i for i in range(n)) # нет await, чистый CPU
# Для CPU-bound в async-приложении:
async def main():
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
result = await loop.run_in_executor(pool, blocking_crunch, 10**7)
Сравнительная таблица «что чем ускорять»:
| Тип нагрузки | Правильный инструмент |
|---|---|
| CPU-bound (вычисления) | multiprocessing / ProcessPoolExecutor |
| I/O-bound, async-либы | asyncio |
| I/O-bound, блокирующие либы | threading / ThreadPoolExecutor / to_thread |
💡 Запоминалка: asyncio ускоряет ожидание, а не вычисление.
⚠️ Ловушка: не «оборачивать» CPU-функцию в async def, надеясь на ускорение — это лишь маскирует синхронный код.
86Что такое async-генераторы, async-контекстные менеджеры и async for?
middle
Короткий ответ: Это асинхронные версии итераторов и контекстных менеджеров: async def + yield создаёт async-генератор, который можно перебирать через async for; __aenter__/__aexit__ дают async with.
Подробно:
import asyncio
# async-генератор: yield + await внутри
async def fetch_pages(urls):
for url in urls:
await asyncio.sleep(0.1) # имитация запроса
yield f"данные {url}"
async def main():
async for page in fetch_pages(["a", "b", "c"]):
print(page)
# async comprehension
pages = [p async for p in fetch_pages(["x", "y"])]
asyncio.run(main())
Async context manager — например, асинхронное соединение с БД/HTTP-сессией:
class AsyncResource:
async def __aenter__(self):
await asyncio.sleep(0.1) # асинхронное открытие
return self
async def __aexit__(self, exc_type, exc, tb):
await asyncio.sleep(0.1) # асинхронное закрытие
async def use():
async with AsyncResource() as r: # await на входе и выходе
...
async for использует протокол __aiter__/__anext__ (с StopAsyncIteration). async with — __aenter__/__aexit__.
⚠️ Ловушка: нельзя использовать async for/async with вне async def. И обычный for по async-генератору не сработает.
87Зачем нужны asyncio.Lock/Semaphore/Queue, если loop однопоточный?
middle
Короткий ответ: Потому что между двумя await другая корутина может вклиниться и изменить общее состояние. asyncio.Lock защищает критическую секцию, охватывающую await. Semaphore ограничивает конкурентность, Queue — потокобезопасный (точнее, loop-safe) обмен.
Подробно:
import asyncio
lock = asyncio.Lock()
balance = 100
async def withdraw(amount):
global balance
async with lock: # не отпустит до выхода из блока
if balance >= amount:
await asyncio.sleep(0.01) # точка переключения внутри секции!
balance -= amount
Без лока между проверкой balance >= amount и списанием другая корутина могла бы «пройти» проверку → перерасход.
Semaphore — ограничить число одновременных операций (например, не больше 10 запросов к API):
sem = asyncio.Semaphore(10)
async def fetch(url):
async with sem: # максимум 10 одновременно
return await http_get(url)
asyncio.Queue — producer/consumer без блокировок:
queue = asyncio.Queue(maxsize=100)
async def producer():
await queue.put(item)
async def consumer():
item = await queue.get(); queue.task_done()
⚠️ Ловушка: не путать asyncio.Lock с threading.Lock — они НЕ взаимозаменяемы. threading.Lock в корутине заблокирует весь loop; asyncio.Lock нельзя использовать между потоками.
88Как связаны корутины и генераторы?
middle
Короткий ответ: Корутины исторически выросли из генераторов: оба умеют приостанавливаться и сохранять состояние. До Python 3.5 корутины писали как генераторы с yield from и декоратором @asyncio.coroutine. Сейчас async/await — отдельный синтаксис, но механика приостановки общая.
Подробно:
Генератор приостанавливается на yield, корутина — на await. Оба хранят свой фрейм (локальные переменные, позицию) между возобновлениями. await под капотом близок к yield from.
# Старый стиль (до 3.5), исторически:
import asyncio
@asyncio.coroutine
def old_style():
yield from asyncio.sleep(1) # как await
# Современный (3.5+):
async def new_style():
await asyncio.sleep(1)
Различия:
- Генератор реализует
__iter__/__next__, его двигаютnext()/for. - Корутина реализует
__await__, её двигает event loop через.send(). async defсyield= async-генератор (отдельная сущность).
💡 На собесе часто: «корутина — это генератор?» — Концептуально родственны (оба suspendable), но в современном Python это разные типы. await ≈ механика yield from.
89Почему нельзя писать await в обычной функции и как «склеивать» sync и async?
middle
Короткий ответ: await разрешён только внутри async def — синтаксическая ошибка иначе. Из синхронного кода в async входят через asyncio.run(); из async в блокирующий sync — через run_in_executor/to_thread.
Подробно:
# ❌ SyntaxError: 'await' outside async function
def sync_fn():
await something()
# ✅ Из sync в async — точка входа:
def main():
result = asyncio.run(async_fn()) # блокирует поток, гоняет loop
# ✅ Из async в блокирующий sync:
async def async_fn():
await asyncio.to_thread(blocking_io)
«Function coloring»: async «заражает» вызывающий код — чтобы вызвать async-функцию, нужно либо быть в async-контексте, либо запустить loop. Нельзя «просто вызвать» корутину из обычного кода и получить результат — получите объект-корутину.
⚠️ Ловушка: нельзя запускать asyncio.run() из уже работающего loop. Для смешивания в Jupyter/во вложенных loop используют nest_asyncio (костыль) или рефакторинг. Также нельзя loop.run_until_complete() внутри корутины.
90Что такое gevent и greenlet?
junior
Короткий ответ: greenlet — библиотека «зелёных потоков» (легковесных корутин с ручным переключением). gevent — фреймворк поверх greenlet, который через monkey-patching делает блокирующие библиотеки неблокирующими, реализуя асинхронность без async/await.
Подробно:
import gevent
from gevent import monkey
monkey.patch_all() # подменяет socket, time.sleep и т.п. на неблокирующие
import requests # теперь requests «асинхронен» прозрачно
def fetch(url):
return requests.get(url).status_code
jobs = [gevent.spawn(fetch, u) for u in urls]
gevent.joinall(jobs)
Отличия от asyncio:
- gevent — неявная кооперативность: переключение спрятано в пропатченных функциях, обычный синхронный код «магически» становится конкурентным. Плюс — не нужно переписывать на
async; минус — неявность, сложнее отлаживать. - asyncio — явная: точки переключения видны по
await.
💡 До появления asyncio (Python 3.4/3.5) gevent был основным способом высоконагруженного I/O. Сейчас новые проекты обычно идут на asyncio, но gevent ещё широко в legacy (gunicorn с gevent-воркерами).
91Как выбрать между threading, multiprocessing и asyncio?
senior
Короткий ответ: CPU-bound → multiprocessing. I/O-bound с тысячами соединений и async-либами → asyncio. I/O-bound с блокирующими библиотеками или умеренной конкурентностью → threading.
Подробно — дерево решений:
Задача CPU-bound (вычисления, хеши, ML, обработка изображений)?
├── ДА -> multiprocessing / ProcessPoolExecutor (обход GIL, параллелизм по ядрам)
└── НЕТ (I/O-bound: сеть, диск, БД)
├── Есть async-библиотеки (aiohttp, asyncpg) и/или нужны тысячи соединений?
│ └── asyncio (один поток, дёшево, масштабируемо)
└── Только блокирующие либы (requests, sync-драйверы) и соединений сотни?
└── threading / ThreadPoolExecutor (потоки отпускают GIL на I/O)
Сводная таблица:
| Критерий | threading | multiprocessing | asyncio |
|---|---|---|---|
| Параллелизм CPU | ❌ (GIL) | ✅ | ❌ |
| Хорош для | I/O-bound, блокир. либы | CPU-bound | I/O-bound, много соединений |
| Память на единицу | КБ-МБ (стек) | десятки МБ (процесс) | КБ (корутина) |
| Масштаб | сотни потоков | ~ число ядер | десятки/сотни тысяч |
| Переключение | вытесняющее (ОС) | вытесняющее (ОС) | кооперативное (на await) |
| Гонки | да (нужны локи) | нет (изоляция) | да (между await) |
| Обмен данными | общая память + локи | pickle/IPC/shm | общая память (1 поток) |
💡 Гибриды реальны: asyncio + ProcessPoolExecutor для CPU-частей; несколько процессов, в каждом event loop (uvicorn --workers N).
92Сколько стоят потоки, процессы и корутины?
middle
Короткий ответ: Корутины — самые дешёвые (переключение в user-space, ~КБ памяти). Потоки — дороже (переключение через ядро ОС, КБ–МБ стека). Процессы — самые дорогие (отдельное адресное пространство, IPC, старт миллисекунды).
Подробно:
| Старт | Память | Стоимость переключения | Сколько разумно | |
|---|---|---|---|---|
| Корутина | ~мкс | ~КБ | вызов функции (user-space) | 10⁴–10⁶ |
| Поток | ~десятки мкс | КБ–МБ (стек) | context switch ядра (~мкс) | 10²–10³ |
| Процесс | мс–десятки мс | МБ | context switch + TLB flush | ~ число ядер |
Почему важно:
- Context switch потока = переход в ядро, сохранение регистров, возможный сброс кэшей. На тысячах потоков накапливается заметный оверхед.
- Корутина переключается внутри одного потока — это просто сохранение/восстановление фрейма Python, без участия ядра.
- Процесс при
spawnзаново импортирует модули, приforkиспользует copy-on-write (дешевле, но страницы копируются при записи).
💡 Поэтому при 10 000 соединений asyncio обходит «поток-на-соединение» на порядок по памяти и переключениям, но при этом сам по себе НЕ ускоряет вычисления.
⚠️ Ловушка: слишком много потоков/процессов = деградация из-за переключений и борьбы за ресурсы. Размер пула подбирают: процессы ≈ число ядер, потоки для I/O — экспериментально (десятки-сотни).
931. Зачем нужны type hints, если Python динамический?
concept
Короткий ответ: Аннотации типов не проверяются интерпретатором в рантайме — они нужны для статического анализа (mypy, pyright), автодополнения в IDE, документации и раннего обнаружения ошибок до запуска кода. Python остаётся динамическим, hints — это «контракт», который проверяют внешние инструменты.
Подробно:
Type hints (PEP 484) не влияют на выполнение программы. Интерпретатор их игнорирует — можно передать строку туда, где ожидается int, и код не упадёт сам по себе.
def add(a: int, b: int) -> int:
return a + b
# Python НЕ проверяет типы — это выполнится без ошибок:
print(add("a", "b")) # "ab" — конкатенация строк!
# Но mypy выдаст ошибку ДО запуска:
# error: Argument 1 to "add" has incompatible type "str"; expected "int"
Зачем тогда нужны:
- Раннее обнаружение ошибок —
mypy/pyrightловят несовпадения типов до прода. - Автодополнение и навигация в IDE (PyCharm, VS Code).
- Документация — сигнатура
def f(x: list[int]) -> dict[str, int]понятнее комментария. - Рефакторинг — при изменении типа инструмент покажет все места, где он используется.
- Контракты в больших командах — снижают «неявные» договорённости.
# Без hints — непонятно, что ожидается:
def process(data, config): ...
# С hints — самодокументируемо:
def process(data: list[dict[str, int]], config: "Config") -> bool: ...
⚠️ Ловушка: Аннотации — не валидация. def f(x: int) не запретит передать строку в рантайме. Если нужна именно проверка значений на входе, используйте pydantic / явные assert / ручные проверки. Hints != runtime validation.
💡 Формула для интервью: «Type hints — инструмент статического анализа и читаемости; Python остаётся duck-typed и динамическим, проверку выполняют mypy/IDE, а не сам интерпретатор».
942. Optional, Union и синтаксис `|`
middle
Короткий ответ: Optional[X] — это Union[X, None], то есть «X или None». Union[A, B] — «либо A, либо B». С Python 3.10+ есть короткий синтаксис X | None и A | B.
Подробно:
from typing import Optional, Union
# Старый синтаксис
def find_user(uid: int) -> Optional[str]: # str | None
return None
def parse(x: Union[int, str]) -> int: # int или str на вход
return int(x)
# Python 3.10+ — оператор | (PEP 604)
def find_user2(uid: int) -> str | None: ...
def parse2(x: int | str) -> int: ...
Optional[X] НЕ означает «аргумент необязателен» — он означает, что значение может быть None. Необязательность задаётся значением по умолчанию.
# Optional про тип, не про обязательность:
def f(x: Optional[int]) -> None: ... # x ОБЯЗАТЕЛЕН, но может быть None
f() # ошибка — аргумент не передан
f(None) # ок
f(5) # ок
# Необязательный аргумент:
def g(x: int | None = None) -> None: ... # типичный паттерн
Сужение типа (narrowing) — mypy понимает проверки на None:
def greet(name: str | None) -> str:
if name is None:
return "Hello, stranger"
return f"Hello, {name}" # здесь name уже str, .upper() и т.п. доступны
⚠️ Ловушка: Мутабельный дефолт def f(items: list[int] = []) — общий список на все вызовы (классический баг). Используйте def f(items: list[int] | None = None) и внутри items = items or [].
⚠️ Ловушка: Не путайте Optional[X] с «необязательностью» — это про допустимость None, а не про наличие аргумента.
953. `List`/`Dict` vs `list`/`dict`, `Any`
middle
Короткий ответ: До Python 3.9 для аннотаций нужны были typing.List, typing.Dict. С 3.9+ можно использовать встроенные list[int], dict[str, int] напрямую (PEP 585). Any отключает проверку типов — «всё разрешено».
Подробно:
# Старый стиль (Python < 3.9) — импорт из typing
from typing import List, Dict, Tuple, Set
def f(x: List[int], y: Dict[str, int]) -> Tuple[int, ...]: ...
# Современный (Python 3.9+) — generic-встроенные типы
def f(x: list[int], y: dict[str, int]) -> tuple[int, ...]: ...
Сейчас typing.List и т.п. помечены как deprecated — предпочтителен встроенный синтаксис.
Any — «эскейп-хэтч»: значение совместимо с любым типом в обе стороны, проверки отключаются.
from typing import Any
def parse(data: Any) -> Any:
return data.whatever() # mypy НЕ ругается — Any "проглатывает" всё
x: Any = get_value()
x.foo().bar[0] # никаких ошибок типов
Any vs object:
Any— отключает проверки; можно вызывать любые методы.object— базовый тип всего, но почти ничего нельзя делать безcast/проверки (типобезопасно).
def f(x: object) -> None:
x.upper() # mypy: error — у object нет .upper()
if isinstance(x, str):
x.upper() # ок после narrowing
⚠️ Ловушка: Any заразен — он «протекает» через выражения и тихо отключает проверку в большой части кода. Используйте точечно; для «неизвестно что, но безопасно» предпочитайте object + isinstance.
964. `Callable`, `TypeVar`, `Generic`
senior
Короткий ответ: Callable[[Args], Ret] описывает функцию/вызываемый объект. TypeVar — типовая переменная для обобщённых (generic) функций, сохраняющая связь типов вход/выход. Generic[T] — базовый класс для создания обобщённых классов.
Подробно:
Callable:
from typing import Callable
# функция, принимающая int и str, возвращающая bool
handler: Callable[[int, str], bool]
def apply(fn: Callable[[int], int], value: int) -> int:
return fn(value)
apply(lambda x: x * 2, 5) # 10
# любые аргументы:
cb: Callable[..., None]
TypeVar — связывает типы, чтобы они «текли» через функцию:
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T: # тип элемента = тип результата
return items[0]
x = first([1, 2, 3]) # x: int
y = first(["a", "b"]) # y: str
# Ограниченный TypeVar (только эти типы):
Num = TypeVar("Num", int, float)
def double(x: Num) -> Num:
return x * 2
# Связанный (bound) — подтипы:
from numbers import Number
N = TypeVar("N", bound=Number)
Generic — обобщённые классы:
from typing import Generic, TypeVar
T = TypeVar("T")
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
s: Stack[int] = Stack()
s.push(1)
v = s.pop() # v: int
В Python 3.12+ есть новый синтаксис (PEP 695) без явного TypeVar:
def first[T](items: list[T]) -> T: # 3.12+
return items[0]
class Stack[T]: # 3.12+
...
⚠️ Ловушка: Один TypeVar в сигнатуре связывает все вхождения — def f(a: T, b: T) -> T требует, чтобы a и b были одного типа. Если нужны независимые — заведите два TypeVar.
975. `Protocol` — структурная типизация
senior
Короткий ответ: Protocol (PEP 544) задаёт «утиную типизацию» статически: класс подходит, если у него есть нужные методы/атрибуты, без явного наследования. Это формализация duck typing для mypy.
Подробно:
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> str: ...
class Circle: # НЕ наследует Drawable явно
def draw(self) -> str:
return "○"
class Square:
def draw(self) -> str:
return "□"
def render(shape: Drawable) -> None: # принимает всё, что умеет draw()
print(shape.draw())
render(Circle()) # ок — структурно совместим
render(Square()) # ок
Это отличается от наследования (номинальной типизации): объекту не нужно знать о протоколе. Удобно для «интерфейсов» сторонних классов.
runtime_checkable — позволяет isinstance с протоколом (проверяет только наличие методов, не сигнатуры):
from typing import Protocol, runtime_checkable
@runtime_checkable
class Sized(Protocol):
def __len__(self) -> int: ...
isinstance([1, 2, 3], Sized) # True
⚠️ Ловушка: runtime_checkable проверяет только наличие атрибутов с нужными именами, но НЕ их сигнатуры и не типы. isinstance(obj, MyProto) может вернуть True для несовместимого по сигнатуре объекта.
💡 Protocol — про «если выглядит как утка». Используйте, когда не можете/не хотите менять иерархию классов, но хотите статически гарантировать наличие методов.
986. `Literal`, `TypedDict`, `Final`, `cast`
senior
Короткий ответ: Literal — конкретные значения как тип (Literal["GET", "POST"]). TypedDict — словарь с типизированными ключами. Final — константа, которую нельзя переприсваивать. cast — подсказка mypy «доверься мне, это тип X» без рантайм-проверки.
Подробно:
Literal — ограничивает значения:
from typing import Literal
def request(method: Literal["GET", "POST", "PUT"]) -> None: ...
request("GET") # ок
request("FETCH") # mypy: error — недопустимое значение
Mode = Literal["r", "w", "a"]
TypedDict — структура словаря:
from typing import TypedDict
class User(TypedDict):
id: int
name: str
email: str
u: User = {"id": 1, "name": "Ann", "email": "a@x.io"}
u["id"] # mypy знает, что это int
# Необязательные ключи:
class Config(TypedDict, total=False):
timeout: int # может отсутствовать
Final — запрет переприсваивания (проверяет mypy):
from typing import Final
MAX_SIZE: Final = 100
MAX_SIZE = 200 # mypy: error — Cannot assign to final name
class C:
PI: Final[float] = 3.14
cast — переопределение типа для mypy (без проверки в рантайме):
from typing import cast
data = get_json() # тип: Any
user = cast(User, data) # говорим mypy: считать это User
# В рантайме cast НИЧЕГО не делает — просто возвращает значение
⚠️ Ловушка: cast не проверяет значение в рантайме — это просто подсказка для статического анализатора. Если объект на самом деле не того типа, вы получите ошибку позже, в неожиданном месте. Не злоупотребляйте — это способ «обмануть» mypy.
⚠️ Ловушка: TypedDict всё ещё обычный dict в рантайме — никакой валидации значений нет (в отличие от pydantic).
997. typing vs runtime — влияют ли аннотации на выполнение?
middle
Короткий ответ: В обычном коде аннотации не влияют на выполнение — они хранятся в __annotations__ и не проверяются. Но некоторые инструменты (pydantic, dataclasses, FastAPI) читают аннотации в рантайме и используют их для генерации логики/валидации.
Подробно:
def f(x: int) -> str:
return str(x)
print(f.__annotations__) # {'x': <class 'int'>, 'return': <class 'str'>}
# Интерпретатор хранит, но не проверяет
Аннотации становятся «строками» при from __future__ import annotations (PEP 563) — ленивое вычисление, помогает с forward references и circular imports:
from __future__ import annotations
class Node:
def __init__(self, next: Node | None = None): # Node ещё не определён — ок,
self.next = next # аннотация хранится как строка
Кто читает аннотации в рантайме:
- dataclasses — генерируют
__init__по аннотированным полям. - pydantic — валидирует значения по типам.
- FastAPI — строит парсинг/валидацию запросов.
- typing.get_type_hints() — резолвит аннотации (включая строковые).
from dataclasses import dataclass
@dataclass
class Point:
x: int # аннотация ОБЯЗАТЕЛЬНА — dataclass без неё не создаст поле
y: int
# Здесь аннотации реально влияют на сгенерированный __init__
⚠️ Ловушка: Поле dataclass без аннотации НЕ становится полем — z = 0 (без z: int = 0) будет просто атрибутом класса. dataclass смотрит именно на __annotations__.
⚠️ Ловушка: С from __future__ import annotations аннотации становятся строками — obj.__annotations__["x"] вернёт "int", а не класс. Для резолва используйте typing.get_type_hints().
1008. `@dataclass`: field, frozen, default_factory, `__post_init__`
middle
Короткий ответ: @dataclass автогенерирует __init__, __repr__, __eq__ по аннотированным полям. field() настраивает поле (например, default_factory для мутабельных дефолтов). frozen=True делает экземпляр неизменяемым. __post_init__ — хук после автогенерированного __init__.
Подробно:
from dataclasses import dataclass, field
@dataclass
class Point:
x: int
y: int = 0 # дефолт
p = Point(1, 2)
print(p) # Point(x=1, y=2) -- авто-repr
print(p == Point(1, 2)) # True -- авто-eq
default_factory — для мутабельных дефолтов (списки, словари):
@dataclass
class Cart:
items: list[str] = field(default_factory=list) # НЕ list = []
a, b = Cart(), Cart()
a.items.append("x")
print(b.items) # [] — у каждого свой список
frozen — иммутабельность (можно использовать как ключ dict / в set):
@dataclass(frozen=True)
class Coord:
lat: float
lon: float
c = Coord(1.0, 2.0)
c.lat = 5.0 # FrozenInstanceError
{c} # хешируемый — можно в set
__post_init__ — валидация/доп. инициализация:
@dataclass
class Rectangle:
width: float
height: float
area: float = field(init=False) # не в __init__, считаем сами
def __post_init__(self) -> None:
if self.width <= 0:
raise ValueError("width must be positive")
self.area = self.width * self.height
Полезные параметры: @dataclass(frozen=True, slots=True, kw_only=True, order=True).
⚠️ Ловушка: field(default_factory=list) обязателен для мутабельных дефолтов — items: list = [] вызовет ValueError (dataclass это запрещает на уровне определения).
⚠️ Ловушка: frozen=True запрещает присваивание даже в __post_init__. Чтобы установить вычисляемое поле во frozen-классе, используйте object.__setattr__(self, "area", ...).
Источники
Источники и редакционная политика
Материалы RecallDeck сопоставлены с официальной документацией и открытыми публикациями компаний, когда первичный источник доступен. Мы не связаны с упомянутыми работодателями, не публикуем конфиденциальные задания и не продаём места в подборках. Формат найма может меняться — уточняйте его у рекрутера.
От чтения к воспроизведению
Отрепетируйте полный цикл интервью.
RecallDeck возвращает сложные темы по расписанию и помогает удерживать в памяти язык, SQL, архитектуру и поведенческие истории.