一休.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 を説明していたときに、ダイナミックスコープの話をしたことがありました。それがこの記事のきっかけです。
歴史をたどりながら今の技術の位置づけを見直してみるのも、ときにはおもしろいものです。本稿からその一端でも感じていただければ幸いです。
一休では、技術を深く理解しながら、よりよいシステムをともに作っていくエンジニアを募集しています。
まずはカジュアル面談からお気軽にご応募ください!
- ラムダ計算においては、ラムダ抽象 λ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 等の操作を裏側で隠蔽する典型的な例でした。↩