[Перевод] Вот почему нужно использовать оператор := в Python

zcp1bsoyiaip9ygglngynuox4oq.jpeg

Сегодня рассказываем о самом странном операторе Python — операторе моржа. Для чего он нужен, и как использовать его с учётом других особенностей языка? Подробности к старту курса по Fullstack-разработке на Python — под катом:


Оператор присваивания (или оператор моржа) появился ещё в Python 3.8, но он всё ещё вызывает споры, а многие необоснованно его ненавидят. Я же попытаюсь убедить вас, что этот оператор — хорошее дополнение к языку, а при правильном применении он может помочь сделать код короче и яснее.

Основы


Посмотрим на основные варианты применения оператора моржа, которые могут убедить вас попробовать, если вы ещё не знакомы с :=.

В первом примере, который я хочу показать, оператор моржа сокращает количество вызовов функций.

Представим функцию func(), которая выполняет очень ресурсоёмкие вычисления. Её работа занимает много времени, поэтому вызывать функцию много раз не хочется:

# "func" вызывается три раза
result = [func(x), func(x)**2, func(x)**3]

# Используем результат "func" в одну строку, без многострочного кода

result = [y := func(x), y**2, y**3]


В объявлении первого списка выше func(x) вызывается трижды. Каждый раз возвращается один и тот же результат — и это пустая трата времени ресурсов. При перезаписи с оператором моржа func() вызывается только один раз: её результат присваивается y и повторно используется для оставшихся значений списка. Вы можете возразить: «Морж не нужен, я могу просто добавить y = func(x) перед объявлением списка!» Да, но это лишняя строка кода, и на первый взгляд — не зная, что func(x) работает очень медленно, — может быть непонятно, для чего нужна переменная y.

Если это не убедило вас, есть ещё кое-что. Вот списковое включение с той же дорогостоящей func():

result = [func(x) for x in data if func(x)]

result = [y for x in data if (y := func(x))]


В первой строке функция func(x) в каждом цикле вызывается дважды. С оператором моржа функция вычисляется один раз, внутри if, а затем результат используется повторно. Длина кода одинаковая, обе строки одинаково читаемы, но вторая в два раза эффективнее. Производительность можно сохранить, заменив оператор моржа на полный цикл for, но для этого потребуется 5 строк кода.

Один из самых распространённых вариантов применения оператора моржа — сокращение вложенных условий, например в сопоставлении при работе с регулярными выражениями:

import re

test = "Something to match"

pattern1 = r"^.*(thing).*"
pattern2 = r"^.*(not present).*"

m = re.match(pattern1, test)
if m:
    print(f"Matched the 1st pattern: {m.group(1)}")
else:
    m = re.match(pattern2, test)
    if m:
        print(f"Matched the 2nd pattern: {m.group(1)}")

# ---------------------

# Чище

if m := (re.match(pattern1, test)):
    print(f"Matched 1st pattern: '{m.group(1)}'")
elif m := (re.match(pattern2, test)):
    print(f"Matched 2nd pattern: '{m.group(1)}'")


Код сократился с 7 до 4 строк, а за счёт удаления вложенного if стал более читаемым.

Следующая в списке — идиома «loop-and-half» («цикл пополам»):

while True:  # Цикл
    command = input("> ")
    if command == 'exit':  # и пополам
        break
    print("Your command was:", command)

# ---------------------

# Чище

while (command := input("> ")) != "exit":
    print("Your command was:", command)


Обычное решение — фиктивный бесконечный цикл while, в котором поток управления передаётся оператору break. Но также можно задействовать оператор моржа, чтобы переназначить значение command, а затем использовать его в условном цикле while в той же строке. Это сделает код намного чище и короче.

Аналогичное упрощение применимо и к другим циклам while, например при чтении файлов построчно или при получении данных из сокета.

Накапливание (аккумулирование) данных на месте


Перейдём к более сложным случаям применения оператора моржа:

data = [5, 4, 3, 2]
c = 0; print([(c := c + x) for x in data])  # c = 14
# [5, 9, 12, 14]

from itertools import accumulate
print(list(accumulate(data)))

# ---------------------

data = [5, 4, 3, 2]
print(list(accumulate(data, lambda a, b: a*b)))

# [5, 20, 60, 120]

a = 1; print([(a := a*b) for b in data])

# [5, 20, 60, 120]


