JavaScriptのメソッドコールの仕組みを深く理解する (参照型とは?)
はじめに
JavaScriptでは関数もオブジェクトです。このことはよく理解されていると思います。関数とメソッドとの明確な違いはなく、どちらも関数オブジェクトである、というところまではよいのですが、関数コールとメソッドコールの違い、あるいはその仕組みは正確に理解されているでしょうか。先日、職場の後輩に問題を出したところ正確に答えられえなかったので、いまさら?と思われるかも知れませんが、関数コールの仕組みを解説します。
関数とメソッド
JavaScriptでは関数とメソッドには本質的な違いはありません。オブジェクトのプロパティとして定義される関数を便宜的にメソッドと呼んでいるだけです。parseInt()などのグローバル関数もグローバルオブジェクトのプロパティであり、関数の中でローカルに定義した関数も概念的にはActivation Objectのプロパティなので基本的には全ての関数はメソッドです。
※ 注:ECMA262 5thではActivation Objectというモデルは消えています。代わりEnvironment Recordというものが登場します。
簡単な例でメソッドコールの仕組みを改めて考えてみましょう。
var value = 987; var obj = { value: 123, getValue: function () { return this.value; } }; var v1 = obj.getValue(); // (J1) => 123 var v2 = (obj.getValue)(); // (J2) => 123 var v3 = (fn = obj.getValue)(); // (J3) => 987
v1とv2には123が代入されます。これは問題ないと思います。一方、v3には987が代入されます。
JavaScriptはメソッドと関数の区別は事実上なく、どちらも関数オブジェクトなのに、(J3)の例のようにその関数オブジェクトを変数に代入してしまうとどうして動作が変わってしまうのでしょうか?
それはthisの値が違うから、というのが一つの短絡的な答えです。callかapply関数を使って明示的にthisの値を渡してやれば期待する結果になります。
var v4 = (fn = obj.getValue).call(obj); // (J4) => 123
でも、(J1)と(J2)が同じなら(J3)も同じでしょ、と思いたくなりませんか?C言語的な発想では、計算の途中結果を変数に代入しても結果は同じはずです。
JavaScriptではC++やJavaと異なり、関数名が位置する部分は通常の式を記述することができて、関数コールはその部分を評価した結果に対して実行されますが、(J3)のように変数に代入するとどうして結果が異なるのでしょうか。これを理解するには、参照型(Reference type)というものを使って説明する必要があります。
参照型とは?
C++にも参照型がありますが、あれと類似のものです。えっ!? JavaScriptに参照型があるなんて知らなかったと驚かれると思いますが、その認識は正しいです。言語の表舞台には登場しない型であり、それそのものを取り出すことはできません。なので、殆どのプログラマは意識していないんじゃないかと思います。言語のメタな意味レベルの話です。
C++の参照型の実体はアドレスです。アドレスがわかれば、そのアドレスに書かれている値を読み取ったり(参照外し; dereference)、逆にそのアドレスに値を書き込んだりできます。
アドレスは値が格納されている場所ですが、JavaScriptのアドレスに相当するものは何でしょうか?JavaScriptでは全ての値は何かのオブジェクトのプロパティとして定義されます。つまり、値が格納されている場所であるアドレスに相当するものは、オブジェクトとプロパティ名の組と考えるのが自然です。オブジェクトとプロパティ名がわかれば、そのプロパティの値を取り出すことも、そのプロパティの値を変更することもできます。要するに、JavaScriptでの参照型に相当するものは、
JavaScriptの参照型: <baseObj, propName>
baseObjはオブジェクト、propNameは文字列としてのプロパティ名
という2組で表現されるデータ構造ということになります。参照型をここではRef
のように表現することにします。参照型に関連するオペレータには参照外しに相当するGetValue(V)
と、代入に相当するPutValue(V,W)
があります。
メソッドコールの真実
JavaScriptで foo.bar.baz
などのようにプロパティアクセスをドット記法(または連想配列)で表しますが、その評価結果は値ではなく、実は参照になっています。これを変数に代入したりするときにGetValue()により参照外しが行われて値になります。
下記のような代入文の左辺式にドット表記でbazプロパティの値が変更できるのも左辺式のfoo.bar.baz
の評価結果が参照型だからです。
foo.bar.baz = 123;
もう、理解して頂けたと思いますが、obj.func()
というメソッドコールのobj.func
部分の評価結果は値である関数オブジェクトではなく、参照型のデータ構造になります。
obj.func
という形式の評価手順を書くと、
- obj部分を評価する。もしそれが参照型であればGetValue()する。
- func部分を評価したものをToString()する。
- 参照型Ref
を返す。
一方、メソッドコールobj.func()
の手順は以下のようになります。
obj.func
を評価して参照型Refのデータを求める。
- 1の結果をGetValue()して関数オブジェクトを取り出す。
- 1の結果のbaseObj部分をthisに束縛して2の結果の関数オブジェクトの本体を実行する。
- 1の結果をGetValue()して関数オブジェクトを取り出す。
もし、(J4)のようにobj.func
の参照型データを変数に代入してしまうと、GetValueによる参照外しが実行されるため、その変数には関数オブジェクト自身が代入されます。従って、メソッドコールではなく、通常の関数コールfunc()
と同じ形式になります。
しかし、通常の関数コールも評価手順は上記のメソッドコールのものと実は全く同じです。obj.func
を評価したものは参照型ですが、単にfunc
を評価したものも参照型です。違いはbaseObj部分がグローバルオブジェクトとなるだけです。
一方、関数の中でローカルに定義した内部関数の場合、その評価結果の参照型のbaseObjの値はActivation Objectというメタな仮想的なオブジェクトになります。そして実際に関数を実行する際に、thisにはActivation Objectではなく、グローバルオブジェクトが束縛されるという手順になります。
関数オブジェクトをメソッドとして実行する場合と、通常の関数として実行する場合にthisの値が異なるという現象は以上のような仕組みに基づいています。
参照型が登場するその他の構文
参照型は代入文の左辺式とメソッドコール以外にも登場します。というより、全ての変数の評価結果は参照型です。ただし、参照のまま存在することはできず、すぐにGetValue()による参照外しが実行される構造になっています。
しかし、一部の構文では参照型がちょっと顔を覗かせるものがあります。参照は最終的な値になる一歩手前の状態、評価を1つ遅らせたものに近いイメージですが、JavaScriptではdeleteとtypeofに参照型を使ったものが登場します。
deleteの引数は評価結果が参照型であれば、そのオブジェクトのプロパティを削除します。参照型でなければ何もしません。delete hoge
を実行する場合、変数hogeに何か値(例えば3)が代入されていたとしても、delete 3
という意味にはならないということです。しかし、hogeの部分はシンボルしか書けないわけではなく任意の式を書くことができます。式を評価した結果が参照型であればdeleteがその参照型に対して適用されます。下記のコードはx>y
のときobjの"a"プロパティが削除され、そうでないとき"b"プロパティが削除されます。
delete ((x > y) ? obj.a : obj.b);
typeofも同様ですが、特に、typeofの引数が参照型である必要性があるのは、値も何もない未定義変数に対してもtypeofが可能になるようにするため(この場合結果は"undefined")です。参照型でなく値として評価したら typeof hogehoge
でReferenceErrorが発生してしまいます。
ReferenceErrorという例外は実はこの参照型に関係する例外です。値が何もセットされていないシンボル(変数)もその評価結果は参照型になります。hogehogeというシンボルがコード中に突然書かれていても、その時点ではエラーではなく参照型Ref
一般に、RefereceErrorはGetValue()で参照外しする際に、baseObjがnullのとき発生するエラーです。
おわりに
関数コールとメソッドコールの簡単な違いについて説明する積もりでしたが、正確に説明しようとすると参照型の話をどうしても避けることができず、今回はちょっと難しい話になってしまいました。しかし、こんな詳細なことを知らなくてもJavaScriptでプログラムは問題なく書けます。また、ここで説明したことはECMAScript 3rdに基づくものです。最新のECMAScript 5thでは若干変更されていますが、基本は同じです。
Javaが登場したときポインタがないということがC/C++プログラマにとって大きなインパクトがあったようです。確かに言語レベルではポインタがないのですが、しかし中身の実装は逆で、オブジェクトが代入される変数は全てポインタのようなものです。RubyやLispなども同様です。そしてJavaScriptも同じです。JavaScriptではそのポインタに相当するものを参照型としてモデル化していると考えることができます。