Python: вопросы на собеседовании

Изменяемость, словари изнутри, генераторы, декораторы, GIL, asyncio, контекстные менеджеры и типичные ловушки языка.

#1Типы данныхjunior

Чем изменяемые типы отличаются от неизменяемых? Почему список нельзя сделать ключом словаря?

Неизменяемые (immutable): int, float, str, tuple, frozenset, bytes. Любая «модификация» создаёт новый объект.
Изменяемые (mutable): list, dict, set, bytearray, пользовательские объекты по умолчанию.

s = "abc"
print(id(s))
s += "d"        # новый объект, id изменился

lst = [1, 2]
lst.append(3)   # тот же объект изменён на месте

Почему список не может быть ключом. Ключ словаря должен быть хешируемым: его хеш не должен меняться за время жизни, иначе словарь не найдёт значение в той корзине, куда его положил. У изменяемых контейнеров __hash__ намеренно равен None, поэтому {[1, 2]: "x"} даёт TypeError: unhashable type: 'list'. Решение — превратить в tuple.

Нюанс, который отличает сильного кандидата: кортеж хешируем, только если хешируемо всё его содержимое. hash((1, [2])) тоже упадёт.

Практическое следствие — все переменные в Python это ссылки. Передали список в функцию и изменили — изменения видны снаружи. Отсюда ловушка изменяемого аргумента по умолчанию.

  • Что будет с def f(x=[])?
  • Как сделать пользовательский класс хешируемым?
#2Ловушки языкаjunior

Что не так с функцией def add(item, items=[])?

Значения аргументов по умолчанию вычисляются один раз — в момент определения функции, а не при каждом вызове. Список создаётся один и разделяется между всеми вызовами:

def add(item, items=[]):
    items.append(item)
    return items

add(1)  # [1]
add(2)  # [1, 2]  — сюрприз

Правильно — использовать None как маркер:

def add(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

То же касается dict, set и любых изменяемых объектов, а также вызовов вроде def log(ts=datetime.now()) — время зафиксируется при импорте модуля.

Линтеры (ruff, pylint) ловят эту ошибку автоматически — правило B006. Упомяните это: интервьюеру важно, что вы знаете не только ловушку, но и инструмент против неё.

  • Где хранятся значения по умолчанию у функции?
  • Когда такое поведение используют намеренно?
#3Генераторыjunior

Что такое генераторы и yield? Чем генератор отличается от списка?

Генератор — функция с yield, которая возвращает значения по одному и сохраняет своё состояние между вызовами. Значения вычисляются лениво — только когда их запрашивают.

def read_lines(path):
    with open(path) as f:
        for line in f:
            yield line.rstrip()

for line in read_lines("huge.log"):   # файл на 10 ГБ читается построчно
    if "ERROR" in line:
        print(line)

Отличия от списка:

  • память — O(1) вместо O(n): элементы не хранятся;
  • генератор одноразовый: второй проход по нему ничего не вернёт;
  • нет len() и доступа по индексу;
  • вычисления происходят при итерации, а не при создании.

Генераторное выражение — ленивый аналог списковой включения: sum(x * x for x in range(10**8)) работает в константной памяти, а sum([x * x for x in range(10**8)]) создаст список на сотни мегабайт.

Где применять: обработка больших файлов и потоков данных, бесконечные последовательности, конвейеры обработки (itertools). Под капотом генератор — это итератор: у него есть __iter__ и __next__, а конец обозначается исключением StopIteration.

  • Чем итерируемый объект отличается от итератора?
  • Что делает yield from?
#4Функцииmiddle

Как работают декораторы? Напишите декоратор, замеряющий время выполнения.

Декоратор — функция, которая принимает функцию и возвращает новую, обычно обёртку с дополнительным поведением. Синтаксис @timer — сахар для func = timer(func). Работает благодаря тому, что функции в Python — объекты первого класса, а обёртка — замыкание.

import functools
import time

def timer(func):
    @functools.wraps(func)          # сохраняем __name__, __doc__
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            print(f"{func.__name__}: {time.perf_counter() - start:.3f} c")
    return wrapper

@timer
def slow():
    time.sleep(0.5)

Декоратор с параметрами — это функция, возвращающая декоратор, то есть три уровня вложенности:

def retry(times=3):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception:
                    if attempt == times - 1:
                        raise
        return wrapper
    return decorator

Что проверяют: знание functools.wraps (без него теряется имя и докстринг, ломается отладка), *args, **kwargs для любой сигнатуры, perf_counter вместо time.time для замеров. Примеры из жизни: @app.get во FastAPI, @pytest.fixture, @lru_cache, @property.

  • В каком порядке применяются несколько декораторов?
  • Как написать декоратор класса?
#5Конкурентностьmiddle

Что такое GIL? Когда использовать threading, multiprocessing и asyncio?

GIL (Global Interpreter Lock) — мьютекс в CPython, который разрешает исполнять байткод Python только одному потоку в каждый момент времени. Он упрощает управление памятью (подсчёт ссылок не нужно защищать), но не даёт потокам параллельно нагружать несколько ядер процессора.

Как выбирать:

  • Задача упирается в ввод-вывод (сеть, диск, база) — GIL отпускается на время ожидания, поэтому подходят threading и особенно `asyncio`: тысячи одновременных соединений в одном потоке без накладных расходов на потоки ОС.
  • Задача упирается в процессор (вычисления, обработка изображений) — нужен `multiprocessing` или concurrent.futures.ProcessPoolExecutor: у каждого процесса свой интерпретатор и свой GIL. Цена — сериализация данных между процессами.
  • Тяжёлые вычисления в NumPy, pandas и других C-расширениях часто отпускают GIL сами, поэтому потоки с ними работают параллельно.

Про будущее: начиная с Python 3.13 есть экспериментальная сборка без GIL (free-threaded, PEP 703). Стоит упомянуть, что вы о ней слышали, но в продакшене по умолчанию GIL по-прежнему есть.

Типичная ошибка на собеседовании — сказать, что потоки в Python бесполезны. Для сетевых задач они вполне работают; бесполезны они только для чистых вычислений на Python.

  • Чем async-функция отличается от обычной?
  • Что будет, если в asyncio вызвать time.sleep?

Ещё 7 вопросов в этом треке

  1. Как устроен dict в Python? Сохраняет ли он порядок ключей?
  2. Чем is отличается от ==? Почему a is b для чисел иногда True, а иногда False?
  3. Что такое *args и **kwargs? Как работает распаковка?
  4. Как работают контекстные менеджеры и оператор with?
  5. Как работает asyncio? Что будет, если внутри корутины вызвать блокирующую функцию?
  6. Что такое MRO и как работает super() при множественном наследовании?
  7. Какова сложность основных операций со list, dict и set? Где чаще всего теряют производительность?