純粋に機能的な言語についての誤解はありますか?


39

私はしばしば次のステートメント/引数に遭遇します:

  1. 純粋な関数型プログラミング言語では副作用がありません(したがって、実用的なプログラムは副作用を持っているため、実際にはほとんど役に立ちません。たとえば、外部の世界と対話するときです)。
  2. 純粋な関数型プログラミング言語では、状態を維持するプログラムを作成できません(多くのアプリケーションでは状態が必要なため、プログラミングが非常に厄介になります)。

私は関数型言語の専門家ではありませんが、これまでこれらのトピックについて理解してきたことを以下に示します。

ポイント1に関しては、純粋に機能的な言語で環境と対話できますが、副作用を引き起こすコード(関数)を明示的にマークする必要があります(たとえば、モナド型によるHaskellで)。また、私が知っている限り、副作用による計算(データの破壊的な更新)も(モナド型を使用して)可能であるべきです(たとえそれが好ましい作業方法ではないとしても)

ポイント2については、いくつかの計算ステップ(Haskellでもモナド型を使用)で値をスレッド化することで状態を表すことができますが、これを行う実際的な経験はなく、私の理解はかなりあいまいです。

それで、上記の2つの文は何らかの意味で正しいのですか、それとも純粋に機能的な言語に関する誤解ですか?それらが誤解である場合、どのようにして生じたのですか?(1)副作用を実装し、(2)状態で計算を実装するHaskellの慣用的な方法を示す(おそらく小さな)コードスニペットを記述できますか?


7
このほとんどは、「純粋な」関数型言語をどのように定義するかにかかっていると思います。
jk。

@jk:「純粋な」関数型言語を定義する問題を回避するために、Haskellの意味での純粋さ(明確に定義されている)を想定してください。どの条件下で関数型言語が「純粋」と見なされるかは、将来の質問のトピックになる可能性があります。
ジョルジオ

どちらの答えにも多くの明確なアイデアが含まれており、受け入れるものを選択することは私にとって困難でした。追加の擬似コードの例のため、sep2kの回答を受け入れることにしました。
ジョルジオ

回答:


26

この答えの目的のために、「純粋に機能的な言語」とは、機能が参照的に透過的である機能的な言語を意味します。これは、純粋に機能的な言語の通常の定義だと思います。

純粋な関数型プログラミング言語では副作用がありません(したがって、実用的なプログラムは副作用を持っているため、実際にはほとんど役に立ちません。たとえば、外部の世界と対話するときです)。

参照の透明性を実現する最も簡単な方法は、実際に副作用を禁止することであり、実際にそうなる言語があります(主にドメイン固有のもの)。しかし、それは確かに唯一の方法ではなく、最も汎用的な純粋に関数型の言語(Haskell、Cleanなど)が副作用を許容します。

また、副作用のないプログラミング言語は実際にはほとんど使用しないと言っても、本当に公平ではないと思います-確かにドメイン固有の言語ではなく、汎用言語であっても、副作用を提供せずに言語が非常に役立つ可能性があると思います。コンソールアプリケーション用ではないかもしれませんが、GUIは、機能的なリアクティブパラダイムなどの副作用なしにうまく実装できると思います。

ポイント1に関しては、純粋に機能的な言語で環境と対話できますが、それらを導入するコード(関数)を明示的にマークする必要があります(たとえば、Haskellではモナド型を使用)。

それはそれを単純化することを少し上回っています。(C ++のconst-correctnessに似ていますが、一般的な副作用がある)副作用のある機能をマークする必要があるシステムがあるだけでは、参照の透明性を確保するには不十分です。プログラムが同じ引数を使用して関数を複数回呼び出せず、異なる結果を取得できないようにする必要があります。次のようにすることでそれを行うことができますreadLine関数ではないもの(HaskellがIOモナドで行うこと)または同じ引数で副作用関数を複数回呼び出せないようにすることができます(それはCleanが行うことです)。後者の場合、コンパイラは、副作用のある関数を呼び出すたびに新しい引数を使用して呼び出しを行い、同じ引数を副作用のある関数に2回渡すプログラムを拒否します。

純粋な関数型プログラミング言語では、状態を維持するプログラムを作成できません(多くのアプリケーションでは状態が必要なため、プログラミングが非常に厄介になります)。

繰り返しになりますが、純粋に関数型の言語は可変状態を許可しない可能性がありますが、上記の副作用で説明したのと同じ方法で実装すると、純粋で可変状態を維持することは確かに可能です。本当に可変状態は、副作用の単なる別の形です。

