スタックレス言語について聞いたことがあります。しかし、そのような言語がどのように実装されるのか、私にはわかりません。誰かが説明できますか?
私たちが持っている最新のオペレーティングシステム(Windows、Linux)は、私が「ビッグスタックモデル」と呼んでいるもので動作します。そして、そのモデルは時々間違っており、「スタックレス」言語の必要性を動機付けています。
「ビッグスタックモデル」は、コンパイルされたプログラムが、マシン命令を使用してスタックポインタ(およびオプションのスタックフレームポインタ)を含むレジスタを非常に迅速に調整し、メモリの連続領域で関数呼び出しに「スタックフレーム」を割り当てることを前提としています。これにより、スタックの領域が大きく連続しているという代償を払って、関数の呼び出し/戻りが高速になります。これらの最新のOSで実行されるすべてのプログラムの99.99%は、ビッグスタックモデルで適切に機能するため、コンパイラ、ローダー、さらにはOSでさえこのスタック領域を「認識」しています。
このようなすべてのアプリケーションに共通する問題の1つは、「スタックの大きさはどれくらいですか?」です。メモリが非常に安価であるため、ほとんどの場合、スタック用に大きなチャンクが確保され(MSのデフォルトは1Mb)、通常のアプリケーション呼び出し構造がそれを使い果たすことはありません。しかし、アプリケーションがそれをすべて使い果たした場合、スタックの最後に到達するために、不正なメモリ参照(「申し訳ありませんが、デイブ、それはできません」)で停止します。
ほとんどのいわゆる「スタックレス」言語は、実際にはスタックレスではありません。これらのシステムが提供する連続スタックを使用しないだけです。代わりに、各関数呼び出しでヒープからスタックフレームを割り当てます。関数呼び出しあたりのコストは多少上昇します。関数が通常複雑である場合、または言語が解釈的である場合、この追加コストは重要ではありません。 (プログラムコールグラフでコールDAGを決定し、DAG全体をカバーするようにヒープセグメントを割り当てることもできます。これにより、コールDAG内のすべてのコールに対してヒープ割り当てと従来のビッグスタック関数呼び出しの速度の両方を取得できます)。
スタックフレームにヒープ割り当てを使用する理由はいくつかあります。
1)プログラムが解決しようとしている特定の問題に応じて深い再帰を実行する場合、必要なサイズがわからないため、「ビッグスタック」領域を事前に割り当てることは非常に困難です。関数呼び出しを不自然に配置して、十分なスタックが残っているかどうかを確認し、残っていない場合は、より大きなチャンクを再割り当てし、古いスタックをコピーして、すべてのポインターをスタックに再調整します。それはとても厄介なので、私は実装を知りません。スタックフレームを割り当てるということは、文字通り割り当て可能なメモリがなくなるまで、アプリケーションが申し訳ないことを言う必要がないことを意味します。
2)プログラムはサブタスクをフォークします。各サブタスクには独自のスタックが必要であるため、提供されている1つの「ビッグスタック」を使用することはできません。したがって、サブタスクごとにスタックを割り当てる必要があります。数千の可能なサブタスクがある場合、数千の「ビッグスタック」が必要になる可能性があり、メモリ需要は突然ばかげています。スタックフレームを割り当てると、この問題が解決します。多くの場合、サブタスクの「スタック」は、字句スコープを実装するために親タスクを参照します。サブタスクフォークとして、「サボテンスタック」と呼ばれる「サブスタック」のツリーが作成されます。
3)あなたの言語には継続があります。これらでは、現在の関数に表示される字句スコープのデータを、後で再利用できるように何らかの方法で保存する必要があります。これは、親スタックフレームをコピーし、サボテンスタックを登って、続行することで実装できます。
[〜#〜] parlanse [〜#〜] 私が実装したプログラミング言語は1)と2)を実行します。私は3)に取り組んでいます。
Stackless Python はまだPythonスタックを持っています(ただし、末尾呼び出しの最適化や他の呼び出しフレームのマージのトリックがあるかもしれません)が、Cスタックから完全に切り離されています通訳。
Haskell(一般的に実装されている)にはコールスタックがありません。評価は グラフ還元 に基づいています。
言語フレームワークParrotに関する素晴らしい記事が http://www.linux-mag.com/cache/7373/1.html にあります。 Parrotは呼び出しにスタックを使用しません。この記事では、この手法について少し説明します。
私が多かれ少なかれ精通しているスタックレス環境(チューリングマシン、アセンブリ、Brainfuck)では、独自のスタックを実装するのが一般的です。言語にスタックを組み込むことについて基本的なことは何もありません。
これらの中で最も実用的なアセンブリでは、使用可能なメモリ領域を選択し、スタックレジスタを一番下を指すように設定してから、インクリメントまたはデクリメントしてプッシュとポップを実装します。
編集:一部のアーキテクチャには専用のスタックがあることは知っていますが、必須ではありません。
私を古代と呼んでください。しかし、FORTRAN標準とCOBOLが再帰呼び出しをサポートしていなかったため、スタックを必要としなかったのを覚えています。確かに、スタックがなかったCDC 6000シリーズマシンの実装を思い出します。サブルーチンを再帰的に呼び出そうとすると、FORTRANは奇妙なことをします。
記録のために、CDC 6000シリーズ命令セットは、コールスタックの代わりに、RJ命令を使用してサブルーチンを呼び出しました。これにより、現在のPC値がコールターゲットの場所に保存され、その後の場所に分岐します。最後に、サブルーチンは呼び出しターゲットの場所への間接ジャンプを実行します。保存したPCをリロードし、効果的に発信者に戻りました。
明らかに、それは再帰呼び出しでは機能しません。 (そして私の記憶では、再帰を試みた場合、CDC FORTRAN IVコンパイラーは壊れたコードを生成するでしょう...)
この記事には、継続についてのわかりやすい説明があります。 http://www.defmacro.org/ramblings/fp.html
継続は、スタックベースの言語で関数に渡すことができるものですが、言語自体のセマンティクスで使用して「スタックレス」にすることもできます。もちろん、スタックはまだそこにありますが、Ira Baxterが説明したように、それは1つの大きな連続したセグメントではありません。
スタックレスCを実装したいとします。最初に気付くのは、これにはスタックが必要ないということです。
_a == b
_
しかし、これはありますか?
_isequal(a, b) { return a == b; }
_
いいえ。スマートコンパイラはisequal
への呼び出しをインライン化するため、それらを_a == b
_に変換します。では、なぜすべてをインライン化しないのですか?確かに、より多くのコードを生成しますが、スタックを取り除く価値がある場合、これは小さなトレードオフで簡単です。
再帰はどうですか?問題ない。次のような末尾再帰関数:
_bang(x) { return x == 1 ? 1 : x * bang(x-1); }
_
実際には偽装のforループであるため、インライン化することはできます。
_bang(x) {
for(int i = x; i >=1; i--) x *= x-1;
return x;
}
_
理論的には、本当に賢いコンパイラがそれを理解してくれるでしょう。しかし、あまり賢くない人は、それでも後藤としてそれを平らにすることができます:
_ax = x;
NOTDONE:
if(ax > 1) {
x = x*(--ax);
goto NOTDONE;
}
_
小さなトレードオフをしなければならない場合が1つあります。これをインライン化することはできません:
_fib(n) { return n <= 2 ? n : fib(n-1) + fib(n-2); }
_
StacklessCは単にこれを行うことはできません。あなたはたくさんあきらめていますか?あんまり。これは通常のCでもうまくできないことです。信じられない場合は、fib(1000)
を呼び出して、貴重なコンピューターがどうなるかを確認してください。
間違っている場合は、遠慮なく訂正してください。ただし、関数呼び出しフレームごとにヒープにメモリを割り当てると、極端なメモリスラッシングが発生すると思います。結局のところ、オペレーティングシステムはこのメモリを管理する必要があります。このメモリスラッシングを回避する方法は、コールフレームのキャッシュだと思います。したがって、とにかくキャッシュが必要な場合は、メモリ内でキャッシュを連続させてスタックと呼ぶこともできます。