9 самых частых задач на Python live-coding (и как их правильно решать)
В этой статье я собрал девять самых частых задач из live-coding этапов собеседований на Python — от декораторов и замыканий до GIL и паттернов. Эти задачи регулярно встречаются в компаниях разного уровня, и их знают те, кто часто участвует в найме.
Мы разберём каждую задачу: как её формулируют интервьюеры, какие типичные ошибки делают кандидаты, и как выглядит корректное решение с пояснениями. Цель статьи простая — помочь разобраться в базовых механизмах Python, которые важны как на собеседованиях, так и в реальной работе.
Live-coding — один из самых непростых этапов собеседования по Python. Здесь не работает заучивание синтаксиса: интервьюеры проверяют фундамент, умение рассуждать и то, насколько хорошо вы понимаете язык «под капотом».
Каждая задача оформлена в формате: вопрос → задача → решение → объяснение → что хотел увидеть интервьюер.
Материал будет полезен тем, кто собирается на собеседование, готовит студентов или хочет закрыть пробелы в ключевых концепциях Python. Поехали!
Задача 1. Реализуем свой range: почему он работает только один раз
Иногда на собеседовании дают простую задачу на понимание протокола итерации в Python. Есть класс RangeLike, который должен вести себя как встроенный range:
Кандидат реализует примерно так:
Первый вызов list(r) работает, а второй возвращает пустой список. Почему?
Решение
Чтобы класс вёл себя как range, нужно разделить итерируемый объект и итератор. RangeLike должен создавать новый итератор при каждом iter(r):
Теперь:
Объяснение
В исходной реализации сам объект RangeLike был итератором: iter возвращал self. А итераторы в Python — одноразовые: после достижения StopIteration их нельзя «перезапустить». Они хранят своё состояние (current) и продолжают быть исчерпанными.
У range поведение другое: он — итерируемый объект, который при каждом вызове iter(range_obj) создаёт новый итератор с нулевым состоянием. Поэтому range можно проходить сколько угодно раз.
Разделение на контейнер (RangeLike) и итератор (RangeIterator) полностью повторяет архитектурный подход Python и делает класс ре-итерируемым.
Задача 2. Asyncio: почему код не запускается конкурентно?
Асинхронность — одна из самых частых тем в live-coding. На собеседованиях любят давать простой фрагмент кода и спрашивать: «А точно ли это выполняется параллельно?»
Вот пример:
Интуитивно кажется, что пять вызовов fetch() должны выполняться вразнобой: ведь внутри есть await asyncio.sleep(). Но вывод всегда — 0, 1, 2, 3, 4 по порядку.
Почему так происходит?
Решение
Код запускает корутины строго последовательно. Чтобы получить конкурентность, корутины нужно запланировать на выполнение:
Теперь все fetch(i) стартуют одновременно и вывод будет случайным.
Можно и короче — без промежуточного списка:
Этот вариант делает то же самое: планирует все корутины и ждёт их параллельного выполнения.
Объяснение
await fetch(i) внутри цикла не создаёт конкурентность. Это обычный последовательный вызов: выполнение main() приостанавливается до тех пор, пока не завершится текущая корутина. Только после этого цикл переходит к следующей итерации.
Чтобы функции выполнялись одновременно, их нужно превратить в задачи:asyncio.create_task() регистрирует корутину в планировщике и возвращает объект задачи, который начнёт выполняться «в фоне».
await asyncio.gather(...) уже не блокирует выполнение шаг за шагом — он ожидает завершения всех задач и позволяет им работать параллельно на уровне событийного цикла.
Важно помнить, что «создать задачу» ≠ «дождаться задачи». Без await вы рискуете потерять ошибки, а без ограничения степени конкурентности — легко уронить сервер, создав тысячи задач одновременно.
Задача 3. Декоратор time_it: измеряем время выполнения функции
Это одна из самых частых задач, проверяющих понимание декораторов и умение корректно работать с функциями. Интервьюер показывает простой пример:
И задаёт вопрос:
«Реализуйте time_it, чтобы он выводил время выполнения функции в секундах, не ломал её поведение и сохранял оригинальные метаданные (имя и docstring).»
Решение
Классический функциональный декоратор с измерением времени и functools.wraps:
Работает так:
Объяснение
Ключевая идея — не блокировать выполнение функции и не терять её результат.time.perf_counter() используется для максимально точного измерения времени. Декоратор принимает любые аргументы через args и *kwargs, передаёт их исходной функции и возвращает её результат.
Вызов @wraps(func) обязателен: он сохраняет имя, документацию и другие метаданные функции — иначе после декорирования slow_operation.__name__ превратится в "wrapper".
Такой подход универсален и подходит как для маленьких утилит, так и для профилирования больших участков кода.
Задача 4. Паттерн Strategy: переписываем логику скидок без if/elif
Классическая проверка архитектурного мышления. Интервьюер показывает функцию:
И спрашивает:
«Как переписать это через паттерн Strategy, чтобы можно было добавлять новые скидки без изменения самой функции?»
Решение
В Python Strategy реализуется естественно — через словарь функций.
Добавить стратегию? Просто дописать:
Функция при этом не меняется вообще.
Объяснение
Здесь мы заменяем цепочку if/elif на маппинг стратегий, где ключ — тип скидки, а значение — объект, реализующий алгоритм. Это и есть паттерн Strategy в чистом виде.
calculate_total теперь не знает, что именно делает каждая стратегия — она просто вызывает её. Благодаря этому решение соблюдает принцип Open/Closed: добавлять новые скидки можно, не меняя код функции.
Стратегии можно оформить и классами, если нужно состояние или сложная логика, но функции проще и подходят в 90% случаев. Python делает реализацию Strategy очень лёгкой благодаря тому, что функции — объекты первого класса.
Задача 5. Декоратор cache: как закешировать функцию без lru_cache
И спрашивает:
«Сделайте такой декоратор @cache, чтобы одинаковые вызовы не пересчитывались заново.»
Ожидаемое поведение:
Решение
Простейшая реализация кеша через замыкание:
Объяснение
functools.wraps важен: он сохраняет имя функции, docstring и корректную сигнатуру — без него обёртка будет выглядеть как просто wrapper, что ломает introspection и документацию.
В отличие от functools.lru_cache, здесь нет лимита хранения и нет вытеснения старых ключей — это простой бесконечный кеш. Проверить работу легко: при повторном вызове строка Computing... не выводится, значит кеш сработал.
Задача 6. finally и подавление исключений: почему ошибка «пропадает»?
Интервьюер показывает такой код:
Кандидат говорит:
«Функция просто вернёт "ок", потому что return в finally выполняется в конце».
Интервьюер просит уточнить:
«А исключение куда делось? Оно точно пропало? Что именно происходит внутри?»
Решение
Корректная реализация, при которой cleanup выполняется, но исключение не теряется, выглядит так:
И программа действительно выбросит:
Объяснение
Главная проблема исходного кода — return в finally полностью подавляет исключение. Механизм такой: когда Python входит в finally, он выбрасывает то, что было в try, и выполняет то, что стоит в finally даже если там return. В результате стек ошибки теряется, и вместо исключения программа возвращает значение "ок".
Именно поэтому такой код считается плохой практикой: он маскирует реальные ошибки и делает отладку практически невозможной. В боевом коде подобные конструкции приводят к «немым» падениям, долгому дебагу и невероятно странным багам.
Исправленный вариант выводит:
И это правильное, ожидаемое поведение — ошибка не теряется, а finally всё равно выполняется.
Задача 7. nonlocal и замыкания: почему переменная «не видна»?
Это классическая задача на понимание областей видимости и работы замыканий. Интервьюер показывает код:
Кандидат говорит:
«Ну тут же 3 + 5, значит будет 8».
Но при запуске код выбрасывает UnboundLocalError. Почему?
Решение
Правильный вариант с nonlocal:
Объяснение
Ошибка возникает потому, что строка a += b — это присваивание. А любое присваивание внутри функции автоматически делает имя локальным для этой функции. Python определяет области видимости статически при компиляции, а не во время выполнения, поэтому он решает заранее: «Раз внутри inner есть присваивание a, значит a — локальная переменная».
Дальше происходит конфликт: при попытке прочитать локальную a до её инициализации интерпретатор выбрасывает UnboundLocalError — это особый случай, когда имя существует как локальное, но его значение ещё не задано. Это не NameError: имя существует, но его нельзя прочесть.
Чтобы сказать Python, что мы хотим модифицировать переменную из внешней функции, нужно явно объявить:
Тогда inner превращается в замыкание: оно «захватывает» a и хранит её состояние между вызовами. Поэтому следующий вызов inner(7) вернёт 15, а не снова 8 или 5: переменная действительно живёт внутри замыкания.
Это важный момент: усиленное присваивание (+=) считается присваиванием, и вызывает ту же проблему, что и обычное a = a + b.
Иногда на собеседовании кандидат пытается сделать global a, но это неверно: переменная находится в enclosing scope, а не в глобальной области. Правильный способ — nonlocal.
Задача 8. Замыкания и цикл: почему все функции возвращают 25?
Это одна из самых частых ловушек на собеседованиях: проверка понимания замыканий и механики late binding в Python.
Интервьюер показывает код:
Кандидат говорит:
«Это же квадраты чисел от 1 до 5: 1, 4, 9, …, 25».
Но программа выводит девять одинаковых чисел — 25. Почему?
Решение
Правильный вариант — «зафиксировать» значение n при создании функции:
Объяснение
Основная причина неожиданного поведения — отложенное связывание (late binding). Lambda внутри цикла не захватывает текущее значение переменной n, она захватывает саму переменную n из внешней области видимости. Все лямбда-функции в списке ссылаются на один и тот же объект n.
После завершения цикла значение n становится равным 5. И когда мы вызываем каждую функцию, она вычисляет:
То есть все девять функций используют одно финальное значение n, а не значения 1…5.
Чтобы «заморозить» текущее значение, его нужно передать как параметр со значением по умолчанию:
Параметры по умолчанию вычисляются в момент определения функции, поэтому каждая lambda сохраняет своё уникальное значение n.
Альтернативы: использовать functools.partial или создавать вложенные функции, принимающие n как аргумент. Принцип тот же: сохранить значение, пока оно не изменилось.
Задача 9. Потоки, процессы и GIL: почему многопоточность не ускоряет CPU-код
Эта задача почти всегда встречается на собеседованиях — она проверяет понимание того, как устроен CPython «под капотом», и чем потоки отличаются от процессов, особенно для CPU-нагруженных задач.
Интервьюер показывает код:
Кандидат говорит:
«Это же одинаковые “четыре потока”, значит на 4 ядрах будет одинаково быстро».
Но результат выполнения показывает противоположное: процессы работают в разы быстрее. Почему так?
Решение
Ключевое объяснение — наличие GIL в CPython.
- Потоки не выполняются одновременно: GIL (Global Interpreter Lock) позволяет только одному потоку выполнять Python-байткод в каждый момент времени.
- Поэтому CPU-нагруженная функция cpu_task в первом варианте фактически работает последовательно, просто переключаясь между потоками.
- А вот multiprocessing создаёт четыре независимых процесса, у каждого свой интерпретатор Python и свой GIL. Эти процессы реально работают на четырёх ядрах одновременно — и получают линейное ускорение.
Объяснение
Этот пример отлично иллюстрирует, как работает GIL. Он блокирует одновременное исполнение Python-кода внутри одного процесса, поэтому многопоточность в CPython подходит для I/O-нагрузок (ожидание сети, диска), но не подходит для чистой CPU-работы.
Проверить отсутствие ускорения легко: достаточно сравнить время выполнения — вариант с потоками почти такой же, как один поток.
Как обойти GIL, не используя процессы? Есть несколько подходов:
- использовать asyncio для I/O-конкурентности;
- переносить тяжёлые вычисления в NumPy, где операции выполняются в C-коде без GIL;
- применять Cython, Numba, Rust-модули — всё, что исполняет работу вне интерпретатора;
- использовать альтернативные реализации Python (PyPy, Pyston, экспериментальный CPython nogil).
Заключение
Все задачи, которые вы увидели в этой статье, — не теория и не учебные примеры. Это реальные вопросы с настоящих собеседований: от Python-разработчиков до QA Automation инженеров. Где-то они проверяют базовые концепции (итераторы, замыкания), где-то — архитектурное мышление или практическое понимание асинхронности и многопоточности.
Применимы ли эти знания в реальной работе? Ответ у каждого свой. Кто-то сталкивается с такими нюансами ежедневно, кто-то — раз в год, а кому-то они пригодятся только на собеседовании. Но одно можно сказать точно: понимание фундаментальных механизмов Python делает вас сильнее как инженера — независимо от того, где именно вы работаете и чем занимаетесь.