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

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

こんにちは!
本日は「クロージャ問題」に関する話題となります。

Pythonでループ内に関数を定義したことはありますか?
もしあるなら、あれれ?な挙動に遭遇したことがあるかもしれません。

本稿では、Pythonプログラマーなら一度は経験する「クロージャ問題」について、初心者にもわかりやすく解説してみたいとおもいます

クロージャとは何か?

そもそも ”クロージャ” とは何でしょうか。

クロージャ(closure)とは、関数が自分の定義されたスコープの変数を覚えて持ち運ぶ仕組み のことです。

もう少し分解すると、次の2つがポイントとなります

  1. 内側の関数が、外側の関数の変数を使える
  2. 外側の関数が終了しても、その変数は生き続ける

普通の関数とクロージャ―を使った関数を比較してみましょう

普通の関数との比較

まずは普通の関数から、

def add(x, y):
    return x + y

print(add(3, 5))  # 8
print(add(3, 7))  # 10

→ 毎回 xy の両方を渡す必要があります。

クロージャを使った関数

こちらがクロージャを使った関数です

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 の実行が終わっても、内側の addx=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 が定義されるが、すべての関数が同じ iendpoint を参照している
  • 関数が実行されるのはループが終わった後
  • その時点で 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.partialpartial を使い、関数に事前に引数を適用して新しい関数を生成する・意図が明確で関数型プログラミング的
・引数が綺麗に固定される
・初心者には少し分かりにくい
・追加の 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が生成したコードをレビューするときは、特に以下を確認すると安心です

  • ループ内で定義された関数の中に iitem をそのまま参照していないか
  • 非同期処理(async def)でクロージャを作っていないか
  • デフォルト引数で誤魔化していないか(def handler(x=i): ...

危険な場合は「クロージャファクトリで書き直して」と修正指示を出しましょう。
ちなみに、このレビューもAIにやらせたほうが早いというときは、上記観点をちゃんとレビューするようにレビュワーAIに指示しましょう。

プロンプトテンプレート

ということで、AIを使うときにコピペできる以下のような「クロージャ問題回避お守りプロンプト」を持っておくと便利ですね。

ループ内で関数を定義する必要がある場合
1. クロージャファクトリパターンを使用する
2. ループ変数は必ず関数の引数として渡す
3. 内部関数で値をキャプチャする
これらを徹底して実装してください。

まとめ

クロージャ問題は「一度はまると地味に時間を奪う」落とし穴ですが、正しい知識を持っていれば怖くないですね
特に クロージャファクトリを習慣化すること で、安全で明確なコードを日常的に書ける(AIに書かせられる)ようになります。

これからループ内で関数を定義するときは、ぜひ今回紹介した方法が参考になれば幸いです。

それでは、また次回お会いしましょう!

Read more

フリーランスHub様にQualiteg Blogをご紹介いただきました

フリーランスHub様にQualiteg Blogをご紹介いただきました

この度、フリーランス向け案件検索サービス「フリーランスHub」様の特集記事「トレンドをキャッチアップ!AIに関する情報が得られるメディア・ブログまとめ」にて、弊社が運営する「Qualiteg Blog」をご紹介いただきました。 掲載記事について フリーランスHub様の記事では、AI技術の最前線で活躍するエンジニアや開発者の方々に向けて、価値ある情報源となるメディア・ブログが厳選して紹介されています。 その中で、Qualiteg Blogを「AI技術の専門知識を実践的なビジネス活用につなげる貴重な情報源」として取り上げていただきました。 特に以下の点を評価いただいております * 実践的なビジネス活用事例の提供 AI新規事業創出や事業選定方法など、経営者やビジネスリーダーが直面する課題への具体的な解決策 * 技術的な深掘りコンテンツ リップシンク技術など、実際のサービスで使用されている技術の開発現場目線での詳細な解説 * 多様な情報発信 代表執筆記事、AIトピックス、講演会動画など、幅広いフォーマットでの情報提供 今後も価値ある情報発

By Qualiteg ニュース
PyTorchの重いCUDA処理を非同期化したらメモリリークした話と、その解決策

PyTorchの重いCUDA処理を非同期化したらメモリリークした話と、その解決策

こんにちは!Qualitegプロダクト開発部です! 今回は同期メソッドを非同期メソッド(async)化しただけなのに、思わぬメモリリーク※に見舞われたお話です。 深層学習モデルを使った動画処理システムを開発していた時のことです。 「処理の進捗をリアルタイムでWebSocketで通知したい」という要件があり、「単にasync/awaitを使えばいいだけでしょ?」と軽く考えていたら、思わぬ落とし穴にはまりました。 プロ仕様のGPUを使っていたにも関わらず、メモリ不足でクラッシュしてしまいました。 この記事では、その原因と解決策、そして学んだ教訓を詳しく共有したいと思います。同じような問題に直面している方の参考になれば幸いです。 ※ 厳密には「メモリリーク」ではなく「メモリの解放遅延」ですが、 実用上の影響は同じなので、この記事では便宜上「メモリリーク」と表現します。 背景:なぜ進捗通知は非同期である必要があるのか モダンなWebアプリケーションの要求 最近のWebアプリケーション開発では、ユーザー体験を向上させるため、長時間かかる処理の進捗をリアルタイムで表示することが

By Qualiteg プロダクト開発部
ゼロトラスト時代のLLMセキュリティ完全ガイド:ガーディアンエージェントへの進化を見据えて

ゼロトラスト時代のLLMセキュリティ完全ガイド:ガーディアンエージェントへの進化を見据えて

こんにちは! 今日はセキュリティの新たな考え方「ゼロトラスト」とLLMを中心としたAIセキュリティについて解説いたします! はじめに 3つのパラダイムシフトが同時に起きている いま、企業のIT環境では3つの大きな変革が起ころうとしています。 1つ目は「境界防御からゼロトラストへ」というセキュリティモデルの転換。 2つ目は「LLMの爆発的普及」による新たなリスクの出現。 そして3つ目は「AIエージェント時代の到来」とそれに伴う「ガーディアンエージェント」という新概念の登場です。 これらは別々の出来事のように見えて、実は密接に関連しています。本記事では、この3つの変革がどのように結びつき、企業がどのような対策を取るべきかを解説いたします 目次 1. はじめに:3つのパラダイムシフトが同時に起きている 2. 第1の変革:ゼロトラストという新しいセキュリティ思想 3. 第2の変革:LLM時代の到来とその影響 4. 第3の変革:AIエージェントとガーディアンエージェント 5. 3つの変革を統合する:実践的なアプローチ 6. 実装のベストプラクティス 7. 日本

By Qualiteg コンサルティング
発話音声からリアルなリップシンクを生成する技術 第4回:LSTMの学習と限界、そしてTransformerへ

発話音声からリアルなリップシンクを生成する技術 第4回:LSTMの学習と限界、そしてTransformerへ

1. 位置損失 (L_position) - 口の形の正確さ 時間 口の開き 正解 予測 L_position = Σᵢ wᵢ × ||y_pred - y_true||² 各時点での予測値と正解値の差を計算。重要なパラメータ(顎の開き、口の開き)には大きな重みを付けます。 jaw_open: ×2.0 mouth_open: ×2.0 その他: ×1.0 2. 速度損失 (L_velocity) - 動きの速さ 時間 速度 t→t+1 v = y[t] -

By Qualiteg 研究部, Qualiteg コンサルティング