そうは言っても、関数型プログラミング言語は間違いなく可変状態を阻止します-特に純粋なものはそうです。そして、それがプログラミングを厄介なものにするとは思わない-まったく逆です。時々(しかし、それほど頻繁ではありませんが)可変状態は、パフォーマンスや明確さを失うことなく避けられません(Haskellのような言語には可変状態のための機能がある理由です)。

それらが誤解である場合、どのようにして生じたのですか?

多くの人々は単に「関数は同じ引数で呼び出されたときに同じ結果を生成しなければならない」と読み、readLine可変状態を維持するようなコードやコードを実装することは不可能であると結論付けていると思います。そのため、純粋に関数型の言語が参照の透明性を損なうことなくこれらのことを導入するために使用できる「チート」を単純に認識していません。

また、可変状態は関数型言語では非常に落胆するため、純粋に関数型の言語ではまったく許可されていないと想定するのはそれほど大きな飛躍ではありません。

(1)副作用を実装し、(2)状態で計算を実装するHaskellの慣用的な方法を示す(おそらく小さな)コードスニペットを記述できますか?

これは、ユーザーに名前を尋ねて挨拶するPseudo-Haskellのアプリケーションです。Pseudo-Haskellは、HaskellのIOシステムを備えた、私がちょうど発明した言語ですが、より一般的な構文、より説明的な関数名を使用し、-表記はありませdoん(IOモナドが正確にどのように動作するかをそらすため):

greet(name) = print("Hello, " ++ name ++ "!")
main = composeMonad(readLine, greet)

手がかりはここつまりreadLine型の値であるIO<String>composeMonadタイプの引数を取る関数であるIO<T>(いくつかのタイプのためにT)とタイプの引数を取る関数である別の引数Tと型の値を返すIO<U>(一部のタイプのためにU)。print文字列を取り、typeの値を返す関数ですIO<void>

typeの値は、type IO<A>の値を生成する特定のアクションを「エンコード」する値ですAcomposeMonad(m, f)新しい生成IOのアクションコード値mの作用に続いてf(x)x値のアクションを実行することによって生成されますm

可変状態は次のようになります。

counter = mutableVariable(0)
increaseCounter(cnt) =
    setIncreasedValue(oldValue) = setValue(cnt, oldValue + 1)
    composeMonad(getValue(cnt), setIncreasedValue)

printCounter(cnt) = composeMonad( getValue(cnt), print )

main = composeVoidMonad( increaseCounter(counter), printCounter(counter) )

以下mutableVariableは、任意の型の値を取りT、を生成する関数ですMutableVariable<T>。この関数getValueは、現在の値を生成するを取得してMutableVariable返しIO<T>ます。setValueとを取り、値を設定MutableVariable<T>するTを返しIO<void>ます。composeVoidMonadは、composeMonad最初の引数がIO意味のある値を生成しないであり、2番目の引数が別のモナドであり、モナドを返す関数ではないことを除いて同じです。

Haskellには、この全体の試練の痛みを軽減する構文糖がありますが、可変状態は、言語が本当に望んでいないものであることは依然として明らかです。


素晴らしい答え、多くのアイデアを明確にします。コードの最後の行は名前を使用する必要がありスニペットcounter、すなわちincreaseCounter(counter)
ジョルジオ

@Giorgioはい、そうです。一定。
sepp2k

1
@Giorgio投稿で明示的に言及し忘れたmainことの1つは、によって返されるIOアクションが実際に実行されるものになるということです。mainそこからIOを返す以外に、IOアクションを実行する方法はありません(unsafe名前に恐ろしく邪悪な関数を使用することなしに)。
sepp2k

OK。スカーフリッジは破壊的なIO価値についても言及しました。彼がパターンマッチング、つまり代数データ型の値を分解できるという事実に言及しているかどうかはわかりませんでしたが、パターンマッチングを使用してIO値でこれを行うことはできません。
ジョルジオ

16

私見あなたは、純粋な言語と純粋な機能の間に違いがあるので混乱しています。関数から始めましょう。関数は、(同じ入力が与えられると)常に同じ値を返し、目に見える副作用を引き起こさない場合、純粋です。典型的な例は、f(x)= x * xのような数学関数です。次に、この関数の実装を検討します。一般的に純粋な関数型言語(MLなど)とは見なされない言語でも、ほとんどの言語で純粋です。この動作を備えたJavaまたはC ++メソッドでさえ、純粋と見なすことができます。

