☝️

JavaScriptの参照レコードとthisバインディング

2022/02/19に公開

JavaScriptの仕様には「参照レコード」という概念があります。参照を意識することで、JavaScriptにおけるメソッド呼び出しの理解と左辺式の評価順序の理解を同時に深めることができます。本稿ではこの「参照レコード」の動機と詳細の説明を試みます。

※ 本記事ではECMAScriptの規格で「参照レコード」と呼ばれている概念を説明します。JavaScriptのオブジェクトは参照渡しのような使い方ができますが、これは本稿で説明する「参照」とは少しだけ異なります。

参照レコードの目的

JavaScriptにおける参照レコードは以下の2つの目的で存在しています。

  • 左辺式の中間評価結果を表現するため。
  • メソッドのレシーバーを決定するため。

左辺式の中間評価結果とは

たとえば a[f()] += 2; というコードを考えます。

function f() { console.log("f()"); return 0; }
const a = [1, 2, 3];
a[f()] += 2;
console.log(a);
// Logged: f()
// => [3, 2, 3];

このとき a[0] には読み取りと書き込みで2回アクセスする必要がありますが、 f() 自体は1度だけ評価されています。これは仕様上、 a[f()] の評価結果を「配列オブジェクト (a) の 0 というプロパティ」という形 (←参照レコード) で持つことで実現されています。

メソッドのレシーバーとは

JavaScriptにおけるメソッドはプロパティの一種です。

"".toString
// => [Function: toString]
("".toString)()
// => ""

ところが、このようにして返ってくる関数オブジェクトを保存してから呼んでみると、thisがバインドされていないことがわかります。

const f = "".toString;
f === "foo".toString;
// => true
f();
// => Uncaught TypeError: String.prototype.toString requires that 'this' be a String

(GoやPythonなどで同じような書き方をすると、thisがバインドされた状態の関数が返ってきます)

実はJavaScriptで foo.barfoo[bar] などのプロパティアクセスを行った時点ではプロパティの読み取りは行われておらず、参照レコードという状態で保存されています (これは「左辺式の中間結果とは」で説明したことと同じです) 。関数呼び出し式は左辺が参照レコードだった場合に参照レコードからthisを取り出すため、直接呼んだときだけthisがセットされるという仕組みになっています。

参照レコードとは

参照レコードはECMAScriptの§6 ECMAScript Data Types and Valuesで説明されている規格上のデータ型です。規格上のデータ型なので、JavaScriptプログラムから参照レコードを直接扱うことはありません。たとえば、参照レコードを変数に保存したり、ユーザー定義関数の戻り値として返したりすることはできません。

参照レコードは以下のようにおよそ4種類に分類されます。

  • プロパティ参照 (Property Reference Record)
    • 通常のプロパティ参照
    • superプロパティ参照
  • ローカル変数参照
    • 解決されたローカル変数参照 (Environment Reference Record)
    • 解決できなかったローカル変数参照 (Unresolvable Reference Record)

参照レコードはJavaScriptの世界の中からは見えませんが、説明のためにあえてTypeScriptのコードで書くと、参照レコードとは以下のようなデータ構造です。

type ReferenceRecord =
  | PropertyReferenceRecord
  | EnvironmentReferenceRecord
  | UnresolvableReferenceRecord;

// foo.bar, foo[bar], foo.#bar 等によってできる参照
type PropertyReferenceRecord = {
  // レシーバオブジェクト
  base: object;
  // プロパティ名
  referencedName: string | symbol | PrivateName;
  // strictモード由来かどうか
  strict: boolean;
  // super参照の場合は、オリジナルのthisオブジェクト。
  // それ以外の場合は指定なし。
  thisValue?: object;
};

