web-dev-qa-db-ja.com

Ocaml / F#の関数がデフォルトで再帰的でないのはなぜですか?

F#とOcaml(そしておそらく他の言語)の関数がデフォルトで再帰的ではないのはなぜですか?

言い換えると、言語設計者が、次のような宣言で明示的にrecと入力するのが良い考えであると判断したのはなぜですか。

let rec foo ... = ...

デフォルトで関数の再帰機能を提供しませんか?なぜ明示的なrec構造が必要なのですか?

94
nsantorello

元のMLのフランスとイギリスの子孫は異なる選択をし、それらの選択は何十年にもわたって現代の変種に受け継がれてきました。したがって、これは単なるレガシーですが、これらの言語のイディオムには影響します。

フランス語のCAMLファミリーの言語(OCamlを含む)では、関数はデフォルトでは再帰的ではありません。この選択により、新しい定義の本体内で以前の定義を参照できるため、これらの言語でletを使用して関数(および変数)定義を簡単に置き換えることができます。 F#はこの構文をOCamlから継承しました。

たとえば、OCamlでシーケンスのシャノンエントロピーを計算するときに関数pを置き換えます。

let shannon fold p =
  let p x = p x *. log(p x) /. log 2.0 in
  let p t x = t +. p x in
  -. fold p 0.0

高階p関数の引数shannonが本文の最初の行の別のpに置き換えられ、次に別のpに置き換えられることに注意してください。体の2行目。

逆に、MLファミリーの言語の英国のSMLブランチは他の選択を行い、SMLのfun- bound関数はデフォルトで再帰的です。ほとんどの関数定義が関数名の以前のバインディングにアクセスする必要がない場合、これによりコードが単純になります。ただし、置き換えられた関数は異なる名前(f1f2など)を使用するように作成されているため、スコープが汚染され、関数の誤った「バージョン」が誤って呼び出される可能性があります。また、暗黙的に再帰的なfun- bound関数と非再帰的なval- bound関数の間に不一致があります。

Haskellは、定義を純粋に制限することにより、定義間の依存関係を推測することを可能にします。これにより、おもちゃのサンプルはよりシンプルに見えますが、他の場所では重大なコストがかかります。

ガネーシュとエディによって与えられた答えは赤いニシンであることに注意してください。彼らは、関数のグループを巨大なlet rec ... and ...内に配置できない理由を説明しました。これは、型変数が一般化されるタイミングに影響を与えるためです。これは、recがSMLのデフォルトであるが、OCamlではないこととは何の関係もありません。

84
Jon Harrop

recを明示的に使用する重要な理由の1つは、静的に型付けされたすべての関数型プログラミング言語の基礎となるHindley-Milner型推論に関係することです(さまざまな方法で変更および拡張されていますが)。

定義が_let f x = x_の場合、タイプは_'a -> 'a_であり、さまざまなポイントでさまざまな_'a_タイプに適用できると予想されます。しかし、同様に、let g x = (x + 1) + ...と書くと、xintの残りの部分でgとして扱われると予想されます。

Hindley-Milner推論がこの区別を処理する方法は、明示的な一般化ステップを介して行われます。プログラムを処理するときの特定の時点で、型システムは停止し、「OK、これらの定義の型はこの時点で一般化されるので、誰かがそれらを使用すると、その型の自由型変数は新しくインスタンス化されているため、この定義の他の使用を妨げることはありません。」

この一般化を行うのに賢明な場所は、相互に再帰的な関数のセットをチェックした後です。それ以前の場合、一般化しすぎると、タイプが実際に衝突する可能性があります。後で、一般化が少なすぎて、複数の型のインスタンス化で使用できない定義を作成します。

それで、型チェッカーがどの定義のセットが相互再帰的であるかを知る必要があるとすると、それは何ができるでしょうか? 1つの可能性は、スコープ内のすべての定義に対して依存関係分析を実行し、それらを可能な限り最小のグループに並べ替えることです。 Haskellは実際にこれを行いますが、F#(およびOCamlとSML)のような無制限の副作用がある言語では、副作用も並べ替えられる可能性があるため、これは悪い考えです。したがって、代わりに、どの定義が相互再帰的であるかを明示的にマークするようにユーザーに要求します。したがって、拡張により、一般化が発生する場所をマークします。

これが良い考えである2つの主な理由があります:

まず、再帰的定義を有効にすると、同じ名前の値の以前のバインディングを参照できなくなります。これは、既存のモジュールを拡張するようなことをしているときに役立つイディオムであることがよくあります。

第2に、再帰値、特に相互再帰値のセットは、順番に進む定義であり、新しい定義はそれぞれ、すでに定義されているものの上に構築されているため、推論するのがはるかに困難です。このようなコードを読むときは、再帰として明示的にマークされた定義を除いて、新しい定義が以前の定義のみを参照できるという保証があると便利です。

10
zrr

いくつかの推測:

  • letは、関数をバインドするためだけでなく、他の通常の値にも使用されます。ほとんどの形式の値は再帰的であることが許可されていません。特定の形式の再帰値が許可されているため(関数、遅延式など)、これを示すために明示的な構文が必要です。
  • 非再帰関数を最適化する方が簡単な場合があります
  • 再帰関数を作成するときに作成されるクロージャには、関数自体を指すエントリを含める必要があります(したがって、関数はそれ自体を再帰的に呼び出すことができます)。これにより、再帰クロージャは非再帰クロージャよりも複雑になります。したがって、再帰が必要ないときに、より単純な非再帰的なクロージャを作成できると便利かもしれません。
  • これにより、以前に定義された関数または同じ名前の値に関して関数を定義できます。これは悪い習慣だと思いますが
  • 追加の安全性?意図したとおりに実行していることを確認します。例えば再帰的にするつもりはないが、関数内で関数自体と同じ名前の名前を誤って使用した場合、(名前が以前に定義されていない限り)文句を言う可能性があります。
  • letコンストラクトは、LISPおよびSchemeのletコンストラクトに似ています。これは非再帰的です。再帰的なLet's用のSchemeには別のletrec構造があります
4
newacct

これを考えると:

let f x = ... and g y = ...;;

比較:

let f a = f (g a)

これとともに:

let rec f a = f (g a)

前者はfを再定義して、以前に定義されたfgaに適用した結果に適用します。後者はfを再定義して永久にループします。gaに適用します。これは通常、MLバリアントでは必要ありません。

そうは言っても、それは言語デザイナースタイルのものです。それに合わせてください。

4
james woodyatt

その大きな部分は、プログラマーがローカルスコープの複雑さをより細かく制御できることです。 letlet*、およびlet recのスペクトルは、電力とコストの両方のレベルを増加させます。 let*let recは、本質的に単純なletのネストされたバージョンであるため、どちらかを使用するとコストが高くなります。このグレーディングにより、目前のタスクに必要なレベルを選択できるため、プログラムの最適化を細かく管理できます。再帰や以前のバインディングを参照する機能が必要ない場合は、単純なletにフォールバックして、パフォーマンスを少し節約できます。

これは、Schemeの段階的等式述語に似ています。 (つまり、eq?eqv?、およびequal?

1
Evan Meagher