Задачи по Python с собеседований
Python·junior

Четыре способа вернуть последовательность

Такое любят спрашивать на скрининге джуна: показывают четыре функции, которые в цикле for печатают одно и то же — числа от 0 до 9 — и просят объяснить, чем они на самом деле различаются. Внешне вывод идентичный, а вот что творится под капотом — совсем разное.

Задача

Есть четыре реализации функции, возвращающей последовательность чисел:

def gen_seq():
    return range(10)                       # 1: range
def gen_seq():
    return [num for num in range(10)]      # 2: генератор списков
def gen_seq():
    return (0, 1, 2, 3, 4, 5, 6, 7, 8, 9)  # 3: кортеж
def gen_seq():
    num = 0
    while num < 10:
        yield num                          # 4: генератор через yield
        num += 1

В цикле for i in gen_seq(): print(i) все четыре печатают числа от 0 до 9. Вопрос собеседующего: в чём разница и когда какой вариант выбрать?

Решение

def via_range():
    return range(5)


def via_list():
    return [n for n in range(5)]


def via_tuple():
    return tuple(range(5))


def via_generator():
    n = 0
    while n < 5:
        yield n
        n += 1


for name, factory in [
    ("range", via_range),
    ("list", via_list),
    ("tuple", via_tuple),
    ("generator", via_generator),
]:
    seq = factory()
    first = list(seq)
    second = list(seq)
    print(f"{name:10} первый проход {first}, тип {type(seq).__name__}, второй проход {second}")

Разбор

Ключ к ответу — прогнать каждую последовательность дважды. Тут и вылезает разница, которую цикл for с одним проходом прячет.

list и tuple — материализованные коллекции: они целиком лежат в памяти. Пробегай их сколько угодно раз, результат один. Плата — память: на миллион чисел уйдёт мегабайты, даже если тебе нужно только просуммировать их за один проход.

range — ленивый, но особенный: это не генератор, а полноценный последовательный объект. Он не хранит все числа, а вычисляет их на лету по формуле, поэтому занимает константную память. При этом его можно итерировать повторно — второй проход снова даёт [0, 1, 2, 3, 4].

А вот yield-функция — настоящий генератор, и здесь главная ловушка. Генератор — это одноразовый итератор: он помнит, где остановился, выдаёт значения по одному и после исчерпания больше ничего не отдаёт. Поэтому второй list(seq) возвращает пустой список — генератор уже «прокручен» до конца и не перематывается. Именно это чаще всего роняет код в проде: кто-то итерирует генератор в цикле, а потом пытается посчитать len() или пройтись ещё раз — и получает ноль строк без единой ошибки.

Практический вывод. Нужны все данные и повторный доступ — бери list/tuple. Нужно пройти большой поток один раз, экономя память (лог-файл, выгрузка из БД построчно) — генератор или range. Разница между «памятью» и «ленивостью» — любимая тема продолжения: часто следом спрашивают про мутабельность аргументов, чтобы проверить, понимаешь ли ты, как Python вообще обращается с объектами.

Ожидаемый результат

range      первый проход [0, 1, 2, 3, 4], тип range, второй проход [0, 1, 2, 3, 4]
list       первый проход [0, 1, 2, 3, 4], тип list, второй проход [0, 1, 2, 3, 4]
tuple      первый проход [0, 1, 2, 3, 4], тип tuple, второй проход [0, 1, 2, 3, 4]
generator  первый проход [0, 1, 2, 3, 4], тип generator, второй проход []

Хочешь так же уверенно решать на собеседовании?

Читать разбор полезно, а настоящая прокачка — самому решать такие задачи и получать обратную связь. Потренируйся в симуляторе аналитика, лёгкий режим — бесплатно.

Прорешать в бесплатной песочнице

Смежные задачи

Вопросы по теме

Из блога