web-dev-qa-db-ja.com

関数型プログラミング-再帰に重点が置かれているのはなぜですか?

私は関数型プログラミング[FP](Scalaを使用)を紹介されています。私の最初の学習から出てきたことの1つは、FPは再帰に大きく依存しているということです。また、pure FPでは、反復的な処理を行う唯一の方法は、再帰関数を記述することです。

また、再帰が頻繁に使用されるため、FPが次に心配する必要があるのはStackoverflowExceptionsでした。これは、いくつかの最適化(スタックフレームのメンテナンスにおけるテール再帰関連の最適化とScala v2.8以降の@tailrecアノテーション)を導入することで解決されました。

再帰が関数型プログラミングのパラダイムにとって非常に重要である理由を誰かが教えてくれませんか?関数型プログラミング言語の仕様に、繰り返し行うと「違反」されるものはありますか?もしそうなら、私もそれを知りたいと思っています。

PS:私は関数型プログラミングの初心者であることに注意してください。質問がある場合は、既存のリソースを説明/回答してください。また、Scalaは特に、反復的な処理をサポートすることも理解しています。

62
peakit

Church Turingの論文 は、異なる計算可能性モデル間の同等性を強調しています。

再帰を使用すると、someの問題を解決するときに可変状態は必要ありません、これにより、意味をより簡単に指定できるようになります。したがって、形式的な意味での解決策はより単純になる可能性があります。

Prologは、関数型言語よりも再帰の有効性(反復がない)、およびそれを使用するときに遭遇する実際的な制限を示していると思います。

45
CapelliC

純粋な関数型プログラミングとは、副作用のないプログラミングを意味します。つまり、たとえばループを記述した場合、ループの本体は副作用を生成できません。したがって、ループで何かを実行したい場合は、前の反復の結果を再利用して、次の反復のために何かを生成する必要があります。したがって、ループの本体は関数であり、以前の実行の結果をパラメーターとして受け取り、次の反復のために自身の結果を使用して自身を呼び出します。これには、ループの再帰関数を直接作成するよりも大きな利点はありません。

些細なことを行わないプログラムは、ある時点で何かを反復する必要があります。関数型プログラミングの場合、これはプログラムが再帰関数を使用する必要があることを意味します。

35

再帰的に処理を行うrequirementをもたらす機能は、不変変数です。

(疑似コードで)リストの合計を計算する単純な関数を考えます。

fun calculateSum(list):
    sum = 0
    for each element in list: # dubious
        sum = sum + element # impossible!
    return sum

ここで、リストの各反復のelementは異なりますが、これを書き直して、ラムダ引数を持つforeach関数を使用して、この問題を取り除くことができます。

fun calculateSum(list):
    sum = 0
    foreach(list, lambda element:
        sum = sum + element # impossible!
    )
    return sum

それでも、sum変数の値は、ラムダを実行するたびに変更する必要があります。これは不変変数を持つ言語では違法であるため、状態を変化させない方法で書き直す必要があります。

fun calculateSum([H|T]):
    return H + calculateSum(T)

fun calculateSum([]):
    return 0

さて、この実装では、コールスタックへのプッシュとコールスタックからのポップが頻繁に必要になり、すべての小さな操作がこれを実行するプログラムは、あまり速く実行されません。したがって、コンパイラーが末尾呼び出しの最適化を行えるように、末尾再帰に書き換えます。

fun calculateSum([H|T], partialSum):
    return calculateSum(T, H + partialSum)

fun calculateSum([], partialSum):
    return partialSum

fun calculateSum(list):
    return calculateSum(list, 0)

もちろん、無限にループしたい場合は、末尾再帰呼び出しが絶対に必要です。そうしないと、スタックオーバーフローが発生します。

Scalaの@tailrecアノテーションは、末尾再帰関数を分析するのに役立つツールです。あなたは「この関数は末尾再帰的です」と主張し、それからコンパイラはあなたが間違っているかどうかを教えてくれます。他の関数型言語と比較して、これはScalaで特に重要です。これは、JVMが実行されるマシンが末尾呼び出しの最適化を適切にサポートしていないため、Scalaで末尾呼び出しの最適化を取得できないためです。すべて同じ状況で、他の関数型言語でそれを得るでしょう。

20
Magnus Hoff

TL; DR:帰納的に、帰納的に定義されたデータを処理しますユビキタス。

より高いレベルの抽象化を操作する場合、再帰は自然です。関数型プログラミングは、関数を使用したコーディングだけではありません。それは、より高いレベルの抽象化を操作することであり、自然に関数を使用します。関数を使用して、意味のあるコンテキストから同じ関数を再利用する(それを再度呼び出す)のは自然なことです。

世界は、同じ/同じビルディングブロックの繰り返しによって構築されます。生地を2つにカットすると、生地は2つになります。数学の導入は数学の中心です。私たち人間は数えます(例:1,2,3 ...)。任意の 帰納的に定義されたthing (たとえば、-{numbers from 1} is {1、and numbers from 2})そのことを定義/構築するのと同じケースに従って、再帰関数で処理/分析するのは自然です。

