Статьи
May 1, 2023

Почему стоит использовать моржовый оператор в Python

Walrus-оператор (или моржовый оператор) существует в Python уже достаточно давно (начиная с версии 3.8), но до сих пор вызывает определённые споры и даже хейт.

В этой статье постараемся убедиться в том, что моржовый оператор действительно является хорошим дополнением к языку, и что если использовать его правильно, он может помочь сделать код более лаконичным и читабельным.

Основы

Если вы ещё не знакомы с :=, давайте рассмотрим некоторые из основных вариантов его использования.

Первый пример — это использование моржового оператора для уменьшения количества вызовов функций. Представим функцию под названием func(), которая выполняет очень дорогостоящие вычисления. Вычисление результатов занимает много времени, поэтому мы не хотим вызывать его много раз:

# "func" вызывана 3 раза
result = [func(x), func(x)**2, func(x)**3]

# Повторное использование результата "func" 
# без разбиения кода на несколько строк
result = [y := func(x), y**2, y**3]

В первом объявлении списка выше func(x) вызывается 3 раза, каждый раз возвращая один и тот же результат, что приводит к пустой трате времени и вычислительных ресурсов. При перезаписи с использованием моржового оператора функция func() вызывается только один раз, присваивая свой результат y и повторно используя его для вычисления оставшихся 2 значений списка. Вы могли бы сказать: "Я могу просто добавить y = func(x) перед объявлением списка, и мне не нужен моржовый оператор!", но это одна дополнительная, ненужная строка кода. Более того, если не знать, что функция func(x) тяжёлая, то существование строки с определением дополнительной переменной и вовсе может быть непонятным.

Если вас не убедило вышесказанное, то вот ещё один аргумент. Рассмотрим следующие варианты list comprehension с той же дорогостоящей 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"Совпадение по первому шаблону: {m.group(1)}")
else:
    m = re.match(pattern2, test)
    if m:
        print(f"Совпадение по второму шаблону: {m.group(1)}")

# ---------------------
# а так чище
if m := (re.match(pattern1, test)):
    print(f"Совпадение по первому шаблону: '{m.group(1)}'")
elif m := (re.match(pattern2, test)):
    print(f"Совпадение по второму шаблону: '{m.group(1)}'")

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

Еще один пример упрощения кода — это "полуторный" идиоматический цикл:

while True:  # Цикл
    command = input("> ")
    if command == "exit":  # И половина
        break
    print("Была выбрана команда:", command)

# ---------------------
# а так чище
while (command := input("> ")) != "exit":
    print("Была выбрана команда:", 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]

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

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

Объявление переменных внутри 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

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

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

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

Any и All

Вы можете использовать функции any() и all(), чтобы проверить, удовлетворяют ли какие-либо или все значения в итерируемом объекте определённому условию. Но что, если мы хотим также сохранить значение, вызвавшее возврат True функции any() (так называемый "свидетель") или значение, вызвавшее False в функции 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} кратно 6.")
    elif two:
        print(f"{i} кратно 2.")
    elif three:
        print(f"{i} кратно 3.")

# NameError: name 'three' is not defined

В примере выше создаётся условие с 2 присваиваниями, объединенными с помощью and, которые проверяют, делится ли число на 2, 3 или 6 на основе того, удовлетворяются ли первое, второе или оба условия.

На первый взгляд это может показаться хорошим трюком, но из-за ленивой проверки если выражение (two := i % 2 == 0) вернёт False, то вторая проверка вообще не будет делаться, и, следовательно, переменная 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"Совпадение по первому шаблону: {m.group(1)}")
    else:
        m = re.match(pattern2, test)
        if m:
            print(f"Совпадение по второму шаблону: {m.group(1)}")

# Совпадение по первому шаблону:: thing
# Совпадение по второму шаблону:: present

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

Чуть выше уже был аналогичный пример, где использовалось if/elif в сочетании с моржовым оператором. Здесь же всё ещё проще, условие сводится if.

Если вы только знакомитесь с моржовым оператором, вы можете заметить, что он приводит к изменению поведения областей видимости переменных в 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 comprehension переменная в них не попадает во внешнюю область видимости, и поэтому любые существующие переменные с таким же именем не будут изменены. Однако, при использовании моржового оператора переменная из генератора (total) остается доступной после возврата генератора, принимая значение изнутри генератора.

Где никогда не стоит использовать моржовый оператор — это вместе с with:

class ContextManager:
    def __enter__(self):
        print("Входим в контекст..")

    def __exit__(self, exc_type, exc_val, exc_tb):
        print("Выходим из контекста..")

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, но в случае, если это не так, это создаёт очень трудные для отладки проблемы.

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

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, и вместо него будет присвоен логический результат.

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

from typing import Optional

value: Optional[int] = None
while value := some_func():
    ...  # Делаем что-либо

Заключение

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

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

Источник: Martin Heinz

👉🏻Подписывайтесь на PythonTalk в Telegram 👈🏻

👨🏻‍💻Чат PythonTalk в Telegram💬

🍩 Поддержать канал 🫶