Comprehensions и функции
Comprehension — питоничный map/filter; generator expression стримит лениво по огромным входам. У функций острая ловушка — аргументы по умолчанию вычисляются один раз при def — плюс *args/**kwargs и LEGB-замыкания, непохожие на JS.
Коллега переносит JS-пайплайн на Python и пишет result = list(map(lambda x: x * 2, filter(lambda x: x > 0, nums))). Работает, но читается как перевод, а не как Python. А другая функция, def add_tag(tag, tags=[]), вдруг начинает возвращать теги из прошлых вызовов, которых она не получала, — призрак, копящийся на весь процесс. Оба случая — один и тот же урок: инструменты Python на уровне выражений и его модель функций ведут себя не как в JavaScript, и расхождения бьют ровно там, где ты перестаёшь следить.
Comprehension: map и filter, слитые в одно выражение
List comprehension — идиоматичная замена цепочке .map() и .filter() в Python. Форма [f(x) for x in xs if pred(x)] читается в порядке вычисления — источник, фильтр, преобразование — и за один проход даёт список. Там, где JavaScript пишет xs.filter(pred).map(f), Python ставит преобразование первым, а фильтр последним, но данные текут так же: каждый элемент xs проверяется pred, и выжившие проходят через f.
nums = [-2, -1, 0, 1, 2, 3]
# JS-эквивалент: nums.filter(x => x > 0).map(x => x * 2)
doubled = [x * 2 for x in nums if x > 0] # [2, 4, 6]
# Тот же синтаксис, другие скобки, другая коллекция:
squares = {x: x * x for x in range(4)} # dict: {0: 0, 1: 1, 2: 4, 3: 9}
unique = {len(w) for w in ["a", "bb", "cc"]} # set: {1, 2}Форма {...} даёт dict или set comprehension в зависимости от того, пишешь ли ты k: v или голый элемент, — настолько прямого аналога объектному литералу в JS нет. Это первая причина, почему comprehensions важны: одно семейство синтаксиса покрывает map, filter и построение любой встроенной коллекции.
Вложенные comprehensions существуют — [c for row in matrix for c in row] расплющивает 2D-список, причём циклы читаются слева направо, как если бы ты вложил for-инструкции. Но именно тут читаемость гибнет быстро. Comprehension с двумя for и условием — на грани того, что читатель распарсит с одного взгляда; три — уже за гранью. Сеньорское суждение: comprehension должен умещаться в голове за один проход. Как только перестаёт — пиши явный цикл. Хитрость в comprehension — это цена, которую платишь на каждом будущем прочтении.
Генераторы: та же форма, но лениво
Замени квадратные скобки на круглые, и [f(x) for x in xs] становится generator expression (f(x) for x in xs). Это не косметика. List comprehension жадный: он строит весь список в памяти до того, как выполнится следующая строка. Генератор ленивый: вычисляет по одному значению, только когда попросят, и держит по сути постоянную память независимо от того, сколько элементов через него проходит.
# Жадно: материализует 10 миллионов int — сотни МБ резидентно.
total = sum([x * x for x in range(10_000_000)])
# Лениво: по одному значению, память остаётся плоской.
total = sum(x * x for x in range(10_000_000))Та же идея движет ключевым словом yield. Функция, содержащая yield, — это функция-генератор: её вызов не выполняет тело, а возвращает объект-генератор, который работает лениво, приостанавливаясь на каждом yield и возобновляясь по следующему запросу. Так стримят данные, не помещающиеся в память.
def read_lines(path):
with open(path) as f:
for line in f:
yield line.rstrip("\n") # резидентна одна строка, а не весь файл
for line in read_lines("huge.log"): # постоянная память на файле 50 ГБ
...Это и есть настоящий сеньорский смысл всей первой половины. Тянись к жадному списку, когда результат нужен не один раз, нужна его длина или индексация. Тянись к ленивому генератору, когда вход большой или неограниченный, ты проходишь по нему один раз и хочешь, чтобы память оставалась плоской, — стриминговый пайплайн, чтение файла, пагинатор API.
▸Почему это работает
Генератор одноразовый: это итератор, который исчерпывается по мере потребления. После одного полного прохода он пуст — второй for-цикл по тому же объекту-генератору не даст ничего, а list(gen) после того, как ты уже проитерировал, вернёт []. Это удивляет тех, кто пришёл из JS-массивов, которые можно перебирать сколько угодно раз. Если нужно пройти данные дважды — либо материализуй их один раз через list(gen), либо построй новый генератор. Лень, экономящая память, ровно и делает его одноразовым.
Функции: аргументы и ловушка, определяющая Python
Прежде чем писать первую питоновскую функцию с опциональным аргументом-списком, выучи одно правило — иначе потратишь полдня на охоту за призраком.
def определяет функцию; аргументы передаются позиционно или по ключу, а параметры могут нести значения по умолчанию: def connect(host, port=5432, *, timeout=30). Голая * помечает всё после неё только-по-ключу — вызывающий обязан писать timeout=10, а не голую позицию, — так держат место вызова читаемым по мере роста сигнатуры. *args собирает лишние позиционные в кортеж, а **kwargs — лишние ключевые в словарь; на месте вызова те же */** распаковывают итерируемое или отображение в аргументы — питоновский аналог JS spread.
def log(level, *args, **kwargs):
print(level, args, kwargs)
log("INFO", "started", port=8080) # INFO ('started',) {'port': 8080}Теперь ловушка. Аргумент по умолчанию вычисляется ровно один раз, когда выполняется инструкция def, — а не на каждый вызов. Если default — изменяемый объект, каждый вызов, опускающий этот аргумент, делит один и тот же объект, и мутации сохраняются между вызовами.
def add_tag(tag, tags=[]): # [] создаётся ОДИН раз, при def
tags.append(tag)
return tags
add_tag("a") # ['a']
add_tag("b") # ['a', 'b'] ← тот же список, переживший вызовыЭто не баг Python; это прямое следствие правила «defaults вычисляются во время определения». JavaScript перевычисляет параметры по умолчанию на каждый вызов, поэтому function f(x = []) свежий каждый раз — ровно наоборот. Идиоматичный питоновский фикс — sentinel:
def add_tag(tag, tags=None):
if tags is None:
tags = [] # свежий список на каждый вызов, опускающий его
tags.append(tag)
return tagsLambda, замыкания и LEGB-scope
lambda создаёт анонимную функцию, но она намеренно ограничена: одно выражение, никаких инструкций, никаких аннотаций. Это не JS arrow function — многострочной lambda не бывает. Используй её для одноразовой key-функции (sorted(words, key=lambda w: len(w))) и тянись к именованному def в тот же миг, когда ей нужна вторая строка.
Функции — объекты первого класса (передаются, возвращаются, хранятся), а внутренняя функция, ссылающаяся на переменную внешней, образует замыкание (closure), захватывая её по ссылке. Разрешение имён идёт по LEGB: Python смотрит в Local-scope, затем в любые объемлющие (Enclosing) scope функций, затем в Global (модульный) scope, затем в Built-ins. Важно: присваивание имени внутри функции делает его локальным на всю функцию, если не сказать иное, — global перепривязывает имя уровня модуля, а nonlocal — имя в ближайшей объемлющей функции. JS тоже разрешает scope лексически, но let/const дают привязку на блок, тогда как единица scope в Python — функция, и «присваивание означает локальное» — более острый край.
def make_counter():
count = 0
def tick():
nonlocal count # без этого count += 1 кидает UnboundLocalError
count += 1
return count
return tick| Идиома JavaScript | Идиома Python | Заметка |
|---|---|---|
xs.map(f) | [f(x) for x in xs] | Comprehension, один проход |
xs.filter(p) | [x for x in xs if p(x)] | Фильтр — это хвостовой if |
x => x * 2 | lambda x: x * 2 | Только одно выражение; иначе def |
f(…arr) / {…obj} | f(*seq) / f(**mapping) | Spread → распаковка |
function(…args) | def f(*args, **kwargs) | Rest-параметры → *args/**kwargs |
генераторы / yield | (f(x) for x in xs) / yield | Та же лень, круглые скобки, не квадратные |
function f(x = []) свежий каждый вызов | def f(x=None) + sentinel | Default в Python вычисляется один раз при def |
Что напечатает add_tag('a'); add_tag('b'), если def add_tag(tag, tags=[]): tags.append(tag); return tags?
g = (x*x for x in range(3)). После total = sum(g) что вернёт list(g)?
Пайплайн должен преобразовать лог-файл 50 ГБ построчно и записать сводку на машине с 2 ГБ RAM. Выбери конструкцию.
- 01Когда выбирать generator expression вместо list comprehension и какова цена?
- 02Почему def f(x=[]) копит состояние между вызовами и каков идиоматичный фикс?
Comprehension — это слитые map и filter Python: [f(x) for x in xs if pred(x)] заменяет цепочку .filter().map() одним читаемым проходом, а форма {...} строит ещё и dict, и set, — но comprehension, который больше не умещается в голове за один проход, должен стать явным циклом. Замена квадратных скобок на круглые превращает его в ленивый generator expression (выражение-генератор, вычисляет по одному значению) — та же ленивая механика, что и yield: по одному значению, постоянная память, одноразовость. Собирай списком, когда результат нужен многократно, нужна длина или индексация; стримь генератором, когда вход большой или одно-проходный. На стороне функций *args/**kwargs собирают и распаковывают аргументы, голая * делает параметры только-по-ключу, а lambda — одноразовое одно-выражение; тянись к def, как только она растёт. Определяющая ловушка: аргументы по умолчанию вычисляются один раз при def, поэтому изменяемый default вроде [] общий для вызовов; используй None плюс sentinel. Наконец, разрешение имён — LEGB, scope — на функцию, а не на блок, и присваивание делает имя локальным, пока ты не тянешься к global или nonlocal, чтобы перепривязать внешнее. Теперь, когда встречаешь в сигнатуре x=None с проверкой сразу в теле или генераторное выражение, переданное прямо в sum(), — ты видишь намеренный паттерн, а не случайность.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.