Выражение присваивания (также известное как оператор walrus) — это функциональность, которая появилась в Python недавно, в версии 3.8. Однако применение walrus является предметом дискуссий и множество людей испытывают безосновательную неприязнь к нему.
Под катом эксперт компании IBM Мартин Хайнц*, разработчик и DevOps-инженер, постарается убедить вас в том, что оператор walrus — действительно хорошее дополнение языка. И его правильное использование поможет вам сделать код более лаконичным и читаемым.
*Обращаем ваше внимание, что позиция автора может не всегда совпадать с мнением МойОфис.
Рассмотрим основы
Если вы вдруг не знакомы с оператором :=
(walrus), давайте для начала рассмотрим некоторые основные случаи использования этого оператора.
В первом примере я покажу вам, как можно использовать оператор walrus, чтобы сократить количество вызовов функции.
Давайте представим функцию func()
, которая производит какие-то очень ресурсоемкие расчеты. Она требует много времени для вычисления результатов, поэтому мы не хотим вызывать ее многократно:
# "func" вызывает трижды
result = [func(x), func(x)**2, func(x)**3]
# Переиспользуем результат функции без разделения кода на несколько строк
result = [y := func(x), y**2, y**3]
В первой форме объявления списка, func(x)
вызывается трижды, каждый раз возвращая один и тот же результат, который каждый раз тратил ресурсы вычислительной машины. Когда мы перепишем выражение с использованием оператора walrus, func()
будет вызвана только один раз, присваивая результат вычисления y
и повторно используя значение для оставшихся двух элементов списка. Вы можете сказать "Просто добавь y = func(x)
перед объявлением списка и тебе не понадобится оператор walrus!". Можно так сделать, но для этого потребуется еще одна дополнительная строка кода и, на первый взгляд, без знания о том, что func(x)
очень медленная, может быть неясно, зачем эта переменная нужна.
Если первый пример не убедил вас, вот еще один. Рассмотрим следующие способы объявления списка тоже с ресурсоемкой func()
:
result = [func(x) for x in data if func(x)]
result = [y for x in data if (y := func(x))]
В первой строке func(x)
вызывается дважды в каждой итерации. Альтернатива — использовать оператор walrus. Значение рассчитывается однократно — в условном выражении, и используется повторно. Размер кода тот же, обе строки одинаково читаемые, но на второй строке реализация в два раза эффективнее. Вы можете избежать применения :=
с сохранением эффективности исполняемого кода путем описания конструкции через обычный цикл, однако для этого потребуется 5 строк кода.
Одно из наиболее типовых применений оператора walrus — в сокращении количества излишних условий, таких как найденные соответствия при использовании регулярных выражений.
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)}'")
Используя walrus мы сократили один и тот же код с 7 до 4 строк, сделав его более читаемым через удаление ненужных if
.
Следующий пример — это так называемая идиома "loop-and-half":
while True: # Loop
command = input("> ")
if command == 'exit': # And a half
break
print("Your command was:", command)
# ---------------------
# Или иначе
while (command := input("> ")) != "exit":
print("Your command was:", command)
Обычное решение заключается в использовании бесконечного цикла, в котором управление циклом осуществляется через объявление break
. Вместо этого мы можем задействовать оператор walrus, чтобы заново присвоить значение исполненной команды и затем использовать его в условии остановки цикла на той же строке, делая код более явным и коротким.
Простое упрощение может быть применено также к другому циклу, while
— например, в случае чтения файлов построчно или получения данных из сокета.
Суммирование данных прямо на месте
Обратимся к более продвинутым примерам использования оператора walrus. В этот раз рассмотрим возможность суммирования данных прямо по месту применения:
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]
В первых двух строках показано, как можно использовать walrus для расчета суммы значений. В этом простом случае функция accumulate
лучше подходит для этой цели (как видно из следующих двух строк). Однако применение itertools
с увеличением сложности и объема кода делает его менее читаемым и, по моему мнению, версия с :=
намного приятнее, чем с lambda
.
Если вы все еще не убеждены, посмотрите сводные примеры в документации (например, сложный процент или логистическую карту): они не выглядят читаемыми. Попробуйте переписать их на использование выражения присваивания :=
, и они будут смотреться намного лучше.
Именованные значения в f-string
Этот пример показывает возможные случаи и ограничения использования :=
в сравнении с лучшими практиками.
Если хотите, то можете использовать оператор walrus в f-string:
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
, которая затем повторно используется на той же строке, предотвращая повторный вызов функции datetime.today()
.
Похожим образом во втором примере объявлена theta
переменная, которая затем используется снова для расчета sin(theta)
и cos(theta)
. В данном случае в выражении также встречается сочетание символов, которое выглядит как "обратный" walrus. Символ =
отвечает за вывод выражения на экран, а в связке с :
используется для форматированного вывода значения выражения.
Заметим также, что выражение с оператором walrus требует обрамления в скобки, чтобы внутри f-string оно интерпретировалось корректно.
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()
используют короткий цикл вычисления результата условия. Это означает, что они остановят вычисление, как только найдут первого "свидетеля" или "контрпример". Поэтому переменная, созданная с помощью оператора walrus, всегда будет давать нам первого "свидетеля"/"контрпример".
Подводные камни и ограничения
Ранее в тексте я пытался мотивировать вас использовать оператор walrus. Полагаю, важно также предупредить о некоторых недостатках и ограничениях в отношении этого оператора.
В предыдущем примере было показано, что короткий цикл вычисления может быть полезен для захвата значений в 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
Во фрагменте выше приведено условное выражение с 2 объединенными присваиваниями, проверяющими, на сколько делится число — на 2, 3 или 6 в порядке очередности (если условие 1 верно, то 2 и 3 тоже верно). На первый взгляд это может казаться интересным трюком, но благодаря короткому циклу вычисления результата, если выражение (two := i % 2 == 0
) будет неверным, то следующее условие будет пропущено, а переменные останутся не определены или будут иметь неактуальные значения от предыдущей итерации.
Короткий цикл вычисления логического результата выражения все же может быть выгодным/намеренным. Можно его использовать с регулярными выражениями для поиска соответствия нескольким шаблонам в строке:
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
вместе с оператором walrus. Здесь же представлено упрощение через схлопывание условия в один if
.
Если вы только познакомились с оператором walrus, то можете заметить, что он заставляет область видимости переменных вести себя иначе в list
comprehensions.
values = [3, 5, 2, 6, 12, 7, 15]
tmp = "unmodified"
dummy = [tmp for tmp in values]
print(tmp)
# Как ожидалось, переменная "tmp" не была переопределена.
# Она по-прежнему имеет привязку к значению "unmodified"
total = 0
partial_sums = [total := total + v for v in values]
print(total) # 50
С использованием обычных list
/dict
/set
comprehensions, область видимости переменной цикла остается внутри конструкции и, следовательно, любая существующая переменная с тем же именем останется неизменной. Однако с использованием оператора walrus, переменная из comprehension total
остается доступной после вычисления значения конструкции, получая присвоенное значение внутри comprehension.
Когда использование walrus в коде становится более удобным для вас, вы можете попробовать использовать его в других случаях. Но есть один случай, в котором вам не удастся его использовать — выражения с 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.enter()
, тогда же как при использовании версии с :=
происходит связь с результатом самого ContextManager()
. Зачастую это не важно, потому что context.enter()
обычно возвращает self
, но в случае, если это не так, будет крайне сложно отладить проблему.
Для более практического примера рассмотрим ниже, что происходит при использовании оператора walrus с контекстным менеджером 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
Еще одна проблема, с которой вы можете столкнуться — относительный приоритет :=
, который ниже, чем другие логические операторы:
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'
if (match := re.match(r"^.*(thing).*", text)) and flag:
print(match.groups()) # ('thing',)
Здесь нам нужно обрамлять выражение присваивания в скобки для обеспечения гарантии, что результат re.match(...)
будет записан в переменную. Если мы этого не сделаем, выражение and
будет рассчитано в первую очередь и переменной будет присвоен логический результат выражения.
И наконец, есть некоторая ловушка или скорее легкое ограничение. В данный момент нельзя использовать аннотации типов на той же строке с оператором walrus. Следовательно, если вы хотите определить тип переменной, то выражение следует разделить на 2 строки:
from typing import Optional
value: Optional[int] = None
while value := some_func():
... # Описание действия
Заключительные мысли
Как в случае с любой особенностью синтаксиса языка, злоупотребление оператором walrus может привести к ухудшению ясности и читаемости кода. Не следует внедрять его в коде при каждом удобном случае. Воспринимайте этот оператор как инструмент — будьте осведомлены о его преимуществах и недостатках и используйте его там, где это уместно.
Если хотите ознакомиться с большим количеством практических использований оператора walrus, сверьтесь с его представлением в стандартной библиотеке CPython — все изменения могут быть найдены в этом PR.
Кроме того, я также рекомендую прочитать PEP 572: в нем содержится еще больше примеров, а также обоснование внедрения оператора в стандартную библиотеку.
Автор: Евгений Земский