Haskell: 優れた手続き型言語
(entropicthoughts.com)副作用を第一級の値として扱う
- Haskellでは副作用(例: ランダムな数値の生成、出力など)を「第一級の値(first class value)」のように扱う
- つまり、
randomRIO(1, 6)のように副作用を生成する関数呼び出しそのものが結果値なのではなく、「いつか実行される動作を記述するオブジェクト」を返す - このオブジェクトは実際に実行されるとランダム値を生成するが、それ以前は単なる実行計画だけを保持している
IO Intのような型は「実際に実行されるとIntを生成する動作」を表し、呼び出し時点で直ちに実行されるのではなく、後で必要な時点に実行される- この性質により、「関数呼び出し = 即時実行」という従来の手続き型言語と異なり、Haskellでは副作用を組み合わせて後で実際に実行できる
doブロックの謎を解く
doブロックは魔法のような構文ではなく、実際には副作用を連結する bind と、順番に実行する then という2つの演算子で構成されている
then
*>演算子は左側の副作用を実行したあと結果値を捨て、右側の副作用を続けて実行する- たとえば
putStr "hello" *> putStrLn "world"は、2つの出力を順番に結合した1つのIO ()動作を作る doブロックで複数行を書くと、内部的にはこのような逐次実行の演算が使われる
bind
>>=演算子は左側の副作用を実行して得た値を、右側の関数に渡す役割を持つ- 例:
randomRIO(1, 6) >>= print_sideは、サイコロの結果をprint_sideに渡して出力する副作用を作る doブロックの<-パターンは、この演算子を簡潔に表現するためのもの
doブロックは2つの演算子ですべて説明できる
- 結局のところ
doブロックは*>と>>=の2つの演算子で構築されている - コードの可読性と簡便さから
do構文はよく使われるが、それ以上に豊かな副作用の組み合わせ関数を活用することで、Haskellの強みをより生かせる
副作用を操作する関数
- 副作用をより多様に扱えるさまざまな関数が標準ライブラリに存在する
pure
pure xは「追加の副作用を一切伴わず、値 x を結果として返す動作」を生成する- 例:
loaded_die = pure 4は、常に4を返すIO Intを作る
fmap
fmap :: (a -> b) -> IO a -> IO bの形で、副作用の結果値に純粋関数を適用して新しい結果値を作る動作を生成する- 例:
length <$> getEnv "HOME"のように、環境変数を取得する副作用にlengthを適用して、その長さを求める動作を生成できる
liftA2, liftA3, …
liftA2、liftA3のような関数は、複数の副作用の結果を1つの純粋関数で結合して新しい副作用を作る- 例:
liftA2 (+) (randomRIO(1,6)) (randomRIO(1,6))は、2つのサイコロの値を合計する副作用を生成する <$>と<*>の組み合わせでも同じことができる
幕間: 何がうれしいのか?
- この方式は他の言語でも可能な単純な機能のように見えるが、Haskellでは副作用の動作をいつでも変数に取り出したり再結合したりしても、実行タイミングや結果が変わらないという利点がある
- 副作用を独立して扱うことで、コードのリファクタリング時に混乱が少なく、等式推論(equational reasoning)に基づく安全な再利用が可能になる
sequenceA
sequenceA [IO a] -> IO [a]は、「副作用の動作のリスト」を「リスト結果を返す単一の副作用の動作」に変換する- 例: 複数の
log動作をリストにまとめておき、後でsequenceAでまとめて実行する、といった使い方ができる - 無限に繰り返される副作用(例:
repeat (randomRIO(1,6)))もリストとして保持しておき、必要なぶんだけtake nしてsequenceAで実行できる
幕間: 便利関数
void、sequenceA_、replicateM、replicateM_などは、結果値を使わない場合や繰り返し実行したい場合に便利- 例:
replicateM_ 500 (putStrLn "I will not cheat again.")のように、繰り返し回数を直接数えなくても副作用を何度も実行できる
traverse
traverse :: (a -> IO b) -> [a] -> IO [b]は、リストの各要素に副作用関数を適用し、その結果をリストとして集める動作を作るsequenceAは実際にはtraverse idと同じで、traverse_は結果を捨てる版である
for
-
forはtraverseと同じ機能だが、引数を逆順に受け取る -
例:
for numbers $ \n -> ...の形で、「forループ」のような構文を自然に表現できる -
こうした組み合わせのおかげで、他の言語では個別の構文で扱う必要がある反復、走査、データ構造の変換を、Haskellではライブラリ関数の組み合わせとして実装できる
副作用の第一級性を積極的に活用する
- Haskellで副作用を第一級の値として積極的に活用すると、コードの重複削減や構造改善が可能になる
- たとえば、キャッシュを使った大きな数の素因数分解ロジックでは、
IOの代わりにStateなどを使って、「副作用は存在するが外部には影響しない」構造を作れる - このように構造化された副作用は必要な部分にだけ適用され、それ以外のコードは純粋関数として保てるため、安全性と柔軟性を同時に確保できる
- 最終的には
evalStateなどで実際に副作用を実行し、結果を純粋な値にできる
気にする必要がまったくないもの
- 昔のHaskellの時代からあったいくつかの名前(
>>、return、mapMなど)は、現在の関数(*>、pure、traverseなど)で置き換えられる - これらは「古い名前」あるいはモナド中心の設計に由来しており、現在では Applicative や、より一般的な Functor ベースのアプローチが推奨される
付録A: 成功と無用さを避ける
- 「Haskellは成功を避ける」という言葉は、「言語が人気や利便性のために根本的な価値を犠牲にしない」という意味である
- 「Haskell is useless」は、当初は完全に純粋な関数しか許さないため、本当に何もできない言語のように見えたが、その後、副作用を「第一級」として扱う方式が導入されたことで実用性を獲得した、という文脈で語られる
付録B: なぜ fmap は副作用にもリストにも map できるのか
fmapは非常に一般的な形(Functor f => (a -> b) -> f a -> f b)を持ち、リスト、Maybe、IO のようなさまざまなコンテナや副作用型に共通して適用できる- リストに
fmapを適用するとすべての要素に関数を適用し、IOに適用すると結果値に関数を適用する - このように「関数を適用できる構造」全般が Functor と呼ばれる
付録C: Foldable と Traversable
Foldableは、要素を走査しながら処理できる構造であるTraversableは、走査だけでなく、新しい要素で同じ形の構造を再構成できる構造であるsequenceAやtraverseが元の構造を保ったまま値を集められるためには、その構造がTraversableである必要がある- ツリーや Set のようなデータ構造では、構造が値によって変わることがあるため、走査だけ可能な場合(
Foldable)と、実際に構造を再構成できる場合(Traversable)が区別される - 必要に応じてリストに変換してから
traverseを使う方法などを通じて、柔軟に副作用を扱える
2件のコメント
Redditを見ていると広告がよく出てきます……でも名前の時点で少し心理的なハードルがあります。
なんだかすごく難しくて強力な言語という感じがします……
Hacker Newsの意見
Haskellの型システムは、ほかの人気言語と比べると複雑さがある。特に
*>,<*>,<*のような演算子は、コードベース全体で学習コストを高める>>=や>>のような演算子を生産性を維持するために再び学び直さなければならないことがあるHaskellは命令型プログラミングを改善するのに役立つ
traverse/mapMの一般化された版は、リストだけでなくすべてのTraversable型に対して動作し、非常に便利であるtraverse :: Applicative f => (a -> f b) -> t a -> f (t b)の形で使えるHaskellは強力なモナドを備えており、これによってHaskellはより手続き的になる
doブロックで中間変数を使えるHaskellで書かれたソフトウェアとしてはImplicitCADがある
Haskellのコードは手続き型言語のように読める一方で、副作用を持つ関数を扱う際の利点を提供する
>>は<i>>の古い名前であり、両方の演算子は左結合演算子である>>はinfixl 1として定義され、<i>>はinfixl 4として定義されているため、<i>>のほうが>>より強く結合するHaskellの
IO aとaは、非同期と同期に似たもののように感じられることがある他の言語では、
console.log("abc")のような関数で簡単なIOを実行できるHaskellを試したことのない人は、GHC拡張を使った実際のHaskellは複雑すぎると感じるかもしれない