JavaScriptの関数(3) – Lexical Environment

前回の記事では、関数の作成関数の呼び出しの過程について簡単に紹介しました。

「関数の呼び出し」で説明したExecution Contextには、LexicalEnvironmentとVariableEnvironmentというコンポーネントがあります。基本的に両コンポーネントはLexical Environmentへの参照であり、最初は同じLexical Environmentを参照します。

executionContext.LexicalEnvironment = executionContext.VariableEnvironment;

そして、JavaScriptコードに基づいてVariableEnvironmentやLexicalEnvironmentの参照が変わることもあります。
今回はLexical Environmentについて詳しく調べてみよう。
なお、この記事では、LexicalEnvironmentとVariableEnvironmentの違いについては扱いません。

Lexical Environment

Lexical Environmentは、Javaスクリプトコードにおいて変数や関数などの識別子を定義するのに使用するオブジェクトと考えると簡単です。Lexical Environmentは、識別子と参照あるいは値を記録するEnvironment Recordouterと呼ばれるもう1つのLexical Environmentを参照するポインタで構成されます。outerは外部Lexical Environmentを参照するポインタで、ネストされたJavaScriptコードでスコープのナビゲーションをするために使用します。

Environment Recordouterを理解しやすいように下の構造をみてみよう。(もちろん実際はこのように単純に動作するわけではありませんが、概念は簡単に理解できます。)

function foo() {
  const a = 1;
  const b = 2;
  const c = 3;
  function bar() {}

  // 2. Running execution context

  // ...
}

foo(); // 1. Call
// Running execution contextのLexicalEnvironment

{
  environmentRecord: {
    a: 1,
    b: 2,
    c: 3,
    bar: <Function>
  },
  outer: foo.[[Environment]]
}

上記の構造では、単に関数の呼び出しに1つのLexical Environmentを表示していますが、実際には関数BlockStatementcatchwithなどのような、さまざまなコード構文と状況によって生成され、破壊されることもあります。

関数のLexical Environmentはいつ作られるか?

前回の記事で、関数の呼び出し- F.[[Call]]には大きく3つの段階があると説明しました。

  1. PrepareForOrdinayCall
  2. OrdinaryCallBindThis
  3. OrdinaryCallEvaluateBody

PrepareForOrdinayCallではExecuton Contextの新規作成しかできませんでしたが、実際にはLexical Environmentも一緒に作成してExecution Contextに保存します。

// PrepareForOrdinayCall(F, newTarget)

callerContext = runningExecutionContext;
calleeContext = new ExecutionContext;
calleeContext.Function = F;

// Execution Contextを作成した後、Lexical Environmentを作成する
localEnv = NewFunctionEnvironment(F, newTarget);

// --- LexicalEnvironmentとVariableEnvironmentの差は、冒頭のリンクを参考にしよう
calleeContext.LexicalEnvironment = localEnv;
calleeContext.VariableEnvironment = localEnv;

executionContextStack.push(calleeContext);
return calleeContext;
 

NewFunctionEnvironment

NewFunctionEnvironmentの動作をみてみよう。

// NewFunctionEnvironment(F, newTarget)

env = new LexicalEnvironment;
envRec = new functionEnvironmentRecord;
envRec.[[FunctionObject]] = F;

if (F.[[ThisMode]] === lexical) {
  envRec.[[ThisBindingStatus]] = 'lexical';
} else {
  envRec.[[ThisBindingStatus]] = 'uninitialized';
}

home = F.[[HomeObject]];
envRec.[[HomeObject]] = home;
envRec.[[NewTarget]] = newTarget;

env.EnvironmentRecord = envRec.
env.outer = F.[[Environment]];

return env;
 

単純ですね。Environment Recordouterを持つLexical Environmentを作成して返却します。関数の環境でthissupernew.targetなどの情報をEnvironment Recordと一緒に初期化しました。次にEnvironment Recordをみてみよう。