// foo (ローカル変数) によってできる参照 (解決できた場合)
type EnvironmentReferenceRecord = {
  // どのスコープのローカル変数として解決されたか。
  base: EnvironmentRecord;
  // ローカル変数の名前
  referencedName: string;
  // strictモード由来かどうか
  strict: boolean;
  // この場合はthisValueは存在しない。
  thisValue?: never;
};

// foo (ローカル変数) によってできる参照 (解決できなかった場合)
type UnresolvableReferenceRecord = {
  // 解決できなかったことを表す定数値が入っている。
  base: "unresolvable";
  // 変数名
  referencedName: string;
  // strictモード由来かどうか
  strict: boolean;
  // この場合はthisValueは存在しない。
  thisValue?: never;
};

参照レコードを返す式

ECMAScriptの規格では、式は値 (ECMAScript Language Types のいずれかの値) または参照レコードのどちらかに評価されるようになっています。このうち、参照レコードを返す可能性のある式はごくわずかです。

ローカル変数参照 (IdentifierReference)

変数式を評価するには、内側のスコープから順に当該変数名を検索します。

  • 見つかった場合、そのスコープ (Environment Record) と変数名を対にして参照レコードとして返します。
    • この時点では変数に入っている値の読み取りは行われません。
  • どのスコープにも当該変数名の束縛が見つからなかった場合、未解決であることを表す参照レコード (変数名が入っている) を返します。
    • この時点ではエラーにはなりません。

プロパティ参照・プライベートプロパティ参照 (. または [])

. または [] を使うとプロパティを参照することができます。

// ドット演算子によるプロパティ参照
foo.bar     // 通常
foo?.bar    // オプショナル演算子
foo?.().bar // オプショナルチェインの一部
super.bar   // superプロパティ参照
// []演算子によるプロパティ参照
foo[bar]     // 通常
foo?.[bar]   // オプショナル演算子
foo?.()[bar] // オプショナルチェインの一部
super[bar]   // superプロパティ参照
// プライベートプロパティ参照
foo.#bar  // 通常
foo?.#bar // オプショナル演算子
foo?.().#bar // オプショナルチェインの一部

これらは全てProperty Reference Recordを返します。つまり、「ベースオブジェクト」と「プロパティ名」を対にして参照として扱います。

superプロパティ参照

superプロパティはやや特殊です。これは通常superメソッド呼び出しのために使われるのですが、superメソッド呼び出しは「スーパークラスのメソッドを」「オリジナルのthisを渡して」呼ぶという特殊なメソッド呼び出しだからです。そのため、superプロパティではベースオブジェクトに相当するオブジェクトが [[Base]][[ThisValue]] の2つに分裂します。

class C {
  f() {
    return this.constructor;
  }
}
class D extends C {
  f() {
    return super.f();
    //     ^^^^^^^
    //       [[Base]] は C.prototype (C.prototype.f が取得される)
    //       [[ThisValue]] は 元のthis (C.prototype.f には元のthisが渡される)
  }
}

これを実現するために、superプロパティが返す参照は以下のような特別なルールが適用されます。

オプショナルチェイン

オプショナルチェインの参照の評価はかなり特殊です。これについては後述します。

import.meta, new.target

import.metanew.target は構文上はプロパティアクセスに近いですが意味的には import.metanew.target というひとつのまとまりで this と同様の扱いを受けます (→参照レコードではなく値を返します)。

括弧式

括弧式 は中の式の評価結果をそのまま返します。もし中の式が参照に評価された場合、括弧式自身も参照を返します。これは以下の式が意図通りに動作することを意味します。

// "".toString() と同じ
((("".toString)))();
// x = 42; と同じ
(((x))) = 42;

関数呼び出し

ECMAScript 5.1までは、関数が参照を返すことが明示的に許されていました。 (ECMAScript 5.1 §8.7 The Reference Specification Type)

The behaviour of assignment could, instead, be explained entirely in terms of a case analysis on the syntactic form of the left-hand operand of an assignment operator, but for one difficulty: function calls are permitted to return references. This possibility is admitted purely for the sake of host objects. No built-in ECMAScript function defined by this specification returns a reference and there is no provision for a userdefined function to return a reference.

