AIがよく間違える「クロージャ問題」の本質と対策

こんにちは!
本日は「クロージャ問題」に関する話題となります。
Pythonでループ内に関数を定義したことはありますか?
もしあるなら、あれれ?な挙動に遭遇したことがあるかもしれません。
本稿では、Pythonプログラマーなら一度は経験する「クロージャ問題」について、初心者にもわかりやすく解説してみたいとおもいます
クロージャとは何か?
そもそも ”クロージャ” とは何でしょうか。
クロージャ(closure)とは、関数が自分の定義されたスコープの変数を覚えて持ち運ぶ仕組み のことです。
もう少し分解すると、次の2つがポイントとなります
- 内側の関数が、外側の関数の変数を使える
- 外側の関数が終了しても、その変数は生き続ける
普通の関数とクロージャ―を使った関数を比較してみましょう
普通の関数との比較
まずは普通の関数から、
def add(x, y):
return x + y
print(add(3, 5)) # 8
print(add(3, 7)) # 10
→ 毎回 x
と y
の両方を渡す必要があります。
クロージャを使った関数
こちらがクロージャを使った関数です
def make_adder(x):
def add(y):
return x + y # 外側の x にアクセスできる!
return add
add_3 = make_adder(3) # 3 を「記憶」した関数を作る
print(add_3(5)) # 8
print(add_3(7)) # 10
make_adder
の実行が終わっても、内側の add
は x=3 を覚えているため、
「3を足す専用の関数」を作ることができました。
クロージャのイメージはこんな感じ↓
make_adder(3)
└─ add(y): return x + y
↑
x=3 が閉じ込められている(クロージャ)
→ 「関数が環境ごと持ち運ぶ」というイメージですね。
では、実用的な例としてカウンターの例をみてみましょう
実用的な例:カウンター
def make_counter(name):
count = 0
def increment():
nonlocal count
count += 1
print(f"{name}: {count}回目")
return count
return increment
coffee_counter = make_counter("コーヒー")
tea_counter = make_counter("紅茶")
coffee_counter() # コーヒー: 1回目
coffee_counter() # コーヒー: 2回目
tea_counter() # 紅茶: 1回目
このように、それぞれ独立した count
を覚えて動くので、複数の状態を簡単に持てるのがクロージャの便利さです。
クロージャ問題とは?
さて、便利な「クロージャ」ですが、「クロージャ問題」とはどういうものでしょう。
実際の問題発生例でみてみたいとおもいます
問題の発生例
以下のコードをみて、頭の中で動作を想像してみましょう。
はい、ループで変数iが0,1,2と変化しますね。
次に 変数iを参照している click_handlerという関数をbuttonsというリストにいれます。ここでは関数の実行結果ではなく、関数そのものを入れている点に注意です
buttons = []
for i in range(3):
def click_handler():
print(f"ボタン {i} がクリックされました")
buttons.append(click_handler)
buttons[0]() # ボタン 2 がクリックされました ← 本当は0のはず
buttons[1]() # ボタン 2 がクリックされました ← 本当は1のはず
buttons[2]() # ボタン 2 がクリックされました
buttons[0]には、一番最初にいれた click_handler関数がはいっていますので、それを実行するには buttons[0]() ですね。(ややトリッキーでしたかね)
さて、その実行結果はというと、「ボタン 2 がクリックされました」と表示されます。
あれ、一番最初に入れたから「ボタン 0 がクリックされました」じゃないの?
って思ってしまうのが「クロージャ問題」です。
このサンプルの結果は、全部「ボタン2」になってしまいます。
なぜこうなるのか?
- Pythonのクロージャは「変数そのもの(参照)」をキャプチャする
- 値をコピーするのではなく、変数 i を参照している
- ループ終了時に
i=2
になっているので、すべての関数から2
が見えてしまう
ということです。
実際のプロジェクトでの例
import asyncio
async def send_requests():
tasks = []
endpoints = ["server0", "server1", "server2"]
for i, endpoint in enumerate(endpoints):
async def make_request(endpoint=endpoint, i=i): # ← 修正済み
print(f"リクエスト送信: {endpoint} (サーバー{i})")
# 実際のAPIコールをここに書く
tasks.append(make_request()) # コルーチンを生成して追加
await asyncio.gather(*tasks)
たとえば上のコードは一見うまく動きそうですが、実行すると…
リクエスト送信: server2 (サーバー2)
リクエスト送信: server2 (サーバー2)
リクエスト送信: server2 (サーバー2)
全部 最後のサーバー (server2) に対して実行されてしまいます。
これはもうとんでもないバグですね。
なぜバグになるのか?
- ループのたびに
make_request
が定義されるが、すべての関数が同じi
とendpoint
を参照している - 関数が実行されるのはループが終わった後
- その時点で
i=2
,endpoint="server2"
になっている - 結果として全部「server2」になってしまう
つまり、ループ変数をクロージャが遅延評価してしまう問題となります。この遅延評価こそがクロージャ―問題の本質です。
解決方法
では、どう解決して行けばいいでしょうか。
いくつかの方法をご紹介いたします
もう↓の例を参照しつつ、解決方法をみていきましょうょう
buttons = []
for i in range(3):
def click_handler():
print(f"ボタン {i} がクリックされました")
buttons.append(click_handler)
buttons[0]() # ボタン 2 がクリックされました ← 本当は0のはず
buttons[1]() # ボタン 2 がクリックされました ← 本当は1のはず
buttons[2]() # ボタン 2 がクリックされました
クロージャ問題を含むコード
方法1: デフォルト引数を使う
この方法では、関数の引数にループ変数をデフォルト値として渡すことで、関数定義時に値を固定する という解決策をとります。
例えば次のように書きます
buttons = []
for i in range(3):
def click_handler(button_id=i):
print(f"ボタン {button_id} がクリックされました")
buttons.append(click_handler)
こうすることで、i
が後から変化しても、button_id
には定義時点の値が入ります。
短く書ける点が魅力ですが、本来のシグネチャには存在しない引数が増えてしまい、コードの意図が少し分かりづらくなるデメリットがあります。
方法2: クロージャファクトリを使う(おすすめ)
この方法では、「関数を返す関数(ファクトリ)」を定義し、その中でループ変数を閉じ込めることで問題を解決します。
def make_click_handler(button_id):
def handler():
print(f"ボタン {button_id} がクリックされました")
return handler
buttons = [make_click_handler(i) for i in range(3)]
このパターンでは、make_click_handler
がそれぞれの値を保持した関数を作るため、ループが終わった後でも正しい値が使われます。
「この値を固定した関数を作る」という意図が明確で、誤用した場合はすぐエラーになるため、安全性や保守性の観点から最も推奨される方法です。
方法3: lambda + デフォルト引数
この方法では、無名関数 lambda
にループ変数をデフォルト引数として渡すことで、関数をその場で生成します。
buttons = [lambda x=i: print(f"ボタン {x} がクリックされました") for i in range(3)]
非常に短く書けるのが利点ですが、コードの読みやすさが低くなり、処理が複雑になると途端に理解しづらくなります。
小さなスクリプトや簡単な使い捨てコードには便利ですが、業務コードやチーム開発では避けた方が無難です。
方法4: functools.partial を使う
この方法では、functools.partial
を用いて「関数にあらかじめ引数を適用しておいた新しい関数」を作ることで解決します。
from functools import partial
def click_handler(button_id):
print(f"ボタン {button_id} がクリックされました")
buttons = [partial(click_handler, i) for i in range(3)]
partial(click_handler, i)
によって、button_id=i
が固定された関数が生成され、リストに格納されます。
意図が明確で関数型プログラミングの発想に近い方法ですが、初心者には少し理解しづらく、追加の import が必要になる点も注意が必要です。
それぞれの解決方法の比較表
方法 | 解決アプローチ | メリット | デメリット | おすすめ度 |
---|---|---|---|---|
方法1: デフォルト引数 | ループ変数をデフォルト引数に渡して、その時点の値を固定する | ・シンプルで短く書ける ・Pythonらしい書き方 | ・不要な引数が関数に現れる ・誤って上書きされても気づきにくい ・型チェッカーで混乱する場合がある | ★3 |
方法2: クロージャファクトリ | 「関数を返す関数」を定義し、値をクロージャで閉じ込める | ・値が安全にカプセル化される ・意図が明確で可読性が高い ・誤用時は即エラーで気づける ・型チェッカーと相性が良い | ・コードがやや長い ・追加の関数定義が必要 | ★5 |
方法3: lambda + デフォルト引数 | lambda式で無名関数を作り、ループ変数をデフォルト引数に渡す | ・最短で書ける ・ちょっとした処理に便利 | ・可読性が低い ・複雑な処理には不向き ・デフォルト引数の問題は方法1と同じ | ★2 |
方法4: functools.partial | partial を使い、関数に事前に引数を適用して新しい関数を生成する | ・意図が明確で関数型プログラミング的 ・引数が綺麗に固定される | ・初心者には少し分かりにくい ・追加の import が必要 | ★3 |
実務で役立つパターン
ここまででクロージャの仕組みと典型的な問題を見てきましたが、ここで、実際のプロジェクトで役立つクロージャのパターンをご紹介いたします
クロージャはたとえば、↓のような場面でクロージャは大活躍してくれます
- 状態管理をシンプルに行いたいとき
- 処理を動的に生成して柔軟に対応したいとき
- クラスを使うほどではないが、ちょっとした「覚えておく仕組み」が欲しいとき
具体例として「進捗レポーター」と「動的バリデーション」という2つのパターンを見てみましょう。どちらも「方法2:クロージャファクトリ」の応用例です!
進捗レポーター
このパターンでは、処理の進捗状況をトラッキングする関数を動的に生成します。
def create_progress_reporter(server_name, total_tasks):
completed = 0
def report_progress(task_name):
nonlocal completed
completed += 1
percent = (completed / total_tasks) * 100
print(f"[{server_name}] {task_name} 完了 ({percent:.1f}%)")
return completed == total_tasks
return report_progress
create_progress_reporter
が返すreport_progress
関数は、外側のcompleted
を覚えているため、呼び出すたびに進捗を更新できます。- サーバーごとに「独立した進捗カウンター」を作れるので、複数の処理を並列に監視するのに便利です。
実務での活用例
サーバーごとのタスク進捗、ファイル処理の進行度、バッチ処理の進行率などを簡単に管理できます。
→ 普通なら外部でクラスや状態管理が必要ですが、クロージャを使うことで簡潔に記述できます。
動的バリデーション
次に、入力フィールドごとに異なる検証処理を持つハンドラーを生成するパターンです。
def create_validation_handler(field_name, validator_func):
def handler(event):
value = event.get('value')
if not validator_func(value):
print(f"検証エラー: {field_name} の値が不正です: {value}")
return False
print(f"✓ {field_name}: OK")
return True
return handler
- フィールド名(
field_name
)と検証ロジック(validator_func
)をクロージャに閉じ込めることで、フィールドごとに独立したハンドラーを作成できます。 - 例えば「メールは @ を含むか」「年齢は0〜150の範囲か」「名前は空文字でないか」といった検証を、それぞれ専用の関数に変換可能です。
実務での活用例
フォーム入力の検証、APIの入力チェック、ログデータのバリデーションなど。
→ ループでフィールドリストを回してハンドラーを自動生成できるため、コードの重複を防ぎ、保守性が大きく向上します。
AIペアプログラミングでのTips
さて、最後に、AIでコードを生成するときに、このクロージャ問題に対して適切な実装をさせるためのTipsを共有いたします
最近は ChatGPT、Claude、Gemini など、コード生成AIを使ったプログラミングが当たり前になってますが、このブログのタイトルにもあるようにAIはけっこう クロージャ問題を含んだコード を生成してしまいます。
AIがやらかした例
handlers = []
for i, item in enumerate(items):
def handler():
process(i, item) # 危険! 全部最後の i を使ってしまう
handlers.append(handler)
このように「ループ内で関数を定義 → ループ変数をそのまま使う」パターンで間違って出力してしまう例はけっこうあるんです。
また、AIは「短く動くコード」を優先して提案することも多く、バグを生まなくても読みづらかったり、modifyしづらいコードが紛れ込みやすいです。
そこで、AIにコードをつくらせるまえに、ちゃんとAIに指導をいれてやる必要があります。
AIへの指示の工夫
といっても指導は簡単です
AIにコードを書かせるときは、プロンプトに
「クロージャ問題を避けるためにクロージャファクトリを使ってください」
と書くだけで精度がかなり上がります。
良い指示例
複数のボタンにイベントハンドラーを設定するコードを書いてください。
ただしクロージャ問題を避けるため、クロージャファクトリパターンを使用してください。
さらに具体的な指示
forループ内で関数を定義する場合は、ループ変数の遅延評価問題を避けるため、
必ずクロージャファクトリを使って実装してください。
はい、この程度の指示でコード品質が向上します。
レビュー時のチェックポイント
さらに、AIが生成したコードをレビューするときは、特に以下を確認すると安心です
- ループ内で定義された関数の中に
i
やitem
をそのまま参照していないか - 非同期処理(
async def
)でクロージャを作っていないか - デフォルト引数で誤魔化していないか(
def handler(x=i): ...
)
危険な場合は「クロージャファクトリで書き直して」と修正指示を出しましょう。
ちなみに、このレビューもAIにやらせたほうが早いというときは、上記観点をちゃんとレビューするようにレビュワーAIに指示しましょう。
プロンプトテンプレート
ということで、AIを使うときにコピペできる以下のような「クロージャ問題回避お守りプロンプト」を持っておくと便利ですね。
ループ内で関数を定義する必要がある場合
1. クロージャファクトリパターンを使用する
2. ループ変数は必ず関数の引数として渡す
3. 内部関数で値をキャプチャする
これらを徹底して実装してください。
まとめ
クロージャ問題は「一度はまると地味に時間を奪う」落とし穴ですが、正しい知識を持っていれば怖くないですね
特に クロージャファクトリを習慣化すること で、安全で明確なコードを日常的に書ける(AIに書かせられる)ようになります。
これからループ内で関数を定義するときは、ぜひ今回紹介した方法が参考になれば幸いです。
それでは、また次回お会いしましょう!