web-dev-qa-db-ja.com

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

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

74
ConditionRacer

コールスタックの(やや)人気のある代替手段は 継続 です。

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

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

@ rwongが指摘 のように、継続渡しスタイルは呼び出しスタックの代替手段です。継続渡しスタイルで記述された(または変換された)プログラムは決して戻りません。そのため、スタックは必要ありません。

別の観点からの質問への回答:スタックフレームをヒープに割り当てることにより、個別のスタックがなくても呼び出しスタックを作成できます。一部のLISPおよびSchemeの実装はこれを行います。

49
Jörg W Mittag

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

プロセッサがシングルレベル、マルチレベルのキャッシュで進歩するにつれて、スタックの重要な利点の1つはキャッシュの局所性です。スタックの最上位は、ほとんどの場合キャッシュにあります。キャッシュヒット率が高い何かを実行できるときはいつでも、最新のプロセッサで正しい方向に進んでいます。スタックに適用されるキャッシュは、ローカル変数、パラメーターなどがほとんど常にキャッシュ内にあり、最高レベルのパフォーマンスを享受することを意味します。

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

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

(私たちはデータ構造の可変性を作品に投げ込むかもしれません...)

これは、他のプログラミングモデルが機能しないことを意味しません。特に、それらが現在のハードウェアの順次命令およびコールスタックモデルに変換できる場合は特にそうです。ただし、ハードウェアが存在する場所をサポートするモデルには明確な利点があります。ただし、状況は常に同じであるとは限らないため、異なるメモリおよびトランジスタテクノロジにより並列処理が可能になるため、将来的に変化が見られる可能性があります。これは、プログラミング言語とハードウェア機能の間の常に妨げになるため、わかります。

35
Erik Eidt

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; 
}

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


14
rwong

いいえ、必ずしもそうとは限りません。

Appelの古い論文を読んでください ガベージコレクションはスタック割り当てよりも高速です継続渡しスタイル を使用し、スタックレス実装を示します。

また、古いコンピュータアーキテクチャ(例: IBM/36 )にはハードウェアスタックレジスタがありませんでした。しかし、OSとコンパイラはconventioncalling Conventions に関連)によってスタックポインタ用のレジスタを予約したため、ソフトウェア コールスタック

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

最近では、プロセッサーは CPUキャッシュ を認識するコールスタック(および呼び出しと戻りのマシン命令)も持っています。

11

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

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

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

_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など)は論理的にこの手法を使用しています。

10
Eric Lippert

1972年にパルナスが システムをモジュールに分解する際に使用する基準について の発表以来、ソフトウェアに情報を隠すことは適切であると合理的に認められてきました。事。これは、構造分解とモジュール式プログラミングに関する60年代の長い議論の後に続きます。

モジュール性

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

動的スコープ

字句スコープが動的な動作を追跡するのに不十分であるとすぐに、違いを追跡するために実行時の簿記が必要になります。

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

例外

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

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

rwong で識別される無限ループ構造は、多くの一般的なプログラミング構造を許可しないが、アプリケーション全体を重要な情報が隠れていないホワイトボックス。

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

3
Pekka

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

これは、コンパイル時に確立された固定メモリロケーションにすべてを格納でき、プロセスがスタックで実行されないことが100%保証されるため、スタックよりもはるかに信頼性が高くなります。今日、私たちがしなければならない「1MBを割り当てて指を交差させる」ことはありません。

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

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

2