Live-coding — один из самых непростых этапов собеседования по Python. Здесь не работает заучивание синтаксиса: интервьюеры проверяют фундамент, умение рассуждать и то, насколько хорошо вы понимаете язык «под капотом».
В этой статье я собрал девять задач, которые чаще всего встречаются на реальных интервью Python-разработчиков и QA Automation инженеров. Это компактные, но показательные примеры: итераторы, замыкания, asyncio, паттерны проектирования, работа с GIL и многое другое.
Каждая задача оформлена в формате: вопрос → задача → решение → объяснение → что хотел увидеть интервьюер.
Материал будет полезен тем, кто собирается на собеседование, готовит студентов или хочет закрыть пробелы в ключевых концепциях Python. Поехали!
Задача 1. Реализуем свой range: почему он работает только один раз
Иногда на собеседовании дают простую задачу на понимание протокола итерации в Python. Есть класс RangeLike, который должен вести себя как встроенный range:
r = RangeLike(3, 7)
print(list(r)) # ожидается [3, 4, 5, 6]
print(list(r)) # и снова [3, 4, 5, 6]
Кандидат реализует примерно так:
class RangeLike:
def __init__(self, start, stop):
self.start = start
self.stop = stop
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current < self.stop:
value = self.current
self.current += 1
return value
raise StopIteration
Первый вызов list(r) работает, а второй возвращает пустой список. Почему?
Решение
Чтобы класс вёл себя как range, нужно разделить итерируемый объект и итератор. RangeLike должен создавать новый итератор при каждом iter(r):
class RangeIterator:
def __init__(self, start, stop):
self.current = start
self.stop = stop
def __iter__(self):
return self
def __next__(self):
if self.current < self.stop:
value = self.current
self.current += 1
return value
raise StopIteration
class RangeLike:
def __init__(self, start, stop):
self.start = start
self.stop = stop
def __iter__(self):
return RangeIterator(self.start, self.stop)
Теперь:
print(list(RangeLike(3, 7))) # [3, 4, 5, 6]
print(list(RangeLike(3, 7))) # [3, 4, 5, 6]
Объяснение
В исходной реализации сам объект RangeLike был итератором: iter возвращал self. А итераторы в Python — одноразовые: после достижения StopIteration их нельзя «перезапустить». Они хранят своё состояние (current) и продолжают быть исчерпанными.
У range поведение другое: он — итерируемый объект, который при каждом вызове iter(range_obj) создаёт новый итератор с нулевым состоянием. Поэтому range можно проходить сколько угодно раз.
Разделение на контейнер (RangeLike) и итератор (RangeIterator) полностью повторяет архитектурный подход Python и делает класс ре-итерируемым.
Задача 2. Asyncio: почему код не запускается конкурентно?
Асинхронность — одна из самых частых тем в live-coding. На собеседованиях любят давать простой фрагмент кода и спрашивать: «А точно ли это выполняется параллельно?»
Вот пример:
import asyncio
import random
async def fetch(i):
await asyncio.sleep(random.random())
print(f"Fetched {i}")
async def main():
for i in range(5):
await fetch(i)
asyncio.run(main())
Интуитивно кажется, что пять вызовов fetch() должны выполняться вразнобой: ведь внутри есть await asyncio.sleep(). Но вывод всегда — 0, 1, 2, 3, 4 по порядку.
Почему так происходит?
Решение
Код запускает корутины строго последовательно. Чтобы получить конкурентность, корутины нужно запланировать на выполнение:
async def main():
tasks = [asyncio.create_task(fetch(i)) for i in range(5)]
await asyncio.gather(*tasks)
Теперь все fetch(i) стартуют одновременно и вывод будет случайным.
Можно и короче — без промежуточного списка:
async def main():
await asyncio.gather(*(fetch(i) for i in range(5)))
Этот вариант делает то же самое: планирует все корутины и ждёт их параллельного выполнения.
Объяснение
await fetch(i) внутри цикла не создаёт конкурентность. Это обычный последовательный вызов: выполнение main() приостанавливается до тех пор, пока не завершится текущая корутина. Только после этого цикл переходит к следующей итерации.
Чтобы функции выполнялись одновременно, их нужно превратить в задачи:
asyncio.create_task() регистрирует корутину в планировщике и возвращает объект задачи, который начнёт выполняться «в фоне».
await asyncio.gather(...) уже не блокирует выполнение шаг за шагом — он ожидает завершения всех задач и позволяет им работать параллельно на уровне событийного цикла.
Важно помнить, что «создать задачу» ≠ «дождаться задачи». Без await вы рискуете потерять ошибки, а без ограничения степени конкурентности — легко уронить сервер, создав тысячи задач одновременно.
Задача 3. Декоратор time_it: измеряем время выполнения функции
Это одна из самых частых задач, проверяющих понимание декораторов и умение корректно работать с функциями. Интервьюер показывает простой пример:
@time_it
def slow_operation():
total = 0
for i in range(10_000_000):
total += i
return total
result = slow_operation()
print("Result:", result)
И задаёт вопрос:
«Реализуйте
time_it, чтобы он выводил время выполнения функции в секундах, не ломал её поведение и сохранял оригинальные метаданные (имя и docstring).»
Решение
Классический функциональный декоратор с измерением времени и functools.wraps:
import time
from functools import wraps
def time_it(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
duration = time.perf_counter() - start
print(f"Function {func.__name__} took {duration:.4f} seconds")
return result
return wrapper
Работает так:
Function slow_operation took 0.4321 seconds
Result: 49999995000000
Объяснение
Ключевая идея — не блокировать выполнение функции и не терять её результат.
time.perf_counter() используется для максимально точного измерения времени. Декоратор принимает любые аргументы через args и *kwargs, передаёт их исходной функции и возвращает её результат.
Вызов @wraps(func) обязателен: он сохраняет имя, документацию и другие метаданные функции — иначе после декорирования slow_operation.__name__ превратится в "wrapper".
Такой подход универсален и подходит как для маленьких утилит, так и для профилирования больших участков кода.
Задача 4. Паттерн Strategy: переписываем логику скидок без if/elif
Классическая проверка архитектурного мышления. Интервьюер показывает функцию:
def calculate_total(price, discount_type):
if discount_type == "none":
return price
elif discount_type == "seasonal":
return price * 0.9
elif discount_type == "vip":
return price * 0.8
elif discount_type == "black_friday":
return price * 0.5
else:
raise ValueError("unknown discount type")
И спрашивает:
«Как переписать это через паттерн Strategy, чтобы можно было добавлять новые скидки без изменения самой функции?»
Решение
В Python Strategy реализуется естественно — через словарь функций.
def no_discount(price):
return price
def seasonal_discount(price):
return price * 0.9
def vip_discount(price):
return price * 0.8
def black_friday_discount(price):
return price * 0.5
DISCOUNTS = {
"none": no_discount,
"seasonal": seasonal_discount,
"vip": vip_discount,
"black_friday": black_friday_discount,
}
def calculate_total(price, discount_type):
try:
strategy = DISCOUNTS[discount_type]
except KeyError:
raise ValueError("unknown discount type")
return strategy(price)
Добавить стратегию? Просто дописать:
DISCOUNTS["new_year"] = lambda price: price * 0.85
Функция при этом не меняется вообще.
Объяснение
Здесь мы заменяем цепочку if/elif на маппинг стратегий, где ключ — тип скидки, а значение — объект, реализующий алгоритм. Это и есть паттерн Strategy в чистом виде.
calculate_total теперь не знает, что именно делает каждая стратегия — она просто вызывает её. Благодаря этому решение соблюдает принцип Open/Closed: добавлять новые скидки можно, не меняя код функции.
Стратегии можно оформить и классами, если нужно состояние или сложная логика, но функции проще и подходят в 90% случаев. Python делает реализацию Strategy очень лёгкой благодаря тому, что функции — объекты первого класса.
Задача 5. Декоратор cache: как закешировать функцию без lru_cache
@cache
def slow_add(a, b):
print("Computing...")
return a + b
print(slow_add(2, 3))
print(slow_add(2, 3))
print(slow_add(4, 5))
print(slow_add(2, 3))
И спрашивает:
«Сделайте такой декоратор
@cache, чтобы одинаковые вызовы не пересчитывались заново.»
Ожидаемое поведение:
Computing...
5
5
Computing...
9
5
Решение
Простейшая реализация кеша через замыкание:
import functools
def cache(func):
cache_storage = {}
@functools.wraps(func)
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
if key in cache_storage:
return cache_storage[key]
result = func(*args, **kwargs)
cache_storage[key] = result
return result
return wrapper
Объяснение
Декоратор создаёт замыкание: словарь cache_storage живёт внутри cache, но доступен из внутреннего wrapper. Ключ кеша — это комбинация args и отсортированных kwargs, чтобы гарантировать hashable структуру.
functools.wraps важен: он сохраняет имя функции, docstring и корректную сигнатуру — без него обёртка будет выглядеть как просто wrapper, что ломает introspection и документацию.
Такой декоратор — это реализация паттерна Decorator: функциональность (кеширование) добавляется, но сама функция не изменяется.
В отличие от functools.lru_cache, здесь нет лимита хранения и нет вытеснения старых ключей — это простой бесконечный кеш. Проверить работу легко: при повторном вызове строка Computing... не выводится, значит кеш сработал.
Задача 6. finally и подавление исключений: почему ошибка «пропадает»?
Это очень частая задачка на собеседованиях, которая проверяет, понимает ли кандидат, что делает finally и почему return внутри него — весьма опасная конструкция.
Интервьюер показывает такой код:
def f():
try:
raise ValueError("ошибка")
finally:
return "ок"
print(f())
Кандидат говорит:
«Функция просто вернёт
"ок", потому чтоreturnв finally выполняется в конце».
Интервьюер просит уточнить:
«А исключение куда делось? Оно точно пропало? Что именно происходит внутри?»
Решение
Корректная реализация, при которой cleanup выполняется, но исключение не теряется, выглядит так:
def f():
try:
raise ValueError("ошибка")
finally:
print("cleanup...")
# вызываем
f()
И программа действительно выбросит:
ValueError: ошибка
Объяснение
Главная проблема исходного кода — return в finally полностью подавляет исключение. Механизм такой: когда Python входит в finally, он выбрасывает то, что было в try, и выполняет то, что стоит в finally даже если там return. В результате стек ошибки теряется, и вместо исключения программа возвращает значение "ок".
Именно поэтому такой код считается плохой практикой: он маскирует реальные ошибки и делает отладку практически невозможной. В боевом коде подобные конструкции приводят к «немым» падениям, долгому дебагу и невероятно странным багам.
Исправление простое: в finally не должно быть return, а только безопасные операции cleanup — закрытие файлов, логирование, освобождение ресурсов. Тогда исключение корректно поднимется наружу.
Исправленный вариант выводит:
cleanup...
Traceback (most recent call last):
...
ValueError: ошибка
И это правильное, ожидаемое поведение — ошибка не теряется, а finally всё равно выполняется.
Задача 7. nonlocal и замыкания: почему переменная «не видна»?
Это классическая задача на понимание областей видимости и работы замыканий. Интервьюер показывает код:
def func():
a = 3
def inner(b):
a += b
return a
return inner
inner = func()
print(inner(5))
Кандидат говорит:
«Ну тут же 3 + 5, значит будет 8».
Но при запуске код выбрасывает UnboundLocalError. Почему?
Решение
Правильный вариант с nonlocal:
def func():
a = 3
def inner(b):
nonlocal a
a += b
return a
return inner
inner = func()
print(inner(5)) # 8
print(inner(7)) # 15
Объяснение
Ошибка возникает потому, что строка a += b — это присваивание. А любое присваивание внутри функции автоматически делает имя локальным для этой функции. Python определяет области видимости статически при компиляции, а не во время выполнения, поэтому он решает заранее: «Раз внутри inner есть присваивание a, значит a — локальная переменная».
Дальше происходит конфликт: при попытке прочитать локальную a до её инициализации интерпретатор выбрасывает UnboundLocalError — это особый случай, когда имя существует как локальное, но его значение ещё не задано. Это не NameError: имя существует, но его нельзя прочесть.
Чтобы сказать Python, что мы хотим модифицировать переменную из внешней функции, нужно явно объявить:
nonlocal a
Тогда inner превращается в замыкание: оно «захватывает» a и хранит её состояние между вызовами. Поэтому следующий вызов inner(7) вернёт 15, а не снова 8 или 5: переменная действительно живёт внутри замыкания.
Это важный момент: усиленное присваивание (+=) считается присваиванием, и вызывает ту же проблему, что и обычное a = a + b.
Иногда на собеседовании кандидат пытается сделать global a, но это неверно: переменная находится в enclosing scope, а не в глобальной области. Правильный способ — nonlocal.
Задача 8. Замыкания и цикл: почему все функции возвращают 25?
Это одна из самых частых ловушек на собеседованиях: проверка понимания замыканий и механики late binding в Python.
Интервьюер показывает код:
functions = []
for n in range(1, 6):
functions.append(lambda: n * n)
for func in functions:
print(func())
Кандидат говорит:
«Это же квадраты чисел от 1 до 5: 1, 4, 9, …, 25».
Но программа выводит девять одинаковых чисел — 25. Почему?
Решение
Правильный вариант — «зафиксировать» значение n при создании функции:
functions = []
for n in range(1, 6):
functions.append(lambda n=n: n * n)
for func in functions:
print(func()) # 1, 4, 9, ... 25
Объяснение
Основная причина неожиданного поведения — отложенное связывание (late binding). Lambda внутри цикла не захватывает текущее значение переменной n, она захватывает саму переменную n из внешней области видимости. Все лямбда-функции в списке ссылаются на один и тот же объект n.
После завершения цикла значение n становится равным 5. И когда мы вызываем каждую функцию, она вычисляет:
i * i → 5 * 5 → 25
То есть все девять функций используют одно финальное значение n, а не значения 1…5.
Чтобы «заморозить» текущее значение, его нужно передать как параметр со значением по умолчанию:
lambda n=n: n * n
Параметры по умолчанию вычисляются в момент определения функции, поэтому каждая lambda сохраняет своё уникальное значение n.
Альтернативы: использовать functools.partial или создавать вложенные функции, принимающие n как аргумент. Принцип тот же: сохранить значение, пока оно не изменилось.
Задача 9. Потоки, процессы и GIL: почему многопоточность не ускоряет CPU-код
Эта задача почти всегда встречается на собеседованиях — она проверяет понимание того, как устроен CPython «под капотом», и чем потоки отличаются от процессов, особенно для CPU-нагруженных задач.
Интервьюер показывает код:
import threading
import multiprocessing
import time
def cpu_task():
total = 0
for _ in range(10**7):
total += 1
return total
# Вариант 1: многопоточность
start = time.time()
threads = [threading.Thread(target=cpu_task) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print("Threads:", time.time() - start)
# Вариант 2: многопроцессность
start = time.time()
processes = [multiprocessing.Process(target=cpu_task) for _ in range(4)]
for p in processes:
p.start()
for p in processes:
p.join()
print("Processes:", time.time() - start)
Кандидат говорит:
«Это же одинаковые “четыре потока”, значит на 4 ядрах будет одинаково быстро».
Но результат выполнения показывает противоположное: процессы работают в разы быстрее. Почему так?
Решение
Ключевое объяснение — наличие GIL в CPython.
Потоки не выполняются одновременно: GIL (Global Interpreter Lock) позволяет только одному потоку выполнять Python-байткод в каждый момент времени.
Поэтому CPU-нагруженная функция
cpu_taskв первом варианте фактически работает последовательно, просто переключаясь между потоками.А вот multiprocessing создаёт четыре независимых процесса, у каждого свой интерпретатор Python и свой GIL. Эти процессы реально работают на четырёх ядрах одновременно — и получают линейное ускорение.
Объяснение
Этот пример отлично иллюстрирует, как работает GIL. Он блокирует одновременное исполнение Python-кода внутри одного процесса, поэтому многопоточность в CPython подходит для I/O-нагрузок (ожидание сети, диска), но не подходит для чистой CPU-работы.
Когда программа выполняет I/O, интерпретатор временно освобождает GIL, позволяя другим потокам работать. Поэтому скачивание файлов или обработка сетевых запросов действительно масштабируются с потоками.
Проверить отсутствие ускорения легко: достаточно сравнить время выполнения — вариант с потоками почти такой же, как один поток.
Как обойти GIL, не используя процессы? Есть несколько подходов:
переносить тяжёлые вычисления в NumPy, где операции выполняются в C-коде без GIL;
применять Cython, Numba, Rust-модули — всё, что исполняет работу вне интерпретатора;
использовать альтернативные реализации Python (PyPy, Pyston, экспериментальный CPython nogil).
Эта задача показывает, что многопоточность ≠ параллелизм, и для CPU-задач в CPython нужно выбирать процессы или нативные расширения.
Заключение
Все задачи, которые вы увидели в этой статье, — не теория и не учебные примеры. Это реальные вопросы с настоящих собеседований: от Python-разработчиков до QA Automation инженеров. Где-то они проверяют базовые концепции (итераторы, замыкания), где-то — архитектурное мышление или практическое понимание асинхронности и многопоточности.
Применимы ли эти знания в реальной работе? Ответ у каждого свой. Кто-то сталкивается с такими нюансами ежедневно, кто-то — раз в год, а кому-то они пригодятся только на собеседовании. Но одно можно сказать точно: понимание фундаментальных механизмов Python делает вас сильнее как инженера — независимо от того, где именно вы работаете и чем занимаетесь.