Python WebAcademy Blog

Pythonのcontextlibとは?with文で使える後片付けの仕組みを自作する方法を初心者向けに解説

|

ファイルを開くときに書くwith文は、実は自分でも作れます。Pythonの標準ライブラリcontextlibを使えば、準備と後片付けをひとまとめにした仕組みを、数行の関数で用意できます。@contextmanagerの基本から、suppress、ExitStack、Python 3.11で加わったchdirまで、動くコードと一緒に初心者向けに解説します。

ファイルを開くとき、with open(...) as f: と書くのはもうおなじみかもしれません。ブロックを抜けると自動でファイルを閉じてくれる、あの便利な書き方です。

では、このwith文を自分で作れるとしたらどうでしょうか。処理の前に準備をして、終わったら必ず後片付けをする仕組みを、自分のコードにも持ち込めたら便利だと思いませんか。

Pythonには、そのための標準ライブラリとしてcontextlibが用意されています。今回は、with文の仕組みをやさしく整理したうえで、contextlibでよく使う機能を順番に見ていきましょう。

そもそもwith文は何をしているのか

contextlibの話に入る前に、with文の中身を少しだけのぞいておきます。

with文は、ブロックに入るときと出るときに、決まった処理を自動で呼び出す構文です。ブロックの中でエラーが起きても、出るときの処理は必ず実行されます。

この入口と出口の処理を持っているオブジェクトを、コンテキストマネージャと呼びます。open関数が返すファイルオブジェクトも、コンテキストマネージャの一種です。

try-finallyで書くと何行になるか

with文がない世界を想像すると、ありがたみがよくわかります。ファイルを確実に閉じるには、try文とfinallyを組み合わせて書く必要があります。

実際に、同じ処理を2通りで書き比べてみましょう。

# with文を使わない書き方
f = open("memo.txt", "w", encoding="utf-8")
try:
    f.write("こんにちは")
finally:
    f.close()  # エラーが起きても必ず閉じる

# with文を使った書き方
with open("memo.txt", "w", encoding="utf-8") as f:
    f.write("こんにちは")

どちらも同じ動きですが、with文のほうが短く、閉じ忘れる余地がありません。後片付けを書き忘れられない形にするのが、with文のいちばんの価値です。

open関数とwith文の基本をおさらいしたい方は、こちらの記事が参考になります。【関連記事】Pythonのファイル読み書きとは?open()とwith文の基本を初心者向けに解説

__enter__と__exit__という2つのメソッド

その正体は、__enter__と__exit__という2つの特殊メソッドを持ったオブジェクトです。with文に入るときに__enter__が、出るときに__exit__が呼ばれます。

__exit__には、ブロックの中で起きた例外の情報が渡されます。例外がなければ、3つの引数はすべてNoneです。

大事なのは、__exit__が真の値を返すと例外が握りつぶされるというルールです。

クラスでこれを書くと、少し行数が増えます。処理時間を測るタイマーを例に見てみましょう。

import time

class Timer:
    def __enter__(self):
        self.start = time.perf_counter()
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        elapsed = time.perf_counter() - self.start
        print(f"経過時間: {elapsed:.3f}秒")
        return False  # 例外は握りつぶさずに外へ伝える

with Timer():
    sum(range(1_000_000))

毎回クラスを作るのは少し面倒ですよね。そこで登場するのがcontextlibです。

@contextmanagerなら関数1つで作れる

contextlibでいちばんよく使うのが、@contextmanagerというデコレータです。これを使うと、クラスを書かずに関数だけでコンテキストマネージャを作れます。

先ほどのタイマーを、@contextmanagerで書き直してみます。

import time
from contextlib import contextmanager

@contextmanager
def timer(label):
    start = time.perf_counter()
    try:
        yield  # ここでwithブロックの中身が実行される
    finally:
        elapsed = time.perf_counter() - start
        print(f"{label}: {elapsed:.3f}秒")

with timer("集計処理"):
    sum(range(1_000_000))

ポイントは、関数の途中にあるyieldです。yieldより前が入口の処理、yieldより後ろが出口の処理になります。

yieldの位置で関数はいったん止まり、そのあいだにwithブロックの中身が実行されます。ブロックが終わると、関数の続きから再開する流れです。

yieldの動きそのものに慣れていない方は、ジェネレータの記事を先に読むとイメージしやすくなります。【関連記事】ジェネレータ(yield)っていつ使うの?巨大なデータをメモリ節約して扱う方法