Первые две строки показывают, как использовать := для вычисления промежуточного результата. В настолько простом случае лучше подойдёт функция itertools, например accumulate, как в двух следующих строках. Но в ситуациях сложнее itertools довольно быстро становится нечитаемым, и, на мой взгляд, версия с := намного лучше, чем с lambda.

Если вы всё ещё не уверены, ознакомьтесь с нечитабельными примерами в документации accumulate (с накоплением процентов или логистическим отображением). Попробуйте переписать их с оператором моржа, они станут выглядеть намного лучше.


nkj2oztxanscb6lhq19l-dfv2z8.jpeg
Именно так, шаг за шагом, вы станете востребованным профессионалом в области IT:


Именование значений внутри f-строки


Этот пример показывает возможности и ограничения :=, но не лучшие практики.
Если очень хочется, := можно использовать внутри f-строк:

from datetime import datetime

print(f"Today is: {(today:=datetime.today()):%Y-%m-%d}, which is {today:%A}")

# Today is: 2022-07-01, which is Friday

from math import radians, sin, cos
angle = 60
print(f'{angle=}\N{degree sign} {(theta := radians(angle)) =: .2f}, {sin(theta) =: .2f}, {cos(theta) =: .2f}')

# angle=60° (theta := radians(angle)) = 1.05, sin(theta) = 0.87, cos(theta) = 0.50


В первом print оператор := определяет переменную today, которая затем используется в той же строке; today избавляет нас от повторного вызова datetime.today().

Точно так же во втором примере объявляется переменная theta, которая повторно используется для вычисления sin(theta) и cos(theta). В этом случае мы также используем его в сочетании с оператором «обратный морж» — на самом деле это просто =, с ним выражение выводится рядом с его значением, а : используется для форматирования выражения.

Обратите внимание: чтобы f-строка правильно интерпретировала выражения с оператором моржа, их нужно заключать в круглые скобки.


Any и All


Чтобы проверить, удовлетворяют ли какие-то значения в некотором итерируемом объекте определённому условию, можно восопользоваться функциями any() и all(). Но что делать, если нужно зафиксировать значение, из-за которого any() вернуло True (так называемое «свидетельство»), или значение, которое привело к сбою all() — «контрпример»?

numbers = [1, 4, 6, 2, 12, 4, 15]

# Возвращает только логические значения, а не обычные

print(any(number > 10 for number in numbers))  # True
print(all(number < 10 for number in numbers))  # False

# ---------------------

any((value := number) > 10 for number in numbers)  # True
print(value)  # 12

all((counter_example := number) < 10 for number in numbers)  # False
print(counter_example)  # 12


И any(), и all() для вычисления выражения используют замыкание, то есть прекращают вычисления, как только находят первое «свидетельство» или «контрпример» соответственно. А значит, при таком трюке созданная оператором моржа переменная всегда будет возвращать первого «свидетеля» или «контрпример».

Подводные камни и ограничения


Хотя выше я пытался убедить использовать оператор моржа,  думаю, также важно предупредить о некоторых его недостатках и ограничениях. Ниже — подводные камни, с которыми вы можете столкнуться.

В предыдущем примере вы видели, что замыкание полезно для захвата значений в any()/all(), но иногда это может привести к неожиданным результатам:

for i in range(1, 100):
    if (two := i % 2 == 0) and (three := i % 3 == 0):
        print(f"{i} is divisible by 6.")
    elif two:
        print(f"{i} is divisible by 2.")
    elif three:
        print(f"{i} is divisible by 3.")

# NameError: name 'three' is not defined [имя 'three' не определено]


Мы создали условие с двумя соединёнными and присваиваниями. Эти and проверяют, делится ли число на 2, 3 или 6 в зависимости от того, выполняются ли первое, второе или оба условия. На первый взгляд это может показаться хорошим приёмом, но из-за замыкания, если выражение (two: = i % 2 == 0) не выполняется, вторая часть будет пропущена, а значит, имя three окажется неопределённым или будет иметь устаревшее значение из предыдущего цикла.

Замыкание может быть и преднамеренным. Использовать его можно с регулярными выражениями, чтобы найти в строке несколько паттернов:

import re

tests = ["Something to match", "Second one is present"]

pattern1 = r"^.*(thing).*"
pattern2 = r"^.*(present).*"

