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

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

# 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

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

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

2 ответа

Решение

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

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 в генераторе, и даже если исключение выдается. Я не знаю, действительно ли это лучшая идея, чем поручить генератору взять на себя ответственность за закрытие файлов.

Весь смысл 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:
        ...

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

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