スタックはプログラムを構造化する唯一の合理的な方法ですか?


74

私が見たほとんどのアーキテクチャは、関数呼び出しの前にコンテキストを保存/復元するために呼び出しスタックに依存しています。プッシュおよびポップ操作がほとんどのプロセッサに組み込まれているのは非常に一般的なパラダイムです。スタックなしで動作するシステムはありますか?もしそうなら、それらはどのように機能し、何のために使用されますか?


5
関数はCのような言語で動作することが期待されているかを考えると(つまり、あなたは巣は、あなたが好きなように深い呼び出すことができ、バックアウト逆の順序で返すことができます)、それは1つが他にどのように私にははっきりしていない可能性の機能を実装し、それは信じられないほどされずに呼び出し、非効率的な。たとえば、プログラマに継続渡しスタイルやその他の奇妙なプログラミングを強制することもできますが、何らかの理由で実際にCPSを低レベルで動作させる人はいないようです。
ケビン

5
GLSLはスタックなしで動作します(その特定のブラケット内の他の言語も同様です)。単に再帰呼び出しを禁止します。
ルーシェンコ

3
また、一部のRISCアーキテクチャで使用される[ 登録]ウィンドウを調べることもできます。
マークブース

2
@Kevin:「初期のFORTRANコンパイラは、サブルーチンでの再帰をサポートしていませんでした。初期のコンピューターアーキテクチャは、スタックの概念をサポートしていませんでした。 Fortran 77では指定されていませんが、多くのF77コンパイラはオプションとして再帰をサポートしていましたが、Fortran 90では標準になりました。en.wikipedia.org/wiki/Fortran#FORTRAN_II
Mooing Duck

3
P8X32A( "プロペラ")マイクロコントローラーには、標準アセンブリ言語(PASM)のスタックという概念はありません。ジャンプを担当する命令は、RAM内の復帰命令を自己修正して、復帰先を決定します。これは任意に選択できます。興味深いことに、「スピン」言語(同じチップ上で実行される解釈された高レベル言語)には、従来のスタックセマンティクスがあります。
Wossname

回答:


50

呼び出しスタックの(ある程度)人気のある代替手段は、継続です。

たとえば、Parrot VMは継続ベースです。データは完全にスタックレスです:データはレジスタに保持され(DalvikやLuaVMなど、Parrotはレジスタベース)、制御フローは継続で表されます(コールスタックを持つDalvikやLuaVMとは異なります)。

SmalltalkおよびLisp VMで一般的に使用されるもう1つの一般的なデータ構造は、スパゲッティスタックです。これは、スタックのネットワークのようなものです。

以下のよう@rwongが指摘し、継続渡しスタイルは、コールスタックに代わるものです。継続渡しスタイルで記述された(または変換された)プログラムは返されないため、スタックは必要ありません。

別の観点から質問に答える:スタックフレームをヒープに割り当てることにより、個別のスタックを持たずにコールスタックを持つことができます。一部のLispおよびScheme実装はこれを行います。


4
スタックの定義に依存します。スタックフレームのリンクリスト(またはポインタの配列または...)が「スタックではなく」「スタックの異なる表現」であるかどうかはわかりません。CPS言語のプログラム(実際)は、スタックに非常に似ている継続の効果的にリンクされたリストを作成する傾向があります(そうでない場合は、GHCをチェックして、「継続」と呼ばれるものを線形スタックにプッシュします)効率のため)。
ジョナサンキャスト

6
継続渡しスタイルで記述された(または変換された)プログラムは決して返されません」...不吉に聞こえます。
ロブペンリッジ

5
@RobPenridge:ちょっとわかりにくいです、私は同意します。CPSは、関数が戻る代わりに、関数が追加の引数として、作業が完了したときに呼び出す別の関数を取ることを意味します。したがって、関数を呼び出し、関数を呼び出した後に実行する必要がある他の作業がある場合、関数が戻って作業を続行するのを待つのではなく、残りの作業をラップします(「継続」 )を関数に追加し、その関数を追加の引数として渡します。呼び出した関数は、戻るなどの代わりにその関数を呼び出します。機能なしでは今までそれだけで、返さない
イェルクWミッターク

3
…次の関数を呼び出します。したがって、呼び出しスタックは必要ありません。以前に呼び出された関数のバインディング状態に戻って復元する必要がないためです。あなたがそれに戻ることができるように過去の状態を持ち歩く代わりに、もし望むなら、あなたは未来の状態を持ち歩く。
ヨルグWミットタグ

1
@jcast:スタックの定義機能はIMOであり、最上位の要素にのみアクセスできます。継続のリストであるOTOHを使用すると、最上位のスタックフレームだけでなく、すべての継続にアクセスできます。たとえば、Smalltalkスタイルの再開可能な例外がある場合、ポップすることなくスタックを走査できる必要があります。そして、コールスタックのなじみのあるアイデアを維持したいまま言語を継続すると、スパゲッティスタックにつながります。これは基本的に、継続がスタックを「フォーク」するスタックツリーです。
ヨルグWミットタグ

36

昔は、プロセッサにはスタック命令がなく、プログラミング言語は再帰をサポートしていませんでした。時間の経過とともに、再帰をサポートする言語が増え、スタックフレーム割り当て機能を備えたハードウェアスイートが続きます。このサポートは、プロセッサーが異なると、長年にわたって大きく異なります。一部のプロセッサは、スタックフレームおよび/またはスタックポインタレジスタを採用しています。いくつかの命令は、単一の命令でスタックフレームの割り当てを達成する命令を採用しました。

プロセッサが単一レベル、さらに複数レベルのキャッシュで進化するにつれて、スタックの重要な利点の1つはキャッシュの局所性です。スタックの最上部はほとんど常にキャッシュにあります。キャッシュヒット率の高い処理を実行できる場合はいつでも、最新のプロセッサを使用して正しい軌道に乗っています。スタックに適用されるキャッシュは、ローカル変数、パラメーターなどがほとんど常にキャッシュにあり、最高レベルのパフォーマンスを享受することを意味します。

つまり、スタックの使用はハードウェアとソフトウェアの両方で進化しました。他のモデルもあります(たとえば、データフローコンピューティングは長期間試行されました)が、スタックの局所性により、非常にうまく機能します。さらに、手続き型コードは、パフォーマンスのためにプロセッサが必要とするものです。1つの命令が次の命令を実行するよう指示します。命令の順序が線形ではない場合、ランダムアクセスをシーケンシャルアクセスほど高速にする方法が分からないため、プロセッサは少なくともまだ遅くなります。(ところで、キャッシュ、メインメモリ、ディスクなど、各メモリレベルで同様の問題があります...)

シーケンシャルアクセス命令の実証されたパフォーマンスとコールスタックの有益なキャッシュ動作との間には、少なくとも現時点では、優れたパフォーマンスモデルがあります。

(データ構造の可変性も同様に機能する可能性があります...)

これは、他のプログラミングモデルが機能しないことを意味するものではありません。特に、今日のハードウェアのシーケンシャル命令と呼び出しスタックモデルに変換できる場合はそうです。しかし、ハードウェアがどこにあるかをサポートするモデルには明確な利点があります。ただし、物事は常に同じであるとは限りません。そのため、異なるメモリおよびトランジスタテクノロジーにより多くの並列処理が可能になるため、将来的に変化が見られる可能性があります。それは、プログラミング言語とハードウェア機能との間のつまらないものです。


9
実際、GPUにはまだスタックがありません。GLSL / SPIR-V / OpenCLで再帰することは禁じられています(HLSLについてはわかりませんが、おそらく異なる理由はわかりません)。関数呼び出しの「スタック」を実際に処理する方法は、非常に多くのレジスタを使用することです。
LinearZoetrope

@Jsor:これは、SPARCアーキテクチャからわかるように、実装の詳細です。GPUのように、SPARCには巨大なレジスタセットがありますが、ラップアラウンドで非常に古いレジスタをRAMのスタックにこぼすスライディングウィンドウがあるという点で独特です。つまり、2つのモデルのハイブリッドです。また、SPARCは、物理レジスタの数、レジスタウィンドウの大きさを正確に指定していなかったため、さまざまな実装は、「関数の呼び出しごとに、1つのウィンドウに十分な」レジスタの規模でどこにでも存在できますスタックに直接こぼれる」
-MSalters

