Метка: next

Циклы и замыкания Python

Когда вы определяете функцию внутри другой функции и используете локальные переменные внешней функции во вложенной, вы создаете замыкание. Время жизни этих переменных «продляется» в особой области видимости enclosing даже после завершения работы внешней функции. Пример: make_adder возвращает функцию-прибавлятор. Объект из переменной a будет жить и работать даже после выхода из make_adder:

def make_adder(a):
    def adder(x):
        return a + x
    return adder

plus_5 = make_adder(5)
print(plus_5(3))  # 8

Здесь я хочу коснуться одной популярной проблемы. Дело в том, что если мы создадим несколько функций внутри одного контекста, то они будут разделять одну область видимости enclosing. Рассмотрим пример создания трех функций в цикле:

def make_adders():
    adders = []
    for a in range(3):
        def adder(x):
            return a + x
        adders.append(adder)
    return adders

adders = make_adders()
for adder in adders:
    print(adder(2))  # 4 4 4

Вместо функций прибавляющих разные числа от 0 до 2, мы получили 3 одинаковых функции, потому что внутри себя они поддерживают ссылку на одну и ту же переменную a, значение которой останется равным 2 после выполнения всего цикла целиком.

Есть простой прием, помогающий «зафиксировать» значения переменной в моменте: достаточно добавить во вложенную функцию дополнительный аргумент со значением по умолчанию, равным нужной переменной a=a:

def make_adders():
    adders = []
    for a in range(3):
        def adder(x, a=a):  # FIX!
            return a + x
        adders.append(adder)
    return adders

adders = make_adders()
for adder in adders:
    print(adder(2))  # 2 3 4

Еще лучше переименовать аргумент, чтобы избежать конфликтов имен и замечаний IDE, например, так:

def adder(x, that_a=a):  # FIX!
    return that_a + x

yield

Пока писал код для этого поста, я наткнулся на одну обманку. Люблю оформлять функции, возвращающие коллекции, как генераторы с ключевым словом yield. Вот так:

def make_adders():
    for a in range(3):
        def adder(x):
            return a + x
        yield adder

adders = make_adders()
for adder in adders:
    print(adder(2))  # 2 3 4

Видите, тут нет фикса a=a! Казалось бы, что код должен также содержать в себе баг и выводить «4 4 4», но он работает, как задумано изначально.

Однако, если мы применим list к генератору, извлекая все значения разом, то баг вернется:

adders = list(make_adders())
for adder in adders:
    print(adder(2))  # 4 4 4

Разгадка. В первом случае происходят следующие действия:

  • a = 0
  • yield функцию (a + x), make_adders становится на паузу
  • печать adder(2) = 0 + 2 = 2
  • make_adders запускается
  • a = 1
  • yield функцию (a + x), пауза
  • печать adder(2) = 1 + 2 = 2
  • … и так далее…

То есть мы запускаем adder только один раз в тот момент, пока переменная a еще равна нужному значению. 

Во втором код list прокручивает make_adders до конца, оставляя a = 2, и все функции выдают одинаковый результат.

Вывод мы должны сделать такой: yield не создает нового замыкания с отдельной переменной a и не освобождает нас от ответственности следить за переменными.

Еще кое-что. 

adders = make_adders()
for adder in adders:
    print(adder(2))  # 2 3 4

next(adders)  # StopIteration

После исполнения цикла в коде выше, генератор adders будет исчерпан. В нем больше не останется значений, и если еще раз запустить цикл по adders, то он пройдет ровно 0 итераций. 

Генератор – вещь одноразовая.

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Найти первый элемент списка по условию

Пускай имеется такая задача: дан список с численными элементами. Требуется найти и вернуть первый отрицательный элемент. Казалось бы, должна быть какая-нибудь встроенная функция для этого, но нет. Придется писать ее самим. Решение в лоб:

items = [1, 3, 5, -17, 20, 3, -6]

for x in items:
    if x < 0:
        print(x)
        break
else:
    print('not found')

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

result = list(filter(lambda x: x < 0, items))[0]
print(result)

По-моему, стало гораздо сложнее, хоть и в одну строку. А может лучше так:

result = [x for x in items if x < 0][0]

Что ж, теперь лаконичнее, но все равно не идеал. Какая самая большая ошибка здесь? Что в первом, что во втором случае идет перебор всего итератора до конца, а потом отбрасываются все лишние значения, кроме нулевого индекса. Тогда как изначальный код останавливается, найдя нужно значение, экономя и время, и память.

Правильное решение

Лучше использовать встроенную функцию next – она возвращает следующий элемент из итератора, а в качестве итератора мы напишем генераторное выражение с if. Вот так:

result = next(x for x in items if x < 0)

Вот это коротко, экономно и очень по-питоновски (in a pythonic way). Остается одна проблемка: если элемент не найден, что будет брошено исключение StopIteration. Чтобы подавить его, достаточно вторым аргументом в next передать значение по-умолчанию. Если оно задано, то оно будет возвращено вместо возбуждения исключения, если в итераторе нет элементов, то есть не найдено удовлетворяющих условию элементов в исходной коллекции. И не забыть обернуть генераторное выражение в скобки:

items = [1, 2, 4]
result = next((x for x in items if x < 0), 'not found')
print(result)  # not found

С произвольной функцией, задающей критерий поиска (ее еще называют предикат – predicate) это выглядит так:

def is_odd(x):
    return x % 2 != 0

result = next(x for x in items if is_odd(x))
# или еще лучше
result = next(filter(is_odd, items))

Так как в Python 3 filter работает лениво, как и генератор, она не «обналичивает» весь исходный список через фильтр, а лишь идет до первого удачно-выполненного условия. Любите итераторы! ✌️ 

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