3 ポイント 投稿者 GN⁺ 2025-01-20 | 2件のコメント | WhatsAppで共有

副作用を第一級の値として扱う

  • 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, …

  • liftA2liftA3 のような関数は、複数の副作用の結果を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 で実行できる

幕間: 便利関数

  • voidsequenceA_replicateMreplicateM_ などは、結果値を使わない場合や繰り返し実行したい場合に便利
  • 例: replicateM_ 500 (putStrLn "I will not cheat again.") のように、繰り返し回数を直接数えなくても副作用を何度も実行できる

traverse

  • traverse :: (a -> IO b) -> [a] -> IO [b] は、リストの各要素に副作用関数を適用し、その結果をリストとして集める動作を作る
  • sequenceA は実際には traverse id と同じで、traverse_ は結果を捨てる版である

for

  • fortraverse と同じ機能だが、引数を逆順に受け取る

  • 例: for numbers $ \n -> ... の形で、「forループ」のような構文を自然に表現できる

  • こうした組み合わせのおかげで、他の言語では個別の構文で扱う必要がある反復、走査、データ構造の変換を、Haskellではライブラリ関数の組み合わせとして実装できる

副作用の第一級性を積極的に活用する

  • Haskellで副作用を第一級の値として積極的に活用すると、コードの重複削減や構造改善が可能になる
  • たとえば、キャッシュを使った大きな数の素因数分解ロジックでは、IO の代わりに State などを使って、「副作用は存在するが外部には影響しない」構造を作れる
  • このように構造化された副作用は必要な部分にだけ適用され、それ以外のコードは純粋関数として保てるため、安全性と柔軟性を同時に確保できる
  • 最終的には evalState などで実際に副作用を実行し、結果を純粋な値にできる

気にする必要がまったくないもの

  • 昔のHaskellの時代からあったいくつかの名前(>>returnmapM など)は、現在の関数(*>puretraverse など)で置き換えられる
  • これらは「古い名前」あるいはモナド中心の設計に由来しており、現在では 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 は、走査だけでなく、新しい要素で同じ形の構造を再構成できる構造である
  • sequenceAtraverse が元の構造を保ったまま値を集められるためには、その構造が Traversable である必要がある
  • ツリーや Set のようなデータ構造では、構造が値によって変わることがあるため、走査だけ可能な場合(Foldable)と、実際に構造を再構成できる場合(Traversable)が区別される
  • 必要に応じてリストに変換してから traverse を使う方法などを通じて、柔軟に副作用を扱える

2件のコメント

 
bbulbum 2025-01-21

Redditを見ていると広告がよく出てきます……でも名前の時点で少し心理的なハードルがあります。
なんだかすごく難しくて強力な言語という感じがします……

 
GN⁺ 2025-01-20
Hacker Newsの意見
  • Haskellの型システムは、ほかの人気言語と比べると複雑さがある。特に *>, <*>, <* のような演算子は、コードベース全体で学習コストを高める

    • 1か月Haskellを使わないと、>>=>> のような演算子を生産性を維持するために再び学び直さなければならないことがある
    • Haskellの概念を人と対話せずに一人で学ぶのは難しい
  • Haskellは命令型プログラミングを改善するのに役立つ

    • 第一級のeffectとパターンを使って、ボイラープレートコードを削減できる
    • 型安全性によって、比較的バグの少ないコードを素早く書ける
  • traverse/mapM の一般化された版は、リストだけでなくすべての Traversable 型に対して動作し、非常に便利である

    • traverse :: Applicative f => (a -> f b) -> t a -> f (t b) の形で使える
    • 他の言語では、似たような効果を得るために多くのコードを手作業で書く必要があった
  • Haskellは強力なモナドを備えており、これによってHaskellはより手続き的になる

    • do ブロックで中間変数を使える
  • Haskellで書かれたソフトウェアとしてはImplicitCADがある

  • Haskellのコードは手続き型言語のように読める一方で、副作用を持つ関数を扱う際の利点を提供する

    • IOモナドを扱うのは複雑で、別のモナド型を使おうとするとさらに複雑になる
  • >><i>> の古い名前であり、両方の演算子は左結合演算子である

    • >>infixl 1 として定義され、<i>>infixl 4 として定義されているため、<i>> のほうが >> より強く結合する
  • Haskellの IO aa は、非同期と同期に似たもののように感じられることがある

    • 前者は待機が必要なpromise/futureを返す
  • 他の言語では、console.log("abc") のような関数で簡単なIOを実行できる

    • それがHaskellのIOと何が違うのかという疑問がある
  • Haskellを試したことのない人は、GHC拡張を使った実際のHaskellは複雑すぎると感じるかもしれない

    • そのためHaskellへの関心が薄れる可能性がある