Python: 50 вопросов на собеседовании junior–middle
Разбор 50 реальных вопросов по Python для junior и middle: типы данных, ООП, GIL и память, asyncio, декораторы и структура проекта. Примеры кода и объяснения.
- 1. Типы данных
- Вопрос 1. Что такое mutable и immutable типы в Python?
- Вопрос 2. Когда использовать list, а когда tuple?
- Вопрос 3. Как устроен dict внутри? Какова сложность основных операций?
- Вопрос 4. Как работает set? Что значит «хэшируемый»?
- Вопрос 5. Что такое *args и **kwargs?
- Вопрос 6. Что такое ловушка изменяемого аргумента по умолчанию?
- Вопрос 7. Чем list comprehension лучше обычного цикла и когда он неуместен?
- Вопрос 8. Что такое расширенная распаковка и оператор walrus (:=)?
- 2. ООП
- Вопрос 9. Чем __init__ отличается от __new__?
- Вопрос 10. Наследование vs композиция — когда что выбрать?
- Вопрос 11. Что такое MRO и как работает множественное наследование?
- Вопрос 12. Какие dunder-методы важны и что они контролируют?
- Вопрос 13. Для чего нужен @property?
- Вопрос 14. Чем @classmethod отличается от @staticmethod?
- Вопрос 15. Что такое абстрактные базовые классы (ABC)?
- Вопрос 16. Что такое dataclasses и зачем они нужны?
- Вопрос 17. Что такое Mixin и как его правильно построить?
- Вопрос 18. Что такое дескрипторный протокол?
- 3. Функции, декораторы, генераторы
- Вопрос 19. Что такое замыкание (closure)?
- Вопрос 20. Как написать декоратор и зачем нужен functools.wraps?
- Вопрос 21. Как работает yield и чем генератор отличается от функции?
- Вопрос 22. Что делает yield from?
- Вопрос 23. Как работает протокол итератора?
- Вопрос 24. Для чего нужны functools.partial и functools.lru_cache?
- Вопрос 25. Когда использовать lambda, а когда она избыточна?
- 4. Память, GIL, производительность
- Вопрос 26. Как Python удаляет объекты? Что такое reference counting?
- Вопрос 27. Как Python находит циклические ссылки?
- Вопрос 28. Что такое GIL и почему он существует?
- Вопрос 29. Как обойти ограничения GIL?
- Вопрос 30. Когда threading полезен при наличии GIL?
- Вопрос 31. Что такое слабые ссылки (weakref)?
- Вопрос 32. Как __slots__ экономит память?
- Вопрос 33. Почему генераторы эффективнее списков для больших данных?
- Вопрос 34. Как профилировать потребление памяти в Python?
- Вопрос 35. Что такое интернирование строк и малых целых чисел?
- Вопрос 36. Чем copy.copy отличается от copy.deepcopy?
- 5. Асинхронное программирование
- Вопрос 37. Чем concurrency отличается от parallelism?
- Вопрос 38. Как работает event loop в asyncio?
- Вопрос 39. Что происходит под капотом при async def и await?
- Вопрос 40. Что такое asyncio.Task и как его создать?
- Вопрос 41. В чём разница между asyncio.gather и asyncio.wait?
- Вопрос 42. Как отменить asyncio-задачу и обработать CancelledError?
- Вопрос 43. Что такое асинхронные генераторы?
- Вопрос 44. Как вызвать блокирующий код из async-контекста?
- Вопрос 45. Когда что выбрать: asyncio, threading, multiprocessing?
- Вопрос 46. Какие типичные ошибки при работе с asyncio?
- 6. Структура проекта и инструменты
- Вопрос 47. Зачем нужны виртуальные окружения и чем они отличаются?
- Вопрос 48. Как управлять зависимостями в большом проекте?
- Вопрос 49. Зачем нужны type hints и что такое mypy?
- Вопрос 50. Как правильно настроить логирование в Python-приложении?
- Итоги
Python сейчас — один из самых востребованных языков в бэкенде: веб-фреймворки, ML-пайплайны, скрипты автоматизации, микросервисы на FastAPI. Рынок насыщен кандидатами, которые «знают Python», а значит планка на собеседовании выросла. Недостаточно уметь писать скрипты — интервьюер хочет понять, как именно вы думаете о памяти, многопоточности и архитектуре кода.
Интервью по Python коварно тем, что язык кажется простым. Это ловушка. За дружелюбным синтаксисом скрываются GIL, сборщик мусора с детектором циклов, event loop с кооперативной многозадачностью и дескрипторный протокол, на котором держится вся магия @property. Вопросы на middle-позицию именно про это — не «как написать for-цикл», а «почему твоя горутина... то есть, корутина, не завершается».
В этой статье разбираем 50 вопросов, которые реально встречаются на собеседованиях Python junior и middle: типы данных, ООП, функции и декораторы, управление памятью и GIL, asyncio, структура проекта. Каждый вопрос — с объяснением механики и примером кода.
Тренировка Python перед собеседованием
Реальные вопросы по asyncio, GIL и ООП. Разбор ответов и фидбек уровня junior/middle.
1. Типы данных
Вопрос 1. Что такое mutable и immutable типы в Python?
В Python каждый объект имеет тип, значение и идентификатор. Изменяемые (mutable) объекты позволяют менять своё содержимое без создания нового объекта — list, dict, set, bytearray. Неизменяемые (immutable) не могут изменить значение после создания — int, float, str, tuple, bytes, frozenset.
Это разграничение влияет на три вещи: хэшируемость (только неизменяемые могут быть ключами словаря), семантику передачи в функции и поведение при копировании.
a = [1, 2, 3]
b = a # b и a указывают на один объект
b.append(4)
print(a) # [1, 2, 3, 4] — изменился через b
x = (1, 2, 3)
y = x
# y += (4,) создаст новый кортеж, x не изменится
Что ждут на собеседовании: mutable — list, dict, set; immutable — int, str, tuple. Неизменяемые хэшируемы и могут быть ключами словаря. Присваивание переменной не копирует объект, а создаёт новую ссылку.
Вопрос 2. Когда использовать list, а когда tuple?
list — для коллекций однородных элементов, которые могут меняться: очередь задач, список пользователей. tuple — для гетерогенных структур с фиксированным значением: координата (x, y), возвращаемое значение из функции, запись в базе.
Tuple незначительно быстрее list при создании и итерации — CPython кэширует маленькие кортежи. Но важнее семантика: кортеж говорит читателю кода «этот набор значений не меняется».
# Семантически tuple уместнее
point = (53.9045, 27.5615) # широта, долгота Минска
rgb = (255, 128, 0)
# list — когда набор меняется
queue = [task1, task2, task3]
queue.pop(0)
Что ждут на собеседовании: tuple для неизменяемых структурированных данных и ключей словаря; list для динамических коллекций. Кортеж чуть быстрее из-за кэширования в CPython.
Вопрос 3. Как устроен dict внутри? Какова сложность основных операций?
dict в Python реализован как хэш-таблица с открытой адресацией. При вставке ключа CPython вычисляет hash(key), находит слот по формуле slot = hash % size, и при коллизии ищет следующий свободный слот по заранее определённому зондированию.
Начиная с Python 3.7 словарь гарантированно сохраняет порядок вставки — это следствие изменения внутреннего представления: отдельный компактный массив индексов + массив пар (ключ, значение).
d = {"a": 1, "b": 2}
print(hash("a")) # хэш строки
# Сложность операций:
# get/set/delete — O(1) амортизированно
# in — O(1)
# Итерация — O(n)
Коллизии делают операции O(n) в худшем случае, но на практике это не встречается при нормальном распределении хэшей.
Что ждут на собеседовании: хэш-таблица с открытой адресацией. get/set/del/in — O(1) амортизированно. С Python 3.7 порядок вставки сохраняется. Ключи должны быть хэшируемыми.
Вопрос 4. Как работает set? Что значит «хэшируемый»?
set — тоже хэш-таблица, но без значений. Только уникальные элементы, операции проверки вхождения за O(1). Операции над множествами (union, intersection, difference) работают за O(min(len(a), len(b))) или O(len(a) + len(b)) в зависимости от операции.
Хэшируемый объект — тот, у которого определён __hash__ и __eq__, причём если два объекта равны (a == b), у них одинаковый хэш. Именно поэтому list не может быть ключом словаря или элементом множества: список изменяем, его хэш нельзя гарантировать постоянным.
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print(a | b) # {1, 2, 3, 4, 5, 6} — объединение
print(a & b) # {3, 4} — пересечение
print(a - b) # {1, 2} — разность
print(a ^ b) # {1, 2, 5, 6} — симметричная разность
# frozenset — неизменяемый set, хэшируемый
fs = frozenset([1, 2, 3])
d = {fs: "value"} # можно как ключ
Что ждут на собеседовании: хэш-таблица без значений. Проверка вхождения O(1). Элементы должны быть хэшируемыми. frozenset — неизменяемая версия, можно использовать как ключ словаря.
Вопрос 5. Что такое *args и **kwargs?
*args собирает позиционные аргументы в кортеж. **kwargs — именованные аргументы в словарь. Оба работают как «поглотители» произвольного числа аргументов.
def log(level, *args, **kwargs):
print(f"[{level}]", *args)
for k, v in kwargs.items():
print(f" {k}={v}")
log("INFO", "user created", "extra", user_id=42, role="admin")
# [INFO] user created extra
# user_id=42
# role=admin
Те же звёздочки работают при вызове — для распаковки:
params = [1, 2, 3]
opts = {"sep": "-"}
print(*params, **opts) # 1-2-3
Что ждут на собеседовании: *args — tuple позиционных аргументов, **kwargs — dict именованных. Порядок в сигнатуре: обычные параметры → *args → keyword-only → **kwargs. Те же синтаксисы работают для распаковки при вызове.
Вопрос 6. Что такое ловушка изменяемого аргумента по умолчанию?
Значения аргументов по умолчанию вычисляются один раз — при определении функции, а не при каждом вызове. Если значение по умолчанию изменяемое, оно разделяется между всеми вызовами.
def append_to(element, to=[]): # список создаётся один раз!
to.append(element)
return to
print(append_to(1)) # [1]
print(append_to(2)) # [1, 2] — сюрприз
print(append_to(3)) # [1, 2, 3]
# Правильно — использовать None как sentinel
def append_to_fixed(element, to=None):
if to is None:
to = []
to.append(element)
return to
Это не баг Python — это намеренное поведение. Иногда оно полезно: functools.lru_cache внутри использует изменяемый кэш-словарь как дефолт. Но для большинства случаев — паттерн None-sentinel.
Что ждут на собеседовании: дефолты вычисляются при def, а не при вызове. Изменяемый дефолт — общий для всех вызовов. Решение: def f(x=None): if x is None: x = [].
Вопрос 7. Чем list comprehension лучше обычного цикла и когда он неуместен?
List comprehension читается как математическая нотация, выполняется быстрее цикла (CPython оптимизирует его в отдельный байткод) и создаёт список за один шаг. Аналогично для dict и set.
# Медленнее и многословнее
squares = []
for x in range(10):
if x % 2 == 0:
squares.append(x ** 2)
# Быстрее и читаемее
squares = [x ** 2 for x in range(10) if x % 2 == 0]
# Dict comprehension
word_len = {word: len(word) for word in ["hello", "world"]}
# Generator expression — вместо списка, когда нужна ленивость
total = sum(x ** 2 for x in range(10**6)) # не создаёт список в памяти
Comprehension неуместен, когда логика сложная (несколько вложенных условий), есть побочные эффекты (логирование, запись в файл), или когда нужна ленивая генерация — тогда лучше generator expression или явный цикл.
Что ждут на собеседовании: comprehension быстрее цикла + append. Generator expression (x for x in ...) — ленивый, O(1) памяти. При сложной логике или побочных эффектах — явный цикл читаемее.
Вопрос 8. Что такое расширенная распаковка и оператор walrus (:=)?
Расширенная распаковка (*) позволяет поймать «остаток» последовательности:
first, *rest = [1, 2, 3, 4, 5]
print(first) # 1
print(rest) # [2, 3, 4, 5]
head, *middle, tail = range(10)
print(head, tail) # 0 9
Walrus operator := (Python 3.8+) присваивает значение и возвращает его в одном выражении — полезен, чтобы не вычислять одно и то же дважды:
import re
data = ["user:42", "invalid", "user:99"]
results = [m.group(1) for s in data if (m := re.match(r"user:(\d+)", s))]
print(results) # ['42', '99']
# Или в while:
while chunk := file.read(8192):
process(chunk)
Что ждут на собеседовании: first, *rest = seq — расширенная распаковка. := (walrus) — присваивание внутри выражения, Python 3.8+. Часто применяется в while для чтения файлов и в comprehension для избежания двойного вычисления.
2. ООП
Вопрос 9. Чем __init__ отличается от __new__?
__new__ создаёт объект — выделяет память и возвращает новый экземпляр. __init__ инициализирует уже созданный объект — устанавливает атрибуты. Обычно переопределяют только __init__. __new__ нужен при наследовании от неизменяемых типов (str, tuple, int) или для паттерна Singleton.
class Singleton:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, value):
self.value = value
a = Singleton(1)
b = Singleton(2)
print(a is b) # True
print(a.value) # 2 — __init__ вызвался дважды
Что ждут на собеседовании: __new__ создаёт объект (вызывается первым), __init__ инициализирует. __new__ переопределяют для иммутабельных типов и Singleton. Если __new__ не возвращает экземпляр cls, __init__ не вызывается.
Вопрос 10. Наследование vs композиция — когда что выбрать?
Наследование выражает отношение «is-a» (кошка — это животное). Композиция — «has-a» (машина имеет двигатель). В Python, как и в большинстве современных языков, рекомендуется предпочитать композицию: она даёт меньшую связность, легче тестируется и не нарушается при изменении базового класса.
# Наследование: уместно, если EmailNotifier действительно IS-A Notifier
class Notifier:
def send(self, message: str) -> None: ...
class EmailNotifier(Notifier):
def send(self, message: str) -> None:
smtp.send(message)
# Композиция: Engine не IS-A Car, а часть Car
class Engine:
def start(self) -> None: ...
class Car:
def __init__(self):
self._engine = Engine()
def start(self) -> None:
self._engine.start()
Наследование создаёт жёсткую связь: изменение базового класса ломает потомков. Глубокие иерархии (5+ уровней) — признак проблемы в дизайне.
Что ждут на собеседовании: наследование — is-a; композиция — has-a. Предпочтительна композиция из-за меньшей связности. Глубокие иерархии — антипаттерн. Миксины — компромисс для переиспользования поведения без is-a.
Вопрос 11. Что такое MRO и как работает множественное наследование?
MRO (Method Resolution Order) — порядок, в котором Python ищет методы при наследовании. Для множественного наследования CPython использует алгоритм C3-линеаризации, гарантирующий, что каждый класс в иерархии встречается ровно один раз и сохраняется локальный порядок.
class A:
def method(self):
print("A")
class B(A):
def method(self):
print("B")
super().method()
class C(A):
def method(self):
print("C")
super().method()
class D(B, C):
pass
D().method()
# B → C → A
# MRO: D → B → C → A → object
print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)
super() работает с MRO, а не просто идёт к «родителю» — поэтому вызов super().method() в B передаёт управление C, не A. Это важно для корректной работы кооперативного множественного наследования.
Что ждут на собеседовании: C3-линеаризация. super() следует MRO, а не идёт к прямому родителю. ClassName.__mro__ показывает порядок. Ромбовидное наследование решается без дублирования вызовов.
Вопрос 12. Какие dunder-методы важны и что они контролируют?
Dunder (double underscore) методы — интерфейс объектной модели Python. Через них объект может вести себя как число, контейнер, контекстный менеджер или поддерживать сравнение.
class Money:
def __init__(self, amount: float, currency: str):
self.amount = amount
self.currency = currency
def __repr__(self) -> str:
return f"Money({self.amount}, '{self.currency}')"
def __str__(self) -> str:
return f"{self.amount} {self.currency}"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Money):
return NotImplemented
return self.amount == other.amount and self.currency == other.currency
def __hash__(self) -> int:
return hash((self.amount, self.currency))
def __add__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError("Currency mismatch")
return Money(self.amount + other.amount, self.currency)
def __lt__(self, other: "Money") -> bool:
return self.amount < other.amount
Если определён __eq__, нужно явно определить __hash__ — Python его обнулит, и объект не сможет быть ключом словаря. Также важны __enter__/__exit__ для контекстных менеджеров, __iter__/__next__ для итераторов.
Что ждут на собеседовании: __repr__ — для отладки, __str__ — для вывода пользователю. __eq__ + __hash__ — всегда вместе. __add__, __lt__ — арифметика и сравнение. Возврат NotImplemented позволяет Python попробовать обратную операцию.
Вопрос 13. Для чего нужен @property?
@property позволяет использовать метод как атрибут — без скобок при обращении. Это даёт возможность добавить валидацию, вычисление или логирование без изменения публичного API класса.
class Temperature:
def __init__(self, celsius: float):
self._celsius = celsius
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError("Ниже абсолютного нуля")
self._celsius = value
@property
def fahrenheit(self) -> float:
return self._celsius * 9 / 5 + 32
t = Temperature(25)
print(t.fahrenheit) # 77.0
t.celsius = -300 # ValueError
Без @celsius.setter атрибут только для чтения. Можно добавить @celsius.deleter для удаления. Важно: @property — это дескриптор под капотом.
Что ждут на собеседовании: @property — геттер без скобок. @x.setter — валидация при присваивании. Без сеттера — read-only. Позволяет изменить реализацию без изменения публичного интерфейса (инкапсуляция).
Вопрос 14. Чем @classmethod отличается от @staticmethod?
@classmethod получает класс (cls) первым аргументом — полезен для фабричных методов и альтернативных конструкторов. @staticmethod не получает ни self, ни cls — это обычная функция в пространстве имён класса, логически связанная с ним.
class Date:
def __init__(self, year: int, month: int, day: int):
self.year, self.month, self.day = year, month, day
@classmethod
def from_string(cls, s: str) -> "Date":
y, m, d = map(int, s.split("-"))
return cls(y, m, d) # cls, а не Date — работает при наследовании
@staticmethod
def is_valid_year(year: int) -> bool:
return 1 <= year <= 9999
d = Date.from_string("2026-03-01")
print(Date.is_valid_year(2026)) # True
Ключевое преимущество @classmethod над @staticmethod: использование cls вместо жёстко заданного имени класса — корректно работает при наследовании.
Что ждут на собеседовании: @classmethod — первый аргумент cls, фабричные методы и альтернативные конструкторы. @staticmethod — нет ни self, ни cls, утилитарная функция в пространстве класса. @classmethod полиморфен при наследовании.
Вопрос 15. Что такое абстрактные базовые классы (ABC)?
ABC — механизм объявления интерфейса, который обязаны реализовать подклассы. Попытка создать экземпляр класса с нереализованными абстрактными методами вызовет TypeError.
from abc import ABC, abstractmethod
class Storage(ABC):
@abstractmethod
def save(self, key: str, value: bytes) -> None: ...
@abstractmethod
def load(self, key: str) -> bytes: ...
def exists(self, key: str) -> bool: # не абстрактный — есть реализация
try:
self.load(key)
return True
except KeyError:
return False
class RedisStorage(Storage):
def save(self, key: str, value: bytes) -> None:
redis.set(key, value)
def load(self, key: str) -> bytes:
return redis.get(key)
# s = Storage() # TypeError: Can't instantiate abstract class
s = RedisStorage() # OK
ABC в Python — не жёсткий контракт как интерфейсы в Java, но они сигнализируют намерение и страхуют от случайного забывания методов.
Что ждут на собеседовании: ABC + @abstractmethod. Экземпляр нельзя создать без реализации всех абстрактных методов. Обычные методы могут иметь реализацию в ABC. Protocol из typing — структурная альтернатива без наследования.
Вопрос 16. Что такое dataclasses и зачем они нужны?
@dataclass автоматически генерирует __init__, __repr__ и __eq__ из аннотаций атрибутов. Это избавляет от шаблонного кода при описании простых классов-данных.
from dataclasses import dataclass, field
from typing import List
@dataclass
class User:
id: int
name: str
email: str
tags: List[str] = field(default_factory=list)
active: bool = True
def __post_init__(self):
self.email = self.email.lower()
u = User(id=1, name="Alice", email="Alice@example.com")
print(u) # User(id=1, name='Alice', email='alice@example.com', tags=[], active=True)
print(u == User(id=1, name="Alice", email="alice@example.com")) # True
frozen=True делает экземпляр неизменяемым (и хэшируемым). field(default_factory=list) — правильный способ задать изменяемое значение по умолчанию. __post_init__ вызывается после сгенерированного __init__.
Что ждут на собеседовании: @dataclass генерирует __init__, __repr__, __eq__. field(default_factory=...) для изменяемых дефолтов. frozen=True — иммутабельность. __post_init__ для дополнительной инициализации. Альтернатива: pydantic с валидацией.
Вопрос 17. Что такое Mixin и как его правильно построить?
Mixin — класс, который предоставляет методы для подмешивания в другие классы без образования отношения is-a. Он не предназначен для самостоятельного использования и обычно не имеет __init__.
class LogMixin:
def log(self, message: str) -> None:
print(f"[{self.__class__.__name__}] {message}")
class JSONSerializeMixin:
def to_json(self) -> str:
import json
return json.dumps(self.__dict__)
class UserService(LogMixin, JSONSerializeMixin):
def __init__(self, name: str):
self.name = name
def create(self) -> None:
self.log(f"Creating user {self.name}")
svc = UserService("Alice")
svc.create() # [UserService] Creating user Alice
print(svc.to_json()) # {"name": "Alice"}
Миксины должны быть тонкими — только одна ответственность. Порядок в списке базовых классов важен для MRO: миксины ставятся до основного класса.
Что ждут на собеседовании: Mixin добавляет поведение без is-a. Нет __init__. Одна ответственность. В списке предков — до основного класса. Используют self.__class__.__name__ вместо жёстко заданного имени.
Вопрос 18. Что такое дескрипторный протокол?
Дескриптор — объект, у которого определены методы __get__, __set__ или __delete__. Когда атрибут класса является дескриптором, Python вызывает эти методы вместо прямого доступа.
@property — это встроенный дескриптор. Но можно написать свой:
class Positive:
"""Дескриптор: значение должно быть > 0"""
def __set_name__(self, owner, name):
self._name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, f"_{self._name}", None)
def __set__(self, obj, value):
if value <= 0:
raise ValueError(f"{self._name} должен быть положительным")
setattr(obj, f"_{self._name}", value)
class Product:
price = Positive()
quantity = Positive()
def __init__(self, price: float, quantity: int):
self.price = price
self.quantity = quantity
p = Product(price=100, quantity=5)
p.price = -10 # ValueError
Дескрипторы лежат в основе @property, @classmethod, @staticmethod и functools.cached_property.
Что ждут на собеседовании: дескриптор — объект с __get__/__set__/__delete__. __set_name__ даёт доступ к имени атрибута. Data descriptor (с __set__) имеет приоритет над __dict__ экземпляра. На них построены property, classmethod, staticmethod.
Python-вопросы в Telegram
Ежедневные разборы GIL, asyncio и паттернов.
3. Функции, декораторы, генераторы
Вопрос 19. Что такое замыкание (closure)?
Замыкание — функция, которая «захватывает» переменные из окружающей области видимости, даже после того, как та область завершила выполнение. Захваченные переменные хранятся в ячейках (__closure__).
def make_counter(start: int = 0):
count = start
def increment():
nonlocal count # без nonlocal — ошибка UnboundLocalError
count += 1
return count
return increment
counter = make_counter(10)
print(counter()) # 11
print(counter()) # 12
# Ловушка: переменная захватывается по ссылке, а не по значению
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs]) # [2, 2, 2] — все захватывают одну i
# Исправление: значение по умолчанию фиксирует текущее
funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs]) # [0, 1, 2]
Что ждут на собеседовании: замыкание захватывает переменную по ссылке, не по значению. nonlocal разрешает присваивание в замыкании. Классический баг с lambda в цикле решается через дефолтный аргумент.
Вопрос 20. Как написать декоратор и зачем нужен functools.wraps?
Декоратор — функция, принимающая функцию и возвращающая обёртку. @decorator — синтаксический сахар для func = decorator(func).
import functools
import time
def timer(func):
@functools.wraps(func) # копирует __name__, __doc__, __annotations__
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} выполнялся {elapsed:.4f}с")
return result
return wrapper
@timer
def slow_operation(n: int) -> int:
"""Вычисляет сумму до n."""
return sum(range(n))
print(slow_operation(10**6))
print(slow_operation.__name__) # slow_operation, а не wrapper
print(slow_operation.__doc__) # Вычисляет сумму до n.
Без functools.wraps декорированная функция потеряет __name__, __doc__ и __annotations__ — это ломает отладку, help() и некоторые фреймворки. Для декораторов с параметрами нужен ещё один уровень вложенности.
Что ждут на собеседовании: декоратор = функция высшего порядка. Три уровня для параметризованного декоратора. functools.wraps — обязательно, сохраняет метаданные. Декораторы применяются снизу вверх при наложении.
Вопрос 21. Как работает yield и чем генератор отличается от функции?
Функция с yield — генераторная функция. Вызов такой функции возвращает объект-генератор, не начиная выполнение. Тело выполняется лениво: по одному шагу при каждом вызове next().
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
fib = fibonacci()
print([next(fib) for _ in range(8)]) # [0, 1, 1, 2, 3, 5, 8, 13]
# Генераторная функция с конечной последовательностью
def read_chunks(file_path: str, size: int = 8192):
with open(file_path, "rb") as f:
while chunk := f.read(size):
yield chunk
# Генератор потребляет O(1) памяти независимо от размера файла
for chunk in read_chunks("large_file.bin"):
process(chunk)
После return (или при выходе из тела) генератор поднимает StopIteration. Генератор можно передавать в next(), for, list(), sum() — любой код, ожидающий итерируемый объект.
Что ждут на собеседовании: yield приостанавливает выполнение и возвращает значение. Состояние сохраняется между вызовами. Потребляет O(1) памяти. return в генераторе поднимает StopIteration со значением.
Вопрос 22. Что делает yield from?
yield from делегирует генерацию вложенному итерируемому объекту — автоматически прокидывает next(), send() и throw() к нему. Это упрощает составные генераторы.
def chain(*iterables):
for it in iterables:
yield from it # эквивалент: for item in it: yield item
print(list(chain([1, 2], [3, 4], [5]))) # [1, 2, 3, 4, 5]
# yield from возвращает StopIteration.value делегата
def inner():
yield 1
yield 2
return "inner done" # это значение доступно через yield from
def outer():
result = yield from inner() # result = "inner done"
print("inner вернул:", result)
yield 3
list(outer()) # inner вернул: inner done → [1, 2, 3]
yield from — основа синтаксиса корутин до Python 3.5 (когда появился async/await). Сейчас применяется для составных генераторов и делегирования.
Что ждут на собеседовании: yield from транспортирует next/send/throw к делегату. Возвращает StopIteration.value. Упрощает плоские рекурсивные генераторы. Историческая роль: основа asyncio до async/await.
Вопрос 23. Как работает протокол итератора?
Любой объект, реализующий __iter__ и __next__, является итератором. __iter__ возвращает сам итератор (return self), __next__ возвращает следующее значение или поднимает StopIteration.
class Range:
def __init__(self, start: int, stop: int, step: int = 1):
self._current = start
self._stop = stop
self._step = step
def __iter__(self):
return self
def __next__(self) -> int:
if self._current >= self._stop:
raise StopIteration
value = self._current
self._current += self._step
return value
for n in Range(0, 5, 2):
print(n) # 0 2 4
Различие: итерируемый (iterable) — имеет __iter__, возвращающий итератор. Итератор — имеет __iter__ и __next__. list — итерируемый, но не итератор; iter(list) возвращает итератор.
Что ждут на собеседовании: протокол — __iter__ + __next__. StopIteration сигнализирует конец. Разница: iterable (имеет __iter__) vs iterator (имеет оба). for вызывает iter(obj), затем next() в цикле.
Вопрос 24. Для чего нужны functools.partial и functools.lru_cache?
partial фиксирует часть аргументов функции, создавая новую функцию с меньшей арностью. lru_cache мемоизирует результаты вызова — кэшировует до maxsize последних результатов.
from functools import partial, lru_cache
# partial: фиксируем логарифм по основанию 2
import math
log2 = partial(math.log, base=2)
print(log2(8)) # 3.0
print(log2(16)) # 4.0
# lru_cache: классический пример с Фибоначчи
@lru_cache(maxsize=None) # maxsize=None — неограниченный кэш
def fib(n: int) -> int:
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(50)) # мгновенно — без кэша было бы O(2^n)
print(fib.cache_info()) # CacheInfo(hits=48, misses=51, ...)
lru_cache требует, чтобы все аргументы были хэшируемыми. Для методов класса осторожно: lru_cache на методе держит ссылку на экземпляр, что может вызвать утечку памяти. Используйте methodtools или functools.cached_property.
Что ждут на собеседовании: partial — карринг/частичное применение аргументов. lru_cache — мемоизация с LRU-стратегией вытеснения. Аргументы должны быть хэшируемыми. cache_info() для мониторинга. functools.cache (Python 3.9+) — то же, что lru_cache(maxsize=None).
Вопрос 25. Когда использовать lambda, а когда она избыточна?
lambda — анонимная функция, ограниченная одним выражением. Полезна в sorted, map, filter, когда функция тривиальна и одноразова. Если функция сложнее трёх слов — лучше def.
# Уместно: ключ для сортировки
users = [{"name": "Bob", "age": 30}, {"name": "Alice", "age": 25}]
sorted_users = sorted(users, key=lambda u: u["age"])
# Уместно: callback
button.on_click(lambda event: handle(event.id))
# Неуместно: нечитаемо
fn = lambda x, y: (lambda a: a * 2)(x) + y # лучше def
# sorted с attrgetter/itemgetter вместо lambda — быстрее
from operator import itemgetter
sorted_users = sorted(users, key=itemgetter("age"))
lambda не может содержать return, yield, assert, присваивание — только выражение. Для мемоизации lru_cache на lambda не навесить — нужен def.
Что ждут на собеседовании: lambda — одно выражение, без statements. Уместна для коротких key-функций в sorted. operator.itemgetter/attrgetter быстрее lambda. Сложная логика — только def.
4. Память, GIL, производительность
Вопрос 26. Как Python удаляет объекты? Что такое reference counting?
CPython отслеживает, сколько ссылок указывает на каждый объект — это поле ob_refcnt в структуре PyObject. Когда счётчик падает до нуля, объект немедленно удаляется и память освобождается. Никакой паузы, никакого сборщика — просто декремент при уничтожении ссылки.
import sys
a = [1, 2, 3]
print(sys.getrefcount(a)) # 2 (a + аргумент getrefcount)
b = a
print(sys.getrefcount(a)) # 3
del b
print(sys.getrefcount(a)) # 2
# __del__ вызывается при удалении объекта
class Tracked:
def __del__(self):
print("объект удалён")
obj = Tracked()
del obj # "объект удалён" — сразу
Преимущество: детерминированное удаление ресурсов (файлы, соединения закрываются мгновенно). Недостаток: не работает для циклических ссылок.
Что ждут на собеседовании: каждый объект хранит ob_refcnt. При обнулении — немедленное удаление без паузы. sys.getrefcount возвращает refcount + 1 (за аргумент). Циклические ссылки не удаляются подсчётом ссылок — нужен GC.
Вопрос 27. Как Python находит циклические ссылки?
Подсчёт ссылок не справляется с циклами: если A ссылается на B и B ссылается на A, их счётчики никогда не обнулятся, даже когда оба недостижимы извне. Для этого есть модуль gc с трёхпоколенным сборщиком.
GC запускается периодически, обходит граф объектов и ищет группы объектов с ненулевым refcount, но недостижимых из корней. Объекты без __del__ удаляются; с __del__ попадают в gc.garbage (в Python 3.4+ это исправлено — большинство удаляется).
import gc
class Node:
def __init__(self, value):
self.value = value
self.next = None
a = Node(1)
b = Node(2)
a.next = b
b.next = a # цикл
del a
del b
# Счётчики ≠ 0, объекты не удалены подсчётом ссылок
collected = gc.collect() # принудительный запуск
print(f"Собрано {collected} объектов")
# Можно отключить GC (если уверены, что циклов нет)
gc.disable()
Что ждут на собеседовании: GC работает поверх reference counting. Три поколения (0, 1, 2) — чаще собирается молодое. gc.collect() — принудительный запуск. gc.disable() — иногда используют в performance-critical коде без циклических ссылок.
Вопрос 28. Что такое GIL и почему он существует?
GIL (Global Interpreter Lock) — мьютекс в CPython, который позволяет в каждый момент времени только одному потоку выполнять байткод Python. Он существует потому, что управление памятью через reference counting небезопасно при многопоточном доступе: без блокировки конкурентное изменение ob_refcnt приведёт к гонкам и повреждению памяти.
import threading
import time
counter = 0
def increment():
global counter
for _ in range(1_000_000):
counter += 1 # не атомарная операция!
threads = [threading.Thread(target=increment) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
# Ожидается 2_000_000, но из-за GIL результат может быть любым
# (+= — это LOAD_GLOBAL, BINARY_ADD, STORE_GLOBAL — три байт-операции)
print(counter)
GIL освобождается при I/O-операциях и на каждые ~100 байт-инструкций (sys.getswitchinterval). Поэтому многопоточность в Python эффективна для I/O-bound задач (сетевые запросы, чтение файлов) и неэффективна для CPU-bound.
Что ждут на собеседовании: GIL — один поток исполняет байткод. Причина: подсчёт ссылок небезопасен без мьютекса. Снимается при I/O и C-расширениях. CPU-bound + threading = нет ускорения. I/O-bound + threading = ускорение есть.
Вопрос 29. Как обойти ограничения GIL?
Три подхода: multiprocessing (отдельные процессы — каждый со своим GIL), C-расширения с Py_BEGIN_ALLOW_THREADS (NumPy, Cython), или concurrent.futures.ProcessPoolExecutor.
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
import math
def cpu_task(n: int) -> float:
return sum(math.sqrt(i) for i in range(n))
data = [10**6] * 4
# Threading — не помогает для CPU-bound
with ThreadPoolExecutor(max_workers=4) as ex:
results = list(ex.map(cpu_task, data))
# ProcessPoolExecutor — реальный параллелизм
with ProcessPoolExecutor(max_workers=4) as ex:
results = list(ex.map(cpu_task, data))
NumPy и pandas для большинства операций снимают GIL — потому что вычисления происходят в C-коде. Проект Nogil (CPython 3.13 free-threaded) снимает GIL опционально, но пока экспериментален.
Что ждут на собеседовании: multiprocessing — процессы с отдельными GIL. C-расширения (NumPy) снимают GIL. ProcessPoolExecutor — пул процессов. Overhead на межпроцессную коммуникацию — pickle. Python 3.13 free-threaded (PEP 703) — опциональное отключение GIL.
Вопрос 30. Когда threading полезен при наличии GIL?
При I/O-операциях поток блокируется, ожидая ответа от сети, диска или БД. В этот момент GIL освобождается, и другие потоки могут работать. Поэтому для I/O-bound задач threading даёт реальное ускорение.
import threading
import urllib.request
import time
urls = [
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
]
def fetch(url: str) -> None:
with urllib.request.urlopen(url) as resp:
print(f"{url}: {resp.status}")
# Последовательно: ~3 секунды
# С threading: ~1 секунда (потоки ждут I/O параллельно)
threads = [threading.Thread(target=fetch, args=(url,)) for url in urls]
start = time.perf_counter()
for t in threads:
t.start()
for t in threads:
t.join()
print(f"Elapsed: {time.perf_counter() - start:.2f}с")
Для I/O в современном Python предпочтительнее asyncio — меньше накладных расходов. threading актуален, когда нужно интегрироваться с синхронными библиотеками.
Что ждут на собеседовании: GIL снимается при I/O. Threading эффективен для I/O-bound (сеть, диск, БД). Для CPU-bound — нет смысла. Современная альтернатива для I/O — asyncio с меньшим overhead.
Вопрос 31. Что такое слабые ссылки (weakref)?
Слабая ссылка указывает на объект, не увеличивая его reference count. Если все обычные ссылки удалены, объект будет удалён даже при наличии слабых ссылок — они станут «мёртвыми». Основное применение — кэши, где не нужно продлевать жизнь объектов.
import weakref
class HeavyObject:
def __init__(self, data: bytes):
self.data = data
obj = HeavyObject(b"lots of data")
weak = weakref.ref(obj)
print(weak()) # <HeavyObject object>
print(weak() is obj) # True
del obj # refcount → 0, объект удалён
print(weak()) # None — объект недоступен
# WeakValueDictionary: автоматически удаляет мёртвые записи
cache = weakref.WeakValueDictionary()
data = HeavyObject(b"cached data")
cache["key"] = data
del data
print(cache.get("key")) # None
Что ждут на собеседовании: weakref не увеличивает refcount. Объект удаляется при обнулении сильных ссылок. weakref.ref(obj)() — вызов возвращает объект или None. WeakValueDictionary — кэш без удержания объектов. Применение: события, observer-паттерн, кэши.
Вопрос 32. Как __slots__ экономит память?
По умолчанию у каждого экземпляра класса есть __dict__ — словарь атрибутов. Это удобно, но затратно: словарь занимает от 200+ байт. __slots__ заменяет __dict__ массивом фиксированных дескрипторов — фактически, структурой.
import sys
class WithDict:
def __init__(self, x, y):
self.x = x
self.y = y
class WithSlots:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
d = WithDict(1, 2)
s = WithSlots(1, 2)
print(sys.getsizeof(d.__dict__)) # ~232 байта (Python 3.12)
# у s нет __dict__ вообще
print(sys.getsizeof(s)) # ~56 байт
# При создании миллиона объектов разница — сотни МБ
Недостаток: нельзя добавить произвольные атрибуты. Если один из предков в иерархии не объявил __slots__, у потомка __dict__ снова появится.
Что ждут на собеседовании: __slots__ убирает __dict__ и снижает потребление памяти на 40–50%. Нельзя добавлять новые атрибуты. Наследование требует __slots__ во всех классах цепочки. Применение: миллионы маленьких объектов (точки, события).
Вопрос 33. Почему генераторы эффективнее списков для больших данных?
Список создаётся весь сразу и хранится в памяти целиком — O(n) памяти. Генератор производит элементы лениво — O(1) памяти, потому что хранит только текущее состояние.
import sys
# Список: все числа в памяти
lst = [x ** 2 for x in range(10**6)]
print(sys.getsizeof(lst)) # ~8 МБ
# Генератор: только состояние
gen = (x ** 2 for x in range(10**6))
print(sys.getsizeof(gen)) # ~120 байт
# Для sum — результат тот же
print(sum(lst) == sum(gen)) # True
# Чтение файла без загрузки в память
def lines(path: str):
with open(path) as f:
for line in f:
yield line.rstrip()
for line in lines("huge_log.txt"):
if "ERROR" in line:
process(line)
Генераторы нельзя переиспользовать — они одноразовые. itertools предоставляет ленивые комбинаторы: chain, islice, tee, groupby.
Что ждут на собеседовании: генератор — O(1) памяти, список — O(n). Генератор одноразовый (исчерпанный не перемотать). itertools — библиотека ленивых операций. Для работы с большими файлами и потоками — всегда генераторы.
Вопрос 34. Как профилировать потребление памяти в Python?
Два основных инструмента: sys.getsizeof для конкретного объекта (без рекурсивного обхода) и tracemalloc для отслеживания всех аллокаций в блоке кода.
import tracemalloc
import sys
# sys.getsizeof — только сам объект, без вложенных
d = {"a": [1, 2, 3]}
print(sys.getsizeof(d)) # ~232 байта — только dict
print(sys.getsizeof(d["a"])) # ~120 байт — только список
# tracemalloc — полная картина
tracemalloc.start()
# код под профилированием
data = [{"x": i, "y": i * 2} for i in range(10000)]
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics("lineno")
for stat in top_stats[:5]:
print(stat)
tracemalloc.stop()
Для production-мониторинга полезны memory_profiler (декоратор @profile на функцию) и pympler. objgraph помогает найти, какие типы объектов потребляют больше всего памяти и кто держит ссылки.
Что ждут на собеседовании: sys.getsizeof — поверхностный размер. tracemalloc — трассировка аллокаций, встроен в stdlib. memory_profiler — построчное профилирование. objgraph.show_most_common_types() — утечки по типам.
Вопрос 35. Что такое интернирование строк и малых целых чисел?
CPython оптимизирует память, переиспользуя объекты для часто встречающихся значений. Целые числа от -5 до 256 создаются при запуске и никогда не удаляются — все переменные с такими значениями указывают на один объект. Строки, выглядящие как идентификаторы (только ASCII, без пробелов), часто интернируются автоматически.
# Малые целые — один объект
a = 100
b = 100
print(a is b) # True
# Большие — разные объекты
a = 1000
b = 1000
print(a is b) # False (в интерактивной сессии может быть True из-за оптимизации)
# Строки-идентификаторы
s1 = "hello"
s2 = "hello"
print(s1 is s2) # True — интернированы
s3 = "hello world" # пробел — не идентификатор
s4 = "hello world"
print(s3 is s4) # зависит от реализации
import sys
s5 = sys.intern("hello world")
s6 = sys.intern("hello world")
print(s5 is s6) # True — явное интернирование
Важно: is проверяет идентичность объектов, == — равенство значений. Никогда не используйте is для сравнения строк и чисел в реальном коде — это деталь реализации.
Что ждут на собеседовании: целые -5..256 — singleton объекты в CPython. Строки-идентификаторы интернируются автоматически. sys.intern() — явное интернирование. is — идентичность, == — равенство. Сравнение строк через is — ошибка.
Вопрос 36. Чем copy.copy отличается от copy.deepcopy?
copy.copy создаёт поверхностную копию: новый объект-контейнер, но вложенные объекты разделяются со старым. copy.deepcopy рекурсивно копирует всё — вложенные объекты тоже новые.
import copy
original = {"users": [{"name": "Alice"}, {"name": "Bob"}]}
shallow = copy.copy(original)
shallow["users"][0]["name"] = "Charlie"
print(original["users"][0]["name"]) # Charlie — вложенный список разделён!
deep = copy.deepcopy(original)
deep["users"][0]["name"] = "Dave"
print(original["users"][0]["name"]) # Charlie — не изменился
# deepcopy обрабатывает циклические ссылки
a = []
a.append(a)
b = copy.deepcopy(a) # не бесконечная рекурсия
deepcopy медленнее и потребляет больше памяти. Для простых структур без вложенности copy и deepcopy дают одинаковый результат.
Что ждут на собеседовании: copy — новый контейнер, те же вложенные объекты. deepcopy — полная независимая копия. deepcopy обрабатывает циклы (через memo-словарь). Классы могут контролировать поведение через __copy__ и __deepcopy__.
Python-вопросы в Telegram
Ежедневные разборы GIL, asyncio и паттернов.
5. Асинхронное программирование
Вопрос 37. Чем concurrency отличается от parallelism?
Concurrency (конкурентность) — структурирование программы так, чтобы несколько задач прогрессировали в перекрывающиеся промежутки времени. Они могут выполняться по очереди на одном ядре. Parallelism (параллелизм) — физическое одновременное выполнение на нескольких ядрах.
В Python: asyncio и threading дают конкурентность; multiprocessing — реальный параллелизм для CPU-задач. GIL делает threading-параллелизм невозможным для Python-кода.
# Конкурентность: asyncio — один поток, несколько задач
import asyncio
async def task(name: str, delay: float) -> str:
print(f"{name}: начало")
await asyncio.sleep(delay) # уступаем управление event loop
print(f"{name}: конец")
return name
async def main():
# gather запускает задачи конкурентно
results = await asyncio.gather(
task("A", 1.0),
task("B", 0.5),
task("C", 0.8),
)
print(results) # ['A', 'B', 'C'] — через ~1 секунду, не ~2.3
asyncio.run(main())
Что ждут на собеседовании: concurrency — структура, parallelism — физика. asyncio/threading — конкурентность. multiprocessing — параллелизм. GIL запрещает Python-параллелизм в потоках. «Concurrency is not parallelism» (Rob Pike).
Вопрос 38. Как работает event loop в asyncio?
Event loop — бесконечный цикл, который: смотрит на очередь готовых задач, берёт следующую, выполняет её до ближайшего await, переходит к следующей задаче. Параллельно отслеживает I/O с помощью системных вызовов (select/epoll/kqueue) и будит задачи, когда их I/O завершился.
import asyncio
async def show_event_loop_concept():
loop = asyncio.get_event_loop()
# Корутина приостанавливается здесь
# event loop переходит к другим задачам
await asyncio.sleep(0) # await asyncio.sleep(0) — явная уступка
# Планирование callback'а
loop.call_soon(print, "вызван из event loop")
# Текущее время (монотонные часы)
print(loop.time())
asyncio.run(show_event_loop_concept())
Event loop — один поток. Блокирующий вызов (синхронный sleep, requests.get без await) замораживает весь loop — ни одна другая корутина не выполнится, пока он не вернёт управление.
Что ждут на собеседовании: один поток, одна задача в момент времени. await — точка переключения. epoll/kqueue для мониторинга I/O. Блокирующий вызов = заморозка всего loop. asyncio.run() создаёт и закрывает loop автоматически.
Вопрос 39. Что происходит под капотом при async def и await?
async def создаёт coroutine function. Вызов возвращает coroutine object — генераторообразный объект с методами send и throw. await приостанавливает корутину и возвращает управление event loop через цепочку yield.
import asyncio
import inspect
async def my_coroutine():
await asyncio.sleep(1)
return 42
coro = my_coroutine()
print(type(coro)) # <class 'coroutine'>
print(inspect.iscoroutine(coro)) # True
# await можно использовать только внутри async def
async def caller():
result = await my_coroutine() # уступает управление, ждёт результата
print(result) # 42
asyncio.run(caller())
Стек вызовов корутин: my_coroutine → await asyncio.sleep(1) → внутри sleep — await Future → event loop получает управление и ждёт таймер. Когда таймер срабатывает, loop будит корутину через future.set_result().
Что ждут на собеседовании: async def → coroutine function; вызов → coroutine object. await снимает управление до готовности awaitable. Под капотом — генераторный протокол с send/throw. asyncio.Future — низкоуровневый примитив синхронизации.
Вопрос 40. Что такое asyncio.Task и как его создать?
Task — обёртка вокруг корутины, которая планирует её на event loop. В отличие от await coroutine() (последовательное выполнение), Task начинает выполнение немедленно, конкурентно с другими задачами.
import asyncio
async def fetch(url: str) -> str:
await asyncio.sleep(0.1) # имитируем I/O
return f"data from {url}"
async def main():
# Создаём задачи — запускаются немедленно
task1 = asyncio.create_task(fetch("https://api1.example.com"))
task2 = asyncio.create_task(fetch("https://api2.example.com"))
# Ждём обе задачи
result1 = await task1
result2 = await task2
print(result1, result2)
# Можно передать имя для отладки
task3 = asyncio.create_task(fetch("https://api3.example.com"), name="api3-fetch")
print(task3.get_name()) # api3-fetch
asyncio.run(main())
Task — подкласс Future. У него есть task.result(), task.done(), task.cancel(). Незахваченные исключения в задаче не сразу поднимаются — только при await task или в хуке asyncio.get_event_loop().set_exception_handler.
Что ждут на собеседовании: create_task — конкурентный запуск, не ждёт завершения. await task — дождаться результата. Задача начинает выполнение на следующей итерации event loop. Не забывайте await задачи, иначе исключение потеряется.
Вопрос 41. В чём разница между asyncio.gather и asyncio.wait?
gather запускает awaitables конкурентно и возвращает список результатов в том же порядке. При исключении в любой задаче (по умолчанию) отменяет остальные и поднимает исключение. wait даёт больше контроля: возвращает два множества — done и pending, можно реагировать по мере завершения.
import asyncio
async def risky(n: int) -> int:
await asyncio.sleep(n * 0.1)
if n == 2:
raise ValueError("ошибка в задаче 2")
return n
async def with_gather():
try:
results = await asyncio.gather(
risky(1), risky(2), risky(3),
return_exceptions=True # не бросаем, а возвращаем исключения
)
for r in results:
if isinstance(r, Exception):
print(f"Ошибка: {r}")
else:
print(f"Результат: {r}")
except Exception as e:
print(f"gather упал: {e}")
async def with_wait():
tasks = {asyncio.create_task(risky(i)) for i in range(4)}
done, pending = await asyncio.wait(
tasks,
return_when=asyncio.FIRST_EXCEPTION
)
for task in done:
if task.exception():
print(f"Упало: {task.exception()}")
for task in pending:
task.cancel()
asyncio.run(with_gather())
Что ждут на собеседовании: gather — простой сбор результатов, return_exceptions=True не бросает. wait — FIRST_COMPLETED/FIRST_EXCEPTION/ALL_COMPLETED. wait принимает только таски/futures, не корутины. gather принимает корутины напрямую.
Вопрос 42. Как отменить asyncio-задачу и обработать CancelledError?
task.cancel() устанавливает флаг отмены и при ближайшем await внутри задачи бросает CancelledError. Задача должна либо пропустить его (и завершиться), либо выполнить cleanup и перебросить.
import asyncio
async def long_task() -> None:
try:
print("начало длинной задачи")
await asyncio.sleep(10)
print("завершилась нормально")
except asyncio.CancelledError:
print("задача отменена — очищаем ресурсы")
# здесь можно закрыть соединения, файлы
raise # обязательно перебросить!
async def main():
task = asyncio.create_task(long_task())
await asyncio.sleep(0.5)
task.cancel()
try:
await task
except asyncio.CancelledError:
print("main: задача успешно отменена")
asyncio.run(main())
Если подавить CancelledError (не перебросить), задача не будет считаться отменённой — это нарушает семантику отмены. asyncio.shield(coro) защищает корутину от внешней отмены.
Что ждут на собеседовании: task.cancel() → CancelledError при ближайшем await. Cleanup — в except CancelledError, затем обязательно raise. asyncio.shield — защита от отмены. task.cancelled() проверяет статус.
Вопрос 43. Что такое асинхронные генераторы?
Асинхронный генератор — async def функция с yield. Итерируется через async for. Используется для потоковой обработки данных из I/O-источников: Kafka, WebSocket, chunked HTTP.
import asyncio
async def async_range(stop: int, delay: float = 0.1):
for i in range(stop):
await asyncio.sleep(delay) # имитируем I/O между элементами
yield i
async def main():
async for value in async_range(5):
print(value)
# Можно использовать async comprehension
results = [v async for v in async_range(5)]
print(results)
# Контекстный менеджер тоже может быть асинхронным
# async with resource() as r: ...
asyncio.run(main())
Асинхронный генератор нельзя использовать в синхронном коде. Финализатор (aclose()) нужно явно вызвать, если не все элементы потреблены — иначе ресурсы могут не освободиться.
Что ждут на собеседовании: async def + yield = async generator. Итерация через async for. aclose() для финализации. async for и async with — только внутри async def. Применение: WebSocket-стримы, Kafka consumers, chunked HTTP.
Вопрос 44. Как вызвать блокирующий код из async-контекста?
asyncio.to_thread (Python 3.9+) запускает синхронную функцию в пуле потоков, не блокируя event loop. Для более ранних версий — loop.run_in_executor.
import asyncio
import time
def blocking_db_query(query: str) -> list:
time.sleep(1) # имитация синхронного запроса
return [{"id": 1, "name": "Alice"}]
async def main():
# asyncio.to_thread — event loop не заморожен
result = await asyncio.to_thread(blocking_db_query, "SELECT * FROM users")
print(result)
# Эквивалент через run_in_executor
loop = asyncio.get_running_loop()
result2 = await loop.run_in_executor(
None, # None = default ThreadPoolExecutor
blocking_db_query,
"SELECT * FROM users"
)
print(result2)
asyncio.run(main())
Для CPU-bound кода to_thread не поможет из-за GIL. В этом случае нужен ProcessPoolExecutor: await loop.run_in_executor(process_pool, cpu_function, arg).
Что ждут на собеседовании: asyncio.to_thread — синхронная функция в потоке, не блокирует loop. run_in_executor(None, ...) — аналог с явным executor. Для CPU-bound — ProcessPoolExecutor. Никогда не вызывайте блокирующий код напрямую в async-функции.
Вопрос 45. Когда что выбрать: asyncio, threading, multiprocessing?
| Задача | Инструмент | Почему |
|---|---|---|
| Много I/O-операций (HTTP, БД) | asyncio | Нет накладных расходов на потоки, тысячи задач на один поток |
| Интеграция с синхронными библиотеками, I/O | threading | GIL снимается при I/O; проще интегрировать с legacy-кодом |
| CPU-bound вычисления (ML, обработка данных) | multiprocessing | Реальный параллелизм, каждый процесс со своим GIL |
| CPU + тонкая оркестрация | ProcessPoolExecutor + asyncio | Удобный API поверх multiprocessing |
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import asyncio
async def hybrid_main():
loop = asyncio.get_running_loop()
# I/O через asyncio
await asyncio.gather(
asyncio.to_thread(io_bound_1),
asyncio.to_thread(io_bound_2),
)
# CPU через процессы
with ProcessPoolExecutor() as pool:
result = await loop.run_in_executor(pool, cpu_bound_task, big_data)
asyncio.run(hybrid_main())
Что ждут на собеседовании: asyncio — I/O-bound с высоким параллелизмом. threading — I/O с синхронными библиотеками. multiprocessing — CPU-bound. На практике для FastAPI/Django-async — asyncio + to_thread для синхронных операций.
Вопрос 46. Какие типичные ошибки при работе с asyncio?
Три самых частых ошибки, которые ломают async-код, не всегда явно:
import asyncio
import time
# Ошибка 1: блокирующий вызов в event loop
async def bad_sleep():
time.sleep(1) # НЕВЕРНО: блокирует весь event loop
await asyncio.sleep(1) # ВЕРНО: уступает управление
# Ошибка 2: забытый await — корутина создаётся, но не выполняется
async def forgot_await():
result = some_coroutine() # НЕВЕРНО: создан объект, но не запущен
result = await some_coroutine() # ВЕРНО
# Ошибка 3: создание задачи без ссылки — GC может убить её
async def fire_and_forget():
# asyncio.create_task(background()) # НЕВЕРНО: задача может быть GC-ована
task = asyncio.create_task(background())
asyncio.current_task().add_done_callback(lambda _: task.cancel()) # правильно хранить ссылку
# Ошибка 4: смешивание sync и async контекстов
def sync_function():
asyncio.run(some_coroutine()) # если уже внутри event loop — RuntimeError
# asyncio.get_event_loop().run_until_complete(...) # то же
Что ждут на собеседовании: time.sleep vs asyncio.sleep. Забытый await — Python 3.11+ предупреждает. Задача без ссылки может быть собрана GC. asyncio.run внутри running loop — RuntimeError. Используйте asyncio.run только на верхнем уровне.
6. Структура проекта и инструменты
Вопрос 47. Зачем нужны виртуальные окружения и чем они отличаются?
Виртуальное окружение изолирует зависимости проекта от системного Python. Разные проекты могут использовать разные версии одних и тех же пакетов без конфликтов.
# venv — стандартная библиотека
python3 -m venv .venv
source .venv/bin/activate
pip install fastapi
# uv — быстрый менеджер (Rust, 10–100x быстрее pip)
uv venv
uv pip install fastapi
# или через uv run без явной активации:
uv run python main.py
# poetry — управляет зависимостями и версиями
poetry new myproject
poetry add fastapi
poetry install
uv стал стандартом де-факто в 2024–2025 годах: он заменяет pip, pip-tools, virtualenv и часть функций poetry. Генерирует uv.lock для воспроизводимых сборок.
Что ждут на собеседовании: изоляция зависимостей. venv — stdlib, минимализм. uv — скорость, lock-файлы. poetry — управление зависимостями + публикация пакетов. Активация .venv/bin/activate или uv run. Никогда не устанавливайте проектные зависимости в системный Python.
Вопрос 48. Как управлять зависимостями в большом проекте?
В production важна воспроизводимость: зафиксированные версии всех транзитивных зависимостей через lock-файл. uv.lock или poetry.lock гарантируют, что на CI и у каждого разработчика одинаковые версии.
# pyproject.toml — современный стандарт (PEP 517/518/621)
[project]
name = "my-service"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.110.0",
"sqlalchemy>=2.0.0",
"pydantic>=2.0.0",
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "ruff>=0.4"]
uv pip compile pyproject.toml -o requirements.lock # или uv lock
uv pip sync requirements.lock # точная установка
Конфликты зависимостей решаются через ограничения (>=, ~=, !=). В микросервисном монорепо каждый сервис имеет свой pyproject.toml и uv.lock.
Что ждут на собеседовании: pyproject.toml — современный стандарт. lock-файл фиксирует транзитивные зависимости. pip-compile/uv lock — генерация lock. Разделяй dev и prod зависимости. В Docker — COPY pyproject.toml uv.lock до COPY . . для кэширования слоёв.
Вопрос 49. Зачем нужны type hints и что такое mypy?
Type hints (Python 3.5+, PEP 484) — аннотации типов, которые не влияют на runtime, но позволяют статическим анализаторам находить ошибки до запуска. mypy — наиболее распространённый статический анализатор типов для Python.
from typing import Optional, Union
from collections.abc import Sequence
def greet(name: str, times: int = 1) -> str:
return f"Hello, {name}! " * times
# Python 3.10+: | вместо Union
def parse_id(value: str | int) -> int:
return int(value)
# Optional[X] = X | None
def find_user(user_id: int) -> Optional[dict]:
return db.get(user_id)
# Generic типы
def first(items: Sequence[int]) -> int | None:
return items[0] if items else None
# Проверка: mypy --strict main.py
mypy ловит: несоответствие типов аргументов, несуществующие атрибуты, None-разыменование без проверки. pyright (Microsoft) — более строгая и быстрая альтернатива, используется в Pylance.
Что ждут на собеседовании: type hints — документация + статическая проверка. mypy / pyright — анализаторы. Optional[T] = T | None. Union → | (Python 3.10+). --strict — максимальная строгость. Дженерики через TypeVar, Generic[T], в Python 3.12 — class Stack[T].
Вопрос 50. Как правильно настроить логирование в Python-приложении?
Стандартный модуль logging — иерархическая система логгеров. Конфигурация через код или dictConfig. В production — структурированные логи (JSON) через python-json-logger или structlog.
import logging
import os
# Базовая настройка для разработки
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s %(name)s %(levelname)s %(message)s",
)
logger = logging.getLogger(__name__)
def process_order(order_id: int) -> None:
logger.info("Processing order", extra={"order_id": order_id})
try:
# ...
logger.debug("Order %d validated", order_id)
except ValueError as e:
logger.exception("Order validation failed", extra={"order_id": order_id})
raise
# Production: JSON + structlog
import structlog
log = structlog.get_logger()
log.info("order_processed", order_id=42, duration_ms=150)
# {"event": "order_processed", "order_id": 42, "duration_ms": 150, "timestamp": "..."}
Никогда не используйте print в production-коде. Уровни: DEBUG → INFO → WARNING → ERROR → CRITICAL. Конфигурируйте уровень через переменную окружения (LOG_LEVEL=INFO), не хардкодьте.
Что ждут на собеседовании: иерархия логгеров (__name__). Уровни DEBUG/INFO/WARNING/ERROR/CRITICAL. Не print, а logger.info. logger.exception в except — логирует traceback. structlog / python-json-logger — структурированные логи для агрегаторов (ELK, Loki). Конфигурация через dictConfig или env.
Итоги
Эти 50 вопросов покрывают ядро того, что проверяют на Python-собеседованиях уровня junior–middle. Если коротко, что должно быть в голове автоматически:
По типам данных: mutable vs immutable — это не просто «что можно менять», это хэшируемость и семантика передачи в функции. Ловушка изменяемого дефолта — классический вопрос-детектор.
По ООП: MRO и super() работают вместе, @property — дескриптор, dataclass заменяет шаблонный код. Миксины — для поведения без is-a.
По памяти и GIL: reference counting + cyclic GC — два слоя. GIL существует из-за подсчёта ссылок. Для CPU-bound — multiprocessing, для I/O-bound — asyncio или threading.
По asyncio: event loop — один поток, await — уступка управления, блокирующий вызов = заморозка всего loop. asyncio.to_thread — мост между мирами.
Если хотите проверить себя в условиях реального интервью с фидбеком — попробуйте тренировку на Lexicon.
Тренировка Python на Lexicon
50 вопросов с разбором, голосовое интервью и фидбек уровня junior/middle.
Смотрите также: Go: 50 вопросов на собеседовании junior–middle — если изучаете несколько языков или переходите с Python на Go.
Автор
Lexicon Team
Читайте также
backend
GIL в Python простыми словами: как работает и что спрашивают на собеседовании
Разбираем Global Interpreter Lock с нуля: история появления, bytecode-переключение, влияние на threading/multiprocessing, обход GIL и free-threaded Python 3.13.
backend
Управление памятью в Python: reference counting, циклический GC и утечки
Глубокий разбор управления памятью в CPython: PyObject, reference counting, циклический GC, weakref, __slots__, pymalloc и диагностика утечек с tracemalloc.
general
Как проходит IT-собеседование в 2026: этапы, вопросы и лайфхаки
Полный разбор процесса найма в IT в 2026 году: HR-скрининг, тестовое задание, техническое интервью, системный дизайн, финальный разговор. Как подготовиться и что отвечать.