再帰はどこにでもあります。とにかく、反復ループは偽装の再帰です。そのループに再び入ると、同じループに再び入るためです(ループ変数が異なる可能性があります)。つまり、それはinventingコンピューティングに関する新しい概念のようなものではなく、discovering基礎のようなものであり、それをexplicitにすることです。


したがって、再帰は自然です。問題に関するいくつかの法則、定義している関数を含むいくつかの方程式(関数が一貫して定義されているという仮定の下で)を一定に保ち、問題を簡略化された用語で再指定し、出来上がりです!私たちは解決策を持っています。

例として、リストの長さを計算する関数(帰納的に定義された再帰的なデータ型)。それが定義されていて、リストの長さを驚くことなく返すと仮定します。従わなければならない法律は何ですか?問題をどのように単純化しても、どの不変条件が保持されますか?

最も直接的なのは、リストをそのヘッド要素とそれ以外の部分に分解することです-別名リストの末尾(リストの定義/構築方法による)。法律は、

length (x:xs) = 1 + length xs

ああ!しかし、空のリストはどうですか?それはそれでなければなりません

length [] = 0

では、このような関数をどのように記述すればよいのでしょうか。..待ってください...すでに記述しました! (Haskellでは、関数の適用が並列で表現されている場合、かっこはグループ化のためだけに使用され、(x:xs)xが最初の要素で、xsが残りのリストです)。

