web-dev-qa-db-fra.com

Est-il sûr de combiner "avec" et "rendement" en python?

C'est un idiome courant dans python pour utiliser le gestionnaire de contexte pour fermer automatiquement les fichiers:

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

# my_file gets automatically closed after exiting 'with' block

Maintenant, je veux lire le contenu de plusieurs fichiers. Le consommateur des données ne sait pas ou ne se soucie pas si les données proviennent de fichiers ou de non-fichiers. Il ne veut pas vérifier si les objets qu'il a reçus peuvent être ouverts ou non. Il veut juste obtenir quelque chose pour lire des lignes. Je crée donc un itérateur comme celui-ci:

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

Cet itérateur peut être utilisé comme ceci:

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

(Notez que le même code pourrait être utilisé pour consommer non pas les fichiers ouverts, mais les listes de chaînes - c'est cool!)

La question est: est-il sûr de générer des fichiers ouverts?

On dirait "pourquoi pas?". Le consommateur appelle l'itérateur, l'itérateur ouvre le fichier et le cède au consommateur. Le consommateur traite le fichier et revient à l'itérateur pour le suivant. Le code de l'itérateur reprend, on quitte le bloc 'avec', le my_open_file l'objet se ferme, passe au fichier suivant, etc.

Mais que se passe-t-il si le consommateur ne revient jamais à l'itérateur pour le fichier suivant? F.e. une exception s'est produite à l'intérieur du consommateur. Ou le consommateur a trouvé quelque chose de très excitant dans l'un des fichiers et a heureusement rendu les résultats à celui qui l'a appelé?

Le code de l'itérateur ne reprendrait jamais dans ce cas, nous n'arriverions jamais à la fin du bloc "avec" et le my_open_file l'objet ne serait jamais fermé!

Ou serait-ce?

31
lesnik

Vous soulevez une critique qui a déjà été soulevée1. Le nettoyage dans ce cas n'est pas déterministe, mais il va se produire avec CPython lorsque le générateur est récupéré. Votre kilométrage peut varier pour d'autres python implémentations ...

Voici un petit exemple:

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')

En exécutant ce script dans CPython, j'obtiens:

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

Fondamentalement, ce que nous voyons, c'est que pour les générateurs épuisés, le gestionnaire de contexte nettoie quand vous vous y attendez. Pour les générateurs qui ne le sont pas épuisés, la fonction de nettoyage s'exécute lorsque le générateur est collecté par le garbage collector. Cela se produit lorsque le générateur est hors de portée (ou, IIRC au prochain cycle gc.collect Au plus tard).

Cependant, en faisant quelques expériences rapides (par exemple en exécutant le code ci-dessus dans pypy), je ne nettoie pas tous mes gestionnaires de contexte:

$ 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

Ainsi, l'affirmation selon laquelle __exit__ va du gestionnaire de contexte est appelée pour toutes les implémentations python est fausse. Les échecs ici sont probablement attribuables à - stratégie de récupération de place de pypy (qui ce n'est pas le cas comptage des références) et au moment où pypy décide de récolter les générateurs, le processus est déjà en cours d'arrêt et par conséquent, cela ne dérange pas ... Dans la plupart des applications du monde réel, les générateurs seraient probablement récupérés et finalisés assez rapidement pour que cela n'ait pas vraiment d'importance ...


Offrir des garanties strictes

Si vous voulez garantir que votre gestionnaire de contexte est finalisé correctement, vous devez prendre soin de fermer le générateur lorsque vous en avez terminé2. La décommentation des lignes g.close() ci-dessus me donne un nettoyage déterministe car un GeneratorExit est levé à l'instruction yield (qui est à l'intérieur du gestionnaire de contexte) et ensuite il est capturé/supprimé par le Générateur...

$ 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, cela signifie que vous pouvez nettoyer vos générateurs en utilisant contextlib.closing:

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

1Plus récemment, certaines discussions ont tourné autour de PEP 5 qui vise à rendre le nettoyage des itérateurs plus déterministe.
2Il est parfaitement correct de fermer un générateur déjà fermé et/ou consommé afin de pouvoir l'appeler sans vous soucier de l'état du générateur.

16
mgilson

Est-il sûr de combiner "avec" et "rendement" en python?

Je ne pense pas que tu devrais faire ça.

Permettez-moi de démontrer la création de certains fichiers:

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

Convainquez-vous que les fichiers sont là:

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

Et voici une fonction qui recrée votre code:

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

Ici, il semble que vous puissiez utiliser la fonction:

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

Mais créons d'abord une liste de compréhension de tous les objets fichier:

>>> 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'>]

Et maintenant, nous voyons qu'ils sont tous fermés:

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

Cela ne fonctionne que jusqu'à la fermeture du générateur. Ensuite, les fichiers sont tous fermés.

Je doute que c'est ce que vous voulez, même si vous utilisez une évaluation paresseuse, votre dernier fichier sera probablement fermé avant que vous ne l'utilisiez.

7
Aaron Hall