引用中にもはっきり書かれているように、そのような関数をJavaScriptプログラムの中で定義することはできません。しかし、ホスト定義の関数 (ネイティブ関数) には参照を返す余地が残されています。

標準化される前のNetscapeのJavaScriptでは、evalが参照を返すことがあったようです。 https://web.archive.org/web/19970617232643/http://home.netscape.com/eng/mozilla/2.0/handbook/javascript/ref_d-e.html#eval_method

Example 4. In the following example, the setValue() function uses eval to assign the value of the variable newValue to the text field textObject.

function setValue (textObject, newValue) {
  eval ("document.forms[0]." + textObject + ".value") = newValue
}

JScriptにもそのような関数があったようです。 https://twitter.com/mod_poppo/status/1489578802561056768

WSHのJScript(ES3準拠)ではScripting.DictionaryのItemメソッドが参照を返したりする

ECMAScript 2015以降では関数が参照を返すのが (ホスト定義関数の含めて) 明示的に禁止されました。

参照を返さない式

他の式は参照を返しませんが、注目に値する例をいくつか挙げます。

  • 条件式 cond ? whenTrue : whenFalse
    • C++の二分探索でたまに出てくる (cond ? lo : hi) = mid; は書けない。
  • コンマ式 (prepare, result)
    • 括弧式と異なり、必ず値にしてから返される。
    • この特徴から、トランスパイラが特定のユースケースで (0, foo) という形で使うことがある。

参照を使う式

参照を受け取ったときに特別な処理をする式は限られています。

関数呼び出し

関数呼び出しは関数に渡す this の値を以下のように決定します。

  • プロパティ参照の場合はベースオブジェクト ([[Base]]) を this として使う。
    • ただし、superプロパティ参照の場合は [[ThisValue]] の値をかわりに this として使う。
  • with文の変数を参照しているときは、withの引数オブジェクトを this として使う
  • それ以外の変数参照の場合 ... undefinedthis として使う。
  • 値の場合 ... undefinedthis として使う。

ただし、このようにして渡された this は被呼び出し側でそのまま受け取られるとは限りません。アロー関数は this を受け取らずに破棄します。また、strict modeでない関数は this がオブジェクトではなかった場合に所定の変換を適用します。

そのほかDirect evalの判定にも参照が使われます。

なお、オプショナルチェイン内で関数呼び出しをした場合はやや特殊な挙動をします。これについては後述します。

タグつきテンプレートリテラル

タグつきテンプレートリテラルは関数呼び出しの構文糖衣であり、参照の扱いも関数呼び出しに準じます。

foo.bar`something` // fooがthisとして渡される

代入・複合代入・インクリメント・デクリメント

代入・複合代入・インクリメント・デクリメントは値を書き込むために参照を期待します。 (for-in, for-of 内の代入も含む)

参照に値を代入するときの挙動はPutValueに説明されています。変数参照の場合 (未解決の変数参照・with文の変数参照・グローバル変数参照) に特殊な挙動がありますが、詳しくは当該セクションを参照してください。

代入が分割代入の場合は、左辺式全体ではなく左辺式の個々の要素が上記のルールで処理されます。

変数の初期化

変数の初期化を行う構文の意味論は「ResolveBindingで参照を取得し、そこにPutValueする」という形で記述されています。その場で生成した参照をその場で消費しているだけなので、プログラマがこのことを意識する機会はありません。

typeof

typeofは引数が未解決の参照の場合、値を読み取らずに "undefined" を返します。それ以外の場合は、読み取った値にもとづいた結果を返します。

これによりエラーを避けながら変数の存在判定を行うことができます。たとえばfetchの存在を検出するときに if (fetch === undefined) と書かずに if (typeof fetch === "undefined") と書くのはこのためです。

