「クロージャは関数と環境のペア」とは?(JavaScript)
2023/12/02はじめに
クロージャは「関数と環境のペア」と言われます。最初は意味がわかりません。
クロージャ(クロージャー、英語: closure)、関数閉包はプログラミング言語における関数オブジェクトの一種。……(中略)…… 関数とそれを評価する環境のペア であるともいえる。
出典:クロージャ - Wikipedia、強調と中略は筆者による
クロージャは、組み合わされた(囲まれた) 関数と、その周囲の状態(レキシカル環境)への参照の組み合わせ です。
出典:クロージャ - JavaScript | MDN、強調は筆者による
これを理解するために調べたことをまとめました。
サンプルプログラム
本記事を通して、以下のプログラムを使用します。
const makeCounter = () => {
let cnt = 0;
return () => {
cnt++;
return cnt;
};
};
const counter1 = makeCounter();
const counter2 = makeCounter();
console.log(counter1()); // 1
console.log(counter2()); // 1
console.log(counter1()); // 2
クロージャの説明として定番のやつですね。
SICPでの説明
SICP(Structure and Interpretation of Computer Programs)という本における説明がわかりやすいので紹介します。
この本で使用されている言語はSchemeですが、JavaScriptに置き換えて説明します。1
この章で説明したいこと
クロージャは、
- 関数を評価すると作成される
- 関数と環境のペアである
- 関数:関数への参照。具体的に何なのかは処理系によって異なる
- 環境:クロージャが作成されたときの環境への参照
- 呼び出されたとき、
- 新しい環境が作成され、その「外側の環境」はクロージャの環境になる
- 新しい環境でクロージャの関数が評価される
環境とは、「束縛の集合」と「外側の環境への参照」のペア。
束縛とは、「変数」と「値」のペア。
Step 0:グローバル環境の作成
プログラムを実行する準備として、次のようなグローバル環境が作成されます。
環境とは、「束縛の集合」と「外側の環境への参照」のペアです。この環境には「Global Env」という名前がついており、区切り線の上に書かれています。
束縛とは、「変数」と「値」のペアです。束縛の集合は区切り線の下に書かれます。現状は束縛はありません。
矢印は外側の環境への参照です。グローバル環境に外側は無いため、バツ印を指しています。
以降は、このグローバル環境でサンプルプログラムを評価していきます。
Step 1:makeCounter
の作成
const makeCounter = () => {
let cnt = 0;
return () => {
cnt++;
return cnt;
};
};
上記のプログラムでは、アロー関数をグローバル環境で評価し、その結果をmakeCounterに代入しています。すると以下の図のようになります。
関数を評価すると、クロージャが作成されます。正方形の2つのペアがクロージャです。
左のFと書かれた正方形は、関数への参照です。参照先が具体的に何なのかは処理系によって異なりますが、例えばAST、バイトコード、マシンコードなどがあり得ます。
右のEと書かれた正方形は、クロージャが作成されたときの環境への参照です。今回は関数をグローバル環境で評価したので、グローバル環境を指しています。
評価結果であるクロージャをグローバル環境のmakeCounter
に代入します。グローバル環境にmakeCounter
とクロージャの束縛が追加されました。
Step 2:makeCounter
の呼び出し(counter1
)
const counter1 = makeCounter();
makeCounter
はクロージャなので、makeCounter()
はクロージャを呼び出しています。
クロージャが呼び出されると、
- 新しい環境が作成され、その「外側の環境」はクロージャの環境になります
- 新しい環境でクロージャの関数が評価されます
Step 2.1:新しい環境が作成され、その「外側の環境」はクロージャの環境になる
新しい環境Env 1を作成します。今回のクロージャの環境はGlobal Envを指しているので、それをEnv 1の外側の環境とします。
Step 2.2:新しい環境でクロージャの関数が評価される
クロージャが指す関数の本体は以下です。
let cnt = 0;
return () => {
cnt++;
return cnt;
};
これをEnv 1で評価していきます。
まず、let cnt = 0;
によってcnt
と0
の束縛が作成されます。
次にreturn () => { cnt++; return cnt; };
の部分ですが、これは関数(アロー関数)の評価結果をreturn
しています。
おさらいですが、関数を評価するとクロージャが作成されます。
クロージャは、「関数への参照」と「クロージャが作成されたときの環境(ここではEnv 1)への参照」のペアです。
これがreturn
されて最終的にcounter1
に代入されるため、結果は以下のようになります。
Step 3:makeCounter
の呼び出し(counter2
)
同様にcounter2
が定義されます。
const counter2 = makeCounter();
結果は以下のようになります。
Step 4:counterの呼び出し
console.log(counter1()); // 1
counter1()
はクロージャを呼び出しています。
おさらいですが、クロージャが呼び出されると、クロージャの環境を外側の環境とする新しい環境が作成され、そこでクロージャの関数が評価されます。
したがって、まず以下ようなEnv 3が作成されます。
次に、クロージャの関数をEnv 3で評価します。クロージャが指す関数の本体は以下のとおりです。
cnt++;
return cnt;
まず、Env 3でcnt++;
を評価します。Env 3に 変数cnt
の束縛はないため、外側の環境である Env 1に探しに行きます。そこでcnt
が見つかるため、Env 1のcnt
をインクリメントして、以下のようになります。
次に、Env 3でreturn cnt;
を評価します。先ほどと同じようにcnt
を探索すると、Env 1で束縛が見つかるため、最終的に1
がreturn
され、console.log
によって表示されます。
同じように、counter2
を呼び出した場合は、Env 2のcnt
がインクリメントされます。
最終的に、すべてのサンプルプログラムの実行が終わった時点では、以下の図のようになっています。
SICPの説明まとめ
クロージャは、
- 関数を評価すると作成される
- 関数と環境のペアである
- 関数:関数への参照。具体的に何なのかは処理系によって異なる
- 環境:クロージャが作成されたときの環境への参照
- 呼び出されたとき、
- 新しい環境が作成され、その「外側の環境」はクロージャの環境になる
- 新しい環境でクロージャの関数が評価される
環境とは、「束縛の集合」と「外側の環境への参照」のペア。
束縛とは、「変数」と「値」のペア。
JavaScriptでは
SICPの説明はSchemeを前提にしていました。
実際のJavaScriptの挙動を確かめるために、ECMAScriptを雰囲気で読んでみましょう。
クロージャの作成(アロー関数の評価)
アロー関数についての章立ては以下のようになっています。
- 15.3 Arrow Function Definitions
- 15.3.1 SS: Early Errors
- 15.3.2 SS: ConciseBodyContainsUseStrict
- 15.3.3 RS: EvaluateConciseBody
- 15.3.4 RS: InstantiateArrowFunctionExpression
- 15.3.5 RS: Evaluation
SSとRSは、それぞれStatic SemanticsとRuntime Semanticsのことみたいです。
Static Semanticsには、プログラムを動かす前にわかるようなことが定義されています。例えば、非同期のアロー関数でないのにawait
が現れるなら文法エラー、などです。
今回は、プログラム実行時の評価の挙動に興味があるので、15.3.5 Runtime Semantics: Evaluationから見ていくと良さそうですね。
以下、今回関心のある部分のみ抜粋します。
# 15.3.5 Runtime Semantics: Evaluation
ArrowFunction : ArrowParameters => ConciseBody
1. Return InstantiateArrowFunctionExpression of ArrowFunction.
# 15.3.4 Runtime Semantics: InstantiateArrowFunctionExpression
ArrowFunction : ArrowParameters => ConciseBody
// 現在の環境をenvとおく
2. Let env be the LexicalEnvironment of the running execution context.
// OrdinaryFunctionCreateを呼び出す。ここで関数(ConciseBody)と環境(env)を渡している
// その結果をclosureとおく
5. Let closure be OrdinaryFunctionCreate(%Function.prototype%, sourceText, ArrowParameters, ConciseBody, LEXICAL-THIS, env, privateEnv).
// closureを返す
7. Return closure.
# 10.2.3 OrdinaryFunctionCreate ( functionPrototype, sourceText, ParameterList, Body, thisMode, env, privateEnv )
// 新規オブジェクト作成し、Fとおく
2. Let F be OrdinaryObjectCreate(functionPrototype, internalSlotsList).
// Fに関数のBodyをセットする
6. Set F.[[ECMAScriptCode]] to Body.
// Fに現在の環境をセットする
13. Set F.[[Environment]] to env.
// Fを返す(アロー関数の評価結果となる)
23. Return F.
おー、やってますね〜。
アロー関数の評価時にオブジェクトを作成し、関数(F.[[ECMAScriptCode]]
)と環境(F.[[Environment]]
)を内部スロットにセットしていそうです。
念のため、それぞれの内部スロットの説明を詳しく見てみます。
- [[ECMAScriptCode]]
- Type:a Parse Node
- 説明:関数のBodyを定義するソース・テキストのルート解析ノード。
- [[Environment]]
- Type:an Environment Record
- 説明:関数が閉包されたEnvironment Record。関数のコードを評価する際の外部環境として使用されます。
出典:10.2 ECMAScript Function Objects, ECMAScript、ただし、表をリストに筆者が改変した。また翻訳はDeepLのものを筆者が修正したもの
思ってたとおりですね。
また、F.[[Environment]]
の中身である、Environment Recordについては……
Environment Recordは、(中略)識別子と特定の変数および関数との関連付けを定義するために使用される仕様タイプです。通常、(中略)FunctionDeclarationやBlockStatement、TryStatementのCatch節など(中略)が評価されるたびに、そのコードによって作成される識別子の束縛を記録する新しいEnvironment Recordが作成されます。
すべてのEnvironment Recordには、[[OuterEnv]]フィールドがあり、これは NULL または外部のEnvironment Recordへの参照です。
出典:9.1 Environment Records, ECMAScript、翻訳はDeepLのものを筆者が改変したもの
SICPの説明で聞いたようなことが書かれてます。よしよし。
ちなみに、OrdinaryFunctionCreate
はアロー関数だけではなく、
function
による(非同期)関数の評価- (非同期)メソッドの評価
- getter / setterの評価
- (非同期)ジェネレータの評価
でも使用されているようでした。
しがたって、今回のサンプルプログラムではアロー関数を使用しましたが、他の書き方でもクロージャが生成されるのは変わらないということです。
「JavaScriptのほとんど(あるいはすべて?)の関数はクロージャ」と言われる理由がわかります。
クロージャの呼び出し(関数呼び出しの評価)
関数呼び出しの評価は、13.3.6.1 Runtime Semantics: Evaluationから見ていくと良さそうです。
以下、今回関心のある部分のみ抜粋します。
# 13.3.6.1 Runtime Semantics: Evaluation
CallExpression : CallExpression Arguments
5. Return ? EvaluateCall(func, ref, Arguments, tailCall).
# 13.3.6.2 EvaluateCall ( func, ref, arguments, tailPosition )
7. Return ? Call(func, thisValue, argList).
# 7.3.14 Call ( F, V [ , argumentsList ] )
3. Return ? F.[[Call]](V, argumentsList).
# 10.2.1 [[Call]] ( thisArgument, argumentsList )
2. Let calleeContext be PrepareForOrdinaryCall(F, undefined).
# 10.2.1.1 PrepareForOrdinaryCall ( F, newTarget )
7. Let localEnv be NewFunctionEnvironment(F, newTarget).
# 9.1.2.4 NewFunctionEnvironment ( F, newTarget )
// 束縛のない新しいEnvironment Recordを作成し、envとおく
1. Let env be a new Function Environment Record containing no bindings.
// envの外側の環境をクロージャの環境にする
6. Set env.[[OuterEnv]] to F.[[Environment]].
というわけで、クロージャの呼び出し時に、その環境を外側の環境とする新しい環境を作成していそうだとわかります。
JavaScriptまとめ
アロー関数の評価結果は関数オブジェクトでした。これは関数([[ECMAScriptCode]]
)と環境([[Environment]]
)を持っているためクロージャです。
[[Environment]]
の中身であるEnvironment Recordは、束縛の集合と、外側の環境への参照([[OuterEnv]]
)を持ちます。これは環境に相当します。
V8では
最後に、実際のJSエンジン内部でクロージャがどのように実現されているか、ふんわり眺めてみましょう。
バイトコードを見てみる
はじめに、makeCounter
のバイトコードを見てみます。バイトコードおよびインタプリタについての説明は公式サイトに譲ります。ここでは、「V8がソースコードから生成したやることリスト」くらいの認識で行きましょう。
ともかく、--print-bytecode
オプションを使用してみます。
$ node -v
v18.3.0
$ node --print-bytecode --print-bytecode-filter=makeCounter sample.js
...
20 E> 0x1c7f8fca6c8 @ 0 : 83 00 01 CreateFunctionContext [0], [1]
0x1c7f8fca6cb @ 3 : 1a fa PushContext r0
...
40 S> 0x1c7f8fca6d0 @ 8 : 0c LdaZero
40 E> 0x1c7f8fca6d1 @ 9 : 25 02 StaCurrentContextSlot [2]
45 S> 0x1c7f8fca6d3 @ 11 : 80 01 00 02 CreateClosure [1], [0], #2
91 S> 0x1c7f8fca6d7 @ 15 : a9 Return
...
ふむり。
まず、CreateFunctionContext
が気になります。makeCounter
の呼び出し時に毎回Contextが作られるということで、これは環境やEnvironment Recordの作成に相当するものかと予想しました。
その後のPushContext
は、コードを実行する環境を新しいものに切り替えてるっぽいですね。
続くLdaZero
とStaCurrentContextSlot
は、let cnt = 0;
に対応してそうです。Zeroを生成して、Context(環境?)にストアしてる?
残りのCreateClosure
とReturn
はそのまんまですね。
デバッグプリントしてみる
次に、--allow-natives-syntax
の%DebugPrint
を使って、CreateClosure
の結果がどのようになっているのかをデバッグプリントしてみます。
サンプルコードの最後に%DebugPrint(counter1);
を追加して、次のコマンドを実行します。
$ node --allow-natives-syntax sample.js
DebugPrint: 0x2d73c590c5f9: [Function]
- map: 0x11b664d813b9 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x20596e7cb2e9 <JSFunction (sfi = 0x358d345f99a1)>
- elements: 0x1fd612d41329 <FixedArray[0]> [HOLEY_ELEMENTS]
- function prototype: <no-prototype-slot>
...
なるほどわからん。
わからんなりに、V8のリポジトリを- function prototype:
で全文検索してみます。
すると、以下の1件だけヒットします。
// ...
void JSFunction::JSFunctionPrint(std::ostream& os) {
Isolate* isolate = GetIsolate();
JSObjectPrintHeader(os, *this, "Function");
os << "\n - function prototype: "; // ここにヒット
if (has_prototype_slot()) {
// ...
出典:v8/src/diagnostics/objects-printer.cc at a619087d49b84eb2f98c7c13d5329c34eca17bde · v8/v8、補足のコメントは筆者による
これはJSFunction
をデバッグプリントするためのコードなので、counter1
はJSFunction
という構造で表現されているはずです。
重要そうな構造の定義を眺めてみる
というわけで、JSFunction
の定義に飛んで周辺を見てみますと、
// JSFunction describes JavaScript functions.
// 訳:JSFunctionはJavaScriptのfunctionsを表す
class JSFunction : public TorqueGeneratedJSFunction<
JSFunction, JSFunctionOrBoundFunctionOrWrappedFunction> {
public:
// ...
// [context]: The context for this function.
// 訳:[context]: このfunctionのcontext
inline Tagged<Context> context();
// ...
// [code]: The generated code object for this function. Executed
// when the function is invoked, e.g. foo() or new foo(). See
// [[Call]] and [[Construct]] description in ECMA-262, section
// 8.6.2, page 27.
// 訳:[code]: 生成されたこの関数のコードオブジェクト。
// foo()やnew foo()など、関数が呼び出されたときに実行される。
// ECMA-262の8.6.2(27ページ)における[[Call]]と[[Construct]]の説明を参照。
DECL_ACCESSORS(code, Tagged<Code>)
出典:v8/src/objects/js-function.h at a619087d49b84eb2f98c7c13d5329c34eca17bde · v8/v8、コメントの翻訳はDeepLのものを筆者が改変したもの
冒頭のコメントを見るに、JSFunction
はJavaScriptの関数を表す内部表現で間違っていなさそうですね。
[code]
と[context]
というメンバは、なんとなくですが関数と環境であるように見えます。なんとなくですけど。
次にContext
の定義周辺も見てみます。
// JSFunctions are pairs (context, function code), sometimes also called
// closures.
// 訳:JSFunctionsは(context, function code)のペアであり、これはクロージャとも呼ばれる。
// [ previous ] A pointer to the previous context.
// 訳:[ previous ] 前のコンテキストへの参照
class Context : public TorqueGeneratedContext<Context, HeapObject> {
public:
//...
inline Tagged<Context> previous() const;
出典:v8/src/objects/contexts.h at a619087d49b84eb2f98c7c13d5329c34eca17bde · v8/v8、コメントの翻訳はDeepLのものを筆者が改変したもの
さきほど、Context
は環境に相当するものだと予想していましたが、冒頭のコメントを見る限り正解っぽいです。ヨッシャ
previous
というメンバには他の環境への参照が入ると書かれています。明示的に書かれていませんが、外側の環境への参照ですかね?
より詳しくデバッグプリントしてみる
先程のJSFunctionPrint
はcontext
の内容を詳しく表示してくれないようでした。
おもむろにobjects-printer.cc
を散歩していたところ、ContextPrint
という、いかにもcontext
を良い感じに表示してくれそうな処理を見つけました。
というわけで、JSFunctionPrint
を改造して、context
とそのprevious
に関する詳しい情報を表示するようにしてみます。
V8のビルド環境を構築します
cd $HOME
mkdir ws # ワークスペースを作成
cd ws
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git --depth 1
PATH="$PATH:$HOME/ws/depot_tools/"
fetch v8
cd v8
sudo apt instal ccache
ccacheを導入するために以下のように変更します。
diff --git a/.gn b/.gn
index 3a73ff4e2a1..572d1e3caf6 100644
--- a/.gn
+++ b/.gn
@@ -27,6 +27,7 @@ no_check_targets = [
default_args = {
# Disable rust dependencies.
enable_rust = false
+ cc_wrapper = "ccache"
}
# These are the list of GN files that run exec_script. This whitelist exists
以下のようにコードを変更します。
diff --git a/src/diagnostics/objects-printer.cc b/src/diagnostics/objects-printer.cc
index 51c4d991d17..3d9c03d64a7 100644
--- a/src/diagnostics/objects-printer.cc
+++ b/src/diagnostics/objects-printer.cc
@@ -1905,6 +1905,12 @@ void JSFunction::JSFunctionPrint(std::ostream& os) {
} else {
os << "not available\n";
}
+
+ os << "\n--- context ---\n";
+ context()->ContextPrint(os);
+
+ os << "\n--- previous context ---\n";
+ context()->previous()->ContextPrint(os);
}
void SharedFunctionInfo::PrintSourceCode(std::ostream& os) {
サンプルコードの最後に%DebugPrint(counter2);
も追加します。
V8をビルドし、改造したデバッグプリントを実行した結果が以下です。ただし関心のない部分を省略しています。
$ ./tools/dev/gm.py x64.release && ./out/x64.release/d8 --allow-natives-syntax ../sample.js
# counter1 (0x3b160004aa55) のデバッグプリント
DebugPrint: 0x3b160004aa55: [Function]
# counter1のcontext
--- context ---
0x10580004aa4d: [Context]
- elements:
0: 0x3b160019a04d <ScopeInfo FUNCTION_SCOPE>
1: 0x3b160019a031 <ScriptContext[5]>
2: 2 # cntの値
# counter1のcontextのprevious
--- previous context ---
0x3b160019a031: [Context] in OldSpace
- elements:
0: 0x3b1600199eb1 <ScopeInfo SCRIPT_SCOPE>
1: 0x3b1600183c79 <NativeContext[284]>
2: 0x3b160004aa19 <JSFunction makeCounter (sfi = 0x3b1600199ee9)>
3: 0x3b160004aa55 <JSFunction (sfi = 0x3b160019a07d)> # counter1
4: 0x3b160004aa85 <JSFunction (sfi = 0x3b160019a07d)> # counter2
# counter2 (0x3b160004aa85) のデバッグプリント
DebugPrint: 0x3b160004aa85: [Function]
# counter2のcontext
--- context ---
0x3b160004aa71: [Context]
- elements:
0: 0x3b160019a04d <ScopeInfo FUNCTION_SCOPE>
1: 0x3b160019a031 <ScriptContext[5]>
2: 1 # cntの値
# counter2のcontextのprevious
--- previous context ---
0x3b160019a031: [Context] in OldSpace
- elements:
0: 0x3b1600199eb1 <ScopeInfo SCRIPT_SCOPE>
1: 0x3b1600183c79 <NativeContext[284]>
2: 0x3b160004aa19 <JSFunction makeCounter (sfi = 0x3b1600199ee9)>
3: 0x3b160004aa55 <JSFunction (sfi = 0x3b160019a07d)> # counter1
4: 0x3b160004aa85 <JSFunction (sfi = 0x3b160019a07d)> # counter2
まず、counter1
のcontext
を見てみましょう。elements
に着目すると2
が表示されています。同様にcounter2
の方は1
が表示されていることから、これらは束縛されたcnt
の値であるようです。
次に、counter1
とcounter2
それぞれのcontext
のprevious
を見てみます。すると、どちらも同じ内容が表示されているとわかります。それぞれのelements
には、makeCounter
の他に2つのJSFunction
の束縛が表示されており、これらはcounter1
とcounter2
にアドレスが一致します。したがって、これらのcontext
はグローバル環境に相当するもののようですね。
全体的に、例の図と似た構造があるのを確認できました。
V8まとめ
アロー関数の評価結果はJSFunction
でした。これは関数([code]
)と環境([context]
)を持つためクロージャです。
context
は束縛の集合(elements
)と、外側の環境への参照(previous
)を持っているため、環境に相当します。
まとめ
SICP、ECMAScript、V8を調べることで、それぞれにクロージャ、環境、束縛に相当する構造があることを確認しました。
そもそもの発端は、会社の新人向けJavaScript研修にサポート講師として参加した際、新人の方からの「クロージャとは何ですか?」という質問に、僕がうまく回答できなかったことでした。
こういう素朴な疑問が、理解を深めるきっかけになったのは良かったです。研修というのは、新人だけでなく講師の知見を広げる場でもあるんだなぁと感じました。
今回のことについてもっと知りたいなら、SICPの4章がおすすめです。環境モデルを含めたメタ循環評価器(ある言語で実装された、その言語自身の評価器)を作ることで理解が深まりますし、非決定性計算や論理プログラミングといった馴染みのないパラダイムに対して、環境モデルがどのように適用されるかというのは、むずかしいですが興味深いと感じました。
JavaScriptのクロージャ周りで困った際には、ぜひ環境の図を描いてみてください。
ありがとうございました。
- また、本記事の後半の説明に合わせて、一部の概念を統合したり、言い換えたりしています。↩