Вот почему вам стоит использовать оператор Walrus в Python

в 9:55, , рубрики: python, walrus, Блог компании МойОфис, мойофис, оператор, переводы, Программирование, Совершенный код
Вот почему вам стоит использовать оператор Walrus в Python - 1

Выражение присваивания (также известное как оператор 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: в нем содержится еще больше примеров, а также обоснование внедрения оператора в стандартную библиотеку.

Автор: Евгений Земский

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js