Безопасно ли сочетать 'с' и 'yield' в python?

В Python распространена идиома использования контекстного менеджера для автоматического закрытия файлов:

with open('filename') as my_file:
    # do something with my_file

# my_file gets automatically closed after exiting 'with' block

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

def select_files():
    """Yields carefully selected and ready-to-read-from files"""
    file_names = [.......]
    for fname in file_names:
        with open(fname) as my_open_file:
            yield my_open_file

Этот итератор может использоваться следующим образом:

for file_obj in select_files():
    for line in file_obj:
        # do something useful

(Обратите внимание, что один и тот же код может использоваться не только для открытых файлов, но и для списков строк - это круто!)

Вопрос в том, безопасно ли открывать открытые файлы?

Похоже, почему бы и нет? Потребитель вызывает итератор, итератор открывает файл, передает его потребителю. Потребитель обрабатывает файл и возвращается к итератору для следующего. Код итератора возобновляется, мы выходим из блока "с", my_open_file объект закрывается, переход к следующему файлу и т. д.

Но что, если потребитель никогда не вернется к итератору для следующего файла? Например, произошло исключение внутри потребителя. Или потребитель нашел что-то очень интересное в одном из файлов и с радостью вернул результаты тому, кто это назвал?

В этом случае код итератора никогда не возобновится, мы никогда не завершим блок 'with', и my_open_file объект никогда не закроется!

Или это будет?

2 ответа

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

Вот быстрый пример:

from __future__ import print_function
import contextlib

@contextlib.contextmanager
def manager():
    """Easiest way to get a custom context manager..."""
    try:
        print('Entered')
        yield
    finally:
        print('Closed')


def gen():
    """Just a generator with a context manager inside.

    When the context is entered, we'll see "Entered" on the console
    and when exited, we'll see "Closed" on the console.
    """
    man = manager()
    with man:
        for i in range(10):
            yield i


# Test what happens when we consume a generator.
list(gen())

def fn():
    g = gen()
    next(g)
    # g.close()

# Test what happens when the generator gets garbage collected inside
# a function
print('Start of Function')
fn()
print('End of Function')

# Test what happens when a generator gets garbage collected outside
# a function.  IIRC, this isn't _guaranteed_ to happen in all cases.
g = gen()
next(g)
# g.close()
print('EOF')

Запустив этот скрипт в CPython, я получаю:

$ python ~/sandbox/cm.py
Entered
Closed
Start of Function
Entered
Closed
End of Function
Entered
EOF
Closed

По сути, мы видим, что для истощенных генераторов менеджер контекста очищается, когда вы ожидаете. Для генераторов, которые не исчерпаны, функция очистки запускается, когда генератор собирается сборщиком мусора. Это происходит, когда генератор выходит из области видимости (или IIRC на следующем gc.collect цикл самое позднее).

Тем не менее, выполняя несколько быстрых экспериментов (например, запуск приведенного выше кода в pypy), Я не очищаю все мои менеджеры контекста:

$ pypy --version
Python 2.7.10 (f3ad1e1e1d62, Aug 28 2015, 09:36:42)
[PyPy 2.6.1 with GCC 4.2.1 Compatible Apple LLVM 5.1 (clang-503.0.40)]
$ pypy ~/sandbox/cm.py
Entered
Closed
Start of Function
Entered
End of Function
Entered
EOF

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


Предоставление строгих гарантий

Если вы хотите гарантировать, что ваш контекстный менеджер завершен должным образом, вам следует позаботиться о том, чтобы закрыть генератор, когда вы закончите с ним 2. Раскомментировать g.close() строки выше дает мне детерминированную очистку, потому что GeneratorExit поднимается на yield оператор (который находится внутри диспетчера контекста), а затем он пойман / подавлен генератором...

$ pypy ~/sandbox/cm.py
Entered
Closed
Start of Function
Entered
Closed
End of Function
Entered
Closed
EOF

$ python3 ~/sandbox/cm.py
Entered
Closed
Start of Function
Entered
Closed
End of Function
Entered
Closed
EOF

$ python ~/sandbox/cm.py
Entered
Closed
Start of Function
Entered
Closed
End of Function
Entered
Closed
EOF

FWIW, это означает, что вы можете очистить ваши генераторы, используя contextlib.closing:

from contextlib import closing
with closing(gen_function()) as items:
    for item in items:
        pass # Do something useful!

1 В последнее время некоторые обсуждения были посвящены PEP 533, цель которого - сделать очистку итератора более детерминированной.
2 Совершенно нормально закрыть уже закрытый и / или израсходованный генератор, чтобы вы могли вызывать его, не беспокоясь о состоянии генератора.

Безопасно ли сочетать 'с' и 'yield' в python?

Я не думаю, что ты должен делать это.

Позвольте мне продемонстрировать создание некоторых файлов:

>>> for f in 'abc':
...     with open(f, 'w') as _: pass

Убедитесь сами, что файлы есть:

>>> for f in 'abc': 
...     with open(f) as _: pass 

А вот функция, которая воссоздает ваш код:

def gen_abc():
    for f in 'abc':
        with open(f) as file:
            yield file

Здесь похоже, что вы можете использовать функцию:

>>> [f.closed for f in gen_abc()]
[False, False, False]

Но давайте сначала создадим понимание списка всех файловых объектов:

>>> l = [f for f in gen_abc()]
>>> l
[<_io.TextIOWrapper name='a' mode='r' encoding='cp1252'>, <_io.TextIOWrapper name='b' mode='r' encoding='cp1252'>, <_io.TextIOWrapper name='c' mode='r' encoding='cp1252'>]

И теперь мы видим, что они все закрыты

>>> c = [f.closed for f in l]
>>> c
[True, True, True]

Это работает только до закрытия генератора. Тогда файлы все закрыты.

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

Другие вопросы по тегам