yieldで値を渡すとasで受け取れる

yieldに値を渡すと、それがwith文のasで受け取る値になります。準備したものを、ブロックの中へ手渡すイメージです。

たとえば、データベースの接続を開いて渡し、最後に必ず閉じる関数は次のように書けます。

import sqlite3
from contextlib import contextmanager

@contextmanager
def open_db(path):
    conn = sqlite3.connect(path)
    try:
        yield conn
    finally:
        conn.close()  # 途中でエラーが起きても必ず閉じる

with open_db(":memory:") as conn:
    conn.execute("CREATE TABLE users (name TEXT)")
    conn.execute("INSERT INTO users VALUES ('sato')")
    print(conn.execute("SELECT name FROM users").fetchall())

yieldは1回だけ、というのが公式ドキュメントのルールです。

try-finallyを忘れると後片付けが飛ばされる

ここは、初心者がとてもつまずきやすいポイントです。yieldをtry-finallyで囲まないと、ブロックの中で例外が起きたときに後片付けが実行されません。

なぜなら、ブロックで起きた例外は、関数の中のyieldの位置で改めて投げ直されるからです。finallyがなければ、yieldより後ろの行にはたどり着けません。

私も昔、ロックを解放する処理をyieldの後ろにそのまま書いてしまったことがあります。テストでは問題なく動いていたのに、本番で一度だけ例外が起きた日から、ロックが外れずに後続のバッチがすべて待ちぼうけになりました。

@contextmanagerを書くときは、yieldをtry-finallyで囲むのを最初の型にすると覚えておくと安心です。

クラスと@contextmanagerの使い分け

ここまでで、コンテキストマネージャの作り方を2つ見てきました。どちらを選べばいいのか、特徴を表で比べてみます。

観点 クラスで書く @contextmanagerで書く
コードの長さ 長くなりがち 短く書ける
状態の持ち方 属性として自由に持てる 関数のローカル変数に持つ
例外の扱い __exit__の戻り値で決める try-exceptで捕まえる
何度も使い回す 同じインスタンスを再利用しやすい 呼ぶたびに作り直す
向いている場面 部品として配布するもの ちょっとした準備と後片付け

日々のコードで使うなら、まずは@contextmanagerで十分です。状態をたくさん持つ場合や、ライブラリとして他人に使ってもらう場合に、クラスを検討するとよいでしょう。

なお、@contextmanagerで作ったものは、デコレータとしても使えます。関数の頭に@timer("処理名")と付ければ、その関数全体の時間を測れます。

デコレータの仕組みそのものは、こちらで詳しく解説しています。【関連記事】Pythonのデコレータ(@)って何?関数の前後で処理を追加する不思議な記法の正体

すぐに使える便利な道具たち

contextlibには、自分で作らなくてもそのまま使える道具も入っています。よく使うものを、追加されたバージョンと一緒に表にまとめました。

名前 できること 追加されたバージョン
suppress 指定した例外を無視する 3.4
redirect_stdout printの出力先を一時的に切り替える 3.4
closing closeメソッドを持つものを確実に閉じる 古くからある
ExitStack 数が決まっていない後片付けをまとめる 3.3
nullcontext 何もしないコンテキストマネージャ 3.7
chdir 作業フォルダを一時的に移動する 3.11

この中から、特に出番の多いものを順に紹介します。

suppressで想定内の例外だけを無視する

ファイルを消したいけれど、もともと存在しなければそれで構わない、という場面はよくあります。try-exceptで書くと4行になる処理が、suppressなら2行で済みます。

具体的には、次のように書きます。

import os
from contextlib import suppress

with suppress(FileNotFoundError):
    os.remove("cache.tmp")  # なければ何もせずに次へ進む

ただし、suppress(Exception)のように広く取りすぎると、本当に気づくべきエラーまで消えてしまいます。

例外をまとめて握りつぶす書き方がなぜまずいのかは、こちらの記事で詳しく触れています。【関連記事】Pythonの例外処理のアンチパターン5選をご紹介!|初心者がやりがちな「べからず集」

redirect_stdoutでprintの出力を横取りする

redirect_stdoutを使うと、ブロックの中のprintの出力先を一時的に変えられます。自分では書き換えられない関数が、画面にしか結果を出さないときに役立ちます。

たとえば、help関数の出力を文字列として受け取るには、次のように書きます。

import io
from contextlib import redirect_stdout

buffer = io.StringIO()
with redirect_stdout(buffer):
    help(len)

text = buffer.getvalue()
print(text.splitlines()[0])

ただし、これはプログラム全体の標準出力を切り替える仕組みです。公式ドキュメントでも、ライブラリのコードや複数のスレッドで動く処理には向かないと注意されています。

chdirで作業フォルダを一時的に移動する

Python 3.11からは、作業フォルダを一時的に移動するchdirが加わりました。ブロックを抜けると、元のフォルダへ自動で戻ってくれます。

これもプログラム全体の状態を変えるので、スレッドや非同期処理との組み合わせは避けましょう。

ExitStackで数の決まらない後片付けをまとめる

複数のファイルを同時に開きたいとき、with文を入れ子にすると右へ右へと深くなっていきます。しかも、開くファイルの数が実行時まで決まらない場合は、入れ子で書くこと自体ができません。

そんなときに頼りになるのがExitStackです。enter_contextで登録したコンテキストマネージャを、ブロックを抜けるときにまとめて片付けてくれます。

複数のCSVファイルの先頭行を読む例で見てみましょう。

from contextlib import ExitStack

filenames = ["a.csv", "b.csv", "c.csv"]

with ExitStack() as stack:
    files = [
        stack.enter_context(open(name, encoding="utf-8"))
        for name in filenames
    ]
    for f in files:
        print(f.name, f.readline().strip())
# ここで、開いたファイルがすべて閉じられる

片付けは、登録したのとは逆の順番で行われます。入れ子のwith文を書いたときと同じ順番なので、依存関係のある後片付けでも安心です。

途中の3つ目のファイルが開けずにエラーになった場合も、それまでに開いた2つはきちんと閉じられます。途中で失敗しても開いた分だけ確実に閉じるのが、ExitStackの強みです。

callbackで普通の関数も登録できる

ExitStackには、コンテキストマネージャではない普通の関数も登録できます。callbackメソッドに関数を渡すと、ブロックを抜けるときに呼び出してくれます。

一時的に作ったファイルを最後に消したい、といった用途にぴったりです。一時ファイルそのものを安全に作る方法は、tempfileの記事で紹介しています。【関連記事】Pythonのtempfileとは?一時ファイルと一時フォルダを安全に作って自動で片付ける方法を初心者向けに解説

実務で使うときの注意点

最後に、contextlibを実務で使うときに気をつけたい点を整理しておきます。どれも一度ハマると原因を見つけにくいものばかりです。

例外を捕まえたら投げ直すのを忘れない

@contextmanagerの中でexceptを使って例外を捕まえた場合、ログを出すだけなら必ずraiseで投げ直します。投げ直さないと、with文は例外が処理済みだと判断して、何事もなかったかのように次の行へ進みます。

これは公式ドキュメントにもはっきり書かれている仕様です。エラーが起きたのに処理が成功したように見える、という一番やっかいな不具合につながるので気をつけましょう。

後片付けはwith文に寄せる習慣を

10年ほど開発に関わってきて感じるのは、後片付けの漏れは、動作確認ではまず見つからないということです。エラーが起きたときにだけ表に出るので、気づくのはたいてい本番環境になってからです。

だからこそ、開いたら閉じる、始めたら終わらせる、という処理を見かけたら、with文にできないか考える癖をつけておきましょう。contextlibは、その考え方を自分のコードに持ち込むための道具です。

まとめ

今回は、Pythonのcontextlibを使って、with文で使えるコンテキストマネージャを作る方法を紹介しました。@contextmanagerを使えば、yieldをはさんだ関数1つで準備と後片付けをまとめられます。

あわせて、例外を無視するsuppress、出力を横取りするredirect_stdout、後片付けをまとめるExitStackといった道具も見てきました。どれも標準ライブラリなので、今日からすぐに使えます。

まずは、手元のコードでtry-finallyを書いている場所を探してみてください。そこがcontextlibの出番かもしれません。

ここまでお読みいただきありがとうございました。

参考情報

次のアクション

記事で学んだ内容を実際に動かしてみよう

Python WebAcademyでは、ブラウザ上でコードを書きながら基礎から実践まで体系的に学べます。

Python WebAcademyの学習画面

あわせて読む

関連記事

ブログ一覧へ

Python学習ロードマップ

まずはこの3講座から

記事で気になったテーマを、順番に手を動かしながら学べます。

ロードマップを見る