Python ExitStackとは?複数の後始末をまとめる方法

Pythonのcontextlib.ExitStackは、数が決まらない後始末をまとめて管理する仕組みです。

「ファイルを何個開くか実行時まで分からない」という場面で、withを入れ子にせずに書けます。


ExitStackの基本的な使い方

from contextlib import ExitStack

with ExitStack() as stack:
    files = [stack.enter_context(open(p, encoding="utf-8"))
             for p in ["a.txt", "b.txt"]]
    print([f.read() for f in files])

print("全部閉じた:", all(f.closed for f in files))

実行結果:

['A', 'B']
全部閉じた: True

stack.enter_context()に渡したものはブロックを抜けるとき自動で後始末されます。


なぜ必要なのか

withは数が決まっていれば並べられます。

with open("a.txt") as fa, open("b.txt") as fb:
    ...

しかし、個数が実行時に決まる場合は書けません。

paths = get_paths()          # 3個かもしれないし100個かもしれない

with open(paths[0]) as f0, open(paths[1]) as f1, ...   # 書けない

ExitStackなら素直に書けます。

from contextlib import ExitStack

paths = ["a.txt", "b.txt"]

with ExitStack() as stack:
    fs = [stack.enter_context(open(p, encoding="utf-8")) for p in paths]
    print("開いた数:", len(fs))

実行結果:

開いた数: 2

後始末は逆順に実行される

from contextlib import ExitStack

with ExitStack() as stack:
    stack.callback(lambda: print("  後始末1"))
    stack.callback(lambda: print("  後始末2"))
    print("  本処理")

実行結果:

  本処理
  後始末2
  後始末1

登録した順と逆に実行されます。withを入れ子にしたときと同じ順序です。

これは理にかなっています。後から作ったものが先に作ったものに依存している可能性があるため、新しいものから片付けるのが安全です。


callback|close以外の後始末を登録する

from contextlib import ExitStack
import os

with ExitStack() as stack:
    path = "work.tmp"
    open(path, "w").close()
    stack.callback(os.remove, path)

    print("  一時ファイルを使った処理")

print("  残っている:", os.path.exists(path))

実行結果:

  一時ファイルを使った処理
  残っている: False

callback(関数, 引数...)の形で任意の後始末を予約できます。withに対応していない処理でも組み込めるのが強みです。


途中で失敗しても登録済みの分だけ片付く

from contextlib import ExitStack

try:
    with ExitStack() as stack:
        stack.enter_context(open("a.txt", encoding="utf-8"))
        stack.callback(lambda: print("  aの後始末"))
        raise ValueError("途中で失敗")
except ValueError as e:
    print("  例外:", e)

実行結果:

  aの後始末
  例外: 途中で失敗

「5個開く予定が3個目で失敗した」場合も、開いた3個は確実に閉じられます。手書きのtry-finallyでは、この「途中まで」の管理が非常に面倒になります。


pop_all|後始末を持ち越す

やや高度ですが、知っておくと役立つ機能です。

from contextlib import ExitStack

def make():
    with ExitStack() as stack:
        f = stack.enter_context(open("a.txt", encoding="utf-8"))
        keep = stack.pop_all()
        return f, keep

f, keep = make()
print("関数を抜けても開いている:", not f.closed)

keep.close()
print("明示的に閉じた:", f.closed)

実行結果:

関数を抜けても開いている: True
明示的に閉じた: True

pop_all()を呼ぶと、登録済みの後始末が別のExitStackへ移されます。元のスタックは空になるので、関数を抜けても閉じられません。

これは「途中で失敗したら片付けるが、成功したら呼び出し側へ渡す」という設計に使います。

def open_all(paths):
    with ExitStack() as stack:
        files = [stack.enter_context(open(p, encoding="utf-8")) for p in paths]
        return files, stack.pop_all()

files, cleanup = open_all(["a.txt", "b.txt"])
try:
    ...
finally:
    cleanup.close()

途中のopen()が失敗すれば、それまでに開いたファイルは自動で閉じられます。全部成功したときだけ呼び出し側に渡ります。


いつ使うか

場面 ExitStackが必要か
ファイル1〜2個 不要(withを並べる)
個数が実行時に決まる 必要
条件によって使ったり使わなかったり 必要
後始末がclose()以外 callback()が便利
成功時だけ呼び出し側へ渡す pop_all()

条件分岐の例です。

from contextlib import ExitStack

def process(log_path=None):
    with ExitStack() as stack:
        data = stack.enter_context(open("data.txt", encoding="utf-8"))
        if log_path:
            log = stack.enter_context(open(log_path, "w", encoding="utf-8"))
        ...

ifの中でwithを書くとブロックが分かれてしまいますが、ExitStackなら同じ階層に並べられます。

なお「使ったり使わなかったり」が1つだけなら、nullcontext()のほうが簡単です。


ExitStackのまとめ

  • ExitStackは数が決まらない後始末をまとめる
  • enter_context()でwith対応オブジェクトを登録
  • callback()で任意の後始末を予約
  • 後始末は登録と逆順に実行される
  • 途中で失敗しても登録済みの分は確実に片付く
  • pop_all()で後始末を呼び出し側へ持ち越せる
  • 1〜2個なら普通のwithで十分

あわせて読みたい

プログラミング学習に興味がある方は、まずは無料体験会へ

無料体験会・お申し込みはこちら