with文がやってくれるのはどこまでなのか
インターネット経由で取得したZIPファイルを手元で加工する、という状況を考える。
たとえば、次のようなコードを書いたとする。
URL
は ZIPファイルを取得できるものならば何でもよいが、今回は環境に配慮してローカルに1回だけダウンロードしてhttp.server
で簡易Webサーバを立てることでキャッシュしている。
import io import urllib.request import zipfile URL = "http://localhost:8000/python-3.12.2-embed-amd64.zip" with urllib.request.urlopen(URL) as f: content: bytes = f.read() with zipfile.ZipFile(io.BytesIO(content)) as zf: names: list[str] = zf.namelist() for name in names: print(name)
urllib.request.urlopen()
経由で取得したものはbytes
であり、ファイルオブジェクトではない。
zipfile.ZipFile
の第一引数file
はファイルのパス、ファイルオブジェクト、 pathlib.Path
のいずれかである必要があるため、urllib.request.urlopen()
経由で取得したものをそのまま渡すわけにはいかない。
そのため、 io.Bytes()
を経由することでインメモリーストリームとして渡す。
このエントリの主題は、上記のコードにおける io.BytesIO(content)
の取り扱いについてである。
with zipfile.ZipFile(io.BytesIO(content)) as zf:
と記述しているので、 names: list[str] = zf.namelist()
が完了したら zipfile.ZipFile(io.BytesIO(content))
の close()
メソッドが呼ばれる。
その際に、中身である io.BytesIO(content)
は閉じられるのだろうか。
公式ドキュメントとソースコードを巡る冒険
公式ドキュメントには
The buffer is discarded when the close() method is called.
とある通り、 BytesIO
の close()
メソッドが呼ばれなければインメモリーストリームのバッファは解放されない。
まず、 ソースコード にある __init__()
から説明に必要な個所を抜き出す。
class ZipFile: fp = None # Set here since __del__ checks it def __init__(self, file, mode="r", compression=ZIP_STORED, allowZip64=True, compresslevel=None, *, strict_timestamps=True, metadata_encoding=None): """Open the ZIP file with mode read 'r', write 'w', exclusive create 'x', or append 'a'.""" ... # Check if we were passed a file-like object if isinstance(file, os.PathLike): file = os.fspath(file) if isinstance(file, str): # No, it's a filename ... else: self._filePassed = 1 self.fp = file self.filename = getattr(file, 'name', None) self._fileRefCnt = 1 ...
ここで認識してほしいのは以下の3点である。
zipfile.ZipFile
の第一引数にファイルオブジェクトを渡すとself._filePassed = 1
となる。この変数は__init__()
でのみ定義され、変更されない。zipfile.ZipFile
の第一引数にファイルオブジェクトを渡すとself.fp = file
、つまりself.fp
にBytesIO
インスタンスが入る。zipfile.ZipFile
の第一引数にファイルオブジェクトを渡すとself._fileRefCnt = 1
となる。今回のスニペットだとself._fileRefCnt = 1
は1のままである。
次に ソースコード にある close()
メソッドを確認する。
def close(self): """Close the file, and for mode 'w', 'x' and 'a' write the ending records.""" if self.fp is None: return try: ... finally: fp = self.fp self.fp = None self._fpclose(fp)
ここでわかることは以下の点である。
- 内部変数
fp
にself.fp
、つまりBytesIO
インスタンスが入る。 - その後、
self.fp
はNone
となる。 self._fpclose(fp)
が実行される。
そして、 ソースコード にある _fpclose()
メソッドを確認する。
def _fpclose(self, fp): assert self._fileRefCnt > 0 self._fileRefCnt -= 1 if not self._fileRefCnt and not self._filePassed: fp.close()
self._fileRefCnt = 1
なので、if
文に到達する際に`self._fileRefCnt == 0
となり、条件式の左辺はTrue
である。self._filePassed = 1
なので、条件式の右辺はFalse
である。- よって、
if not self._fileRefCnt and not self._filePassed:
はFalse
となり、fp.close()
は実行されない。
以上より、「 io.BytesIO(content)
は閉じられるのだろうか」という疑問は「閉じられません」となる。
では、閉じるにはどうすればよいのか、自分で明示的に閉じるしかない。
with io.BytesIO(content) as bs: with zipfile.ZipFile(bs) as zf: names: list[str] = zf.namelist()
こうすれば、 names: list[str] = zf.namelist()
が実行された後、zipfile.ZipFile
が閉じられ、 io.BytesIO
が閉じられる。
これで安心して眠りにつくことができる。
ガベージコレクションの存在
しかし、このスニペット程度ならば、ガベージコレクション先生が気を利かしてくれるはずだ。
import io import gc import urllib.request import zipfile gc.enable() URL = "http://localhost:8000/python-3.12.2-embed-amd64.zip" with urllib.request.urlopen(URL) as f: content: bytes = f.read() data = io.BytesIO(content) with zipfile.ZipFile(data) as zf: names: list[str] = zf.namelist() for name in names: print(name) print(gc.is_tracked(data))
... True
当然、 io.BytesIO(content)
はガベージコレクタによって捕捉されており、必要に応じて回収されるだろう。
今後の課題
今回のスニペット程度ではガベージコレクタによって回収されるので、閉じ忘れても特に支障はないと思われる。
「ZIPファイルを手元で加工する」の内容如何では、ガベージコレクションによって回収されない状況が起き得るか、が自分の課題である。
また、 gc
モジュールの使い方がイマイチわかっていないので、それの調査も課題である。