呼び出しスタックモデルの欠点は、ヒープの任意のビットが実行可能であれば、悪用としての自己修正プログラムが可能になるため、配列やアドレスオーバーフローを非常に注意深く監視する必要があることです。
ベンペン

14

TL; DR

  • 関数呼び出しメカニズムとしての呼び出しスタック:
    1. 通常はハードウェアによってシミュレートされますが、ハードウェアの構築の基本ではありません
    2. 命令型プログラミングの基本
    3. 関数型プログラミングの基本ではない
  • 「後入れ先出し」(LIFO)の抽象化としてのスタックは、コンピューターサイエンス、アルゴリズム、さらには非技術的な領域の基礎です。
  • 呼び出しスタックを使用しないプログラム編成の例:
    • 継続渡しスタイル(CPS)
    • ステートマシン-すべてがインライン化された巨大なループ。(Saab Gripenファームウェアアーキテクチャに触発され、Henry Spencerによる通信に起因し、John Carmackによって複製されたとされています。) (注#1)
    • データフローアーキテクチャ-キュー(FIFO)で接続されたアクターのネットワーク。キューは、チャネルと呼ばれることもあります。

この回答の残りの部分は、考えや逸話のランダムなコレクションであり、そのため多少混乱しています。


(関数呼び出しメカニズムとして)説明したスタックは、命令型プログラミングに固有のものです。

命令型プログラミングの下には、マシンコードがあります。マシンコードは、命令の小さなシーケンスを実行することにより、コールスタックをエミュレートできます。

マシンコードの下には、ソフトウェアの実行を担当するハードウェアがあります。現代のマイクロプロセッサはここで説明するには複雑すぎますが、非常にシンプルな設計が存在し、遅いが同じマシンコードを実行できることが想像できます。このようなシンプルなデザインは、デジタルロジックの基本要素を利用します。

  1. 組み合わせロジック、つまりロジックゲートの接続(および、または、...)「組み合わせロジック」はフィードバックを除外することに注意してください。
  2. メモリ、すなわちフリップフロップ、ラッチ、レジスタ、SRAM、DRAMなど
  3. 残りのハードウェアを管理する「コントローラー」を実装できるように、組み合わせロジックとメモリで構成される状態マシン。

以下の議論には、命令型プログラムを構築する別の方法の例がたくさん含まれていました。

プログラムなどの構造は次のようになります。

void main(void)
{
    do
    {
        // validate inputs for task 1
        // execute task 1, inlined, 
        // must complete in a deterministically short amount of time
        // and limited to a statically allocated amount of memory
        // ...
        // validate inputs for task 2
        // execute task 2, inlined
        // ...
        // validate inputs for task N
        // execute task N, inlined
    }
    while (true);
    // if this line is reached, tell the programmers to prepare
    // themselves to appear before an accident investigation board.
    return 0; 
}

このスタイルは、マイクロコントローラー、つまりソフトウェアをハードウェアの機能のコンパニオンと見なす人に適しています。



@Peteris:スタックはLIFOデータ構造です。
クリストファー・クロイツィヒ

1
面白い。私はそれを逆に考えたでしょう。たとえば、FORTRANは命令型プログラミング言語であり、初期バージョンでは呼び出しスタックを使用していませんでした。ただし、再帰は関数型プログラミングの基本であり、スタックを使用せずに一般的な場合に再帰を実装できるとは考えていません。
TED

@TED-関数型言語の実装では、保留中の計算を表すスタック(または通常はツリー)データ構造がありますが、マシンのスタック指向のアドレス指定モードを使用する命令や呼び出し/戻り命令でさえ、必ずしも実行する必要はありません(ネストされた/再帰的な方法で-おそらくステートマシンループの一部として)。
-davidbak

@davidbak-IIRC、再帰的アルゴリズムは、スタックを取り除くことができるようにするために、ほとんど再帰的でなければなりません。おそらく最適化できる他のケースもいくつかありますが、一般的なケースでは、スタックが必要です。実際、私はどこかにこの浮動の数学的な証拠があると言われました。この答えは、それが教会チューリングの定理であると主張しています(チューリングマシンがスタックを使用しているという事実に基づいていると思いますか)
TED

1
@TED-あなたに同意します。ここでの誤解は、OPの投稿を読んで、マシンアーキテクチャを意味するシステムアーキテクチャについて話していることだと思います。ここで回答した他の人も同じ理解を持っていると思います。そのため、コンテキストであることを理解ている私たちは、機械語命令/アドレス指定モードのレベルでスタックを必要としないと答えて答えました。しかし、この質問は、単に言語システムが一般的に呼び出しスタックを必要とするという意味に解釈することもできます。その答えノーですが、さまざまな理由があります。
-davidbak

11

いいえ、必ずしもそうではありません。

Appelの古い論文のGarbage Collectionを読むと、Stack Allocationよりも速くなることがあります継続渡しスタイルを使用し、スタックレス実装を示しています。

また、古いコンピューターアーキテクチャ(IBM / 360など)にはハードウェアスタックレジスタがなかったことにも注意してください。しかし、OSやコンパイラは、スタックポインタ用レジスタを予約大会(に関連する規則を呼び出し、彼らはソフトウェアの持つことができるように)コールスタックを

原則として、プログラム全体のCコンパイラとオプティマイザは、呼び出しグラフが静的に認識され、再帰(または関数ポインタ)なしのケース(組み込みシステムにやや一般的)を検出できます。このようなシステムでは、各関数は戻りアドレスを固定された静的な場所に保持できます(それが1970年時代のコンピューターでのFortran77の動作でした)。

最近では、プロセッサにはCPUキャッシュを認識するコールスタック(およびコール&リターンマシン命令)もあります


1
FORTRAN-66が出たとのサポートを必要なときにかなり確信してFORTRANは、静的なリターンの場所を使用して停止SUBROUTINEしてFUNCTION。ただし、以前のバージョン(FORTRAN-IVおよび場合によってはWATFIV)については正しいです。
TMN

そしてもちろん、COBOL。IBM / 360の優れた点-ハードウェアスタックアドレッシングモードが欠落していても、非常に多くの使用が得られました。(R14、そうだったと思いますか?)そして、PL / I、Ada、Algol、Cなどのスタックベースの言語用のコンパイラがありました。
davidbak

確かに、私は大学で360を勉強しましたが、最初は戸惑っていました。
JDługosz

1
@JDługosz360を検討するコンピュータアーキテクチャの近代的な学生のための最善の方法は、複数の命令フォーマット...など、いくつかの異常とはいえ...非常に単純なRISCマシンとしてあるTRTRT
-davidbak

レジスタを移動するための「ゼロと追加パック」はどうですか?しかし、リターンアドレスのスタックではなく「ブランチアンドリンク」が復活しました。
JDługosz

10

これまでにいくつかの良い答えがあります。スタックや「制御フロー」という概念をまったく使わずに言語を設計する方法の、実用的ではないが非常に教育的な例を挙げましょう。階乗を決定するプログラムは次のとおりです。

function f(i) => if i == 0 then 1 else i * f(i - 1)
let x = f(3)

このプログラムを文字列に入れ、テキスト置換によってプログラムを評価します。したがって、評価するときf(3)、次のように検索を実行し、i for 3に置き換えます。

function f(i) => if i == 0 then 1 else i * f(i - 1)
let x = if 3 == 0 then 1 else 3 * f(3 - 1)

すばらしいです。次に、別のテキスト置換を実行します。「if」の条件がfalseであることがわかり、別の文字列置換を実行して、プログラムを生成します。

function f(i) => if i == 0 then 1 else i * f(i - 1)
let x = 3 * f(3 - 1)

ここで、定数を含むすべての部分式で別の文字列置換を実行します。

function f(i) => if i == 0 then 1 else i * f(i - 1)
let x = 3 * f(2)

そして、あなたはこれがどうなるかを見る。これ以上のことは言いません。私たちが着くまで、一連の文字列置換を続けてlet x = 6、完了することができました。

私たちは伝統的にローカル変数と継続情報のためにスタックを使用します。覚えておいて、スタックはあなたがどこから来たのかを教えてくれず、その戻り値を手に入れて次にどこに行くのかを教えてくれます。

プログラミングの文字列置換モデルでは、スタックに「ローカル変数」はありません。関数が引数に適用されると、スタック上のルックアップテーブルに入れられるのではなく、仮パラメーターが値に置き換えられます。また、プログラムの評価は単純に文字列置換のルールを適用して、異なるが同等のプログラムを生成するため、「次へ進む」ことはありません。

もちろん、実際に文字列の置換を行うことはおそらく進むべき道ではありません。しかし、「等式推論」をサポートするプログラミング言語(Haskellなど)は、論理的にこの手法を使用しています。


3
Retinaは、計算に文字列操作を使用するRegexベースのプログラミング言語の例です。
アンドリューピリザー

2
@AndrewPiliser このクールな男によって設計および実装されています

3

システムをモジュールに分解する際に使用される基準に関する1972年のParnasの出版以来、ソフトウェアに隠された情報は良いことであると合理的に受け入れられてきました。これは、構造分解とモジュール式プログラミングに関する60年代の長い議論の結果です。

モジュール性

マルチスレッドシステムの異なるグループによって実装されるモジュール間のブラックボックス関係の必要な結果には、再入可能性を許可するメカニズムと、システムの動的なコールグラフを追跡する手段が必要です。制御された実行フローは、複数のモジュールに出入りする必要があります。

動的スコープ

動的な動作を追跡するには語彙スコープが不十分であるとすぐに、違いを追跡するために実行時のブックキーピングが必要になります。

(定義上)スレッドに現在の命令ポインターが1つしかない場合、LIFOスタックは各呼び出しを追跡するのに適しています。

例外

したがって、継続モデルはスタックのデータ構造を明示的に保持していませんが、どこかに保持する必要があるモジュールのネストされた呼び出しがまだあります!

宣言型言語でさえ、評価履歴を維持するか、逆にパフォーマンス上の理由で実行計画をフラット化し、他の方法で進捗を維持します。

rwongによって識別される無限ループ構造は、多くの一般的なプログラミング構造を許可しない静的スケジューリングを備えた高信頼性アプリケーションで一般的ですが、重要な情報を隠さないホワイトボックスと見なす必要があります。

複数の同時エンドレスループは、関数を呼び出さないため、リターンアドレスを保持する構造を必要としません。共有変数を使用して通信する場合、これらは簡単に旧式のFortranスタイルのリターンアドレス類似物に退化する可能性があります。


1
あなたは「想定して隅に身を描く任意のマルチスレッドシステムを」。結合された有限状態マシンの実装には複数のスレッドが含まれる場合がありますが、LIFOスタックは必要ありません。FSMには、LIFOの順序ではなく、以前の状態に戻るという制限はありません。だから、それは本当のマルチスレッドシステムであり、それが成り立たない。また、「並列独立関数呼び出しスタック」としてのマルチスレッドの定義に限定すると、循環定義になります。
MSalters

私はそのように質問を読みません。OPは関数呼び出しに精通していますが、他のシステムについて尋ねます。
MSalters

@MSalters同時エンドレスループを組み込むために更新されました。モデルは有効ですが、スケーラビリティが制限されます。中程度の状態のマシンであっても、コードの再利用を許可するために関数呼び出しを組み込むことをお勧めします。
ペッカ

2

すべての古いメインフレーム(IBM System / 360)には、スタックという概念がまったくありませんでした。たとえば、260では、パラメーターはメモリ内の固定位置に構築され、サブルーチンが呼び出されると、R1パラメーターブロックを指しR14、リターンアドレスを含む状態で呼び出されました。呼び出されたルーチンは、別のサブルーチンを呼び出したい場合R14、その呼び出しを行う前に既知の場所に保存する必要があります。

これは、スタックよりもはるかに信頼性が高いため、すべてはコンパイル時に確立された固定メモリの場所に保存でき、プロセスがスタックを使い果たすことがないことを100%保証できます。最近私たちがしなければならない「1MBを割り当てて指を交差させる」ことはありません。

キーワードを指定することにより、PL / Iで再帰的なサブルーチン呼び出しが許可されましたRECURSIVE。つまり、サブルーチンで使用されるメモリは、静的に割り当てられるのではなく、動的に割り当てられます。しかし、再帰呼び出しは現在と同じくらいまれでした。

スタックレス操作により、大規模なマルチスレッド化がはるかに簡単になります。そのため、現代の言語をストークレスにしようとすることがよくあります。たとえば、スタックではなく動的に割り当てられたメモリを使用するようにC ++コンパイラをバックエンドで変更できなかった理由など、まったく理由はありません。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.