このようなプログラミングスタイルを可能にするために必要な言語は、その言語に [〜#〜] tco [〜#〜] が含まれていることだけです(おそらく、少し贅沢な [〜# 〜] trmco [〜#〜] )なので、スタックの爆発はなく、設定されています。


もう1つは純粋性です。コード変数やデータ構造(レコードのフィールドなど)の不変性です。

これにより、「変化する」可変変数/データを隠すのではなく、いつ変化するかを追跡する必要がなくなるため、コードで時間が明確になります。命令コードでは変数の値のみを「変更」することができますこれから-過去にその値をあまり変更することはできませんか?

その結果、記録された変更履歴のリストが作成され、変更はコードで明示されます。x := x + 2の代わりに、let x2 = x1 + 2と記述します。コードについての推論が非常に簡単になります。


[〜#〜] tco [〜#〜] を使用した末尾再帰のコンテキストで不変性に対処するには、アキュムレータ引数パラダイムの下で、上記の関数lengthのこの末尾再帰的な再書き込みを検討してください。

length xs = length2 0 xs              -- the invariant: 
length2 a []     = a                  --     1st arg plus
length2 a (x:xs) = length2 (a+1) xs   --     length of 2nd arg

ここでTCOは、直接ジャンプに加えて呼び出しフレームの再利用を意味します。したがって、length [1,2,3]の呼び出しチェーンは、関数のパラメーターに対応する呼び出しスタックフレームのエントリを実際に変更していると見なすことができます。

length [1,2,3]
length2 0 [1,2,3]       -- a=0  (x:xs)=[1,2,3]
length2 1 [2,3]         -- a=1  (x:xs)=[2,3]
length2 2 [3]           -- a=2  (x:xs)=[3]
length2 3 []            -- a=3  (x:xs)=[]
3

純粋な言語では、値を変更するプリミティブがないため、変更を表現する唯一の方法は、更新された値を引数としてa functionに渡して、さらに処理することです。その後の処理が以前と同じである場合は、当然、同じ関数を呼び出して、更新された値を引数として渡す必要があります。そして、それは再帰です。


そして、以下は、引数リストの長さを計算するすべての履歴を作成しますexplicit(そして必要に応じて再利用できます):

length xs = last results
  where
     results = length3 0 xs
     length3 a []     = [a]
     length3 a (x:xs) = a : length3 (a+1) xs

Haskellでは、これは保護された再帰、またはコアカージョンとして可変的に知られています(少なくとも私はそうです)。

7
Will Ness

副作用の回避は、関数型プログラミングの柱の1つです(もう1つは、高次関数を使用することです)。

命令型フロー制御をどのように利用できるか想像してみてくださいなし突然変異に依存しています。出来ますか?

もちろんfor (var i = 0; i < 10; i++) ...は突然変異(_i++_)に依存します。実際、条件付きループ構成はすべて実行します。 while (something) ...では、somethingはいくつかの変更可能な状態に依存します。確かに、while (true) ...はミューテーションを使用しません。ただし、そのループから抜け出したい場合は、if (something) breakが必要です。本当に、突然変異に依存しない再帰以外の(非無限の)ループメカニズムについて考えてみてください。

for (var x in someCollection) ...はどうですか?現在、関数型プログラミングに近づいています。 xは、ループの本体に対するparameterと考えることができます。名前の再利用は、値の再割り当てとは異なります。おそらく、ループの本体では、xの式としての_yield return_ ing値(突然変異なし)です。

まったく同じように、forループの本体を関数foo (x) ...の本体に移動し、より高次の関数を使用してsomeCollectionにマップすることができます-ループを置き換えますMap(foo, someCollection)のようなもので構築します。

しかし、ライブラリ関数Mapはミューテーションなしでどのように実装されますか?もちろん、再帰を使用しています!それはあなたのために行われます。ループ構造を置き換えるために高次関数の2番目のピラールを利用し始めると、再帰関数を自分で実装する必要が少なくなります。

さらに、末尾呼び出しの最適化では、再帰的な定義は反復プロセスと同等です。このブログ投稿もお楽しみいただけます。 http://blogs.msdn.com/b/ashleyf/archive/2010/02/06/recursion-is-the-new-iteration.aspx

6
AshleyF

関数型プログラミングに不可欠と考える2つのプロパティがあります。

  1. 関数は最初のクラスメンバーです(便利にするために、2番目のプロパティが必要であるため)

  2. 関数は純粋です。つまり、同じ引数で呼び出された関数は同じ値を返します。

命令型でプログラミングする場合は、代入を使用する必要があります。

Forループについて考えてみましょう。これにはインデックスがあり、反復ごとにインデックスの値は異なります。したがって、このインデックスを返す関数を定義できます。その関数を2回呼び出すと、異なる結果が得られる可能性があります。したがって、原則2を破る。

原則2を破ると、関数の引き渡し(原則1)は非常に危険になります。これは、関数の結果が、いつ、どのくらいの頻度で関数が呼び出されるかに依存する可能性があるためです。

6
Jens Schauder

再帰には「特別な」ものは何もありません。これは、プログラミングと数学で広く使用されているツールであり、それ以上のものではありません。ただし、関数型言語は通常最小限です。彼らはパターンマッチング、型システム、リスト内包などのような多くの凝った概念を導入していますが、それは非常に一般的で非常に強力ですが、シンプルで原始的なツールの構文糖に過ぎません。このツールは、関数の抽象化と関数の適用です。言語コアのシンプルさがそれについての推論をはるかに容易にするので、これは意識的な選択です。また、コンパイラの記述も簡単になります。このツールに関してループを記述する唯一の方法は、再帰を使用することです。そのため、命令型プログラマーは、関数型プログラミングは再帰についてであると考えるかもしれません。そうではなく、この構文糖衣をgotoステートメントにドロップできない貧弱なループのファンシーループを模倣する必要があるだけなので、最初に突き刺したものの1つです。

再帰が必要な(間接的である可能性がある)もう1つのポイントは、再帰的に定義されたデータ構造の処理です。最も一般的な例はlist ADTです。 FPでは通常、このように定義されますdata List a = Nil | Branch a (List a)。ここでのADTの定義は再帰的であるため、処理関数も再帰的である必要があります。ここでも再帰はありませんとにかく特別:再帰的な方法でのそのようなADTの処理は、命令型と関数型言語の両方で自然に見えます。まあ、リストのようなADTの命令型ループの場合はまだ採用できますが、異なるツリー状の構造の場合は採用できません。

したがって、再帰に関して特別なことは何もありません。これは、単に別のタイプの関数アプリケーションです。ただし、最新のコンピューティングシステムの制限(デファクトスタンダードのクロスプラットフォームアセンブラーであるC言語での設計決定が不十分なため)のため、関数呼び出しは末尾呼び出しであっても無限にネストすることはできません。そのため、関数型プログラミング言語の設計者は、許可される末尾呼び出しを末尾再帰(scala)に制限するか、トランポリング(古いghc codegen)などの複雑な手法を使用するか、直接asm(現代のghc codegen)にコンパイルする必要があります。

TL; DR:FPの再帰には特別なものはありません。少なくともIPに限られますが、JVMの制限により、末尾再帰はscalaで許可されている唯一のタイプの末尾呼び出しです。 。

6
permeakra

新しいFP学習者のために、2セントを追加したいと思います。いくつかの回答で述べたように、再帰は不変変数を使用することですが、なぜそれを行う必要があるのですか?これは、プログラムを複数のコアで並行して簡単に実行できるためですが、なぜそれが必要なのですか?単一のコアでプログラムを実行し、いつものように満足できないのですか?いいえ、処理するコンテンツは日々増加しているためです。 CPUクロックサイクルは、コアを追加するよりも大幅に増やすことはできません。過去10年間、消費者向けコンピュータのクロック速度は最大2.7 GHzから3.0 GHzであり、チップ設計者は、ますます多くのトランジスタを搭載することに問題を抱えています。また、FPは非常に長い間使用されていましたが、再帰を使用し、メモリは当時非常に高価だったため、ピックアップされませんでしたが、クロック速度は年々高まっているため、コミュニティはOOP Editで続行します。編集:非常に高速で、数分しかありませんでした

1
Eklavyaa

前回関数型言語(Clojure)を使用したときは、再帰を使用したくもありませんでした。最終結果に到達するまでは、すべてが一連の物として扱われ、それに関数が適用されてパーツプロダクトが取得され、別の関数が適用されます。

再帰は、gを処理するために通常処理する必要がある複数の項目を処理するための1つの方法であり、必ずしも最も明確な方法ではありません

1
Bradford K Hull