コンピュータサイエンスでは、末尾呼び出しとは、プロシージャの最終アクションとして実行されるサブルーチン呼び出しのことです。 [ 1 ] 末尾呼び出しのターゲットが同じサブルーチンである場合、そのサブルーチンは末尾再帰的であると言われ、これは直接再帰の特殊なケースです。末尾再帰(または末尾再帰)は特に有用であり、実装において最適化しやすい場合が多いです。
末尾呼び出しは、呼び出しスタックに新しいスタックフレームを追加することなく実装できます。現在のプロシージャのフレームの大部分は不要になり、末尾呼び出しのフレームを適切に修正して置き換えることができます(プロセスのオーバーレイと同様ですが、関数呼び出しの場合です)。その後、プログラムは呼び出されたサブルーチンにジャンプできます。このようなコードを標準の呼び出しシーケンスの代わりに生成することを、末尾呼び出しの排除または末尾呼び出しの最適化と呼びます。末尾呼び出しの排除により、末尾位置のプロシージャ呼び出しをgoto文と同じくらい効率的に実装できるため、効率的な構造化プログラミングが可能になります。Guy L. Steeleの言葉を借りれば、「一般に、プロシージャ呼び出しは、パラメータも渡す GOTO 文として有用に考えることができ、[マシンコード] JUMP 命令として統一的にコーディングできます。」[ 2 ]
すべてのプログラミング言語が末尾呼び出しの削除を必要とするわけではありません。しかし、関数型プログラミング言語では、末尾呼び出しの削除は言語標準によって保証されることが多く、末尾再帰が同等のループと同量のメモリを使用することが可能になります。関数が自身を呼び出す末尾再帰呼び出しの特殊なケースは、一般的な末尾呼び出しよりも呼び出し削除に適している場合があります。言語のセマンティクスが一般的な末尾呼び出しを明示的にサポートしていない場合でも、コンパイラは兄弟呼び出し、つまり呼び出し元と同じ型を受け取り、同じ型を返す関数への末尾呼び出しを最適化できることがよくあります。[ 3 ]
関数が呼び出されると、コンピュータは呼び出し元、つまり戻りアドレスを「記憶」しておく必要があります。そうすることで、呼び出しが完了したときに結果を持ってその場所に戻ることができます。通常、この情報は呼び出しスタックに保存されます。呼び出しスタックは、呼び出し先に到達した順序で戻り先をリスト化したものです。さらに、コンパイラは呼び出された関数のローカル変数にメモリを割り当て、レジスタの内容(存在する場合、および/または関連する場合)をスタックにプッシュします。通常、これは保存されたレジスタ、レジスタ以外のローカル変数用に割り当てられた領域、戻りアドレス、および呼び出しパラメータ(レジスタで渡されない限り)を含むスタックフレームを割り当てることによって行われます。末尾呼び出しの場合、呼び出し元を記憶したり、レジスタの内容を保持したりする必要はありません。代わりに、末尾呼び出しの削除により、新しいスタックフレームの割り当てが回避され、既存のスタックフレームに最小限の変更を加えた後、それを渡します。末尾呼び出しされた関数は、元の呼び出し元に直接戻ります。[ 4 ] ただし、これにより呼び出し元のスタックフレームが完全に失われるため、デバッグの妨げになる場合があります。末尾呼び出しは、ソースコード内の他のすべてのステートメントの後に字句的に現れる必要はありません。重要なのは、呼び出し元の関数が末尾呼び出しの直後に戻り、末尾呼び出しの結果があればそれを返すことです。なぜなら、最適化が実行される際に呼び出し元の関数はスキップされるからです。
非再帰関数呼び出しの場合、呼び出せる関数の種類がそれほど多くないため、通常は時間とスペースを少ししか節約できない最適化です。しかし、末尾呼び出しによって再帰が発生する再帰関数や相互再帰関数を扱う場合、関数が直接的または間接的に自身を呼び出し、その都度新しい呼び出しスタックフレームを作成するため、節約されるスタックスペースと戻り値の数は大幅に増加する可能性があります。末尾呼び出しの削除により、漸近的なスタックスペースの要件が線形、つまりO (n) から定数、つまり O(1) に減少することがよくあります。そのため、末尾呼び出しの削除は、 SchemeやMLファミリーの言語など、一部のプログラミング言語の標準定義で要求されています。[ 5 ] [ 6 ] Scheme 言語定義では、どの構文形式が末尾コンテキストで結果を取得できるかを指定することで、末尾位置の直感的な概念を正確に形式化しています。[ 7 ] 末尾呼び出しの排除により、同時に無制限の数の末尾呼び出しをアクティブにできる実装は、「適切に末尾再帰的」とも呼ばれる。[ 5 ]
スペース効率や実行効率に加えて、末尾呼び出しの排除は、継続渡しスタイル(CPS)として知られる関数型プログラミングのイディオムにおいて重要です。そうしないと、スタック領域がすぐに不足してしまうからです。
末尾呼び出しは、関数の構文上の終了直前に配置される。
int a ( int n ); int b ( int n );int foo ( int data ) { a ( data ); return b ( data ); }ここでは、a(data)と はどちらもb(data)呼び出しですが、はbプロシージャが戻る前に最後に実行されるものであり、したがって末尾に位置します。ただし、すべての末尾呼び出しが必ずしもサブルーチンの構文上の末尾に位置するとは限りません。
int c ( int n );int bar ( int data ) { if ( a ( data ) > 0 ) { return b ( data ); } return c ( data ); }bここでは、とへの呼び出しはどちらも末尾にあります。これは、最初の呼び出しが構文的にはの本体cの末尾ではないにもかかわらず、それぞれがif分岐の末尾にあるためです。bar
次の例を考えてみましょう。
int foo1 ( int data ) { return a ( data ) + 1 ; }int foo2 ( int data ) { int ret = a ( data ); return ret ; }int foo3 ( int data ) { int ret = a ( data ); return ( ret == 0 ) ? 1 : ret ; }の呼び出しはa(data)では末尾にありますfoo2が、やでは末尾にはありません。なぜなら、呼び出し元が戻り値を返す前に検査または変更できるように、制御は呼び出し元に戻る必要があるからです。foo1foo3
以下のプログラムはSchemeの例です: [ 8 ]
;; 階乗 : number -> number ;; n 以下のすべての正の整数の積を計算します。 ( define ( factorial n ) ( if ( = n 0 ) 1 ( * n ( factorial ( - n 1 )))))これは末尾再帰のスタイルで書かれていません。なぜなら、乗算関数("*"))が末尾にあるからです。これは以下と比較できます。
;; 階乗 : number -> number ;; n 以下のすべての正の整数の積を計算します。 ( define ( factorial n ) ( fact-iter 1 n )) ( define ( fact-iter product n ) ( if ( = n 0 ) product ( fact-iter ( * product n ) ( - n 1 ))))このプログラムは、適用順序評価を前提としています。内部プロシージャは、制御フローの最後にfact-iter自身を呼び出します。これにより、インタプリタまたはコンパイラは、通常次のような実行を再編成できます。[ 8 ]
階乗(4)を呼び出す call fact-iter (1 4) ファクトイターを呼び出す(4 3) call fact-iter (12 2) call fact-iter (24 1) 24を返す 24を返す 24を返す 24を返す 24を返す
空間と時間の両面において、より効率的な形態へと移行する。
階乗(4)を呼び出す call fact-iter (1 4) 引数を(4 3)に置き換える 引数を(12 2)に置き換えます 引数を(24 1)に置き換える 24を返す 24を返す
この再編成により、呼び出し関数のアドレス以外の状態をスタックまたはヒープに保存する必要がなくなり、fact-iter中間結果の格納に呼び出しスタックフレームが再利用されるため、スペースが節約されます。また、プログラマは極めて深い再帰処理でスタックやヒープの領域が不足する心配をする必要もありません。一般的な実装では、末尾再帰方式はもう一方の方式よりも大幅に高速になりますが、その差は定数倍程度です。
関数型言語を使用するプログラマーの中には、この機能を活用するために、再帰コードを末尾再帰に書き換える人がいます。そのためには、多くの場合、product関数に「アキュムレータ」引数(上記の例では)を追加する必要があります。
末尾再帰モジュロconsは、明示的に一度だけ設定される言語と見なされるPrologのコンパイルの文脈でDavid HD Warren [ 9 ]によって導入された末尾再帰最適化の一般化です。これは、 1974年にDaniel P. FriedmanとDavid S. Wise [ 10 ]によってLISPコンパイル技術として記述されました(ただし、名前は付けられませんでした) 。名前が示すように、これは再帰呼び出しの後に実行する必要がある唯一の操作が、そこから返されるリストの前に既知の値を追加すること(または一般的には、定数個の単純なデータ構築操作を実行すること)である場合に適用されます。したがって、この呼び出しは、前述のcons操作を除いて(「モジュロ」)末尾呼び出しになります。しかし、再帰呼び出しから出るときにリストの先頭に値をプレフィックスすることは、再帰呼び出しに入るときにこの値を成長中のリストの末尾に追加することと同じであり、暗黙のアキュムレータパラメータのように、副作用としてリストが構築されます。次のPrologの断片は、この概念を示しています。
したがって、末尾再帰変換では、このような呼び出しは、まず新しいリストノードを作成してそのfirstフィールドを設定し、次にノードのフィールドへのポインタを引数として末尾呼び出しを行い、再帰的に値を埋めるという処理に変換されます。遅延評価されるデータコンストラクタによって再帰が保護されてrestいる場合にも、同様の効果が得られます。これは、Haskellのような遅延評価プログラミング言語では自動的に実現されます。
以下のコードは、連結リストを複製するC言語の再帰関数を定義するものです(比較のために、同等のSchemeおよびPrologコードもコメントとして示しています)。
この形式では、関数は末尾再帰ではありません。なぜなら、再帰呼び出しが入力リストの残りの部分を複製した後、制御が呼び出し元に戻るからです。残りの部分を複製する前に先頭ノードを割り当てたとしても、呼び出し後に再帰呼び出しの結果をフィールドに挿入する必要がありますnext。[ a ] そのため、この関数はほぼ末尾再帰です。ウォーレンの方法は、フィールドを埋める責任を再帰呼び出し自体に押し付け、末尾呼び出しにします。[ b ]番兵先頭ノードを使用してコードを簡略化します。next
呼び出し元が返されるリストの先頭に要素を追加するのではなく、呼び出し先がリストの末尾に要素を追加するようになりました。処理は、再帰呼び出しが結果を返した後にリストの末尾から逆方向に処理するのではなく、再帰呼び出しの前にリストの先頭から順方向に処理され、その後再帰呼び出しがさらに処理を進めます。このように、これは累積パラメータ法に似ており、再帰的な計算を反復的な計算に変換します。
この手法の特徴として、実行コールスタック上に親フレームが作成され、末尾呼び出し最適化が有効になっている場合、末尾再帰呼び出し先はそれを自身のコールフレームとして再利用できる。
末尾再帰的な実装は、累積ループとして明示的に反復的な実装に変換できます。
1977年にシアトルで開催されたACM会議で発表された論文の中で、 Guy L. SteeleはGOTOと構造化プログラミングに関する議論を要約し、プロシージャの末尾位置でのプロシージャ呼び出しは、通常、不要なスタック操作を排除して、呼び出されたプロシージャへの直接的な制御の移譲として扱うのが最適であると指摘した。[ 2 ]このような「末尾呼び出し」は、プロシージャ呼び出しが遍在する言語であるLispでは非常に一般的であるため、この形式の最適化は、他の実装と比較してプロシージャ呼び出しのコストを大幅に削減する。Steeleは、不適切に実装されたプロシージャ呼び出しが、プロシージャ呼び出しと比較してGOTOが安価であるという人為的な認識につながったと主張した。Steeleはさらに、「一般に、プロシージャ呼び出しは、パラメータも渡すGOTO文として有用に考えることができ、[マシンコード] JUMP命令として統一的にコーディングできる」と主張し、マシンコードのスタック操作命令は「最適化とみなされる(逆ではない)」とした。[ 2 ]スティールは、Lisp のプロシージャ呼び出しのコストがはるかに低いため、Lisp で最適化された数値アルゴリズムは、当時入手可能だった商用 Fortran コンパイラによって生成されたコードよりも高速に実行できるという証拠を挙げた。スティールがジェラルド・ジェイ・サスマンと共に開発した Lisp の方言であるSchemeでは、末尾呼び出しの削除がどのインタプリタでも実装されることが保証されている。[ 11 ]
末尾再帰は、一部の高級言語、特に関数型言語や論理言語、 Lispファミリーのメンバーにとって重要です。これらの言語では、末尾再帰は反復を実装する最も一般的な方法(そして場合によっては唯一の方法)です。Schemeの言語仕様では、スタックを増大させないように末尾呼び出しを最適化する必要があります。Perlでは、関数名を受け取る「goto」ステートメントのバリアントを使用して、末尾呼び出しを明示的に行うことができます。goto &NAME;[ 12 ]
しかし、関数引数とローカル変数をコールスタックに格納する言語実装(多くの言語のデフォルト実装であり、少なくともx86などのハードウェアスタックを備えたシステムではそうである)の場合、一般的な末尾呼び出し最適化(相互末尾再帰を含む)の実装には問題があります。呼び出し先の起動レコードのサイズが呼び出し元のサイズと異なる場合、スタックフレームの追加クリーンアップまたはサイズ変更が必要になる可能性があります。このような場合、末尾再帰の最適化は容易ですが、一般的な末尾呼び出し最適化を効率的に実装するのはより困難になる可能性があります。
例えば、Java仮想マシン(JVM)では、末尾再帰呼び出しは(既存の呼び出しスタックを再利用するため)排除できますが、一般的な末尾呼び出しは(呼び出しスタックを変更するため)排除できません。[ 13 ] [ 14 ]その結果、 JVMをターゲットとするScalaなどの関数型言語は、直接末尾再帰を効率的に実装できますが、相互末尾再帰は実装できません。
GCC 、LLVM/Clang、およびIntelコンパイラスイートは、より高い最適化レベルまたはオプションが渡された場合に、 C言語やその他の言語に対して末尾呼び出し最適化を実行します。 [ 15 ] [ 16 ] [ 17 ]指定された言語構文が明示的にサポートしていない場合でも、呼び出し元と呼び出し先の戻り値の型が同等であり、両方の関数に渡される引数の型が同じか、呼び出しスタック上で同じ量の記憶領域を必要とすると判断できる場合はいつでも、コンパイラはこの最適化を実行できます。[ 18 ]-foptimize-sibling-calls
様々な実装方法が利用可能です。
関数型プログラミング言語や論理プログラミング言語のインタプリタやコンパイラは、末尾呼び出しをより効率的な反復形式に最適化することがよくあります。たとえば、Schemeプログラマは、while ループを末尾位置のプロシージャ呼び出しとして表現し、Scheme コンパイラまたはインタプリタが末尾呼び出しをより効率的なジャンプ命令に置き換えることを期待します。[ 19 ]
アセンブリを直接生成するコンパイラの場合、末尾呼び出しの削除は簡単です。スタック上のパラメータを固定した後、呼び出しオペコードをジャンプオペコードに置き換えるだけで済みます。コンパイラの観点から見ると、上記の最初の例は最初に擬似アセンブリ言語に変換されます(実際には、これは有効なx86アセンブリです)。
foo: baz を呼び出すbar を呼び出すret末尾呼び出し除去により、最後の2行が1つのジャンプ命令に置き換えられます。
foo: bazを呼び出し、 bar にジャンプしますサブルーチンが完了すると、不要なステートメントを省略して、bar直接 の戻りアドレスに戻ります。fooret
通常、呼び出されるサブルーチンにはパラメータを渡す必要があります。したがって、生成されたコードは、末尾呼び出しサブルーチンにジャンプする前に、呼び出しフレームが適切に設定されていることを確認する必要があります。たとえば、呼び出しスタックにリターンアドレスだけでなくサブルーチンのパラメータも含まれるプラットフォームでは、コンパイラは呼び出しスタックを調整するための命令を出力する必要がある場合があります。そのようなプラットフォームでは、次のコードの場合:bar
int foo ( int a , int b ) { baz ( a ); return bar ( b ); }ここでa、およびbはパラメータであり、コンパイラはそれを次のように翻訳する可能性があります。[ c ]
フー:mov reg ,[ sp + a ] ; スタック (sp) パラメータから a をフェッチしてスクラッチレジスタに格納します。push reg ; baz が期待する場所に a をスタックに置くbazを呼び出す。baz はpop ; スタックからaを削除mov reg ,[ sp + b ] ; スタック (sp) パラメータから b をフェッチしてスクラッチ レジスタに格納します。push reg ; b を、bar が期待する場所にスタックに配置するバーを呼び出す; Aはbを使用するpop ; スタックからbを削除します。退役末尾呼び出し最適化ツールは、コードを次のように変更することができます。
フー:mov reg ,[ sp + a ] ; スタック (sp) パラメータからデータ 1 をスクラッチ レジスタにフェッチします。push reg ; baz が期待する場所に a をスタックに置くbazを呼び出す。baz はpop ; スタックからaを削除mov reg ,[ sp + b ] ; スタック (sp) パラメータから b をフェッチしてスクラッチ レジスタに格納します。mov [ sp + a ], reg ; b を bar が期待する場所に配置するjmp bar ; bar は b を使用し、すぐに呼び出し元に戻ります。このコードは、実行速度とスタック領域の使用効率の両面において、より効率的です。
コンパイラの観点から見ると、純粋な末尾呼び出しは再帰関数で最も顕著に現れます。単一のパラメータを受け取り、データを処理する最終アクションとして自身を呼び出す関数の擬似アセンブリの例を考えてみましょう。
int foo ( int x ) { if ( data == 0 ) { return data ; } return foo ( data - 1 ); }最適化されていないコンパイラはこれを標準的な呼び出しシーケンスに変換し、再帰ごとに新しいフレームをスタックにプッシュします。
foo: mov reg ,[ sp + x ] ; スタックパラメータからデータを取得cmp reg , 0 ; 基本ケースチェックje end dec reg ; x を変更push reg ; 次の呼び出しのために新しい x をスタックにプッシュcall foo ; 再帰呼び出し (スタックが拡張されます) pop ; 戻り後にスタックをクリーンアップend: ret末尾呼び出し最適化ツールは、呼び出し後に現在のスタックフレームが不要になったことを認識します。コードを変更して、引数をその場で破壊的に更新し、ジャンプして、スタックを厳密に制限します。空間:
foo: mov reg ,[ sp + x ] ; スタックパラメータから x を取得cmp reg , 0 ; 基本ケースチェックje end dec reg ; x を変更mov [ sp + x ], reg ; 既存のスタックパラメータを破壊的に更新jmp foo ; 先頭に直接ジャンプ (スタックは境界が維持されます) end: retこの最適化されたコードは、物理的には命令型whileループと同一であり、厳密に制限されたメモリと最大限の速度で実行されます。
ベアメタル環境や形式オートマトン理論において、純粋な末尾呼び出しは主にその空間計算量によって定義されます。純粋な末尾呼び出しは、再帰中にスタック空間が厳密に制限されている場合に発生します。スタックポインタが再帰の深さに比例して増加しないことを保証することで、末尾呼び出しは、マイクロカーネルやブートセクタなどの厳密な物理メモリ制約内で、無限に深い再帰評価を実行できるようにします。これにより、呼び出しスタックは物理的に制限された状態機械へと変換されます。
再帰を伴わない末尾呼び出しは、物理的にはアセンブリJMP命令と同等です。この特性により、末尾呼び出しは高性能コルーチンを実装するための基本的なプリミティブとなっています。従来のループ処理を次の状態への直接ジャンプに置き換えることでCALL、RET実行エンジンは一定のスタック領域内で異なる機能ユニット間で制御を「引き渡す」ことができます。このメカニズムは継続渡しスタイルの中核を成すもので、プログラムは決して戻り値を返すことなく、一連の末尾呼び出しを実行して協調的な状態間を遷移します。
多くのSchemeコンパイラは中間ターゲットコードとしてCを使用するため、Cコンパイラが末尾呼び出しを最適化しない場合でも、スタックを拡張せずに末尾再帰をCでエンコードする必要があります。多くの実装では、関数を繰り返し呼び出すコードであるトランポリンと呼ばれるデバイスを使用することでこれを実現しています。すべての関数はトランポリンを介して呼び出されます。関数が別の関数を末尾呼び出す必要がある場合、直接呼び出して結果を返すのではなく、呼び出す関数のアドレスと呼び出しパラメータを(自身が呼び出された)トランポリンに返します。トランポリンは、指定されたパラメータを使用して次にこの関数を呼び出す処理を行います。これにより、Cスタックが拡張されず、反復処理を無限に続けることができます。
Groovy、Visual Basic .NET、C#などの高階関数をサポートする言語では、高階関数を使用してトランポリンを実装することが可能です。[ 20 ]
すべての関数呼び出しにトランポリンを使用することは、通常の C 関数呼び出しよりもかなりコストがかかるため、少なくとも 1 つの Scheme コンパイラであるChicken は、 Andrew Appelの未発表の提案からHenry Bakerが最初に説明した手法 [ 21 ] を使用しています。この手法では、通常の C呼び出しが使用されますが、呼び出しごとにスタック サイズがチェックされます。スタックが許容される最大サイズに達すると、スタック上のオブジェクトは、すべての生存データを別のヒープに移動することによって、 Cheney アルゴリズムを使用してガベージ コレクションされます。その後、スタックは巻き戻され (「ポップ」)、プログラムはガベージ コレクションの直前に保存された状態から再開されます。Baker は、「Appel の方法は、時折エンパイア ステート ビルから飛び降りることで、多数の小さなトランポリン バウンドを回避する」と述べています。[ 21 ]ガベージ コレクションにより、相互末尾再帰が無限に継続できることが保証されます。ただし、このアプローチでは、呼び出し元のスタック フレームがまだ存在するという保証がないため、C 関数呼び出しが決して戻ってこないことが求められます。したがって、プログラムコードの内部的な書き換えがはるかに劇的になります。継続渡しスタイルです。
while声明との関連性末尾再帰は、例えば明示的な反復であるwhile文と関連付けることができます。
手続きfoo( x ) もしp ( x ) ならばbar( x ) を返すそうでなければfoo(baz( x ))を返す
の中へ
手続きfoo( x ) while true if p ( x ) return bar( x ) else x ← baz( x )
ここで、 x は複数の変数を含むタプルである可能性があります。その場合、依存関係が尊重されるように、代入文x ← baz( x ) の実装には注意が必要です。補助変数を導入するか、スワップ構造を使用する必要があるかもしれません。
より一般的には、
手続きfoo( x ) もしp ( x ) ならばbar( x ) を返す。そうでなければ もしq ( x ) ならばbaz( x )を返す。 ... else if r ( x ) return foo(qux( x )) ... そうでなければ、 foo(quux( x ))を返す
変換できる
手続きfoo( x ) while true if p ( x ) return bar( x ) else if q ( x ) return baz( x ) ... else if r ( x ) x ← qux( x ) ... それ以外の場合はx ← quux( x )
例えば、このJuliaプログラムは、末尾再帰を用いないfactorial階乗の定義を示しています。
関数factorial ( n :: Integer ) :: Integern == 0 の場合1を返すそれ以外return n * factorial ( n - 1 )終わり終わり確かに、n * factorial(n - 1)はへの呼び出しをラップします。しかし、アキュムレータと呼ばれるfactorial引数を追加することで、末尾再帰定義に変換できます。[ 8 ]a
factorialこのJuliaプログラムは、階乗の末尾再帰的な定義を与えます。
function factorial ( n :: Integer , a :: Integer ) :: Integer if n == 0 : return a else return factorial ( n - 1 , n * a ) end endfunction factorial ( n :: Integer ) :: Integer return factorial ( n , 1 ) endfact_iterこのJuliaプログラムは、階乗の反復的な定義を示します。
function fact_iter ( n :: Integer , a :: Integer ) :: Integer while n > 0 a = n * a n = n - 1 end return a endfunction factorial ( n :: Integer ) :: Integer return fact_iter ( n , one ( n )) endrecur特別な形式があります。[ 22 ]tailrec関数の修飾子があります[ 30 ]tailcallgoto &NAME;[ 34 ]tailcall()R.4.4.0で導入された関数です[ 41 ]@tailrecオプションで注釈を付けることも可能です。注釈を付けると、関数が末尾再帰でない場合はコンパイルエラーになります[ 45 ]。tailcallコマンド[ 49 ]があります。if ( ls ) { head = ( LinkedList * ) malloc ( sizeof ( * head )); head- > value = ls- > value ; head- > next = duplicate ( ls- > next ); }if ( ls ) { head = ( LinkedList * ) malloc ( sizeof ( * head )); head- > value = ls- > value ; duplicate ( ls- > next , & ( head- > next )); }call命令は、まず現在のコード位置をスタックにプッシュし、次にラベルで示されたコード位置へ無条件ジャンプを実行します。 このret命令は、まずスタックからコード位置をポップし、次に取得したコード位置へ無条件ジャンプを実行します。