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, не используя процессы? Есть несколько подходов:

  • использовать asyncio для I/O-конкурентности;

  • переносить тяжёлые вычисления в NumPy, где операции выполняются в C-коде без GIL;

  • применять Cython, Numba, Rust-модули — всё, что исполняет работу вне интерпретатора;

  • использовать альтернативные реализации Python (PyPy, Pyston, экспериментальный CPython nogil).

Эта задача показывает, что многопоточность ≠ параллелизм, и для CPU-задач в CPython нужно выбирать процессы или нативные расширения.

Заключение

Все задачи, которые вы увидели в этой статье, — не теория и не учебные примеры. Это реальные вопросы с настоящих собеседований: от Python-разработчиков до QA Automation инженеров. Где-то они проверяют базовые концепции (итераторы, замыкания), где-то — архитектурное мышление или практическое понимание асинхронности и многопоточности.

Применимы ли эти знания в реальной работе? Ответ у каждого свой. Кто-то сталкивается с такими нюансами ежедневно, кто-то — раз в год, а кому-то они пригодятся только на собеседовании. Но одно можно сказать точно: понимание фундаментальных механизмов Python делает вас сильнее как инженера — независимо от того, где именно вы работаете и чем занимаетесь.

Комментарии (0)