delete

delete は参照を期待します。値が渡されたときはなぜかエラーにならずにtrueを返します。 (歴史的な事情?)

その他の細かい挙動は当該セクションを参照してください。

参照を使わない式・文とGetValue

前節で説明した以外のほぼすべての場所では式の評価結果として値を期待します。参照を受け取った場合、GetValueで値を読み取ります。特に以下のような場所でもGetValueが呼ばれることは特記に値します。

  • 式文foo.bar; では bar に対応するgetterが実行される可能性がある。
  • コンマ式(foo.bar, 0) では bar に対応するgetterが実行される可能性がある。
  • for文の第一式第二式、第三式for(foo.bar; foo.bar; foo.bar); では bar に対応するgetterが実行される可能性がある。

式は値を返す場合と参照を返す場合があります。GetValueに値を渡した場合はその値がそのまま返されるようになっているため、単にGetValueで変換しておけばどちらの場合にも対応できます。

GetValueは参照が未解決のときエラーになります。また、GetValue内ではオブジェクトプロパティの読み取りが行われる場合があり、ここでgetterがエラーを返すと例外がそのまま伝搬されます。

オプショナルチェインと参照

オプショナルチェインにおける参照の扱いはやや複雑です。これはオプショナルチェインが本質的に値に基づいた分岐であると同時に、参照を必要とする処理 (プロパティ参照・関数呼び出し) を含んでいるからです。

オプショナルチェインが実現するのは以下のような分岐です。 (foo.bar?.baz.quux?.corge を例とします)

  • オプショナルチェインの開始位置まで (foo.bar) を評価する。
  • その値がnullish value (undefined, null) ではなかった場合:
    • オプショナルチェインを普通のCallExpressionのチェイン (foo.bar.baz.quux?.corge) とみなして実行を再開する (.baz.quux?.corge 部分を評価する)。
  • その値がnullish value (undefined, null) だった場合:
    • オプショナルチェインの末尾までの処理 (.baz.quux 部分) をスキップする。
    • オプショナルチェインの末尾までの結果がundefinedだったとみなして実行を再開する (?.corge 部分を評価する)。

この分岐を実現するために、オプショナルチェインの開始位置でいきなり参照を GetValue で値に変換します。しかし、この時点ではもとになった参照は破棄せずに保管しておきます。チェインの中に入るとき、その最初の処理に参照と値の両方を渡します。

  • チェインの最初が関数呼び出し ?.() のときは、呼び出すべき関数としては取得済みの値をそのまま使います。 this の決定には取得済みの参照をそのまま使います。
    • タグつきテンプレート ?.`...` でも同様だと思われますが、現時点で規格中の記述が欠落しているようです。
  • チェインの最初がそれ以外の処理 (?.[bar], ?.bar, ?.#bar) のときは、取得済みの値を使います。

チェインの開始位置で値がnullishだった場合は単にundefinedを返します。これは以下の理由で問題ありません。

  • undefinedはcallableではないため、関数呼び出しの目的でthisの取得ができなくてもよい。 ((foo.bar?.baz)() はできるが、 foo.bar がnullishだった場合のthisの扱いは考えなくてもよい)
  • オプショナルチェインを代入系の処理の左辺式に直接置くことはできないため、この目的でオリジナルの参照を維持する必要がない。 (foo?.bar = 42; はできない)

まとめ

  • ECMAScriptの仕様書では代入可能な領域を指すために参照レコードという概念が定義されている。
  • JavaScriptのメソッド呼び出しはプロパティ参照と関数呼び出しの組み合わせであるにもかかわらず、メソッドをプロパティとして参照して得られる関数オブジェクトには this がバインドされていない。this がバインドされていないのにメソッド呼び出しが成立するのは参照から this を決定しているからである。
  • super によって作られる参照ではプロパティ検索用の this とメソッド呼び出し用の this が区別されている。

関連

Discussion