【Python】コンテキストマネージャ(with文)の仕組みと自作方法

Python

こんにちは、かつコーチです。

with open("file.txt") as f:というコードは頻繁に書くものの、裏側の仕組みまでは説明できない、という方は少なくないと思います。

「なぜwithを使うとファイルを閉じ忘れないのか」

「自分でwithに対応したクラスを作れるのか」

この記事では、with文の基本、__enter____exit__を使った自作方法、そしてcontextlib.contextmanagerを使ったより簡潔な書き方までを解説します。

読み終える頃には、with文の裏側を理解し、自分のクラスやDB接続処理にも応用できるようになります。

with文の基本

ファイル操作でよく見るwith文

with文の最も典型的な例が、ファイル操作です。

with open("sample.txt", "w") as f:
    f.write("こんにちは")

print("ファイルは自動的に閉じられている")
print(f.closed)

実行結果は次の通りです。

ファイルは自動的に閉じられている
True

withブロックを抜けた時点で、f.close()を明示的に呼んでいないにもかかわらず、ファイルが自動的に閉じられています。

なぜwith文が必要なのか

with文を使わない場合、ファイルを閉じる処理を自分で書く必要があります。

f = open("sample.txt", "w")
try:
    f.write("こんにちは")
finally:
    f.close()

print(f.closed)

実行結果は次の通りです。

True

tryfinallyを使えば同じことは実現できますが、ファイルを開くたびにこの記述を繰り返すのは面倒です。

さらに、finallyを書き忘れると、途中でエラーが起きたときにファイルが閉じられないままになってしまいます。

with文は、この「開いたものを確実に閉じる」という定型処理を、コンパクトかつ安全な形で表現できる仕組みです。

コンテキストマネージャとは、このwith文の裏側で動く、「開始処理」と「終了処理」をセットで管理するオブジェクトのことを指します。

enter/exitでの自作方法

コンテキストマネージャの最小構成

自分で定義したクラスをwith文に対応させるには、__enter____exit__という2つの特殊メソッドを実装します。

class FileManager:
    def __init__(self, filename, mode):
        self.filename = filename
        self.mode = mode
        self.file = None

    def __enter__(self):
        print(f"{self.filename}を開きます")
        self.file = open(self.filename, self.mode)
        return self.file

    def __exit__(self, exc_type, exc_value, traceback):
        print(f"{self.filename}を閉じます")
        if self.file:
            self.file.close()


with FileManager("sample.txt", "w") as f:
    f.write("こんにちは")

実行結果は次の通りです。

sample.txtを開きます
sample.txtを閉じます

with FileManager(...) as f:と書くと、__enter__が呼ばれ、その返り値がfに代入されます。

withブロックを抜けるタイミングで、自動的に__exit__が呼ばれる仕組みです。

exitの引数の意味

__exit__は、self以外に3つの引数(exc_typeexc_valuetraceback)を受け取ります。

これらは、withブロック内で例外が発生した場合に、その例外の情報を受け取るための引数です。

class SafeDivider:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type is ZeroDivisionError:
            print("ゼロ除算のエラーを検知しました")
            return True  # 例外を握りつぶす
        return False


with SafeDivider() as sd:
    result = 10 / 0
    print(result)

print("処理を継続できている")

実行結果は次の通りです。

ゼロ除算のエラーを検知しました
処理を継続できている

例外が発生していない場合、exc_typeにはNoneが入ります。

__exit__Trueを返すと、発生した例外は「処理済み」として扱われ、withブロックの外側にエラーが伝播しなくなります。

False(または何も返さない)場合は、例外がそのまま外側に伝わります。

contextlib.contextmanagerの使い方

デコレータで簡潔に書く

クラスを丸ごと定義する__enter____exit__方式は、やや記述量が多くなります。

標準ライブラリのcontextlib.contextmanagerを使うと、ジェネレータ関数だけでコンテキストマネージャを作れます。

from contextlib import contextmanager


