Является ли хорошей практикой выходить из контекстного менеджера?

Недавно я написал метод, который возвращал последовательность открытых файлов; другими словами, что-то вроде этого:

# this is very much simplified, of course
# the actual code returns file-like objects, not necessarily files
def _iterdir(self, *path):
    dr = os.path.join(*path)
    paths = imap(lambda fn: os.path.join(dr, fn), os.listdir(dr))

    return imap(open, paths)

Синтаксически я не ожидаю закрытия результирующих объектов, если я сделаю что-то вроде:

for f in _iterdir('/', 'usr'):
    make_unicorns_from(f)
    # ! f.close()

В результате я решил обернуть _iterdir в менеджер контекста:

def iterdir(self, *path):
    it = self._iterdir(*path)

    while 1:
        with it.next() as f:
            yield f

Кажется, что он работает правильно.

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

Ответ 1

Есть две проблемы, которые я вижу. Во-первых, если вы пытаетесь использовать более одного файла за раз, все ломается:

list(iterdir('/', 'usr')) # Doesn't work; they're all closed.

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

Если исключение происходит в make_unicorns_from(f):

for f in iterdir('/', 'usr'):
    make_unicorns_from(f) # Uh oh, not enough biomass.

Файл, который вы использовали, не будет закрыт, пока генератор не будет собран. В этот момент будет вызываться метод генератора close, бросая исключение GeneratorExit в точку последнего yield, и исключение заставит диспетчер контекста закрыть файл.

С подсчетом ссылок CPython это обычно происходит немедленно. Тем не менее, при реализации без ссылки или при наличии эталонного цикла генератор может не собираться до тех пор, пока не будет запущен протокол GC-анализа. Это может занять некоторое время.


Моя кишка говорит, чтобы оставить закрытие файлов вызывающему. Вы можете сделать

for f in _iterdir('/', 'usr'):
    with f:
        make_unicorns_from(f)

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

Ответ 2

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

Здесь полностью упрощен пример:

def with_open():
    with open(...) as f:
        yield f

Рассмотрим исключение в его использовании:

for _ in with_open():
    raise NotImplementedError

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

Также рассмотрите неполные, исключающие исключение выходы:

for _ in with_open():
    break

for _ in with_open():
    return

next(with_open())

Один из вариантов - вернуть сам менеджер контекста, чтобы вы могли:

def with_open():
    yield partial(open, ...)

for filecontext in with_open():
    with filecontext() as f:
        break

Другим, более прямым решением было бы определить функцию как

from contextlib import closing

def with_open(self, *path):
    def inner():
        for file in self._iterdir(*path):
            with file:
                yield file

    return closing(inner())

и использовать его как

with iterdir() as files:
    for file in files:
        ...

Это гарантирует закрытие без необходимости переместить открытие файла вызывающему.