for test in tests:
    m = re.match(pattern1, test)
    if m:
        print(f"Matched the 1st pattern: {m.group(1)}")
    else:
        m = re.match(pattern2, test)
        if m:
            print(f"Matched the 2nd pattern: {m.group(1)}")

# Сопоставлено с первым паттерном: thing

# Сопоставлено со вторым паттерном: present

for test in tests:
    if m := (re.match(pattern1, test) or re.match(pattern2, test)):
        print(f"Matched: '{m.group(1)}'")
        # Сопоставлено: 'thing'
        # Сопоставлено: 'present'


Мы уже видели версию этого фрагмента в первом разделе статьи, где в сочетании с оператором моржа использовались if/elif. Здесь код упрощается ещё больше: условное выражение сокращается до единственного if.

Если вы только знакомитесь с оператором моржа, то заметите, что он заставляет переменные области видимости вести себя по-разному при списковом включении:

values = [3, 5, 2, 6, 12, 7, 15]

tmp = "unmodified"
dummy = [tmp for tmp in values]
print(tmp)  # Как и ожидалось, "tmp" не разбился на элементы. Он по-прежему "немодифицированный"

total = 0
partial_sums = [total := total + v for v in values]
print(total)  # Выводит: 50


При обычном списковом включении list, dict или set переменная цикла не просачивается в окружающую область, и поэтому любые существующие переменные с тем же именем не изменятся. Но с оператором моржа переменная из включения (total в приведённом выше коде) останется доступной после завершения включения, она примет значение из внутреннего включения.

Когда вы освоитесь с оператором моржа, то сможете попробовать его в других случаях. Но вот одно место, где вы никогда не должны его использовать, — это оператор with:

class ContextManager:
    def __enter__(self):
        print("Entering the context...")

    def __exit__(self, exc_type, exc_val, exc_tb):
        print("Leaving the context...")

with ContextManager() as context:
    print(context)  # None

with (context := ContextManager()):
    print(context)  # <__main__.ContextManager object at 0x7fb551cdb9d0>


С обычным синтаксисом with ContextManager() as context: ...context привязывается к возвращаемому значению context.__enter__(), а если вы используете версию с := — к результату ContextManager(). Часто это не имеет большого значения, ведь context.__enter__() обычно возвращает self, но если это не так, то при отладке возникнут большие проблемы.

Вот что происходит, когда вы используете оператор моржа с менеджером контекста closing:

from contextlib import closing
from urllib.request import urlopen

with closing(urlopen('https://www.python.org')) as page:
    for line in page:
        print(line)  # Вывод HTML-кода страницы

with (page := closing(urlopen('https://www.python.org'))):
    for line in page:
        print(line)  # TypeError: 'closing' object is not iterable [объект 'closing' не итерируемый]


Ещё одна проблема, с которой вы можете столкнуться, — приоритет оператора моржа, ведь он ниже приоритета логических операторов:

text = "Something to match."
flag = True

if match := re.match(r"^.*(thing).*", text) and flag:
    print(match.groups())  # AttributeError: 'bool' object has no attribute 'group' [у объекта 'bool' нет атрибута 'group']

if (match := re.match(r"^.*(thing).*", text)) and flag:
    print(match.groups())  # ('thing',)


Здесь мы видим, что присваивание нужно заключить в круглые скобки, чтобы гарантировать, что результат re.match(...) присваивается переменной. Если этого не сделать, выражение and будет вычисляться первым, то есть будет присвоен логический результат.

И, наконец, не ловушка, а скорее небольшое ограничение. Сейчас с оператором моржа нельзя использовать подсказки встроенного типа. Поэтому, если захочется указать тип переменной, нужно разбить её на 2 строки:

from typing import Optional

value: Optional[int] = None
while value := some_func():
    ...  # Что-то делаем


Заключительные мысли


Оператором моржа, как и любой другой особенностью синтаксиса, можно злоупотребить: снизить ясность и читабельность кода. Не нужно писать его везде, где только возможно. Относитесь к этому оператору как к инструменту — знайте его преимущества и недостатки, а используйте там, где это уместно.

Если хочется увидеть более практичное и эффективное использование оператора моржа, узнайте, как он появился в стандартной библиотеке CPython — все эти изменения можно найти в этом PR. И рекомендую прочитать PEP 572, в котором есть обоснование появления оператора и ещё больше примеров.


image
А мы поможем прокачать ваши навыки или с самого начала освоить профессию, востребованную в любое время:

© Habrahabr.ru