@contextmanager
def file_manager(filename, mode):
    print(f"{filename}を開きます")
    f = open(filename, mode)
    try:
        yield f
    finally:
        print(f"{filename}を閉じます")
        f.close()


with file_manager("sample.txt", "w") as f:
    f.write("こんにちは")

実行結果は次の通りです。

sample.txtを開きます
sample.txtを閉じます

yieldの前が__enter__に、yieldの後(finallyブロック)が__exit__に相当します。

yield fで返した値が、with ... as ffに代入される仕組みです。

try/finallyで確実に後始末する

@contextmanagerを使う際は、yieldを必ずtryブロックの中に置き、後始末の処理をfinallyブロックに書くのが定石です。

from contextlib import contextmanager
import time


@contextmanager
def measure_time():
    start = time.time()
    try:
        yield
    finally:
        end = time.time()
        print(f"処理時間: {end - start:.4f}秒")


with measure_time():
    total = sum(range(1_000_000))
    print(f"合計: {total}")

実行結果は次のようになります(時間は環境によって変わります)。

合計: 499999500000
処理時間: 0.0421秒

withブロックの中でエラーが起きても、finallyがあるおかげで、確実に計測結果が出力されます。

yieldtryの外に置いてしまうと、ブロック内で例外が起きた際に、後始末の処理が実行されなくなるため注意が必要です。

よくあるつまずきポイント・エラー対処

yieldをtryの外に書いて後始末が実行されない

筆者がDB接続処理を@contextmanagerで自作していたとき、yieldtryの外に書いてしまい、エラー発生時にコネクションが解放されない不具合に遭遇したことがあります。

❌ Before(tryの外にyieldを書いた誤った書き方)

from contextlib import contextmanager


@contextmanager
def db_connection():
    print("接続を開始")
    conn = {"status": "open"}  # 疑似的な接続オブジェクト
    yield conn
    conn["status"] = "closed"
    print("接続を解放")


try:
    with db_connection() as conn:
        raise ValueError("何らかの処理エラー")
except ValueError as e:
    print(f"エラーを捕捉: {e}")

実行結果は次の通りです。

接続を開始
エラーを捕捉: 何らかの処理エラー

ValueErrorが発生した時点でyieldより後の処理がスキップされてしまい、「接続を解放」というログが出力されていません。

このパターンを実際のDB接続コードで使うと、コネクションが解放されないまま残り続け、接続プールの枯渇といった障害につながります。

✅ After(try/finallyで確実に解放する書き方)

from contextlib import contextmanager


@contextmanager
def db_connection():
    print("接続を開始")
    conn = {"status": "open"}
    try:
        yield conn
    finally:
        conn["status"] = "closed"
        print("接続を解放")


try:
    with db_connection() as conn:
        raise ValueError("何らかの処理エラー")
except ValueError as e:
    print(f"エラーを捕捉: {e}")

実行結果は次の通りです。

接続を開始
接続を解放
エラーを捕捉: 何らかの処理エラー

yieldtryブロックの中に置き、後始末をfinallyブロックに書くことで、例外の有無にかかわらず「接続を解放」が確実に実行されるようになりました。

自作コンテキストマネージャを書く際は、このtryfinallyの配置を必ずセットで意識するようにしましょう。

まとめ

この記事のポイント

  • with文は、開始処理と終了処理をセットで確実に実行するための仕組みである
  • コンテキストマネージャは__enter____exit__を実装したクラスとして自作できる
  • __exit__Trueを返すと、発生した例外は握りつぶされる
  • contextlib.contextmanagerを使うと、ジェネレータ関数だけで簡潔にコンテキストマネージャを作れる
  • @contextmanagerを使う際は、yieldtryブロックの中に置き、後始末はfinallyブロックに書く

次に読むべき記事

  • ジェネレータとyieldの使い方
  • デコレータの基本:関数を拡張する仕組みを解説
  • 例外処理(try/except)の書き方

タグ: Python, 上級者向け, 基本文法

タイトルとURLをコピーしました