Четыре способа вернуть последовательность
Такое любят спрашивать на скрининге джуна: показывают четыре функции, которые в цикле 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, второй проход []
Хочешь так же уверенно решать на собеседовании?
Читать разбор полезно, а настоящая прокачка — самому решать такие задачи и получать обратную связь. Потренируйся в симуляторе аналитика, лёгкий режим — бесплатно.
Прорешать в бесплатной песочнице