それでは、純粋な言語とは何ですか?厳密に言えば、純粋な言語では純粋ではない関数を表現できないと予想されるかもしれません。これを、純粋な言語の理想主義的な定義と呼びましょう。このような動作は非常に望ましいことです。どうして?純粋な関数のみで構成されるプログラムの良いところは、プログラムの意味を変えずに関数アプリケーションをその値に置き換えることができることです。これにより、プログラムについて推論するのが非常に簡単になります。結果がわかれば、計算された方法を忘れることができるからです。また、純度により、コンパイラが特定の積極的な最適化を実行できる場合があります。

では、内部状態が必要な場合はどうでしょうか?計算前の状態を入力パラメーターとして、計算後の状態を結果の一部として追加するだけで、純粋な言語の状態を模倣できます。Int -> Boolあなたの代わりにのようなものを得ますInt -> State -> (Bool, State)。依存関係を明示的にするだけです(プログラミングパラダイムでは適切なプラクティスと見なされます)。ところで、このような状態模倣機能をより大きな状態模倣機能に結合する特にエレガントな方法であるモナドがあります。このようにして、純粋な言語で間違いなく「状態を維持」できます。ただし、明示する必要があります。

だから、これは私が外部と対話できるということですか?結局のところ、有用なプログラムは、有用であるために現実世界と相互作用しなければなりません。しかし、入力と出力は明らかに純粋ではありません。特定のファイルに特定のバイトを書き込むことは、初めてうまくいくかもしれません。ただし、まったく同じ操作を2回実行すると、ディスクがいっぱいなのでエラーが返される場合があります。明らかに、ファイルに書き込むことができる純粋な言語(理想的な意味)は存在しません。

そのため、ジレンマに直面しています。ほとんど純粋な関数が必要ですが、いくつかの副作用が絶対に必要であり、それらは純粋ではありません。今、純粋な言語の現実的な定義は、他の部分からの純粋な部分を分離するためにいくつかの手段が存在しなければならないということでしょう。メカニズムは、不純な操作が純粋な部品に侵入しないようにする必要があります。

Haskellでは、これはIOタイプで行われます。IOの結果を破壊することはできません(安全でないメカニズムなしでは)。したがって、IOモジュール自体で定義された関数でのみIO結果を処理できます。幸いなことに、IO結果を取得し、その関数が別のIO結果を返す限り、その関数で処理できる非常に柔軟なコンビネーターがあります。このコンビネーターはbind(または>>=)と呼ばれ、typeを持ちIO a -> (a -> IO b) -> IO bます。この概念を一般化すると、モナドクラスに到達し、IOがたまたまそのインスタンスになります。


4
Haskell(unsafeその名前に含まれる関数を無視する)があなたの理想主義的な定義をどのように満たしていないか、私は本当にわかりません。Haskellには不純な関数はありません(これも無視しunsafePerformIOます)。
sepp2k

4
readFileそしてwriteFileいつも同じ返しますIO同じ引数与えられ、値を。だから、2つのコードスニペットを例えばlet x = writeFile "foo.txt" "bar" in x >> xwriteFile "foo.txt" "bar" >> writeFile "foo.txt" "bar"同じことを行います。
sepp2k

3
@AidanCully「IO機能」とはどういう意味ですか?タイプの値を返す関数IO Something?その場合、同じ引数を使用してIO関数を2回呼び出すことは完全に可能です。- putStrLn "hello" >> putStrLn "hello"ここでは両方がputStrLn同じ引数を持つように呼び出します。もちろん、これは問題ではありません。先ほど言ったように、両方の呼び出しで同じIO値が得られるからです。
sepp2k

3
writeFile "foo.txt" "bar"関数呼び出しを評価してもアクション実行されないため、@ scarfridgeを評価してもエラーは発生しませ。前の例で、バージョンのあるバージョンにletはIO障害が発生する機会が1つしかなく、バージョンのないバージョンにletは2 つの機会があると言っているのであれば、それは間違いです。どちらのバージョンにもIO障害が発生する可能性が2つあります。以来letバージョンがコールを評価するためにwriteFile、一度だけのないバージョンながらlet評価すること二回、あなたはそれが関数が呼び出される頻度問題ではないことがわかります。それが唯一の重要頻度結果...
sepp2k

6
@AidanCully「monadメカニズム」は、暗黙的なパラメーターを回避しません。このputStrLn関数は、型の引数を1つだけ取りますString。あなたが私を信じないなら、そのタイプを見てください:String -> IO ()。それは確かに型の引数を取りませんIO-その型の値を生成します。
sepp2k
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.