Python 3.8: почему в конструкции with… as не стоит as заменять оператором присваивания?

Теперь, когда PEP 572 принят, в Python 3.8 можно использовать выражения присваивания:

with f := open('file.txt'):
    for l in f:
        print(f)

вместо:

with open('file.txt') as f:
    for l in f:
        print(f)

Эти кусочки кода будут работать одинаково? В чем особенность ключевого слова as в инструкции with ... as в Python 3.8? Не противоречит ли она дзену Python: «Должен существовать один — и желательно только один — очевидный способ сделать это»?

Обычно нет веских причин заменять оператор as на := в инструкции with; иногда это вообще крайне неправильно. Если вы сомневаетесь, всегда используйте with ... as, когда требуется обернуть выполнение блока инструкций менеджером контекста.

В инструкции with context_manager as managed, переменная managed связана со значением, которое возвращает метод context_manager.__enter__(), тогда как в случае с with managed := context_manager, managed привязана к самому context_manager и значение, возвращаемое методом __enter__() отбрасывается. Поведение почти одинаковое для открытых файлов, потому что метод __enter__() у них возвращает сам объект экземпляра класса (self).

Вот случай, когда as и := приблизительно эквивалентны:

_mgr = (f := open('file.txt')) # `f` is assigned here, even if `__enter__` fails
_mgr.__enter__()               # the return value is discarded

exc = True
try:
    try:
        BLOCK
    except:
        # The exceptional case is handled here
        exc = False
        if not _mgr.__exit__(*sys.exc_info()):
            raise
        # The exception is swallowed if exit() returns true
finally:
    # The normal and non-local-goto cases are handled here
    if exc:
        _mgr.__exit__(None, None, None)

В то время как форма as может принимать вид:

_mgr = open('file.txt')   # 
_value = _mgr.__enter__() # the return value is kept

exc = True
try:
    try:
        f = _value        # here f is bound to the return value of __enter__
                          # and therefore only when __enter__ succeeded
        BLOCK
    except:
        # The exceptional case is handled here
        exc = False
        if not _mgr.__exit__(*sys.exc_info()):
            raise
        # The exception is swallowed if exit() returns true
finally:
    # The normal and non-local-goto cases are handled here
    if exc:
        _mgr.__exit__(None, None, None)

То есть f := open(...) будет устанавливать f значение, возвращаемое open, тогда как with open(...) as f связывает f со значением, которое возвращает неявный вызов метода __enter__().

Если речь идет о файлах и потоках, вызов file.__enter__() вернет self, если не произошла ошибка, поэтому поведение в обоих случаях почти одинаковое. Единственная разница заключается в том, что __enter__() генерирует исключение.

Утверждение, что выражение присваивания заменяет оператор as, является заблуждением, потому что существует много классов, где _mgr.__enter __() возвращает объект, отличный от self. В этом случае выражение присваивания работает иначе: менеджер контекста назначается вместо управляемого объекта. Например, unittest.mock.patch — менеджер контекста, который возвращает mock-объект. Вот пример из документации:

>>> thing = object()
>>> with patch('__main__.thing', new_callable=NonCallableMock) as mock_thing:
...     assert thing is mock_thing
...     thing()
...
Traceback (most recent call last):
  ...
TypeError: 'NonCallableMock' object is not callable

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

>>> thing = object()
>>> with mock_thing := patch('__main__.thing', new_callable=NonCallableMock):
...     assert thing is mock_thing
...     thing()
...
Traceback (most recent call last):
  ...
AssertionError
>>> thing
<object object at 0x7f4aeb1ab1a0>
>>> mock_thing
<unittest.mock._patch object at 0x7f4ae910eeb8>

Здесь mock_thing привязан к менеджеру контекста вместо нового mock-объекта.


По материалам Andras Deak и Antti Haapala.