最近 Haskell を簡単に調べてみたのですが、モナドが本質的に何であるかについて、簡潔で実用的な説明は何でしょうか?
私がこれまでに目にした説明のほとんどは、かなりわかりにくく、実用的な詳細が欠けていることがわかりました。
ベストアンサー1
まず、モナドという用語は、数学者でなければ、少し意味が空虚です。別の用語としては、計算ビルダーがあり、これは、モナドが実際に何に役立つかをもう少し説明しています。
操作を連鎖させるためのパターンです。オブジェクト指向言語のメソッド連鎖に少し似ていますが、仕組みが少し異なります。
このパターンは主に関数型言語 (特にモナドを広く使用する Haskell) で使用されますが、高階関数 (つまり、他の関数を引数として受け取ることができる関数) をサポートする任意の言語で使用できます。
JavaScript の配列はこのパターンをサポートしているので、これを最初の例として使用してみましょう。
Array
パターンの要点は、関数を引数として受け取るメソッドを持つ型 (この場合は ) があることです。指定された操作は、同じ型のインスタンスを返す必要があります (つまり、 を返しますArray
)。
まず、モナド パターンを使用しないメソッド チェーンの例を示します。
[1,2,3].map(x => x + 1)
結果は です[2,3,4]
。引数として提供している関数は配列ではなく数値を返すため、コードはモナド パターンに準拠していません。同じロジックをモナド形式で表すと次のようになります。
[1,2,3].flatMap(x => [x + 1])
ここでは を返す操作を指定しているArray
ので、パターンに準拠しています。flatMap
メソッドは、配列内のすべての要素に対して指定された関数を実行します。呼び出しごとに結果として配列 (単一の値ではなく) が返されることを想定していますが、結果の配列セットを 1 つの配列にマージします。そのため、最終結果は同じで、配列になります[2,3,4]
。
map
(や のようなメソッドに提供される関数引数はflatMap
、JavaScript では「コールバック」と呼ばれることがよくあります。より一般的なので、ここでは「操作」と呼びます。)
複数の操作を(従来の方法で)連鎖させると、次のようになります。
[1,2,3].map(a => a + 1).filter(b => b != 3)
配列内の結果[2,4]
モナド形式での同じ連鎖:
[1,2,3].flatMap(a => [a + 1]).flatMap(b => b != 3 ? [b] : [])
同じ結果、配列が生成されます[2,4]
。
モナド形式は非モナド形式よりもかなり醜いことにすぐに気づくでしょう。これは、モナドが必ずしも「良い」わけではないことを示しています。モナドは、時には有益で、時にはそうでないパターンです。
モナド パターンは別の方法で組み合わせることができることに注意してください。
[1,2,3].flatMap(a => [a + 1].flatMap(b => b != 3 ? [b] : []))
ここでは、バインディングは連鎖ではなくネストされていますが、結果は同じです。これは、後で説明するように、モナドの重要な特性です。つまり、2 つの操作を組み合わせても、1 つの操作と同じように扱うことができます。
この操作では、異なる要素型を持つ配列を返すことができます。たとえば、数値の配列を文字列の配列などに変換することはできますが、配列のままである必要があります。
これは、Typescript 表記法を使用してもう少し正式に記述できます。配列の型は でArray<T>
、 はT
配列内の要素の型です。メソッドはflatMap()
型の関数引数を受け取りT => Array<U>
、 を返しますArray<U>
。
一般化すると、モナドは、Foo<Bar>
型の関数引数を受け取りBar => Foo<Baz>
、を返す「bind」メソッドを持つ任意の型ですFoo<Baz>
。
これはモナドが何であるかの答えです。この答えの残りでは、モナドが Haskell のような言語でなぜ役立つパターンになり得るのかを例を挙げて説明します。Haskell はモナドをうまくサポートしています。
Haskell と Do 記法
map/filter の例を Haskell に直接変換するには、flatMap
次の演算子に置き換えます>>=
。
[1,2,3] >>= \a -> [a+1] >>= \b -> if b == 3 then [] else [b]
演算子>>=
は Haskell の bind 関数です。flatMap
オペランドがリストの場合は JavaScript と同じ動作をしますが、他の型の場合は異なる意味にオーバーロードされます。
しかし、Haskell にはモナド式専用の構文である -block もありdo
、これにより bind 演算子が完全に非表示になります。
do
a <- [1,2,3]
b <- [a+1]
if b == 3 then [] else [b]
これにより、「配管」が非表示になり、各ステップで適用される実際の操作に集中できるようになります。
-blockではdo
、各行が操作です。ブロック内のすべての操作は同じ型を返す必要があるという制約は変わりません。最初の式はリストなので、他の操作もリストを返す必要があります。
戻る矢印は<-
一見代入のように見えますが、これはバインドで渡されるパラメータであることに注意してください。したがって、右側の式が整数のリストである場合、左側の変数は単一の整数になりますが、リスト内の各整数に対して実行されます。
例: 安全なナビゲーション (Maybe タイプ)
リストについてはこれで十分です。モナド パターンが他の型にどのように役立つかを見てみましょう。
一部の関数は必ずしも有効な値を返さない場合があります。Haskell では、これは -type で表されます。これは、または のMaybe
いずれかのオプションです。Just value
Nothing
常に有効な値を返す操作の連鎖は、もちろん簡単です。
streetName = getStreetName (getAddress (getUser 17))
しかし、関数のいずれかが を返す可能性がある場合はどうなるでしょうかNothing
? それぞれの結果を個別に確認し、そうでない場合にのみ値を次の関数に渡す必要がありますNothing
。
case getUser 17 of
Nothing -> Nothing
Just user ->
case getAddress user of
Nothing -> Nothing
Just address ->
getStreetName address
非常に多くの繰り返しチェックがあります。チェーンがもっと長くなったらどうなるか想像してみてください。Haskell はモナド パターンを使用してこれを解決しますMaybe
。
do
user <- getUser 17
addr <- getAddress user
getStreetName addr
このdo
-block は、 型の bind 関数を呼び出しますMaybe
(最初の式の結果が であるためMaybe
)。bind 関数は、値が の場合にのみ次の操作を実行しJust value
、それ以外の場合は をそのまま渡しますNothing
。
ここでは、モナドパターンを使用してコードの繰り返しを回避しています。これは、他の言語がマクロを使用して構文を簡素化する方法に似ていますが、マクロは同じ目的をまったく異なる方法で達成します。
よりクリーンなコードを生み出すのは、Haskell のモナド パターンとモナドに適した構文の組み合わせであることに注意してください。モナドの特別な構文サポートがない JavaScript のような言語では、この場合、モナド パターンでコードを簡素化できるかどうかは疑問です。
可変状態
Haskell は可変状態をサポートしていません。すべての変数は定数であり、すべての値は不変です。ただし、State
型を使用して可変状態によるプログラミングをエミュレートできます。
add2 :: State Integer Integer
add2 = do
-- add 1 to state
x <- get
put (x + 1)
-- increment in another way
modify (+1)
-- return state
get
evalState add2 7
=> 9
このadd2
関数はモナド チェーンを構築し、初期状態として 7 で評価されます。
明らかに、これは Haskell でのみ意味をなします。他の言語は、変更可能な状態をすぐにサポートします。Haskell は、一般的に言語機能を「オプトイン」します。つまり、必要なときに変更可能な状態を有効にし、型システムによってその効果が明示的であることを保証します。IO は、この別の例です。
IO
このIO
型は、「不純な」関数を連鎖して実行するために使用されます。
putStrLine
他の実用的な言語と同様に、Haskell には、など、外部の世界とインターフェースする組み込み関数が多数ありますreadLine
。これらの関数は、副作用を引き起こしたり、非決定的な結果をもたらすため、「不純」と呼ばれます。時間を取得するなどの単純なものでも、結果が非決定的であるため不純であると見なされます。同じ引数で 2 回呼び出すと、異なる値が返される可能性があります。
純粋関数は決定論的です。その結果は渡された引数にのみ依存し、値を返す以外に環境に副作用はありません。
Haskell は純粋関数の使用を強く推奨しています。これはこの言語の大きなセールス ポイントです。純粋主義者にとっては残念なことに、何か役に立つことを行うには不純な関数が必要です。Haskell の妥協案は、純粋と不純をきれいに分離し、純粋関数が直接的または間接的に不純な関数を実行できないようにすることです。
これは、すべての不純な関数に型を与えることによって保証されますIO
。Haskell プログラムのエントリ ポイントは型main
を持つ関数であるIO
ため、トップ レベルで不純な関数を実行できます。
しかし、言語はどのようにして純粋関数が不純な関数を実行するのを防ぐのでしょうか。これは Haskell の遅延特性によるものです。関数は、その出力が他の関数によって消費される場合にのみ実行されます。しかし、IO
に割り当てる以外に値を消費する方法はありませんmain
。したがって、関数が不純な関数を実行する場合は、 に接続されmain
、IO
型である必要があります。
IO 操作にモナド チェーンを使用すると、命令型言語のステートメントと同様に、操作が線形かつ予測可能な順序で実行されることも保証されます。
これで、ほとんどの人が Haskell で書く最初のプログラムが完成します。
main :: IO ()
main = do
putStrLn ”Hello World”
do
操作が 1 つしかなく、バインドするものがない場合は、このキーワードは不要ですが、一貫性を保つためにそのまま残しておきます。
この()
型は「void」を意味します。この特別な戻り値の型は、副作用のために呼び出される IO 関数にのみ役立ちます。
より長い例:
main = do
putStrLn "What is your name?"
name <- getLine
putStrLn ("hello" ++ name)
これにより、一連のIO
操作が構築され、関数に割り当てられるためmain
、実行されます。
IO
とを比較すると、Maybe
モナド パターンの汎用性がわかります。 ではMaybe
、パターンは条件付きロジックをバインディング関数に移動することでコードの繰り返しを回避するために使用されます。 では、パターンは、型のすべての操作が順序付けられ、操作が純粋関数に「漏れる」ことがないようIO
にするために使用されます。IO
IO
まとめ
私の主観的な意見では、モナド パターンは、パターンのサポートが組み込まれている言語でのみ本当に価値があります。そうでない場合は、コードが過度に複雑になるだけです。しかし、Haskell (および他のいくつかの言語) には、面倒な部分を隠してくれる組み込みのサポートがあり、パターンはさまざまな便利な用途に使用できます。たとえば、次のようになります。
- 繰り返しコードを避ける(
Maybe
) - プログラムの区切られた領域に対して、変更可能な状態や例外などの言語機能を追加します。
- 不快なものと良いものを区別する (
IO
) - 組み込みドメイン固有言語(
Parser
) - 言語に GOTO を追加します。