Environment Record – Identifier bindings

Environment Recordとは、識別子のバインディングを記録するオブジェクトを指します。簡単に言うと、変数、関数などが記録されているところです。実質的にDeclarative Environment RecordとObject Environment Recordの2種類があり、さらにGlobal Environment Record、Function Environment Record、Module Environment Recordがあります。これらは次のような継承関係を持ちます。

   Environment Record
                                                    |
                    -----------------------------------------------------------------
                    |                               |                               |
        Declarative Environment Record     Object Environment Record     Global Environment Record
                    |
            --------------------------------
            |                              |
Function Environment Record     Module Environment Record

ここでは関数に注目して、Function Environment Recordをみてみよう。Declarative Environment Recordに変数や関数の情報が含まれている場合、Function Environment Recordは追加的にnew.targetthissuperなどの情報を持ちます。

{
  environmentRecord: { // = FunctionEnvironmentRecord
    //.... 上記と同じ

    [[ThisValue]]: global, // Any
    [[ThisBindingStatus]]: 'uninitialized', // 'lexical' | 'initialized' | 'uninitialized'
    [[FunctionObject]]: foo, // Object
    [[HomeObject]]: undefined, // Object | undefined,
    [[NewTarget]]: undefined // Object | undefined
  },
  outer: foo.[[Environment]]
}

ECMAScript 5までのExecution Contextに詳しい方なら、ここで1つ違う点を見つけるでしょう。this結合です。以前はthis結合をExecution Contextで管理していましたが、ECMAScript 2015(ES6)からEnvironment Recordで管理しています。したがって、thissupernew.targetなど、すべてFunction Environment Recordで確認ができます。Recordが識別子情報を管理するオブジェクトであるため、これがより合理的だと考えられます。
(最終的に最初の目的であったthisnew.targetsuper参照が、どこで保存され、参照できるか分かりました。)

outer environment reference – スコープチェーン

これまで頻繁にouterについて言及していますが、正確に何であるか調べてみよう。JavascriptはLexical Scopeを持つ言語です。識別子検索において当然スコープチェーンを含んでいます。outerはこのスコープチェーンのために存在するリファレンスです。ECMAScript 3版まではScope Chainという用語で明示していましたが、ES5からはLexical nesting structure、またはLogical nesting of Lexical Environment valuesなどと表現しています。おそらく3版から5版に更新するうちに、Listではなくouter参照を活用する実装に変わり、これに合わせて変更されたのではないかと推測します。

次のコードでouterを活用して識別子を探す過程をみてみよう。

// global
const globalA = 'globalA';

function foo() {
  const fooA = 'fooA';

  function bar() {
    const barA = 'barA';

    console.log(globalA);   // globalA
    console.log(fooA);      // fooA
    console.log(barA);      // barA
    console.log(unknownA);  // Reference Error
  }

  bar();
}

foo();

以下はEnvironmentsを簡単に表します(thisのような特別な値は省略します)。

GlobalEnvironment = {
  // Global Environment Recordには
  // Object Environment RecordとDeclarative Environment Recordなどが一緒に存在するが、ここでは区別しない
  environmentRecord: {
    globalA: 'globalA'
  },
  outer: null
};

fooEnvironment = {
  environmentRecord: {
    fooA: 'fooA'
  },
  outer: globalEnvironment // fooはGlobalで生成された
}

barEnvironment = {
  environmentRecord: {
    barA: 'barA'
  },
  outer: fooEnvironment // barはfooの中で生成された
}

barのenvironmentではfooAglobalAを検索できないため、outer参照によって上位environmentに上がって識別子を検索していきます。outernullであるにも関わらず、unknownAのように検索できない識別子であれば、Reference Errorが発生します。

デフォルトパラメータ(Default parameter)とLexical Environment

いよいよコードを見ることができそうです!

function add(a, b) {
  return a + b;
}

function foo(a, b = add(a, 1)) {
  return `foo ${a + b}`;
}

function bar(a = add(b, 1), b) {
  return `bar ${a + b}`;
}

console.log(foo(1)); // foo 3
console.log(bar(undefined, 1)); // Error

単にデフォルトパラメータを順番に処理するため、a = add(b, 1)の構文ではbに対する参照が見つからず、エラーが発生します。letconstのTDZ(Temporal dead zone)と同じような動作です。

実際に、デフォルトパラメータで重要な部分は別にあります。それはLexical Environmentを新規に作成することです。一見すると何を意味するかよくわかりません。

次のコードをみてみよう。

const str = 'outerText';

function foo(fn = () => str) {
  const str = 'innerText';

  console.log(fn());
}

foo(); // 'outerText'

コードを見ると、outerTextが出力されるのが当然だと感じます。しかし最初単純に考えたように、パラメータや一般変数をすべて同じEnvironmentのRecordに保存して使用すると、fn関数で参照する必要があるstr識別子はfoo、内部のstr、すなわちinnerTextを参照してしまうでしょう。これは常識的ではない動作で、あたかも関数の外部から内部スコープを参照して変更させる形になってしまい、複数の問題を引き起こす可能性があります。このためデフォルトのパラメータは、関数の内部を参照できないようにする必要があります。そのためには、パラメータ、関数の内部変数をEnvironmentから分離して、追加的なスコープチェーンを作成する必要があります。

したがって、関数が実行され、変数を初期化するときは、次のような動作を行います。(実際は、さらに多くの分岐動作がありますが、簡略化してstrict modeと仮定します。)

1. env = calleeContext.LexicalEnvironment;
2. envRec = env.environmentRecord;

3. envRecにパラメータを登録し、初期化する

4. If (基本値のパラメータがないなら)
  4-1. Environmentが区別される必要がないので、既存のenvRecに一般変数(VarScoped)も登録する
  4-2. varEnv = env;
  4-3. varEnvRec = envRec;
5. Else(= もし基本値のパラメータがあれば)
  5-1. varEnv = NewDeclarativeEnvironment(env);
  5-2. varEnvRec = varEnv.environmentRecord;
  5-3. calleeContext.VariableEnvironment = varEnv;
  5-4. varEnvRecに一般変数(VarScoped)を登録する

6. lexEnv = varEnv;
7. lexEnvRec = lexEnv.environmentRecord;
8. calleeContext.LexicalEnvironment = lexEnv;
9. lexEnvRecに4, 5で登録できなかった変数(LexicallyScoped)もすべて登録する
10. 内部にある関数オブジェクトを初期化する

簡略化していますがそれでも少し複雑にみえます。より簡単に説明すると – 関数が呼び出されるとき、パラメータを最初に初期化し、デフォルト値のパラメータがあれば、新しいEnvironmentを追加して、ここに関数の内部の変数を登録します。このように、Environmentを新たに作ってしまうので、デフォルトのパラメータは関数の内部を参照できません。しかし、関数の内部ではパラメータを参照できるネスト構造を作成することができます。

おわりに

関数の作成、呼び出し、Execution contextとLexical Environmentについて調べました。基本的な関数の呼び出しだけ調べても思ったより複雑でしたが、thisbinding、関数オブジェクトが持つ属性、[[Call]]の動作、Execution context、デフォルトパラメータとLexical Environmentなど、多彩な機能と動作を確認できました。もしこの記事がECMAScriptを理解する上で少しでも役立ったなら、さらに進めてGenerator機能、Async関数、ClassなどをECMAScriptスペックを確認してみよう。スペックや文書を読んで確認するのは難しいかもしれませんが、このような努力が急速に発展しているECMAScriptをより早く理解するのに、大きいに役立つと思います。

Reference

ECMAScript2017 – https://www.ecma-international.org/ecma-262/8.0/index.html

TOAST Meetup 編集部

TOASTの技術ナレッジやお得なイベント情報を発信していきます
pagetop