Skip to content

Instantly share code, notes, and snippets.

@znd-milktea
Last active August 29, 2015 14:19
Show Gist options
  • Save znd-milktea/df7d7774180f8fea9591 to your computer and use it in GitHub Desktop.
Save znd-milktea/df7d7774180f8fea9591 to your computer and use it in GitHub Desktop.

すごいHaskell読書会 in 大阪 2週目 #16 第14章「もうちょっとだけモナド」

この章の概要

この章では、モナドの具体例として、いくつかのMonadクラスのインスタンスを扱います。
この章で紹介されるモナドの利用方法は、世の中に数多く存在するモナドライブラリの基本となります。

本文では

  • xx型
  • xx型のMonadクラスのインスタンス定義(returnと>>=演算子の定義)

の2つをまとめて「xxモナド」と呼ぶことにします。つまり「xx型」は「xxモナド」を構成する要素の1つです。


14.1 Writer? 中の人なんていません!

まずは、Writerモナドの登場です。Writerモナドは、モナド計算を通じてモノイド値を合成していく計算を提供します。

この節のapplyLog関数の例は、ログ出力という目的を通して、Writerモナドを導入する動機を説明しています。
まず最初に現れるisBigGang関数では、大きな盗賊団かどうかの判定結果を返しながら、処理内容のログ出力を行おうとしています。

グローバル変数が扱える言語であれば、下記のような実装ができます。

  • 関数の戻り値は判定結果
  • ログ出力は関数の内側で、ログを保持するグローバル変数に追加

しかし、Haskellではグローバル変数が扱えないので、isBigGang関数の例では下記の実装で代替します。

  • 関数の戻り値は、判定結果とログのタプル
  • ログ出力は関数の外側で、ログを保持する変数に追加
isBigGang :: Int -> (Bool, String)
isBigGang x = (x > 9, "Compared gang size to 9.")


main :: IO ()
main = do
    let log = []

    let (ret1, newLog1) = isBigGang 3
        log' = log ++ newLog1
    print ret1

    let (ret2, newLog2) = isBigGang 30
        log'' = log' ++ newLog2
    print ret2


    print log''

結果

False
True
"Compared gang size to 9.Compared gang size to 9."

ここで重要なのは「それまでのログ内容を引継ぎ、そこに新たなログを追加する」という点です。処理の度にそれまでのログがクリアされては意味がありません。
本書に記載されている「文脈の付いた値を食わせる」とは、新たなログを追加する処理を実行する際に、それまでのログ内容と値をセットで渡すことを指します。

上記のような、値とログをタプルで返す関数を実行する際、ログ出力の文脈に沿って処理を実行する場合には、下記処理は必ずセットになります。

  • 関数を実行する。
  • 関数が返したログを、それまでのログ内容に追加する。

この処理をまとめたのが、applyLog関数になります。

applyLog :: (a, String) -> (a -> (b, String)) -> (b, String)
applyLog (x, log) f = let (y, newLog) = f x in (y, log ++ newLog)


isBigGang :: Int -> (Bool, String)
isBigGang x = (x > 9, "Compared gang size to 9.")


main :: IO ()
main = do
    let log = []

    let (ret1, log') = applyLog (3, log) isBigGang
    print ret1

    let (ret2, log'') = applyLog (30, log') isBigGang
    print ret2


    print log''

モノイドが助けにきたよ

本節では、前節のログ出力から、Monoidを利用して「関数の戻り値とは別に、モノイド値を計算の過程で合成して、それも返す計算」という一般化された文脈を導き出しています。

前節のapplyLog関数はString(=Charのリスト)の値にログを追加していましたが、作成されるログは1行の文字列なので、ログ間の境目がわかりづらくなっています。
そこで、ログをStringのリストにしたいという動機が生まれます。幸い、String(=Charのリスト)もStringのリストも、どちらも「何かの型のリスト」なので、applyLog関数が本書のように一般化できます。

一方で、Stringでは処理効率が悪いので、ログをByteStringにしたいという動機もあります。しかし、ByteStringは「何かの型のリスト」では無いので、上記のapplyLog関数が適用できません。なので、このままではByteString版のapplyLog関数を定義することになります。

{-# LANGUAGE OverloadedStrings #-}

import Data.ByteString


applyLogWithBS :: (a, ByteString) -> (a -> (b, ByteString)) -> (b, ByteString)
applyLogWithBS (x, log) f = let (y, newLog) = f x in (y, log `append` newLog)


isBigGangWithBS :: Int -> (Bool, ByteString)
isBigGangWithBS x = (x > 9, "Compared gang size to 9.")


main :: IO ()
main = do
    let log = "Smallish gang."
    let (ret, log') = (3, log) `applyLogWithBS` isBigGangWithBS
    print ret

    print log'

※本書では、pack関数を明示的に用いてStringリテラルをByteStringに変換していますが、通常はGHCのOverloadedStrings拡張を用いて、pack関数の記述を不要にします。

上記の問題を、HaskellではリストやByteStringをMonoidクラスのインスタンスにすることで解決しています。
Monoidクラスのメソッドmappend関数が、リストならば(++)演算子で、ByteStringならばappend関数で実装されているので、本書のように、Monoid版のapplyLog関数を定義すれば、リストにもByteStringにも適用可能になります。

applyLog関数がMonoidクラスを用いて定義されたことによって、applyLog関数はより一般的に「関数の戻り値とは別に、モノイド値を処理の過程で合成して、それも返す計算」という文脈を処理できる関数になりました。

addDrink関数の例は、指定した食べ物に対してそれに合う飲み物を返し、さらにそれまでの注文の合計金額に対して飲み物の値段を加算する処理です。
金額を、MonoidクラスのインスタンスであるSumで定義することで、applyLog関数を用いて上記の処理を実現しています。

Writer型

本節では「関数の戻り値とは別に、モノイド値を計算の過程で合成して、それも返す計算」という文脈を処理するモナドとして、Writerモナドを紹介しています。

H本では、Writer型は2要素のタプルをnewtypeでラップしたものと紹介しています。概念的には全く問題ないのですが、現在の実装はモナド変換子WriterTを用いて定義されています。
mtlのドキュメント
このような概念と実装の違いを意識させないように、Writer型のデータコンストラクタをエクスポートせずに、writer関数でタプルをWriter型に変換するようにしています。

逆に、Writer型からタプルを得る関数としてrunWriter関数があります。
Writer型に限らず、計算を表すモナドの型のフィールドには、レコード構文でrun~という名前が付けることが良くあります。
これは、モナドによって組み上げられた計算(=アクション)を評価して計算結果を得る関数、という意味合いがあります。
※なので、レコード構文によるフィールド名としては考えないほうがわかり易いと思います。

  • アクション → モナドによって組み上げられた計算。
  • run~関数 → アクションを評価して、計算結果を得る関数
monadicAction :: (Monad m) => m a
monadicAction = x1 >>= x2 >>= ... >>= xn -- モナドを用いて計算(=アクション)を定義

let x = run monadicAction -- アクションを評価して計算結果を得る

余談ですが、何故Haskellが純粋関数型言語の枠組みで副作用を扱えるのかと言うと、 アクションを返す関数は「与えられた引数に対して、副作用無く、常に同じアクションを返す」という点で、純粋関数なのです。
但し、そのアクションを評価して得られる計算結果については、評価する際の環境によって変化するのです。

p.161の注釈も、やんわりとこの辺の事情を説明しています。
putStrLn関数は「文字列を表示する、という副作用を持つ関数」ではなく、「文字列を表示する、というI/Oアクションを返す純粋関数」なのです。
I/Oアクションの場合は、main関数の外側のランタイムで評価が行われ、入出力が実行されます。

(>>=)演算子は、前節までのapplyLog関数とほぼ同じ定義になっています。
return関数は、Monoidクラスの単位元であるmempty関数を用いて定義しています。これにより、それまでのモノイド値には何ら影響を与えることなく、アクションから値のみを返すことができます。

Writerをdo記法で使う

本節では、Writerモナドをdo記法で扱うことを紹介しています。
do記法で扱うことによって、複数のアクションを合成する記述が簡単になります。

logNumber関数は、引数で受け取った関数をログに出力し、その値を返すWriterアクションを返す関数です。
multWithLog関数は、3と5をlogNumber関数に渡して返されたアクションと、ログ出力は行わずに3 * 5の結果を返すアクションを合成して返す関数です。

注意点としてはdo記法において「文脈の付いた値」とは、各アクションから戻される値のことです。multWithLog関数の場合では変数aやbに格納される値がそれに該当します。
logNumber関数に渡される3や5には、文脈は付加されていません。冒頭のisBigGang関数の例とは、やや状況が異なります。

multWithLog関数が返すWriterアクションを、runWriter関数で評価することで、本書のような実行結果が得られます。

併せて、tell関数について説明があります。writer関数よりはtell関数を使うのが一般的なので、logNumber関数も下記の方がわかり易いかもしれません。

logNumber :: Int -> Writer [String] Int
logNumber x = do
    tell ["Got number: " ++ show x]
    return x

プログラムにログを追加しよう!

本節では、ユークリッドの互助法のアルゴリズムを実行する関数に、ログ出力機能を追加しています。

互助法自体は主題ではないので端折りますと(汗
ポイントとしては、純粋関数版のgcd`関数と、Writerモナドを用いたgcd'関数の定義に殆ど差が無い点です。
これは、Writerモナドがログ(モノイド値)の合成に関する処理を隠蔽してくれているためです。

本書では、アクションの評価結果のモノイド値を取得する方法としてsnd関数を使っていますが、一般的に、モノイド値のみを取得したい場合には、execWriter関数を用います。このexec~関数も、計算を表すモナドでよく現れる関数です。

mapM_ putStrLn $ execWriter (gcd' 8 3)

非効率なリスト構築

本節では、Writerモナドで用いるモノイドの結合に関する注意点を記述しています。

モノイドがリストの場合、結合には(++)演算子が用いられますが、(++)は左結合が深くなると極端に非効率になることに留意する必要があります。
おさらいすると、(++)演算子のイメージは「左側のリストの末尾要素を1つずつ切り出して、右側のリストの先頭に繋げる」という操作です。左結合が深いと、折角作った結果のリストをもう一度全部切り出して右側に繋げる、という操作を繰り返すことになります。

最初のgcd'関数の場合は、作成されるメッセージは右結合で作成されるので、比較的効率よく結合が行われます。
しかし、gcdReverse関数の場合はメッセージが左結合で作成され、再帰呼び出しが深くなるほど左結合も深くなるため、それにともなって効率がグングン落ちてきます。

プログラミングの際に、いちいち「これは左結合になるから云々」と考えるのも面倒なので、出来ればそれを意識しなくても効率的なモノイドを利用することを検討したほうが良いでしょう。

差分リストを使う

ということで、本節ではそのようなモノイドとして差分リストを紹介しています。
差分リストの結合は、直接リストを作成せずに「リストを作成する関数を右結合する関数」を返します。差分リストの実装として、本書ではDiffListを定義しています。

DiffList自体は、受け取ったリストを右結合する関数です。
そしてMonoidクラスのメソッドであるmappend関数は、2つのDiffListの関数合成として定義しています。関数合成の結果出来上がった関数は、引数で受け取ったリストを、右結合の一番深いところで適用する関数になります。これにより、常に右結合ができあがります。

差分リストは受け取ったリストを右結合する関数なので、最終的に具体的なリストにするには、一番右側に空リストを結合すれば良いです。fromDiffList関数がそれに当たります。

性能の比較

この節では、普通のリストと差分リストの性能の比較を行っています。
本では性能を伝えることが難しいので、実際に計測してみましょう。

GHCのRTSオプションを用いれば、プロファイリングが可能です。
第45回 GHCの性能測定機能でボトルネックを探す - 本物のプログラマはHaskellを使う

下記のオプションでコンパイルを行えば、実行後にプロファイリングの結果が出力されます。

ghc -with-rtsopts="-t" ソースファイル名

finalCountDown関数の引数を5000にした場合の結果は下記の通りです。

差分リスト版

<<ghc: 22596648 bytes, 45 GCs, 600269/1211920 avg/max bytes residency (3 samples
), 3M in use, 0.00 INIT (0.00 elapsed), 0.08 MUT (0.43 elapsed), 0.00 GC (0.01 e
lapsed) :ghc>>

通常のリスト版

<<ghc: 1052183416 bytes, 1381 GCs, 37165350/137063408 avg/max bytes residency (1
3 samples), 370M in use, 0.00 INIT (0.00 elapsed), 0.34 MUT (1.04 elapsed), 2.59
 GC (2.67 elapsed) :ghc>>

14.2 Reader? それはあなたです!

続いてReaderモナドの登場です。Readerモナドは、モナド計算を通じて共通の値を参照できる計算を提供します。

まずは、関数がファンクタであることのおさらいをしています。
特にアプリカティブファンクタとして、(+)演算子を持ち上げた場合の例は、持ち上げ後の(+)演算子の引数に与えられる(*2)と(+10)はいずれも、関数の引数として与えられた3に適用されます。
この3が、計算の中で共通に参照される値です。

モナドとしての関数

本節では、更にモナドとしての関数について記述しています。

諸事情により、先のページに出ているaddStuff関数の例を挙げて説明します。
但し、本書の例では関数モナドを使っている雰囲気がわかりにくいので、関数の型宣言で明示的に関数モナドを用いていることを記述しています。

addStuff :: ((->) Int) Int
addStuff = do
    a <- (*2)
    b <- (+10)
    return (a+b)


main :: IO ()
main = do
    print $ addStuff 3

結果

19

関数モナドが表す文脈は「共通の値を参照する」というものです。上記の例では、(*2)も(+10)も、同じ3に適用しています。
return関数は、共通の値は無視して、与えられた引数だけを結果として返しています。

Readerモナド

本書では、関数モナドのことをReaderモナドと呼んでいますが、実際には「共通の値を参照する」という文脈に関して、 より一般化された(本来の)Readerモナドが別途定義されています。
今回は本書の内容に代えて、(本来の)Readerモナドを説明します。
※この辺の事情があって、addStuff関数の例を先に挙げてしまいました。

関数モナドの場合は、共通の値を参照する場合は、常にその値を引数にとる関数である必要があります。
例えば、共通の値がInt値で、関数モナド内でelem関数にその値を渡したい場合、ラムダ式等を用いて、 共通の値を引数にとることが出来る関数に変換してあげる必要があります。

find :: [Int] -> ((->) Int) Bool
find xs = do
    r <- (\x -> elem x xs)
    return r


findMulti :: ((->) Int) Bool
findMulti = do
    a <- find [1, 2, 3, 4, 5]
    b <- find [6, 7, 8, 9, 10]
    return (a || b)


main :: IO ()
main = do
    print $ findMulti 3

Readerモナドには、Writerモナドの場合と同様に、runReader関数が用意されています。
runReader関数は、Readerアクションと、アクションで共通に参照する値を渡すと、アクションの評価が行われて結果が返される、と考えるとわかりやすいかもしれません。
共通の値は、Readerモナドの範囲内では、ask関数を用いて参照することができます。

import Control.Monad.Reader


find :: [Int] -> Reader Int Bool
find xs = do
    x <- ask
    let r = elem x xs
    return r


findMulti :: Reader Int Bool
findMulti = do
    a <- find [1, 2, 3, 4, 5]
    b <- find [6, 7, 8, 9, 10]
    return (a || b)


main :: IO ()
main = do
    print $ (runReader findMulti) 3

14.3 計算の状態の正体

本節では、「状態付き計算」という文脈を扱うStateモナドについて記述されています。
Stateモナドは、純粋関数型言語の枠組みで、読み書き可能なグローバル変数をシミュレートできる、重要なモナドです。

前の章で乱数を扱いましたが、乱数を発生させるために、以前の乱数ジェネレータを参照して、新しい乱数ジェネレータを保持する ということが必要でした。このままでは、新しい乱数ジェネレータを受け取るたびに新しい名前を定義する必要があり、めちゃくちゃ不便です。
読み書き可能なグローバル変数が扱えれば、そこに乱数ジェネレータを保持することで引き回す必要がなくなります。
そういった記述を可能にするのがStateモナドです。

状態付きの計算

本節では、純粋関数で状態付き計算を扱う方法について記載されています。

s -> (a, s)

上記の型宣言は「初期状態を受け取って、計算結果と終了状態のタプルを返す関数」と解釈できます。
終了状態を直接グローバル変数に書き込まない代わりに、戻り値として返すようにしています。 この終了状態を、次の状態付き計算の初期状態として渡せば、一連の計算の中で変化する状態を扱うことが出来ます。
このあたりはWriterモナドのときと同じ考え方です。

スタックと石

本節では、状態付き計算の具体例としてスタックを用いています。

スタック自体も主題ではないのでやっぱり端折りますと(汗
pop関数もpush関数も、前節で紹介した、状態つき計算を扱う関数の型宣言の形式になっています。push関数には余分にInt型の引数がありますが、部分適用すれば同じ型宣言になります。

stackManip関数の例で、実際にスタック操作を行っていますが、計算の結果返された状態に対して、毎回異なる名前をつけて次の計算に渡しています。先程の乱数ジェネレータの例と同じで、面倒くさいですね。
この操作をより簡便に記述できるのが、Stateモナドになります。

Stateモナド

本節では、状態付き計算を扱うStateモナドについて説明しています。

State型のMonadクラスのメソッドであるreturn関数は、何も状態を変えずに、与えられた値を戻り値として返すStateアクションを返します。 (>>=)演算子は、下記の計算を行います。

  1. 左側の(State h)はStateアクションなので、初期状態sを与えて評価して結果の値aと新しい状態newStateを得る。
  2. 右側のfは、値を与えるとStateアクションを返す関数なので、1.の結果の値aを渡して、Stateアクション(State g)を得る。
  3. 2.で得られたStateアクション(State g)に、1.の新しい状態newStateを渡して、最終的な結果の値と最終状態を得る。

上記の計算を連結することで、状態がモナド計算を通じて引き継がれていきます。

また、これまでのWriterモナドやReaderモナドと同様に、Stateアクションを評価するrunState関数があります。runState関数は、Stateアクションと初期状態を渡すと、アクションの評価が行われて結果と最終状態が返される、と考えるとわかりやすいかもしれません。

これらStateモナドを用いて、前節のスタックを再定義しています。push関数とpop関数の定義はまだちょっとわかりにくいですが(後でわかり易くなります)、stackManip関数は、do記法を用いてかなり直感的に記述ができるようになりました。

本書ではrunState関数のみの説明ですが、最終状態のみが欲しい場合はexecState関数、結果のみが欲しい場合はevalState関数が用意されています。

execState stackManip [5, 8, 2, 1]

結果

[8,2,1]
evalState stackManip [5, 8, 2, 1]

結果

5

stackManip関数とstackStuff関数を組み合わせる例は、do式(正確には>>=演算子)を用いて作り上げた計算もまた、 アクションを返す関数として、より大きな計算の部品として組み込むことができることを示しています。
小さな部品から出発して、大きな計算を組み上げるのは、どんな言語にも共通なセオリーですね。

状態の取得と設定

この節では、Stateモナドを使うに当たって便利な関数であるget関数とput関数を紹介しています。

get関数は状態を取得する関数、put関数は状態を上書きする関数です。 これらの関数を用いるのが通常であり、state関数を直接使うことはあまり無いかと思います。
pop関数とpush関数を、これらを使って再定義していますが、かなりわかり易くなりましたね。

また後半では、計算を組み上げるには、状態の型が同じStateアクション同士であるべきことを説明しています。

乱数とStateモナド

この節では、最初にStateモナドを導入する動機となった、乱数を扱う計算をStateモナドで記述しています。

random関数は、乱数ジェネレータを状態とみなすと、初期状態を受け取って値と最終状態を返すという型宣言になっています。 これは、Stateモナドで扱う状態付き計算と全く同じ形式なので、state関数で包むことでStateモナドで乱数ジェネレータの状態管理を行うことができます。
threeCoins関数では、乱数ジェネレータの管理を記述しなくても良くなったので、冒頭の例に比べるととても楽になりました。

Errorを壁に

この節では、Eitherを用いて、計算に失敗した際には、付加情報をつけてそのことを伝える計算を紹介しています。

Maybeでモナド計算が組み上げられたのと同じように、Eitherでも失敗を伝えるモナド計算を組み上げることが出来ます。
またEitherなら、Left値で失敗に関する情報(エラーメッセージ等)を付加することができます。

(Either e)型のMonadクラスのインスタンス定義も、Maybeのときと殆ど同じです。
Eitherは型引数が2つあるので、Left値の型を部分適用した型をMonadクラスのインスタンスとしています。これは、失敗として通知される付加情報の型は、あるモナド計算の中では常に同じものであることを表しています。

本書では、Left側の型はErrorクラスの型クラス制約があると記述されていますが、訳注の通り、現在ではその制約はありません。
なので、本節の後半にあるようなエラーは出なくなっています。

ここからは余談ですが、厳密には型クラス制約を外しただけでは、なぜエラーにならないのかが判りにくいと思います。
現に、ghciではなく、下記ソースをコンパイルするとエラーになってしまいます。

main :: IO ()
main = do
    print $ Right 3 >>= \x -> return (x + 100)

これは、ソース中にはLeft値の型に関する情報が全く無く、型推論に失敗するためです。原則としては、こっちの動きの方が正しい動きです。
では、なぜghciではエラーが発生しなくなるかと言うと、ghciではデフォルトでExtendedDefaultRulesオプションが有効になっているためです。 ExtendedDefaultRulesオプションが有効になると、明示化されていない型変数に対して幾つかの型クラスに属することを前提としてくれます。その中にShowクラスがあるので「Left側が何かわからないけどShowクラスに属することにしたから、Either全体もShowのインスタンスとして有効」と判断してくれます。

上記ソースで、ghciと同じような形でコンパイルしたい場合は、LANGUAGEプラグマでExtendedDefaultRulesオプションを有効にします。

{-# LANGUAGE ExtendedDefaultRules #-}


main :: IO ()
main = do
    print $ Right 3 >>= \x -> return (x + 100)

どちらかと言えば、ExtendedDefaultRulesはghciのような対話環境向けの便利オプションなので、コンパイル環境で指定することはあまり無いかと思います。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment