朝日ネット 技術者ブログ

朝日ネットのエンジニアによるリレーブログ。今、自分が一番気になるテーマで書きます。

Haskellのつまづきやすいポイントを解説 ~モナドと effect system ~

こんにちは、開発部のjiweenです。

本記事では、Haskellについて誤解しやすい・つまづきやすいポイントである、Haskellにおける手続き型プログラミングについて紹介します。手続き型言語を書いたことはあるがHaskellを書いたことがない人を対象にしています。

記事の目的

Haskellは純粋関数型言語です。関数型スタイル自体は近年関数型でない言語でも取り入れられており、ある程度イメージできる方も多いと思います。しかし、Haskellの特殊なところは"純粋"関数型であるところです。純粋関数型では基本的に副作用が起こせない (関数の入力と出力しか使えない) ため、通常の手続き型言語とは全く異なる方法で手続きを記述します。誤解されがちですが、Haskellでは手続き型プログラミングを完全に排除するわけではありません。必要に応じて、モナドという概念を使って手続き型プログラミングを行います。

Haskellのこの特殊性は他の言語から乗り換える際の障壁です。逆に、手続きの扱い方でつまづかなければ、Haskellを (他の言語と同程度に) 実用するのは比較的難しくありません。本記事ではその障壁を取り除くことを目指します。

更に、その手続きの扱い方は通常の手続き型言語と異なるメリットがあることについても説明します。

用語

手続き型プログラミング

プログラムの状態を変更する "命令" 群を、 "手続き" (またはサブルーチン) と呼ばれる呼び出し可能な単位にまとめ、その手続きによってプログラムを構築するのが手続き型プログラミングです。1

作用と副作用

作用 (effect) はあまり厳密な用語ではありませんが、計算中に発生する、何か (状態) との相互作用全般を指します。よく使われる「副作用 (side effect)」という言葉は、何らかの意味で「主」ではない作用という意味であり、例えば関数について使う場合は関数の入出力以外の全ての作用を指します。2

なお、ややこしいのですが、慣用的には「副作用」が作用の意味で使われることもあります。

作用の意味から分かるように、命令は作用であり、手続き型言語における手続きは作用の付いた (effectful) 計算と言えます。

純粋 (pure) /不純 (impure) な関数

純粋な関数 (pure function) とは、 1. 副作用がなく、 2. 同じ入力に対して同じ出力を返す (参照透過性がある) 関数のことです。3

要するに、入力のみに依存して出力を返し、それ以外何もしないように見えるということです。

Haskellは純粋関数を元にした言語であるため純粋関数型言語と呼ばれます。

関連する表現として、Haskellの文化では、IOモナド (後述) に変換される関数や処理を不純 (impure) と呼び、そうでないものを純粋と呼ぶことがあります。 (これは"純粋"の厳密な定義とは異なります)

モナドを使ったコード例

Haskellではモナドという概念を使って、作用のある計算、つまり手続き的な計算を表現します。詳細はともかく、値レベルのコードは普通の手続き型言語とそれほど変わりません。以下に (架空の) モナドを使ったコード例を示しますが、多くのプログラマは感覚で大体読めてしまうと思います。

-- Int型の状態を読み書きできる、 State Int モナド

proc1 :: State Int Int
proc1 = do
  s <- get    -- 状態を取り出す
  put (s + 1) -- 1を足した値を状態に書き込む
  double      -- 2倍する
  get         -- 状態を取り出す

double :: State Int ()
double = do
  s <- get    -- 状態を取り出す
  put (s * 2) -- 2倍した値を状態に書き込む
-- 画面への文字列入出力を行う、 Console モナド

proc2 :: Console ()
proc2 = do
  printLine "Input your name..."
  name <- getLine
  printLine ("hello, " ++ name)

さて、これらのコードはどう見ても手続き型言語であり、作用を扱っているように見えます。Haskellが純粋関数型言語だという話はどこへ行ったのでしょうか?

実のところ、Haskellが副作用を起こせないというのは作用を全く扱わないという意味ではありません。一言で言えば、Haskellは作用を "副作用" としては記述しませんが、値 (いわば主作用) として明示的に記述します。関数が作用を起こすには作用を表す値を出力として返すのです。

これを達成できるのがモナドです。

モナドとして書くメリット

この方法はHaskellの制限を回避するためのただの小細工に聞こえるかもしれませんが、大きなメリットがあります。それは、副作用を扱うには必ずモナドの値として間接的に書かなければならないので、どんな副作用を扱っているかが型レベルで分かる、ということです。

例えば、先程のコードでInt型の状態に対する作用 (今は関数の出力として明示的に扱っているため、"副"作用ではなく単に作用と呼びます) を書きましたが、そのせいで型レベルに State Int という情報がくっついていますよね。作用を使うと必ず型に現れるということは、作用の有無が型から分かるということです。基本的にHaskellプログラマは作用を必要な時だけ使い、他の部分は、より簡単な作用のない関数として書きます。

IOモナド

Haskell外部への作用 (Input/Output) は、最終的に全てIOモナドに変換してコンパイラに渡す必要があります4。そのため、Haskellのmain関数は次のような型を持ちます。

main :: IO ()

IOモナドの値には「実行される予定の作用」が記述されており、この作用はコンパイラによって解釈されます。Haskellを書く側にとって関数の実行はあくまで純粋ですが、関数はIOモナドの値を出力することによってコンパイラに不純な作用を依頼することができます。

IOモナドが、不純な作用、外部への作用を記述するための唯一の抜け穴となっているのです。

例えば上記で例に挙げた Console モナドは標準ライブラリに含まれるものではなく私が勝手に仮定したもので、最終的にはIOモナドに変換する必要があります。

do構文

State IntConsole の例ではまるで手続き型言語のような書き方をしましたが、これはモナドに用意された特殊な構文、 do 構文の力です。詳細は省きますが、 do 構文を使うと作用を単に上から下に並べるように書くことができます。

effect system を使ったコード例

モナドによって純粋関数型言語で作用を扱えるようになり、手続き型プログラミングが行えます。しかし、実用のためにはもう少し追加の仕組みがあると便利です。それが effect system です。

通常 effect system を使うと、単に1種類の作用を書くだけでなく、複数の作用を組み合わせて同時に使ったり、作用を他の作用に変換することができるようになります。これにより作用をモジュラーに書けますし、「どんな作用が含まれるか」を型レベルで細かく記述できるようになります。細かく分けられた型を使うことで、プログラマの設計意図をコード上に表現したり、プログラムを安全にしたりすることができます。

Haskellの effect system はいろいろなライブラリ実装があります。私は最近Effectfulといライブラリを使用していますが、どのライブラリも概ね同じようなインターフェイスになっています 5

Haskellにおける 典型的な effect system を使ったコードは以下のようになります。以下のコードでは、 State Int, Console effect を組み合わせて使っています。

--------------------------------------------------------------------------------
-- effectの宣言
--------------------------------------------------------------------------------

-- State s effect を定義
data State s :: Effect where
  -- 状態に新しい値を置く
  Put :: s -> State s ()
  -- 状態を取得する
  Get :: State s s

-- put, get は Put, Get 命令を作るためのヘルパー関数
put :: State s :> es => s -> Eff es ()
put s = send $ Put s
  -- ↑ Put命令はそのままだと State s () 型なので、Eff es () 型に埋め込む

get :: State s :> es => Eff es s
get = send Get

-- Console effect を定義
data Console :: Effect where
  -- 文字列を出力する
  PrintLine :: String -> Console ()
  -- 1行の文字列入力を取得する
  GetLine :: Console String

-- printLine, getLine は PrintLine, GetLine 命令を作るためのヘルパー関数
printLine :: Eff Console :> es => String -> Eff es ()
printLine s = send $ PrintLine s

getLine :: Eff Console :> es => Eff es String
getLine = send GetLine

--------------------------------------------------------------------------------
-- effectの使用
--------------------------------------------------------------------------------

-- State Int と Console というeffectを使用し、空の結果を返す関数
-- (関数の戻り値の型に Eff がつくと、effectful な計算であることが分かる)
app :: (State Int, Console) :> es => Eff es ()
app = do -- (Eff es) はモナドなのでdo構文が使える
  printState -- 現在の状態を出力
  increment -- 1足す
  increment -- 1足す
  printState -- 現在の状態を出力

-- 現在の状態を出力する
printState :: (State Int, Console) :> es => Eff es ()
printState = do
  current <- get
  printLine ("current state: " ++ show current)

-- 状態に1を足す
increment :: State Int :> es => Eff es ()
increment = update (+1)

-- 状態を更新するための関数を受け取り、状態を更新する
update :: State Int :> es => (Int -> Int) -> Eff es ()
update f = do
  state <- get -- 現在の状態をgetする
  put (f state) -- 更新後の状態をputする

--------------------------------------------------------------------------------
-- effectの解釈
--------------------------------------------------------------------------------

-- 手続きに含まれる State Int effect を実際に解釈するための関数
-- 初期値と、State Int が含まれる手続きを受け取り、State Int が含まれていない手続きに変換する
runState :: Int -> Eff (State Int : es) a -> Eff es a
-- 実装は省略

-- runConsole の定義は省略 (補足を参照)

-- State Int と Console の effect を含む手続きを解釈する。State Int の初期値は0
main :: IO ()
main = runConsole . runState 0 $ app -- このプログラムは0と2を出力する

このように effect を細分化すると、どんな effect が発生するかを型で制限することができコードが分かりやすく安全になるというメリットがあります。

-- これだと、型からはどんなeffectが起こるか分からない (自宅が爆発するかも!)
app :: IO ()

-- この型なら、State Int & Console effect しか使っていないことが分かる
app :: (State Int, Console) :> es => Eff es ()

これが effect system を用いた手続き型プログラミングと、従来の手続き型プログラミングとの違いの1つです。そして、Haskellでは、このような手続き型プログラミングと純粋関数型プログラミングを組み合わせることができるのです。

まとめ

本記事ではHaskellで手続き型プログラミングを行う方法について説明しました。

Haskellを汎用プログラミング言語として扱う場合この話題は重要なのですが、ガイダンス的な説明が少ないと思ったのでこの記事を書きました。

お役に立てれば幸いです。

本記事で紹介したモナドや effect system は通常の手続き型言語と同じようなことを実現する仕組みでもありますが、それだけではなく、とても応用範囲の広いものです。 通常イメージされるIOやメモリ操作などの手続き (もっともHaskellでメモリ操作を直接行う機会は稀ですが) 以外にも、無効値の処理 (他の言語におけるnullやoptionalに似たもの) や例外処理、DI、リソース管理などいろいろなものをモナド/effectとして扱うことができます。

この仕組みはコードを読みやすくする他、コード設計においても非常に便利なので、保守性の高いコードや読みやすいコードを書くことに興味のある方にはぜひHaskellをおすすめします。

補足 (おまけ)

ここから先は、もう少し詳しく知りたい方のための補足です。

モナドについて

1つの見方として、モナド (monad) は手続きです。手続きは作用付き計算なので、モナドを使うと手続き型言語のようなことができるということです。

モナドを定義する性質はいくつかありますが、最も特徴的なのは、モナド m は次の関数 flatMap を持つということです。(実際のHaskellではこの flatMap>>= と書かれます)

flatMap :: m a -> (a -> m b) -> m b

この構文の読み方はこれから説明します。

まず、 flatMap の右側にはその型が書いてあります。

Haskellは遅延評価であるため、 x 型の値には「結果が x になる計算」を入れられることに注意してください。

今モナドは作用だと考えようとしているところなので、 m x は「結果が x 型の作用付き計算」だと考えてください。

a -> m b は「a の値を受け取って、結果が b 型の作用付き計算を返す関数」(の型) と読めます。

flatMap は「結果が a の作用付き計算」と「a の値を受け取って、結果が b 型の作用付き計算を返す関数」を受け取って、「結果が b 型の作用付き計算」を返す関数です。つまり flatMap は「作用付き計算 m a と、その続きの作用付き計算 a -> m b を受け取って、1つの作用付き計算 m b にする」という関数なのです。

もっと抽象的には、 flatMap は「2つの作用付き計算を結合する」関数と捉えることができます。このように順序を伴って結合できること自体が作用を定義する重要な性質です (順序を持たなければ状態との相互作用を記述できないことを考えると、直感的に理解できるかもしれません)。

モナドがどのように作用を表現しているのか、具体例を見てみましょう。

先ほど出てきた State Int の値は (double を展開すると) 以下のようになります。

do
  s <- get
  put (s + 1)
  s' <- get
  put (s' * 2)
  get

do構文は糖衣構文であり、実際は次のような値と等価です。

get >>= (
  \s -> put (s + 1) >>= (
    \() -> get >>= (
      \s' -> put (s' * 2) >>=(
        \() -> get
      )
    )
  )
)

状態に対する作用 (getput を使った指示) が >>= (flatMap) によってつながって並んでいるのが分かるでしょうか。この値は手続き型言語のプログラムに対応します。実際 get, put, >>= の定義を与えれば、このプログラムは実行することができます。(どのように与えるのかについては色々な方法がありますが、ここでは省略します)

なぜ flatMap と呼ぶのか

>>= がしばしば flatMap と呼ばれるのはなぜでしょうか。それは、flatMap の型上の仕事を想像してみると分かります。

flatMapm aa -> m b を受け取って m b を返す必要がありますが、まず m a の中に入っている aa -> m b を適用し、 m (m b) を作ることができます。このように、 m a の中の a に関数を適用する操作は map と呼ばれます。

次に、 m (m b) のように二重になった m を1つに潰すことで、最終的な m b を作ることができます。このような操作は flat, flatten と呼ばれることがあります。(haskellではこの関数は join と呼ばれるのですが)

このように、 >>=mapflat の2つの操作をしているとみなせるので flatMap と呼ぶことがあります。作用付き計算/手続きをまさに結合しているのは flat の部分になります。

effect system におけるeffectの解釈

先程の effect system のコード例では、effectを解釈する部分を省略しました。その部分について踏み込みます。

まず runState の実装についてですが、大抵の場合、State effect はプリミティブなeffectとしてライブラリで事前定義されています。runState も実装済みでしょうから、それを使うだけで済みます。

runConsole については、Console effect はあまりプリミティブではないので、手動でIOモナドへの変換を行う必要があるでしょう。effect system では、普通IOモナドのeffect版が定義されています。ここではそれを IOE と呼びます。

IOE を使うと、 runConsole の型は次のようになります。

runConsole :: IOE :> es => Eff (Console : es) a -> Eff es a

effect stack に IOE を仮定して Console effect を剥がす、つまり ConsoleIOE に変換しているということが型から読み取れます。

実装はライブラリにもよりますが、例えば次のようになります。

runConsole = interpret $ \case
  -- Console effect を受け取って、IOEに変換する
  PrintLine s -> liftIO $ putStrLn s
  GetLine -> liftIO $ getLine s

-- putStrLn, getLine は標準ライブラリ関数
putStrLn :: String -> IO ()
getLine :: IO String
-- liftIO はIOモナドをIOEに持ち上げるライブラリ関数
liftIO :: IOE :> es => IO a -> Eff es a

このように多くの場合、effectの解釈は他のeffectへ変換するだけで済みます。ライブラリによってはinterpreterを (effectの変換によってではなく) プリミティブな方法で書くこともでき、その方法はライブラリによって異なります。しかし、その機会はあまりありません。

IOEを最終的にIOモナドに戻す関数もライブラリに用意されています。

-- IOE effect だけになった計算を、コンパイラに渡せる形式であるIOモナドに変換する
runIOE :: Eff [IOE] a -> IO a

そして、main関数は正確には次のようになります。

main :: IO ()
main = runIOE . runConsole . runState 0 $ app

-- なお、runConsoleとrunStateは独立しているので入れ替えても同じ
--  main = runIOE . runState 0 . runConsole $ app

途中の型を細かく見ると次のようになっています。effect stack の effect が1つずつ解釈され、最後はIOEだけが残っているのが分かります。

main = runIOE a
  where
    --                          ↓ effect stack
    a   = runConsole a'  :: Eff [IOE] ()
    a'  = runState 0 a'' :: Eff [Console, IOE] ()
    a'' = app            :: Eff [State Int, Console, IOE] ()

採用情報

朝日ネットでは新卒採用・キャリア採用を行っております。

新卒採用 キャリア採用|株式会社朝日ネット


  1. https://en.wikipedia.org/wiki/Procedural_programming
  2. https://en.wikipedia.org/wiki/Side_effect_(computer_science)
  3. https://en.wikipedia.org/wiki/Pure_function
  4. ただし、正確には、順序を保つ必要のない外部作用についてはIOモナドで表す必要はありません。例えば、引数を足し算するだけの関数であっても出力を計算する過程でメモリを使用したり、CPUを使用して熱を発生させたりするでしょうが、そのメモリ使用の順序は入れ替え可能である(とHaskellではされている)ためIOモナドをつける必要がありません。同様に、メモリへの作用を利用した関数だとしても、関数のスコープの外から見てその作用の順序がどうでもよければ、スコープの外ではモナドを外すことができます。この用途にはSTモナドを使います。STモナドでは、スコープ内で使用したメモリへの参照を外部に持ち出せない仕組みによって、スコープ内の作用の順序に外から依存できないようになっています。
  5. インターフェイスが似通っているのは、おそらく Algebraic Effects と呼ばれる概念が共通の基礎になっているためです。しかし、例えば Higher-order Effect という追加の概念についてはライブラリごとに挙動の微妙な違いがあります。