[Перевод] Вот почему нужно использовать оператор := в Python
Сегодня рассказываем о самом странном операторе 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
(с накоплением процентов или логистическим отображением). Попробуйте переписать их с оператором моржа, они станут выглядеть намного лучше.
Именно так, шаг за шагом, вы станете востребованным профессионалом в области 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, в котором есть обоснование появления оператора и ещё больше примеров.
А мы поможем прокачать ваши навыки или с самого начала освоить профессию, востребованную в любое время: