Skip to content

関数呼び出し

関数呼び出しは JavaScript の実行のもっとも基本的な単位です。式を評価して呼び出し先(callee)を求め、引数を評価し、呼び出された関数のためのローカル領域を用意し、制御をそこへ移し、戻り値を持って呼び出し元へ帰ってくる。この一連の流れをエンジンがどう組み立てるかは、実行速度・スタック使用量・仕様適合の容易さに直結します。加えて JavaScript には、this の動的束縛、arguments オブジェクト、末尾呼び出し(PTC)といった、呼び出し規約を複雑にする固有の要求があります。この章では、呼び出しの一連の手順とスタック上のフレーム構造、this の解決、arguments の実体化コスト、メソッド呼び出しに潜むレシーバの罠、末尾呼び出しによるスタック節約、そしてネイティブ関数を呼ぶ境界を扱います。

呼び出しの一連の手順

一回の関数呼び出しは、概念的には次の手順に分解できます。

  1. callee の評価: f(...)fobj.m(...)obj.m を評価して、呼び出す関数オブジェクトを得る。メンバ呼び出しの場合は、このときレシーバ(obj)も一緒に確定する。
  2. 引数の評価: 実引数を左から右へ順に評価する。評価の途中でユーザコード(getter・toString・スプレッドのイテレータなど)が走りうる。
  3. 呼び出し可能性の検査: 得た値が呼び出し可能でなければ TypeError を投げる。
  4. フレームの確保: 呼び出される関数のためのアクティベーションレコード(呼び出しフレーム)を用意し、ローカルスロット・引数・this を配置する。
  5. 制御の移動: 命令ポインタを関数の本体へ移す。インタプリタなら次に実行するバイトコードを関数先頭へ切り替える。
  6. 本体の実行と復帰: 関数が return するか本体の末尾に達したら、戻り値を呼び出し元のフレームへ返し、フレームを破棄して呼び出し元の続きへ戻る。

このうち順序は仕様で観測可能に定められている部分があります。特に callee を引数より先に評価すること、引数を左から右へ評価することは、副作用の順序として守らなければなりません。

フレームのレイアウト

呼び出しフレーム(アクティベーションレコード)は、一つの関数呼び出しに固有の実行状態をまとめたものです。スタックマシン型のインタプリタでは、値スタックの上にフレームを積み上げていくのが素直な設計です。一つのフレームには概念的に次が含まれます。

  • 引数領域: 呼び出し元が積んだ実引数。宣言された仮引数より多くても少なくてもよい(過剰分は arguments からのみ見え、不足分は undefined)。
  • this スロット: この呼び出しの this 値(後述。アロー関数は持たない)。
  • ローカルスロット: 仮引数と関数内で宣言されたローカル変数のための固定枠。コンパイル時に個数と割り当てが決まる。
  • オペランドスタック: 式評価の途中値を積む作業領域。
  • 戻り情報: 復帰先の命令位置と、呼び出し元のフレーム基準位置(フレームポインタ)。

呼び出し時には、呼び出し元が引数を値スタックの先頭へ積み、その上に新しいフレームの基準点を置きます。呼び出し先から見ると、自分の引数とローカルは基準点からの固定オフセットでアクセスでき、スロット番号を添字にした配列アクセス一つで読み書きできます。復帰時にはこのフレームを丸ごと切り捨て、戻り値だけを呼び出し元のオペランドスタックに残します。

値スタックは上方向へ伸び、フレームは次のように積まれます(上ほど古い領域)。

flowchart TB
    caller["呼び出し元のオペランドスタック"]
    args["実引数 arg0, arg1, …(呼び出し元が積む)"]
    subgraph newframe["新フレーム(基準点=フレームポインタ)"]
        thisslot["this スロット"]
        locals["ローカルスロット 0..n"]
        calleeops["呼び出し先のオペランドスタック(本体の式評価に使う)"]
    end
    caller --> args --> newframe
    thisslot --> locals --> calleeops

このレイアウトの利点は、確保が「スタックポインタを進めるだけ」で済み、復帰が「戻すだけ」で済むことです。フレームごとにヒープ確保しないため、呼び出しは軽量になります。ただしクロージャに捕捉される変数は、フレームを破棄しても生き続けなければならないため、スタック上のスロットとは別に扱う必要があります(捕捉変数の扱いは第II部「バイトコードとコンパイラ」を参照)。

this の束縛

this は、関数がどう定義されたかではなく、どう呼び出されたかで決まります。エンジンは呼び出しの形に応じて this を求め、フレームの this スロットへ格納します。

呼び出しの形による決定

  • メソッド呼び出し obj.m() / obj["m"](): レシーバ objthis になる。callee の評価過程でベースオブジェクトが確定するので、それをそのまま渡す。
  • 通常呼び出し f(): レシーバがない。この場合の this はモードで異なる(次項)。
  • call / apply / bind: 明示された this 値を使う。bind は束縛済みの this を持つ新しい関数を作る。
  • new f(): 新しく生成したオブジェクトが this になる(コンストラクタ呼び出し。通常の [[Call]] ではなく [[Construct]])。

strict と sloppy の違い

レシーバのない通常呼び出しでの this は、関数が strict モードか否かで挙動が分かれます。

  • strict モード: 渡された this(通常呼び出しでは undefined)がそのまま入る。thisundefined のまま。
  • sloppy モード: thisundefined または null のとき、グローバルオブジェクトで置き換える。さらにプリミティブ値(数値・文字列など)が this になる場合は、対応するラッパーオブジェクトへボックス化する。

仕様上は、関数を呼ぶ側の抽象操作が this を求め、関数側の初期化(OrdinaryCallBindThis)がモードに応じてこの置換・ボックス化を行います。エンジンは、関数が strict かどうかをコンパイル時に確定しておき、呼び出しフレームを初期化する段でこの分岐を一度だけ行うのが効率的です。sloppy の置換・ボックス化は毎回の分岐とグローバル参照(あるいはラッパー生成)を伴うため、strict のほうが this 束縛は安価です。

strict モード sloppy モード
通常呼び出しの this undefined のまま グローバルオブジェクトに置換
null/undefined を明示 そのまま渡る グローバルオブジェクトに置換
プリミティブを this そのまま渡る ラッパーオブジェクトにボックス化
束縛のコスト 分岐なしで安価 置換・ボックス化の分岐が要る

アロー関数と語彙的 this

アロー関数は自分専用の this を持ちません。本体に現れる this は、定義された場所の外側スコープの this をそのまま指します。これは、アロー関数のフレームに this スロットを設けず、変数参照としての this を外側の関数環境レコードから解決することで実現します。

  • アロー関数を呼び出すとき、レシーバや call/apply で渡された this は無視される(自分の this スロットがないので束縛しようがない)。
  • arguments も同様に語彙的で、アロー関数は自前の arguments を持たず、外側関数のものを参照する。

実装としては、アロー関数を通常の関数と同じ呼び出し経路に乗せつつ、this(と argumentsnew.targetsuper)の解決だけを「自フレームではなく捕捉した外側環境から取る」ように切り替えるのが素直です。これにより、アロー関数は this の束縛コストをそもそも払わずに済みます。

arguments オブジェクト

通常関数の本体からは、渡された全実引数を保持する arguments オブジェクトが参照できます。これには二つの形があり、モードで使い分けられます。

  • mapped(sloppy モードかつ単純な仮引数リスト): arguments の要素と名前付き仮引数が相互に連動する。arguments[0] を書き換えると第一仮引数の値も変わり、逆も同様。
  • unmapped(strict モード、またはデフォルト値・分割代入・レストパラメータを含む場合): 呼び出し時点の実引数のスナップショットを持つだけで、仮引数とは連動しない。

mapped な連動は、arguments の各インデックスを対応する仮引数スロットへのエイリアス(別名参照)として実装する必要があり、値のコピーではなくスロットへの間接参照を保持します。この仕組みは実装が煩雑なうえ最適化を妨げるため、仕様はデフォルト値やレストパラメータが一つでもあれば unmapped に切り替えると定めています。

mapped unmapped
有効になる条件 sloppy かつ単純な仮引数のみ strict、またはデフォルト値/分割代入/レストを含む
仮引数との連動 あり(相互にエイリアス) なし(スナップショット)
実装 インデックスごとにスロットへの間接参照 実引数のコピー配列
最適化との相性 悪い(エイリアスが解析を妨げる) 良い

実体化のコストと遅延戦略

arguments オブジェクトは、通常の配列風オブジェクトとして素直に作るとそれなりに重い割り当てです。ところが多くの関数は arguments をまったく使いません。そこで一般的なのは、本体が arguments を実際に参照する場合にのみ実体化する遅延戦略です。

  • コンパイル時に、関数本体が arguments を参照するかを静的に判定する。参照がなければ arguments オブジェクトを一切作らない。
  • 参照があっても、arguments.lengtharguments[i] の読み取りしか行わないと分かる場合は、実オブジェクトを作らず値スタック上の実引数を直接読む形に落とせることがある。
  • レストパラメータ(...args)は arguments に依存しない明示的な受け取り方であり、必要な引数だけを配列として受けられるため、arguments の実体化を避ける手段にもなる。

「使われないものは作らない」というこの遅延は、arguments を触らない大多数の呼び出しから丸ごとコストを取り除きます。実体化が避けられない場合でも、mapped より unmapped のほうが単純なコピーで済み、後続の最適化も妨げません。

実装上の罠: メソッド値は実際のレシーバへ(再)束縛せよ

メソッド呼び出し obj.m(args) は、内部的には「obj から m を読み取る」ステップと「その関数を呼ぶ」ステップに分かれます。正しい thisobj、すなわち読み取りに使ったベースオブジェクトでなければなりません。ここに、最適化を急ぐと踏みやすい落とし穴があります。

メソッド呼び出しは頻出なので、obj.m の読み取り結果(関数値)をインラインキャッシュなどでキャッシュしたくなります。危険なのは、関数値と一緒に「読み取り時点のレシーバ」まで取り込んで、後続の呼び出しでそのレシーバを使い回してしまう設計です。関数値だけを見ると同じ m でも、呼び出しごとにベースオブジェクトは変わりえます。

// 概念的な誤り: メソッド値にレシーバを事前束縛してキャッシュする
cached = { fn: obj.m, receiver: obj }   // 読み取り時のレシーバも一緒に取り込む
...
cached.fn.call(cached.receiver, args)   // 別の呼び出し箇所でも古いレシーバを使う

具体的な破綻の形はいくつかあります。

  • メソッドの借用: a.m を取り出して obj に対して呼ぶ(const g = a.m; obj.m = g; obj.m())。呼び出し箇所のレシーバは obj なのに、読み取り時の a を使い回すと this が食い違う。
  • プロトタイプ経由: child.m()m がプロトタイプ由来でも、this はプロトタイプではなく child。読み取りで見つけた定義位置のオブジェクトをレシーバにしてはならない。
  • 同じ callee・異なるレシーバ: ループで arr[i].m() を回すと、callee が同一形状でもレシーバは毎回変わる。

正しい実装の原則は明快です。メソッド値のキャッシュはあくまで「どの関数を呼ぶか」の高速化にとどめ、this は必ず呼び出し箇所で評価した実際のベースオブジェクトを渡すこと。言い換えると、関数値は使い回してよいが、レシーバは呼び出しのたびに(再)束縛しなければなりません。インラインキャッシュは「このオブジェクト形状ならメソッド m はこの関数」というマッピングを覚えるものであって、レシーバそのものを覚えるものではない、と切り分けるのが安全です。call/apply/bind を明示的に使うコードだけでなく、素の obj.m() でもこの分離を守らないと、this の取り違えという観測可能な誤りに直結します。

末尾呼び出し(Proper Tail Calls)

関数の末尾位置(その呼び出しの結果がそのまま関数の戻り値になる位置)にある呼び出しは、呼び出し元のフレームをもう使いません。ならば新しいフレームを積む代わりに、現在のフレームを再利用して呼び出し先へ制御を移せます。これが Proper Tail Calls(PTC、適切な末尾呼び出し)で、末尾再帰が定数スタックで回るようになります。

function loop(n, acc) {
  if (n === 0) return acc;
  return loop(n - 1, acc + n);  // 末尾位置: 戻り値がそのまま呼び出し元の戻り値
}

PTC がなければ loopn に比例したフレームを積み、大きな n でスタック溢れになります。PTC があれば、loop の各呼び出しは自分のフレームを次の loop に譲るため、スタックは一段のまま回り続けます。仕様上はこの再利用の準備を PrepareForTailCall が担い、末尾位置での呼び出しは現在の実行文脈を捨ててから呼び出し先へ移ると規定されています。

末尾位置になる条件・ならない条件

PTC が適用されるのは、呼び出しが厳密な意味での末尾位置にあり、かつ strict モードのときです。次は末尾位置になりません。

  • return f(x) + 1 のように、呼び出し結果にさらに演算を施す場合(呼び出し後に加算が残る)。
  • try ブロック内の呼び出し(finally や catch のために呼び出し元の文脈が残りうる)。
  • コンストラクタ呼び出し new f()(new 式は仕様上そもそも末尾位置として定義されていない)。
  • ログや後処理が呼び出しの後に続く場合。

つまり「呼び出しの後に、その戻り値を待ってやることが何もない」ことが条件です。エンジンはコンパイル時に各呼び出しが末尾位置かを判定し、末尾なら通常の call ではなくフレーム再利用版の命令を出します。フレーム再利用では、現在のフレームの引数・ローカル領域を新しい呼び出しの引数で上書きし、命令ポインタを呼び出し先の先頭へ移します。

割り込み・ウォッチドッグとの両立

末尾再帰は定数スタックで回る反面、止まらない末尾再帰は無限ループと同じく CPU を占有し続けます。フレームを積まないためスタック溢れでは止まりません。したがって PTC を実装するときは、末尾呼び出しによるフレーム再利用点を、割り込みやウォッチドッグ(実行時間制限)を確認するセーフポイントにしておく必要があります。

  • 素朴なループ(バックエッジ)にセーフポイントを置くのと同様に、末尾呼び出しでフレームを再利用する箇所でも、割り込み要求や実行時間超過をチェックする。
  • こうしておけば、while (true) の無限ループが割り込めるのと同じく、末尾再帰の無限ループもホストからの中断要求で止められる。

セーフポイントの置き方は第II部「インタプリタ実行」およびネイティブ境界の議論と同じ考え方です。PTC はスタックを節約する最適化であって、実行を無停止にしてよい理由にはなりません。中断可能性は必ず維持します。

ネイティブ関数・ホスト関数の呼び出し

呼び出し先は JS で書かれた関数とは限りません。エンジンの組み込み関数(Array.prototype.map など)やホストが提供する関数は、エンジンの実装言語(ネイティブコード)で書かれています。呼び出し規約は、この境界をまたげるよう設計する必要があります。

  • 統一された呼び出し口: JS 関数もネイティブ関数も同じ呼び出し命令から呼べるよう、関数オブジェクトが「本体が JS バイトコードか、ネイティブ実装か」を保持し、呼び出し側は種別に応じて分岐する。
  • 引数の受け渡し: ネイティブ関数へは、this・実引数・(必要なら)new.target を、両者が合意した形(値スタック上の領域や引数構造体)で渡す。ネイティブ側はそこから引数を取り出し、戻り値を同じ規約で返す。
  • 不足・過剰引数: JS 同様、不足分は undefined、過剰分は無視または可変長として受ける。

重要なのは、ネイティブ関数の呼び出しは GC が走りうる地点であることです。ネイティブ実装がヒープに何かを確保したり、コールバックや型強制を通じて JS へ再入したりすれば、その最中に GC が動きえます。したがって、呼び出し元がネイティブ関数を呼ぶ前後で生かしておきたい値は、GC から見える場所に置かれていなければなりません。ネイティブ実装の内部でも、確保・再入をまたいで保持する一時値のピン留めが必要です。この境界の危険と対策(ピン留め・ハンドルスコープ・永続ハンドル、GC ストレスによる検出)は第III部「ネイティブコードとの相互作用と GC 安全性」で詳しく扱います。呼び出し規約の観点では、「ネイティブ呼び出し=セーフポイント」であることを前提に、フレームやレシーバがその時点で GC 可視であることを保証する、と押さえておけば十分です。

まとめ

  • 関数呼び出しは、callee 評価 → 引数評価(左から右)→ フレーム確保 → 制御移動 → 復帰の手順で進む。副作用の観測順序は仕様で守られる。
  • 呼び出しフレームは値スタック上に積むと軽量になる。引数・this・ローカルスロット・オペランドスタック・戻り情報を、基準点からの固定オフセットで扱う。
  • this は呼び出しの形で決まる。strict では通常呼び出しの thisundefined のまま、sloppy では undefined/null をグローバルに置換しプリミティブをボックス化する。アロー関数は this スロットを持たず語彙的に解決する。
  • arguments は mapped(sloppy・単純仮引数、仮引数と連動)と unmapped(strict やデフォルト値等、スナップショット)がある。多くの関数は使わないため、参照する場合のみ実体化する遅延戦略が効く。
  • メソッド呼び出しでは、関数値をキャッシュしてもレシーバは呼び出し箇所ごとに(再)束縛する。読み取り時のレシーバを取り込んで使い回すと this の取り違えを生む。
  • PTC は末尾位置の呼び出しで現在フレームを再利用し、末尾再帰を定数スタックで回す。new 呼び出しや非末尾位置には適用されない。フレーム再利用点はセーフポイントにし、無限末尾再帰も割り込めるようにする。
  • ネイティブ/ホスト関数の呼び出しは統一された呼び出し口から行い、この境界は GC が走りうるセーフポイントである。

参考文献