Lisp
イベント
該当するコンテンツが見つかりませんでした
マガジン
該当するコンテンツが見つかりませんでした
技術ブログ
電通 総研 クロス イノベーション 本部の山下です。2025年11月-12月にかけて開催されたKiroの Hackathon イベントであるKiroweenに参加しましたので、そのレポートをお送りします。 このイベントはKiroを使ってアプリケーションを開発することを目的とした ハッカソン イベントです。 作るもののテーマがハロウィンをモチーフにしたイベントになっています。 参加要件など 以下のような参加要件になっていました。 実際の詳細は 公式サイト をご覧ください。 基本的にKiroを使ってアプリケーション開発をすればよいのですが、テーマが指定されているのが特徴です。 Resurrection: お気に入りの技術を復活させる Frankenstein: 複数の技術を組み合わせてアプリを作る Skeleton Crew: ス ケルト ンを作成し、それから複数のアプリを作る Costume Contest: 洗練された不気味なデザインのアプリを作る といったテーマのようです(日本語訳は筆者による)。 自分はResurrectionを選びました。参加するにあたりテーマ選定にかなり悩んだのですが、知人から自分が普段 Common Lisp を使っていて、それは十分に古い技術なのではという指摘を受けて、確かにその通りだなということで決めました。 作ったもの Kabotanというアプリケーションを実装しました。Kabotanは Common Lisp を使って作った、HTMXとLLMを組み合わせたアプリケーションです。ハロウィンにちなんだ機能を提供していて、質問に答えたり、ハロウィンに関する文章を生成したりすることができます。 なぜ Common Lisp を採用したかというと、古い技術と見なされており、テーマのResurrectionにも合っているためです。一方で自分は普段それなりに Common Lisp を使っているので少しでも Common Lisp の良さを知ってもらえたらと思い選びました。 Kabotanは以下のURLで公開しています。 https://github.com/dentsusoken/kabotan/ Kabotanは以下のような アーキテクチャ になっています。 フロントエンド: HTMX + Tailwind CSS バックエンド: Common Lisp (clack + hunchentoot) LLM: llama.cppを利用したローカルモデル(gpt- oss -120bなどを想定) モダンなアプリケーションではフロントエンドにReactやVue.jsなどの JavaScript フレームワーク を使うことが多いですが、今回はシステムの大部分を Common Lisp で実装したかったため、HTMXを採用しました。 フロントエンドにHTMXを使うことでフロントエンドの JavaScript コードを最小限に抑え、アプリケーションの大部分を Common Lisp で実装することができました。 実際の画面の例を以下に示します。 特に各 コンポーネント 間のやり取りではServer Sent Event(SSE)を利用して、LLMからの応答をリアルタイムに受け取れるようにしています。これにより、ユーザはLLMが応答を生成している間も進捗を確認でき、より インタラクティブ な体験が可能となっています。 個人的には Common Lisp でも現代的なアプリケーションの実装は十分に可能ということを示せたのではないかと思います。 ちなみに、 Common Lisp を含む Lisp 系の言語は括弧が多いことで有名です。慣れるとS式は読みやすいのですがなれないと苦労するかもしれません。例えばKabotanのindex.htmlを返す部分は以下のようなコードになっています。 ( defun serve-index ( env ) "Serve the main index.html page. The Lack session middleware automatically handles session cookies, so we don't need to manually set them here." ( declare ( ignore env )) ( let (( html ( uiop:read-file-string "public/index.html" :external-format :utf-8 ))) `( 200 ( :content-type "text/html; charset=utf-8" ) ( ,html ) ) )) Lisp 系言語ではこのS式と呼ばれる (関数名 引数1 引数2 ... 引数N) というような記法でプログラム自体を記述します。このデータもプログラム本体も全てこのS式で表現することで、非常に強力なマクロを作れたりするのが特徴となっています。 実装するうえで苦労したところ Common Lisp をKiroで利用するにあたって苦労した点、工夫した点がいくつかありました。 Common Lisp をKiroで利用するための整備 まず、 Common Lisp をKiroが利用できるようにするための整備です。例えば、 Common Lisp には標準でデバッガが実装されており、エラー発生時などには自動的にデバッガが起動します。 Common Lisp で広く使われている開発環境のSLIMEではこれを便利に利用することができます。しかし、この機能はKiroなどのAIにとっては対話的な操作が必要になってしまいAIの操作を阻害してしまいます。 また、ASDF(Another System Definition Facility)という Common Lisp の デファクトスタンダード なビルド管理システムがあります。これも事前に定義を行っておきひな形のアプリケーションが動作するような状態まで整備を行いました。その上で、makeを利用して常にデバッガを起動しないオプションを付けて起動するようにし、Kiroからもmake経由で実行するような形にしました。 最終的には以下のような Makefile のエントリとなりました。 --disable-debugger を実行時に引数で渡し、ASDFを使ってKabotanをビルド、実行する形になっています( ql:quickload がASDFを内部で呼ぶ仕組みになっています)。 ROS = ros LISP_IMPL = sbcl SYSTEM = kabotan TEST_SYSTEM = kabotan-test run: $(ROS) -L $(LISP_IMPL) run -- \ --disable-debugger \ --eval '(ql:quickload :$(SYSTEM))' \ --eval '(uiop:quit (kabotan:main))' Server Sent Eventへの対応 Kiroでのアプリケーション開発において、Server Sent Event(SSE)に対応させるのに苦労しました。SSEはサーバからクライアントへリアルタイムにデータを送信するための技術であり、LLMの応答をリアルタイムに受け取るために必要でした。 ブラウザ-Kabotan間のSSE対応 Common Lisp のWebフレームワークであるclackやhunchentootは直接このSSEをサポートしておらず、独自に実装する必要がありました。これはclackのソケットを直接操作する機能を利用して、SSEに対応させることができました。 Kabotan-llama.cpp間のSSE対応 llma.cppのサーバにとってKabotanはSSEのクライアントとして振る舞う必要があります。 これも Common Lisp のHTTPクライアントライブラリのdexadorを利用して独自に実装する必要がありました。dexadorは通信に利用しているソケットを扱うことができ、これを操作することでSSEに対応させることができました。 その他苦労した点 HTMX周りはKiroに色々指示を出さないとうまく対応できないことがあり苦労しました。HTMXはフロントエンドの JavaScript コードを減らすことができる利点がありますが、Kiroにその利点を理解してもらうのが難しい場合があり、何も指示を行わないとフロントエンドの JavaScript でほとんどの実装を行ってしまい、HTMXの利点がない構成になってしまうことがありました。 またLLMを利用するアプリケーションはテストに時間がかかってしまいます。 そしてKiroはコマンドの応答待ち時間が最大で20分になっていますが、稀にこれを超えてしまうことがありました。こうなってしまうと、Kiroはテストを途中で打ち切ったり問題がないのに問題があると判定して編集作業を行おうとしたり、逆に問題があるのに問題ないと判断してしまったりすることがあり、開発効率が低下することがありました。 Kiroの使い方について Hackathon 全体を通じてどのようにKiroを活用したのかについても紹介します。 Kiroを使ううえで重要だと感じたポイントは以下のとおりです。 Spec、Steeringの活用 Hookの活用 テストの工夫 特に、SpecとSteeringの使い分けは重要だと感じました。 Specという名前を見るとSpec側に詳細な仕様を書くべきだと考えがちですが、実際にはSteering側に詳細な仕様を書く方が効果的でした。例えば、 アーキテクチャ に関する指示、設計上の選択といったものはSteeringに記載し、実装が進むにつれて状況が変わるたびにSteeringはプロジェクトの実際の状況を表すように更新する必要がありました。 そして、Specは実際の小さな作業を行うために必要な最小限の仕様に留めておく方が効果的でした。基本的な動作の概要を伝えて、Design.mdを作成してもらい、Task.mdを生成してもらうようにしました。つまり、Specは スクラム 開発などでいうところの「ユーザーストーリー」に近い役割を果たし、Steeringが「詳細な要件定義書」や「設計書」に近い役割を果たす形です。 これらを前提に置き、詳細な設計などはVibe CodingでKiroと相談しながら進め随時Steeringを更新したり、簡単なバグ修正などは直接修正したりして進めました。一定規模を超える作業になりそうな場合はSpecを作成して対応してもらい、 リファクタリング などの作業もSpecとして作成して随時実施するようにしました。 以下は開発時のKiroの画面の様子です。Agent Steeringに色々設計上を指定しておき、作業ごとにSpecを作り開発していきました。 また、Hookも積極的に活用しました。Hookを使うことで、Kiroが生成したコードに対して自動的に追加の処理を行うことができます。Kabotanでは lisp ファイルが更新されたときに自動的にテストが実行されるようにHookを設定しました。Hookは便利なのですが、TaskとしてKiroが実行してしまうためHookを実行している間新しいタスクの着手が出来ないという欠点もあります。つまり、タスクが完了したとKiroが報告してくるので次のタスクを実行しようとするが、Hookが動作している間は新しいタスクに着手できないということです。しかもKiroは現在実行中のタスクを一望するインタフェースが分かりづらい位置にあるので最初は苦労しました。 以下のUIで実行中のタスクなどが確認できます。クリックして初めて詳細が分かるようになっています。常に表示されていると便利なのですが今後是非改善してほしいですね。 テストの書き方も簡単な 単体テスト であればKiroに生成してもらうようにして、実際の動作を確認するような総合テストについては細かく指示を出してKiroに生成してもらうようにしました。総合テストでは受け入れのためのテストを作るような指示を出し、それをこまめに実行するような運用を行いました。これは最終的な動作だけはちゃんと確認したいという意図でした。 Kiroが良くなっていた点 Kiroが発表されてから時間が経過しており、その間にKiro自体も改善されていました。今回の Hackathon を通じて特に良くなっていたと感じた点は以下のとおりです。 利用できるモデルが増え、特にClaude Sonnet 4.5が利用できるようになりました。これにより、生成されるコードの品質が向上しています。また、利用中にKiroがGA(General Availability)になりQ Developer CLI がKiro CLI になったという変化もありました。これに合わせてアカウント管理などもKiro側で行うことが可能になり、より使いやすくなっていました。特に上限に達した場合にも追加で課金を行うことで利用が可能になるのはとても便利になった点です。Q Developerを試していたころは上限に達すると利用できなくなってしまい、開発が中断されてしまうことがありました。新規アカウントをその都度発行するという手段もあるのですが、会社のアカウントで利用している場合は難しい場合もあるので、追加課金で対応できるのは便利です。 また、プロパティベースのテストが生成できるようになりました。以前は 単体テスト などの具体的な値を使ったテストが中心でしたが、今回はプロパティベースのテストを生成するように指示を出すことで、より広範囲な動作確認が可能になります。受入テストなどでは特に有効だと感じました。 まとめ Kiroの Hackathon イベントであるKiroweenに参加し、 Common Lisp を使ったHTMX+LLMアプリケーションであるKabotanを開発しました。Kiroを活用することで、効率的に開発を進めることができ、 Common Lisp でも近代的なアプリケーションの実装が可能であることを示せたと感じています。 またKiroは言語の限定なく利用できるということが 公式ドキュメント で記載されています。 Common Lisp でも問題なく対応出来ました。採用する機会が少ない言語も含めて色々な言語でアプリケーション開発可能であることも確認できました。 Kiro自体も改善されており、より使いやすくなっていました。今後もKiroを活用して様々なアプリケーション開発に挑戦していきたいと考えています。 以上、Kiroween参加レポートでした。 私たちは一緒に働いてくれる仲間を募集しています! 電通総研 キャリア採用サイト 電通総研 新卒採用サイト 執筆: @yamashita.tsuyoshi レビュー: Ishizawa Kento (@kent) ( Shodo で執筆されました )
本記事は「 Property-Based Testing Caught a Security Bug I Never Would Have Found 」を翻訳したものです。 ターゲット型ランダムテストが実際のセキュリティ脆弱性を発見したとき セキュリティ脆弱性は、私たちがテストしようと思わないコードの隅に隠れていることがよくあります。正常系テストを書き、想像できるいくつかの境界値ケースをテストしますが、考えもしない入力についてはどうでしょうか? LLM がデフォルトでこれらのシナリオを処理していると仮定することが多いですが、LLM が生成したコードも人間が書いたコードと同様にバグや脆弱性を含む可能性があります。ユーザーがアプリケーションに悪意のある文字列を入力したらどうなるでしょうか? これは、Kiro の 最新の GA 機能 を使用して AI でチャットアプリケーション用のストレージサービスを構築するテストを行ったときに起こったことです。 仕様駆動開発(SDD)ワークフロー に従って、Kiro は要件を慎重に定義し、テスト可能なプロパティを抽出し、API キーの保存と取得のための一見単純なコードを実装しました。実装は堅実に見えました。コードレビューでも承認されたでしょう。従来の単体テストも通過したでしょう。 しかし、プロパティベーステストの 75 回目の反復で、予期しないことが起こりました。ラウンドトリップケースのプロパティテスト全体が失敗したのです。単純な保存と取得操作であるはずが、代わりに JavaScript プロトタイプの誤った処理を露呈しました。これは、早期に欠陥を排除するよう注意しないと、将来的にセキュリティ問題につながる可能性があるバグです。 この投稿では、プロパティベーステスト(PBT)が人間の直感や従来のテスト手法では見逃されたであろうセキュリティバグをどのように発見したかのストーリーを紹介します。以下について説明します。 Kiro が定義した仕様とプロパティ 重大な欠陥を含んでいた一見無害な実装 PBT の入力空間の体系的な探索が脆弱性をどのように発見したか 脆弱性に対処する修正 これが安全なソフトウェア構築にとってなぜ重要なのか これは単なる理論的な演習ではありません。自動テスト技術が、セキュリティ研究者を夜も眠れなくするエッジケースを、本番環境に到達する前に発見できることの実例です。 背景 一部の顧客とアプリケーションの構築に取り組み、仕様のプロンプトを検討する際、Kiro はユーザーデータをブラウザの localStorage に保存するチャットアプリケーション用のストレージシステムを実装していました。主要な機能の一つは、異なる LLM プロバイダー(OpenAI、Anthropic など)の API キーを保存することでした。ユーザーはプロバイダー名をキーとして API キーを保存できます。このオブジェクトは以下のような API を持ちます。 storageService.saveApiKey("openai", "sk-abc123..."); storageService.saveApiKey("anthropic", "sk-ant-xyz..."); Kiro は SDD に従って以下の要件を策定しました。 ### 要件 6 **ユーザーストーリー:** ユーザーとして、異なる LLM プロバイダーの API キーを設定したい。そうすることで、自分のアカウントを使用してコストを管理できる。 #### 受け入れ基準 1. ユーザーが設定を開いたとき、チャットアプリケーションは各 LLM プロバイダーの API キー入力フィールドを表示する 2. ユーザーが API キーを保存したとき、チャットアプリケーションはそれをローカルストレージに安全に保存する 3. API キーが無効または欠落している場合、チャットアプリケーションは明確なエラーメッセージを表示し、メッセージ送信を防ぐ 4. チャットアプリケーションはセキュリティのため UI で API キー値をマスクする 5. ユーザーが API キーを削除したとき、チャットアプリケーションはその LLM プロバイダーを無効にする 受け入れ基準 2 について詳しく見てみましょう。Kiro はこれを重要な正確性プロパティとして選択しました。 **プロパティ 19: API キーストレージのラウンドトリップ** *任意の* プロバイダーに保存された API キーについて、ストレージから取得すると同じキー値が返される。 **検証対象: 要件 6.2** Kiro はこれを「ラウンドトリップ」プロパティと呼んでいます。ラウンドトリップは正確性プロパティの一般的な形で、任意の値から始めて、一連の操作を実行し、同じ値で終わるものです。この場合、任意の文字列値 provider と key から始めて以下を行いました。 ストレージの provider の下に key を保存 provider に関連付けられた値を取得 そして、取得した値は key と等しくなければなりません。これが真でない場合(異なる値を取得したり、例外が発生したりする場合)、明らかに実装に何か問題があります。この仕様は素晴らしく見えるので、承認して Kiro に API を実装してもらいます。 LLM は API の一部として以下のコードを生成しました。 /** * 特定のプロバイダーの API キーを保存 */ saveApiKey(provider: string, apiKey: string): void { try { const apiKeys = this.loadAllApiKeys(); apiKeys[provider] = apiKey; localStorage.setItem( StorageService.API_KEYS_KEY, JSON.stringify(apiKeys) ); } catch (error) { if (error instanceof Error && error.name === 'QuotaExceededError') { throw new Error('ストレージクォータを超過しました。API キーを保存できません。'); } throw error; } } その後、Kiro はプロパティベーステストを使用してこのコードをテストし、期待するプロパティが実際に成り立つという証拠を収集しました。プロパティ 19 をチェックするために、Kiro は TypeScript 用の fast-check ライブラリを使用して以下のテストを書きました。 describe('プロパティ 19: API キーストレージのラウンドトリップ', () => { /** * 機能: llm-chat-app, プロパティ 19: API キーストレージのラウンドトリップ * 検証対象: 要件 6.2 * * プロバイダーに保存された任意の API キーについて、ストレージから取得すると * 同じキー値が返される。 */ it('保存と読み込みサイクルを通じて API キーを保持する', () => { fc.assert( fc.property( fc.string({ minLength: 1, maxLength: 100 }), // プロバイダー名 fc.string({ minLength: 10, maxLength: 200 }), // API キー (provider, apiKey) => { // 各プロパティテスト実行前に localStorage をクリア global.localStorage.clear(); // API キーを保存 storageService.saveApiKey(provider, apiKey); // 読み込み直す const loaded = storageService.loadApiKey(provider); // 元の値と一致することを確認 expect(loaded).toBe(apiKey); } ), { numRuns: 100 } ); }); Kiro がこのテストを実行すると、試行 #75 で失敗が発生しました!Kiro は失敗を Shurinking し、以下の反例を報告しました。プロバイダー "__proto__" と API キー " " 。 何が起こっているのか? プロパティベーステストはプロバイダー名にランダムな文字列を生成し、75 回のテスト実行後、プロバイダー名として文字列 "__proto__" を生成しました。これにより、以下の反例でテストが失敗しました。 反例: ["__proto__"," "] プロバイダー名 __proto__ で API キーを保存してから読み込もうとすると、奇妙なことが起こり、期待した値を取得できません。Kiro は Shurinking を使用して最小反例を提示して問題を特定し、問題から余分な詳細を取り除くのに役立ちます。この場合、apiKey 文字列をジェネレーターで許可される最小の文字列(スペースのみを含む)に Shurinking します。これは、問題が値ではなく、奇妙なキーが問題を引き起こしていることを示しています。JavaScript に詳しい方なら、このエラーはすぐに目に付くでしょうが、そうでない方は読み続けてください。 これは JavaScript がオブジェクトシステムを実装する方法の特徴です。より伝統的なオブジェクト指向プログラミング言語(Java、Python、SmallTalk など)は、クラスの概念を使用します。各クラスは、オブジェクトの構築方法を記述し、異なるオブジェクト間の継承関係を記述するコードベースの静的メンバーです。JavaScript は「プロトタイプ」と呼ばれる代替アプローチを使用します。プロトタイプベースのオブジェクトシステムでは、クラスは存在しません。代わりに、すべてのオブジェクトには、コードとデータを継承すべき親オブジェクトを指すプロトタイプと呼ばれる特別なフィールドが含まれています。これにより、継承関係を動的に設定できます。JavaScript では、このプロトタイプは __proto__ フィールドに存在します。フィールドを文字列に設定しようとしたとき、JavaScript エンジンはこれを拒否し、元のプロトタイプをそのまま保持しました。これにより、プロパティテストの第 2 ステップで provider を検索したときに、元のプロトタイプ(空のオブジェクト)を取得することになります。 プロトタイプへの書き込みが例のように無害というわけではありません。 provider と apiKey は攻撃者の制御下にあるため、攻撃者が apiKey に文字列以外の値を取得する方法を見つけた場合、プロトタイプに値を注入でき、オブジェクトのプロパティからのさらなる読み取りが攻撃者制御の値を返す可能性があります。 これは悪用可能でしょうか?いいえ。 apiKeys オブジェクトは十分に長く存在せず、シリアル化後すぐに解放され、 JSON.stringify は __proto__ フィールドをスキップすることを知っています。また、グローバルプロトタイプを変更するのではなく、 apiKeys のプロトタイプのみを上書きしています。しかし、コードのリファクタリングにより、この悪用不可能な脆弱性をより広範囲な影響を与える可能性のあるものに変える新しいコードパスが導入される可能性があります。プロパティベーステストが提供するテスト力は、これを即座に捕捉して、コードベースにおいて微妙な不正確さや難しいエッジケースが増えるのを防ぐのに役立ちます。 Kiro はこれをどのようにテストしたのか? プロバイダー名 __proto__ で API キーを保存してから読み込もうとしたとき、保存した API キーの代わりに空のオブジェクト {} を取得しました。なぜこれが起こったのでしょうか?内部で何が起こったかについてもう少し背景を理解しましょう。 PBT の利点の1つと言われているのはバイアスです。単体テストでは、テストを書いた人(モデルまたは人間)がエッジケースを考慮しようとしましたが、自分自身の内部バイアスによって制限されています。同じ(モデル/人)が実装を書いたので、実装中に考えなかったエッジケースを思いつくのは困難だと考えるのが妥当です。この場合、プロパティベーステストを使用することで、テストフレームワークを作った人たちの集合知が使えます。この場合、一般的なバグタイプの体系的知識“をプロセスに注入しています。( __proto__ は、fast-check コミュニティの作者によって PBT ジェネレーターにエンコードされた一般的なバグ文字列の一つです)をテストプロセスに注入しています。 続行する前に注意すべき点は、PBT コードに { numRuns: 100 } があることです。これは、ジェネレーターがバグを見つけようとする 100 回の反復があることを意味します。Kiro はこれをデフォルトにしていますが、プログラムに求める信頼レベルに応じて、この値を上げたり下げたりできます。時にはもっと必要ですが、実装のテストに少し時間がかかるため、100 回以上の入力テストを実行するパフォーマンスが開発ライフサイクルのその段階ではまだ価値がない場合もあります。良い点は、必要に応じていつでもこれを上げたり下げたりできることです。 修正 Kiro は MITRE の高効果緩和戦略 に基づいて 2 つの防御策を実装しました。 1. 安全な保存( saveApiKey 内) // プロトタイプ汚染を避けるため null プロトタイプオブジェクトを作成 const safeApiKeys = Object.create(null); Object.assign(safeApiKeys, apiKeys); safeApiKeys[provider] = apiKey; Object.create(null) で作成されたオブジェクトにはプロトタイプチェーンがないため、 __proto__ は単なる通常のプロパティになります。 2. 安全な取得( loadApiKey 内) // hasOwnProperty を使用してキーを安全にチェック return Object.prototype.hasOwnProperty.call(apiKeys, provider) ? apiKeys[provider] : null; より大きな視点 このストーリーは、Kiro が SDD の一部としてプロパティベーステストを使用する理由を示しています: プロパティは要件に直結 – 「任意のプロバイダー名について、ラウンドトリップする」というプロパティは、要件をそのまま変換したものです。 ランダム生成は予期しないエッジケースを発見 – 人間と LLM は、テストする入力についてバイアスを持っています。ランダム生成はテストケースを徹底的に追い込みます 実行可能な仕様 – プロパティは実行できる仕様です。「コードは何をすべきか」(要件)と「コードは実際にそれを動かすのか」(テスト)の間のギャップを埋めます。 タイトなフィードバックループ – プロパティが失敗すると、デバッグを容易にする最小限の反例を取得します。Kiro はこれを使用してコードを修正し、迅速な反復サイクルを作成できます。 このバグは Kiro での実際の開発中に発見されました。プロパティベーステストは、以下の方法では発見が非常に困難だったであろうセキュリティ弱点をキャッチしました。 手動コードレビュー 手動で選んだ例を使った従来の単体テスト 統合テスト
一休.com Advent Calendar 2025 の25日目の記事です。 一休.com レストランの開発を担当している恩田 @takashi_onda です。 最近はあまり聞かれることのないダイナミックスコープの話をしてみたいと思います。 はじめに 現代のプログラミング言語ではレキシカルスコープがあまりに当たり前になってしまっていて、ダイナミックスコープという概念自体を聞いたことがない、という人も多いのではないかと思います。 プログラミング言語の歴史を学ぶ際に少し触れられている程度で、実際、手元の『コンピュータプログラミングの概念・技法・モデル』を繙いてみても、900ページ近い大著にもかかわらずダイナミックスコープについての言及は1ページにも満たないほどです。 このようにダイナミックスコープは歴史の中で消えていった概念のように見えます。ですが、用語としては廃れた一方で、今日でも似た仕組み自体が実は再発明されています。使い方に注意は必要ですが、うまくはまると既存コードへの侵襲を最小に抑えながら文脈を伝播させる手段として、今も有効な選択肢だからではないでしょうか。 本稿では、ダイナミックスコープの歴史を振り返りながら、なぜ今も形を変えてその考え方が引き継がれているのか、文脈伝播の観点から見直してみたいと思います。 レキシカルスコープとダイナミックスコープ まずは定義の確認からはじめたいと思います。 現代のプログラミング言語において、私たちが当たり前のように享受しているのがレキシカルスコープ(静的スコープ)です。 const x = 'Global' ; function printX ( suffix ) { const prefix = 'value is ' console . log ( ` ${ prefix }${ x }${ suffix } ` ) ; } function withLocalX () { const x = 'Local' ; printX ( '!' ) ; } withLocalX () ; // -> 'value is Global!' printX ( '?' ) // -> 'value is Global?' ここで、 printX に現れる変数に注目して、用語 1 をふたつ紹介します。 束縛変数(bound variable): 関数の引数( suffix )や内部での宣言( prefix )によって、その場で意味が確定する変数を指します。 自由変数(free variable): 関数の中で宣言も引数定義もされていない変数を指します。この例では x がそれにあたります。 レキシカルスコープとダイナミックスコープの違いは、この自由変数をどう解決するかにあります。 レキシカルスコープのルールはシンプルです。自由変数の意味は関数が定義された場所によって静的に決まる、というものです。上の例では printX が定義された場所の外側にある値 'Global' が参照されます。呼び出し元である withLocalX の内部に同名の変数があっても、それは無視されます。 この性質により、私たちはコードの構造から変数の由来を一意に辿ることができるという恩恵に与っています。 ごくごく自然に感じられると思います。 さて、今回取り上げるダイナミックスコープ(動的スコープ)を見てみましょう。ダイナミックスコープは、自由変数の解決をコード上の位置ではなく、実行時の呼び出しスタックに委ねます。 Perl の local 宣言 2 を例に見てみましょう。 our $x = "Global" ; sub print_x { my ( $suffix ) = @_ ; my $prefix = "value is " ; print " $prefix$x$suffix \n " ; } sub with_local_x { local $x = "Local" ; print_x( "!" ); } with_local_x(); # -> "value is Local!" print_x( "?" ); # -> "value is Global?" print_x が呼ばれる際、その自由変数 $x の値は自分を呼び出している実行時のコールスタックの状態で決定されます。 with_local_x の中で $x が一時的に変更されているため print_x はその値 "Local" を出力します。そして with_local_x の実行が終われば、その一時的な束縛が解除され $x の値はふたたび "Global" が参照されるようになります。 ダイナミックスコープの歴史 現代の感覚では、ダイナミックスコープは予測不能で不確実なものに見えると思います。では、なぜこのような仕組みが生まれ、利用されてきたのでしょうか。その経緯を振り返ってみたいと思います。 副産物としての誕生 ダイナミックスコープの起源は、1950年代後半の初期の Lisp に遡ります。 初期の Lisp においてダイナミックスコープは、意図的に設計された機能というよりは、素朴な実装の帰結でした。当時のインタプリタにおいて変数の値を解決するもっとも単純な方法は、実行時のシンボルテーブル(A-list と呼ばれる連想リスト)をスタックの根元に向かって順に検索することでした。関数を定義時の環境と一緒に保持するという発想(後にクロージャと呼ばれるもの)はまだなく、この素直な実装が、結果としてダイナミックスコープを生み出しました。 John McCarthy は後に、ダイナミックスコープを、意図した仕様ではなく単なる実装上のバグであり、いずれ修正されるだろうと考えていたと回想しています 3 。 引数バケツリレーの回避策としての受容 しかし、この偶然の挙動は実用上の利便性をもたらしました。 プログラムが複雑化し、関数の呼び出し階層が深くなると、末端の処理で必要になる設定値やフラグを、すべての中間関数に引数として渡し続ける必要が出てきます。いわゆるバケツリレー問題ですね。 ダイナミックスコープを利用すれば、呼び出し元で変数を一時的に束縛するだけで、中間層のコードを一切変更することなく、深い階層にある関数に情報を伝播させることができました。 Scheme によるレキシカルスコープの確立 この状況に変化をもたらしたのが、1970年代に登場した Scheme です。 Gerald Jay Sussman と Guy L. Steele Jr. は、ラムダ計算の理論を忠実に実装する過程で、関数が定義された時点の環境を保持するレキシカルスコープを導入しました。これにより、関数の挙動が呼び出し元に依存するという不確実性が排除され、数学的な一貫性とモジュールとしての独立性が確保されました。 これ以降、プログラミング言語のメインストリームはレキシカルスコープへと収束していき、ダイナミックスコープは扱いの難しいかつての仕組みとして、多くの言語から姿を消していくことになります。 Emacs Lisp における意図的な選択 Scheme がレキシカルスコープによって数学的に整合したモデルを確立していった一方で、Emacs Lisp は長らくダイナミックスコープをデフォルトとして採用し続けました 4 。 当時の計算資源の制約といった実装上の理由もあったようですが、結果としてこの選択は、実行時に振る舞いを拡張・上書き可能なエディタ、というより環境であった Emacs の目指すところと噛み合っていたように思います。 エディタの拡張においては、既存のコマンドやその内部実装に手を入れることなく、ある処理の文脈だけを少し変更したい、という要求が頻繁に現れます。Emacs Lisp では、こうした要求をダイナミックスコープによって自然に満たすことができました。 よく知られている例が、検索時の大文字・小文字の区別を制御する case-fold-search という変数です。この変数を let によって一時的に束縛するだけで、その内部で呼ばれる標準の検索コマンド群の挙動をまとめて変更できます。 ( defun my-case-sensitive-search ( keyword ) ( let (( case-fold-search nil )) ( search-forward keyword ))) 文脈伝播(Context Propagation) プログラミング言語全体に立ち返れば、前述の通り主流となったのはレキシカルスコープでした。関数の振る舞いが呼び出し元の状態に依存する性質は、大規模化・複雑化するソフトウェア開発において、扱いが難しかったためです。 レキシカルスコープがコードの予測可能性をもたらした一方で、アプリケーション開発には別の課題が残されました。文脈の伝播(Context Propagation)です。 Webアプリケーションを例にとれば、認証情報やトレーシングIDなどの情報は、処理の開始から終了まで、あらゆる階層の関数で参照したくなる横断的な関心事 5 です。レキシカルスコープでナイーブに実装すると、すべての関数にバケツリレーで渡さなければならず、中間層は不要な責務を負うことになります。 この明示的な記述の煩雑さを避けるため、言語仕様の外側でダイナミックスコープ的な挙動を実現する仕組みが実用化されてきました。Java における ThreadLocal がその代表例です。言語レベルでは静的なスコープによる安全性を選びつつも、ランタイムで暗黙的に文脈を引き継ぐ機構が初期から用意されていました。 ここからしばらく、現代のプログラミング言語で文脈伝播がどう実現されているかを見ていきたいと思います。 各節の細部を追わなくても、明示的に渡すアプローチと暗黙的に伝播させるアプローチがそれぞれ存在する、という雰囲気だけ掴んでもらえれば十分です。 Go の context パッケージ まずは明示的に文脈を渡す例として Go を見てみます。Go では context.Context を関数の第一引数として渡す規約が確立されており、キャンセル処理やタイムアウト、リクエストスコープの値を伝播させます。 func HandleRequest(w http.ResponseWriter, r *http.Request) { ctx := r.Context() traceId := generateTraceID() ctx = context.WithValue(ctx, traceIdKey, traceId) result, err := processOrder(ctx, orderId) // ... } func processOrder(ctx context.Context, orderId string ) (*Order, error ) { // 中間層も ctx を受け取り、下位に渡す return repository.FindOrder(ctx, orderId) } func (r *Repository) FindOrder(ctx context.Context, orderId string ) (*Order, error ) { traceId := ctx.Value(traceIdKey).( string ) r.logger.Info( "finding order" , "traceId" , traceId, "orderId" , orderId) // ... } 文脈が引数として明示されるため、関数シグネチャを見ればその関数が文脈を必要とすることが分かります。 しかし、 context.WithValue で渡される値については事情が異なります。 ctx に何が入っているかはシグネチャからは分からず、実行時に ctx.Value(key) で取り出すまで不明です。つまり、 context.Context という引数は明示的に渡されていますが、その中身へのアクセスはキーによる動的な参照になっています。 では、型によってこの暗黙性を解消する方法はあるのでしょうか。 Reader Monad 関数型プログラミングの世界では、この課題に対する手法として Reader Monad が知られています。 Reader Monad の本質は単純です。環境 R を受け取って値 A を返す関数 R => A を、合成可能な形で扱えるようにしたものです。Scala で書いてみましょう。 case class Reader[R, A](run: R => A) { def map[B](f: A => B): Reader[R, B] = Reader(r => f(run(r))) def flatMap[B](f: A => Reader[R, B]): Reader[R, B] = Reader(r => f(run(r)).run(r)) } これで環境に依存する計算を合成可能な形で表現できます。さきほどの例を Reader Monad で実装します。 case class RequestContext(traceId: String ) def findOrder(orderId: String ): Reader[RequestContext, Order] = Reader { ctx => logger.info(s "finding order: traceId=${ctx.traceId}, orderId=$orderId" ) repository.find(orderId) } def processOrder(orderId: String ): Reader[RequestContext, Result] = for { order <- findOrder(orderId) result <- validateAndProcess(order) } yield result def handleRequest(orderId: String ): Reader[RequestContext, Response] = for { result <- processOrder(orderId) } yield Response(result) // 実行時に環境を注入 val ctx = RequestContext(traceId = "abc-123" ) val response = handleRequest( "order-789" ).run(ctx) 関数のシグネチャに注目してください。 Reader[RequestContext, Order] という戻り値の型を見るだけで、この関数が RequestContext を必要とすることが分かります。必要な文脈が型レベルで明示されています。 また、for 内包表記により、環境の受け渡しを省略できます。 processOrder は findOrder を呼び出していますが、 ctx を渡すコードはどこにもありません。Reader の flatMap が環境を伝播してくれるからです。 この手法により、文脈の明示性と記述の簡潔さを両立できます。 Scala の context parameter このような書き方はよく使われるため、Scala では性質の近い機能が言語レベルでサポートされています。 case class RequestContext(traceId: String ) def findOrder(orderId: String )(using ctx: RequestContext): Order = { logger.info(s "finding order: traceId=${ctx.traceId}, orderId=$orderId" ) repository.find(orderId) } def processOrder(orderId: String )(using ctx: RequestContext): Result = { val order = findOrder(orderId) // ctx は暗黙的に渡される validateAndProcess(order) } def handleRequest(orderId: String )(using ctx: RequestContext): Response = { val result = processOrder(orderId) // ctx は暗黙的に渡される Response(result) } // 呼び出し側で given を定義 given ctx: RequestContext = RequestContext(traceId = "abc-123" ) val response = handleRequest( "order-789" ) // ctx は暗黙的に解決される using キーワードにより、コンパイラがスコープ内から適切な値を探して自動的に引数を補完します。中間層での明示的な受け渡しが不要でありながら、シグネチャには文脈が明示されています。 これは、レキシカルスコープの型安全性を維持しつつ、ダイナミックスコープが解決していたバケツリレー問題に対処する言語レベルの解答と言えます。 ただし、中間層の関数も (using ctx: RequestContext) をシグネチャに持つ必要があり、文脈の存在自体は伝播経路上のすべての関数に現れます。 ThreadLocal / AsyncLocalStorage ここまで見てきたのは、いずれも文脈を明示的に表現する手法でした。次に、暗黙的に文脈を伝播させる仕組みを見ていきます。 Java の ThreadLocal は JDK 1.2(1998年)で導入されました。ThreadLocal は、スレッドごとに独立した値を保持する仕組みです。 Webアプリケーションでは、1つのリクエストが1つのスレッドで処理される実行モデルが一般的でした。このモデルにおいて、リクエストスコープの情報(認証情報やトランザクションなど)を、引数で渡すことなく処理の流れ全体で共有する用途で ThreadLocal は広く使われてきました。 先ほどと同じ例を Java で書いてみましょう。 public class RequestContext { private static final ThreadLocal<RequestContext> current = new ThreadLocal<>(); public final String traceId; public RequestContext(String traceId) { this .traceId = traceId; } public static RequestContext current() { return current.get(); } public static <T> T runWith(RequestContext ctx, Supplier<T> block) { RequestContext previous = current.get(); current.set(ctx); try { return block.get(); } finally { current.set(previous); } } } public Order findOrder(String orderId) { var ctx = RequestContext.current(); logger.info( "finding order: traceId=" + ctx.traceId + ", orderId=" + orderId); return repository.find(orderId); } public Result processOrder(String orderId) { var order = findOrder(orderId); return validateAndProcess(order); } public Response handleRequest(String orderId) { var result = processOrder(orderId); return new Response(result); } // エントリーポイント var ctx = new RequestContext( "abc-123" ); var response = RequestContext.runWith(ctx, () -> handleRequest( "order-789" )); findOrder も processOrder も引数に文脈を持っていません。 RequestContext.current() を呼び出すだけで、呼び出し元で設定された値を取得できます。そして runWith のブロックを抜ければ、以前の値に戻ります。Perl の local が実現していた振る舞いと同じですね。 現在では非同期・並行処理が一般的になり、それらに対応した Java の ScopedValue(JDK 21〜、プレビュー)や、Node.js の AsyncLocalStorage が同様の機能を提供しています。これらは値のネストと復元が API に組み込まれており、ダイナミックスコープがコールスタックを遡って値を解決する仕組みにより近いものになっています。 React Context ここで少し視点を変えて、フロントエンドに目を向けてみましょう。 関数呼び出しの連鎖がコールスタックを形成するように、React ではコンポーネントの親子関係がツリー構造を形成します。そしてここでも、同じバケツリレー問題が現れます。 React では、親から子へデータを渡す際に props を使います。しかし、深くネストしたコンポーネントに値を届けるには、途中のすべてのコンポーネントが props を受け取って下に渡す必要があります。いわゆる props drilling です。 中間層のコンポーネントが自身では使わない props に依存することは、コンポーネントの再利用性を損ない、不要な再レンダリングの原因にもなります。React Context を使えば、Context で囲んだ範囲内のどの深さのコンポーネントからでも、中間層を経由せずに値を取得できます。 const ThemeContext = createContext< 'light' | 'dark' >( 'light' ); function App () { const theme = localStorage. getItem ( 'theme' ) ?? 'light' ; return ( < ThemeContext value = { theme } > < Header /> < Main /> < Footer /> </ ThemeContext > ); } function Main () { // Main は theme を知らない return < Sidebar /> ; } function Sidebar () { const theme = use(ThemeContext); // 中間層を飛び越えて取得 return < div className = { theme } > ... </ div > ; } Context のネストによって値を上書きでき、そのスコープを抜ければ外側の値に戻る。コンポーネントツリーという軸は異なりますが、これもダイナミックスコープの再発見と言えそうです。 侵襲を抑える ここまで見てきた通り、文脈伝播には万能の解決策がありません。 明示的に引数で渡せば、依存関係は明確になりコードの追跡も容易です。しかし、中間層が自身では使わない引数を知らなければならないという問題が残ります。暗黙的な伝播を使えば中間層の負担は消えますが、今度は依存関係が見えにくくなります。 このトレードオフに対して、別の軸から考えてみたいと思います。既存コードへの侵襲を抑える、という制約を置いた場合、ダイナミックスコープ的な振る舞いはどのように評価できるでしょうか。 現実のコードベースは往々にして理想通りにはなっていません。テストや文脈伝播の仕組みは必要だとわかっていても、スケジュールや優先度の都合で後回しにしたまま、コードが蓄積されてしまうことは起こりがちです。そこに手を入れるとき、引数で明示的に渡したり Reader Monad を導入するのが正攻法ですが、中間層をすべて修正するコストが見合わないこともあります。 以下では、ダイナミックスコープ的な仕組みの利用が、妥協ではあっても有効な選択肢になった例を具体的に見ていきます。いずれも呼び出し元の文脈に応じて値を差し替えたいという要求であり、これはまさにダイナミックスコープが解決していた問題です。実際に遭遇したケースを簡素化して紹介します。 あとからテストダブル たとえば、外部 API を直接呼び出している関数があり、テストを書きたいとします。理想的には依存性注入で差し替えられる設計になっているべきですが、現実にはそうなっていないコードも多いでしょう。 このとき、API クライアントを AsyncLocalStorage 経由で参照するように変更すれば、テスト時だけテストダブルを差し込むことができます。中間層の関数シグネチャを変更する必要はありません。 具体例を見てみましょう。 // 本番用の取得関数をデフォルト値として設定 const fetchCategoriesContext = new AsyncLocalStorage< ( ids : CategoryId []) => Promise < Category []> >( { defaultValue : defaultFetchCategories } ) function getFetchCategories () { const fetchCategories = fetchCategoriesContext.getStore() if (!fetchCategories) { throw new Error ( 'unreachable: defaultValue is set' ) } return fetchCategories } この getFetchCategories を利用してカテゴリを取得する関数を定義します。 export async function getCategory ( id : CategoryId ): Promise < Category | undefined > { const fetchCategories = getFetchCategories() const categories = await fetchCategories( [ id ] ) return categories[ 0 ] } テスト時にはテストダブルを差し込む関数を用意します。 /** * テスト時に fetchCategories を差し替えて実行する */ export async function withTestFetchCategories < T >( fetchCategories : ( ids : CategoryId []) => Promise < Category []> , body : () => T | Promise < T > ): Promise < T > { return fetchCategoriesContext.run(fetchCategories, body) } テストコードでは、 withTestFetchCategories のスコープ内でテスト対象を呼び出します。 getCategory を利用しているコードも、同じスコープ内で実行すればテストダブルが注入されます。 test ( 'カテゴリを取得できる' , async () => { const stubFetch = async ( ids : CategoryId []) => [ { id : ids[ 0 ], name : 'テストカテゴリ' } ] await withTestFetchCategories(stubFetch, async () => { const result = await getCategory( 'cat-1' ) expect (result?. name ).toBe( 'テストカテゴリ' ) } ) } ) あとからキャッシュ 先ほどのカテゴリデータの例の続きです。ひとつのリクエストを処理する中で getCategory が何度も呼ばれていることがわかりました。毎回バックエンドから取得せずに済むようにキャッシュを導入しましょう。 DataLoader を使えばキャッシュできますが、グローバルにキャッシュすると更新の反映やメモリ管理が複雑になります。そこでリクエスト単位でインスタンスを作ることにしました。具体的には、DataLoader をリクエストスコープで保持するために AsyncLocalStorage をもうひとつ追加します。 type CategoryDataLoader = DataLoader < CategoryId , Category | undefined > const categoryDataLoaderContext = new AsyncLocalStorage< CategoryDataLoader >() /** リクエスト単位で DataLoader を保持する */ export async function withCategoryDataLoader < T >( request : Request , body : () => T | Promise < T > ): Promise < T > { const loader = createCategoryDataLoader(request) return categoryDataLoaderContext.run(loader, body) } function createCategoryDataLoader ( request : Request ): CategoryDataLoader { const fetchCategories = getFetchCategories() return new DataLoader< CategoryId , Category | undefined >( async ( ids ) => fetchCategories(ids, request), { cache : true } ) } getCategory は DataLoader 経由で取得するように書き換えます。 function getCategoryDataLoader (): CategoryDataLoader { const loader = categoryDataLoaderContext.getStore() if (!loader) { throw new Error ( 'No categoryDataLoader in context' ) } return loader } // 利用側は DataLoader の存在を意識しない export async function getCategory ( id : CategoryId ): Promise < Category | undefined > { return getCategoryDataLoader().load(id) } リクエストを受けるエントリーポイントで withCategoryDataLoader を適用します。 export async function loader ( { request } : Route.LoaderArgs ) { return await withCategoryDataLoader(request, async () => { // この中で getCategory を呼び出す処理 } ) } これで、中間層の関数が DataLoader を引き回す必要はなく、リクエスト単位のキャッシュが有効になります。 あとから文脈伝播 実は上の例では、キャッシュだけでなく文脈伝播も実現しています。 fetchCategories の実装を見てみましょう。 async function defaultFetchCategories ( ids : readonly CategoryId [] , request : Request ): Promise <( Category | undefined )[]> { const response = await fetch (BACKEND_API, { method : 'POST' , headers : { 'Content-Type' : 'application/json' , 'X-Forwarded-For' : request. headers . get ( 'X-Forwarded-For' ) ?? '' , 'Cookie' : request. headers . get ( 'Cookie' ) ?? '' , } , body : JSON . stringify ( { ids } ), } ) return response.json() } withCategoryDataLoader に渡された request が DataLoader の生成時にキャプチャされ、バックエンドへのリクエスト時に cookie や X-Forwarded-For ヘッダを引き継いでいます。 getCategory を呼び出す側は、この伝播の仕組みを意識する必要がありません。 必要になったときに、コードの変更を最小に保ちながら、段階的に導入できる点がこのアプローチの利点です。 一方で、エントリーポイントで withCategoryDataLoader の適用を忘れると実行時エラーになる、という脆さがあります。依存関係が型に現れないため、コンパイル時には検出できません。これはダイナミックスコープ的な仕組みに共通する課題であり、トレードオフとしての慎重な検討が必要です。 おわりに React Context を説明していたときに、ダイナミックスコープの話をしたことがありました。それがこの記事のきっかけです。 歴史をたどりながら今の技術の位置づけを見直してみるのも、ときにはおもしろいものです。本稿からその一端でも感じていただければ幸いです。 一休では、技術を深く理解しながら、よりよいシステムをともに作っていくエンジニアを募集しています。 www.ikyu.co.jp まずはカジュアル面談からお気軽にご応募ください! job.persona-ats.com ラムダ計算においては、ラムダ抽象 λx. M の本体 M に現れる変数のうち、λx によって束縛されているものを束縛変数、それ以外を自由変数と呼びます。 ↩ 古い Lisp の例を考えていたのですが Perl でも local で書けることを同僚が教えてくれました。 ↩ John McCarthy, History of Lisp (1978). "In modern terminology, lexical scoping was wanted, and dynamic scoping was obtained. I must confess that I regarded this difficulty as just a bug and expressed confidence that Steve Russell would soon fix it." ↩ 現在の Emacs Lisp ではレキシカルスコープを選択することが可能です。 ↩ 横断的関心事(cross-cutting concern)といえば2000年代に注目された AOP(Aspect Oriented Programming, アスペクト指向プログラミング)ですが、その主要なユースケースのひとつに文脈伝播の自動化がありました。ログ出力やトランザクションのコンテキストなどは、ThreadLocal 等の操作を裏側で隠蔽する典型的な例でした。 ↩
動画
該当するコンテンツが見つかりませんでした











