Безопасно ли комбинировать «с» и «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

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

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

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

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

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

Или нет?


person lesnik    schedule 26.01.2017    source источник
comment
Итератор будет очищен, когда он выйдет за пределы области видимости, что и должно быть в упомянутых вами случаях.   -  person J. P. Petersen    schedule 26.01.2017
comment
Если вы сохраните ссылку на генератор в потребителе (например, producer=select_files()), вы можете использовать его метод .throw, чтобы сообщить ему о выключении. docs.python.org/3/reference/expressions.html#generator. выбросить.   -  person Terry Jan Reedy    schedule 26.01.2017
comment
У генераторов @TerryJanReedy есть метод close, который лучше служит цели остановки генератора, а не генерирует там случайное исключение ...   -  person Bakuriu    schedule 27.01.2017
comment
В любом случае такая же проблема возникает, если вы просто передаете содержимое файла: with open(...) as f: for line in f: yield line. Потребитель может не исчерпать генератор и, следовательно, файл не может быть закрыт. Это проблема ленивого ввода-вывода в целом. Лучше открывать файлы внутри нетерпеливого кода и передавать их ленивым функциям.   -  person Bakuriu    schedule 27.01.2017
comment
Хотя это напрямую не касается вопроса OP ... Альтернативный способ справиться с этой ситуацией - использовать fileinput. См. Также stackoverflow.com/questions/16095855/   -  person mgilson    schedule 27.01.2017


Ответы (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 Совершенно нормально закрыть уже закрытый и / или использованный генератор, чтобы вы могли вызвать не беспокоясь о состоянии генератора.

person mgilson    schedule 26.01.2017
comment
Очистка в этом случае не является детерминированной - я не уверен, что полностью понимаю это утверждение. Означает ли это, что происходящее зависит от поведения сборщика мусора? - person lesnik; 26.01.2017
comment
@lesnik - Да, это значит. - person mgilson; 26.01.2017
comment
@lesnik - Я больше думал об этом сегодня вечером (возможно, потому, что меня беспокоит, что я не всегда хорошо очищаю эти вещи в моем коде ...). В любом случае, похоже, что существует есть способ заставить генераторы очиститься, когда вы закончите с ними. Я переписал / обновил ответ, чтобы объяснить, как это возможно. - person mgilson; 27.01.2017
comment
Особая благодарность за внимание к PEP-533 - для меня большой сюрприз, что здесь задействован сборщик мусора! - person lesnik; 27.01.2017
comment
Вы упомянули, что если итератор исчерпан, диспетчер контекста очищается (более или менее), когда вы ожидаете. Почему более или менее? Разве в этом случае не все просто? - person lesnik; 27.01.2017
comment
@lesnik - Думаю, да, но всегда есть кто-то, кто думает, что должно быть иначе ;-). Тем не менее, мне, наверное, не стоило там замораживать свое заявление ... - person mgilson; 27.01.2017

Безопасно ли комбинировать «с» и «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]

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

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

person Aaron Hall    schedule 26.01.2017
comment
Здравствуйте, что бы порекомендовали подойти к этой проблеме в 2020 году, как вы думаете, пожалуйста? - person Samir Sadek; 09.02.2020