Mercurial > hg > Papers > 2014 > toma-master
view paper/chapter1.tex @ 39:a7981a22f12e
describe the eval monad
author | Daichi TOMA <toma@cr.ie.u-ryukyu.ac.jp> |
---|---|
date | Tue, 04 Feb 2014 02:26:58 +0900 |
parents | 8d934599d0c5 |
children | ff15fb78a3ae |
line wrap: on
line source
\chapter{Haskellとは} \label{ch:haskell} Haskell とは純粋関数型プログラミング言語である。 \section{純粋関数型プログラミング} 関数とは、一つの引数を取り一つの結果を返す変換器のことである。 関数型プログラミング言語では、関数を引数に適用させていくことで計算を行う。 既存の手続き型言語と異なり、手順を記述するのではなく、この関数が何であるかということを記述する。 例えば、Haskell でフィボナッチ数列を定義するにはソースコード\ref{src:fib}のように記述する。 Haskell は、関数を引数を適用するという考えに基づいて、抽象的なプログラミングを可能とする。 \begin{lstlisting}[label=src:fib, caption=フィボナッチ数列] fib :: Int -> Int fib 0 = 0 fib 1 = 1 fib n = fib (n-2) + fib (n-1) \end{lstlisting} fib :: Int -$>$ Int は、関数の型宣言である。この-$>$は左結合である。 この関数は、Int を受け取って Int を返す関数ということを示す。 フィボナッチ数列の関数が三行に渡って書かれているが、これは Haskell のパターンマッチを活用している。 引数が、0 ならば 2 行目の fib が呼び出される。 引数が、1 ならば 3 行目の fib が呼び出される。 上から順に引数と一致する行がないか調べていき、引数が 0 でも 1 でもなければ引数は n に束縛される。 フィボナッチ数列の関数は、自分自身を使って再帰的に定義している。 再帰は、関数型プログラミング言語において必要不可欠な要素である。 手続き型言語では、配列とループを主に用いてプログラミングを行うが、Haskell ではリスト構造と再帰を用いる。 純粋関数型プログラミングでは、変数の代入は一度のみで後から書き換えることはできない。 フィボナッチ数列の関数でも、一度引数を束縛した n を書き換えることはできない。 関数にできることは、何かを計算してその結果を返すことだけであり、引数が同じならば関数は必ず同じ値を返すことが保証されている。 この性質は関数の理解を容易にし、プログラムの証明を可能にする。 正しいと分かる単純な関数を組み合わせて、より複雑な正しい関数を組み立てていくのが関数型言語のプログラミングスタイルである。 関数型プログラミング言語は、関数を変数の値にすることができる。 つまりこれは、関数を第一級オブジェクトとして扱うことができるということである。 Haskell は、引数として関数を取ったり返り値として関数を返すことができる高階関数を定義できる。 高階関数の例として Haskell のカリー化が挙げられる。 Haskell では、全ての関数は一度に一つの引数だけを取る。 複数の引数を取るようにみえる関数は、実際には1つの引数を取り、その次の引数を受け取る関数を返す。 このように関数を返すことで全ての関数を一引数関数として表すことをカリー化という。 カリー化によって、関数を本来より少ない引数で呼び出した際に部分適用された関数を得ることができる。 \section{型} Haskell では、すべての式、すべての関数に型がある。 値の型は、その値が同じ型の別の値と何らかの性質を共有していることを示す。 例えば、数値は加算できる、文字列は表示できるといった性質である。 型はプログラムに抽象をもたらす。 抽象を導入することで、低水準の詳細を気にせずプログラミングが可能になる。 例えば、値の型が文字列ならば、どのように実装されているかという細かいことは気にせず、 その文字列が他の文字列と同じように振る舞うとみなすことができる。 Haskell は静的型検査によりエラーを検出することができる。 Haskell では、評価の際に型に起因するエラーが起きないことを保証している。 引数として整数を受け取る関数に文字列を渡そうとしても Haskell のコンパイラはこれを受け付けない。 Haskell は、すべての式、すべての関数に型があるためコンパイル時に多くのエラーを捕まえることができる。 エラーの検出の例として、Haskell で最もよく使われるデータ構造リストで確認を行う。 また、リストの定義とあわせてデータ型の定義についても説明する。 リストとは、角括弧で包まれた同じ型の要素の並びである。[1,2,3] などと表現する。 リストは以下のように定義されている。 \begin{lstlisting}[label=src:list, caption=Haskellのリスト定義] data [] a = [] | a : [a] \end{lstlisting} data というのは新しい型を定義する際に利用するキーワードである。 [] というのが型名である。 型名の右に a というのがあるが、これは多相型を表すのに使う型変数である。 リストは、Intのリスト、Floatのリストといった様々な型のリストを作ることができ、型変数を用いてそれを実現する。 型変数が何の型になるのかという情報は実行時には決まっており、Haskell の型安全を保つ。 = の右側が、新しい型の定義である。 $|$ は、もしくはという意味である。 つまりリストは、[] もしくは、a : [a] という値になることが分かる。 [] は空リストを表す。型名と同じであるが、型とは名前領域が異なるため問題ない。 型名はプログラム中では注釈としてしか使われないためである。 a : [a] は再帰的なデータ構造である。 何らかの型の値 a を、: で繋げて、再度リストの定義を呼んでいる。 リストは無限に繋げることができ、リストの終端は空のリスト、つまり [] で終わる。 [1,2,3]という様にリストを表すが、これは単なるシンタックスシュガーであり、 内部では 1 : 2 : 3 : [] のように表現される。 リストは、: を使うことで新しい要素を加えることができるが、型変数は1つであり、全ての要素は同じ型の要素である必要がある。 違った型の要素を付け加えようとすると Haskell はコンパイル時にエラーを出す。 例えば、Int のリスト [3,4] に、文字である 'b' を付け加えようとした場合以下の様なエラーが発生する。 \begin{lstlisting}[label=src:list, caption=Haskellのコンパイル時エラー] <interactive>:3:7: Couldn't match type `Int' with `Char' Expected type: [Char] Actual type: [Int] \end{lstlisting} 型検査でも捕まえられないエラーは存在する。 例えば、式 "1 `div` 0" は、型エラーではないが、0 での除算は定義されていないので評価時にエラーとなる。 \subsubsection{型推論} Haskell は型推論を持つ。 型推論のない静的型付け言語は、プログラマが型の宣言を行うことが強制されるが Haskell では型の宣言は必須ではない。 例として、開発したデータベースで実装した関数に対して型推論を行ってみる。 関数の詳細な動作はデータベースの実装で述べるが、getChildrenという、関数がある。 \begin{lstlisting}[caption=getChildren関数] getChildren node path = elems map where target = getNode node path map = children target \end{lstlisting} 型の注釈なしに関数を定義し、Haskell の対話環境である GHCi で型情報を取得してみる。 型情報を取得するには、:type の後ろに関数名を入力する。 \begin{lstlisting}[caption=型情報の取得] *Jungle> :type getChildren getChildren :: Node -> [Int] -> [Node] \end{lstlisting} そうすると、推論された型情報 Node -$>$ [Int] -$>$ [Node]が得られる。 この型情報は期待する型の定義と一致する。 Haskell では、プログラマが型の宣言を行わずとも、型を推論し型安全を保つ。 しかしながら、明示的な型宣言は可読性の向上や問題の発見に役に立つため、トップレベルの関数には型を明記することが一般的である。 \section{モナド} Haskell では、さまざまな目的にモナドを使う。 I/O 処理を行うためには IO モナドを使う必要がある。 プログラミングを行うにあたり、I/O 処理は欠かせないため、モナドの説明を行う。 モナドとは、型クラスの 1 つである。 型クラスは型の振る舞いを定義するものである。 ある型クラスのインスタンスである型は、その型クラスに属する関数の集まりを実装する。 これは、それらの関数がその型ではどのような意味になるのか定義するということである。 モナドとなる型は、型変数として具体型をただ1つ取る。 これにより何かしらのコンテナに包まれた値を実現する。 モナドの振る舞いは型クラスとして実装し、関数として return および $>>$= (bind) を定義する。 \begin{lstlisting}[label=monad, caption=モナドに属する関数] return :: Monad m => a -> m a (>>=) :: Monad m => m a -> (a -> m b) -> m b \end{lstlisting} return は値を持ち上げてコンテナに包む機能を実装する(図\ref{fig:monad_return})。 \begin{figure}[!htbp] \begin{center} \includegraphics[scale=0.6]{./images/monad_return.pdf} \end{center} \caption{モナドに属する return 関数} \label{fig:monad_return} \end{figure} bind は、「コンテナに包まれた値」と、「普通の値を取りコンテナに包まれた値を返す関数」を引数にとり、コンテナに包まれた値をその関数に適用する(図\ref{fig:monad_bind})。 適用する際、前のコンテナの結果に依存して、後のコンテナの振る舞いを変えられる。 \begin{figure}[!htbp] \begin{center} \includegraphics[scale=0.6]{./images/monad_bind.pdf} \end{center} \caption{モナドに属する $>$$>$= (bind) 関数} \label{fig:monad_bind} \end{figure} この2つの関数を利用することにより、文脈を保ったまま関数を繋いでいくことができる。 Haskell の遅延評価は記述した順序で実行することを保証しないが、モナドの bind は実行順序の指定も可能で、IO モナドを bind で繋いだものは記述順に実行することができる。 \subsubsection{Maybe モナド} 文脈を保ったまま関数を繋いでいくとはどういうことなのか、具体例を用いて説明する。 Maybe 型は失敗する可能性を扱うデータ型である。 \begin{lstlisting}[ caption=Maybe型の定義] data Maybe a = Nothing | Just a \end{lstlisting} 失敗したことを表す Nothing、もしくは成功したことを表す Just a のいずれかの値を取る。 Maybe 型が使われている例として、Data.Map の lookup 関数がある。 Data.Map は Key と Value を保持する辞書型のデータ構造である。 何らかの Key を渡して、Data.Map から値を取得しようとした時、返される値は Maybe 型である。 何かしらの値が取得できた場合は、Just a として値に Just がついて返される。 取得できなければ、Nothing が返る。 Maybe モナドを使いたい場面は、失敗するかもしれないという計算を繋いでいく時である。 Maybe モナドの定義をみていく。 \begin{lstlisting}[caption=Maybeモナドの定義] instance Monad Maybe where return x = Just x Nothing >>= f = Nothing Just x >>= f = f x \end{lstlisting} Maybe モナドの return は、値をJustで包む。 これがコンテナに包む機能という意味である。 $>>$= (bind) は、「コンテナに包まれた値」と、「普通の値を取りコンテナに包まれた値を返す関数」を引数にとり、コンテナに包まれた値をその関数に適用すると説明した。 Maybe モナドの場合、コンテナが Nothing なら、そのまま Nothing を返す。 コンテナがJustならば、Just に包まれた値を取り出し、「普通の値を取りコンテナに包まれた値を返す関数」に適用する。 失敗するかもしれない計算を繋いでいくとはどういうことなのか。 単純な関数を定義して更に説明していく。 \begin{lstlisting}[caption=up関数とdown関数] up 4 = Nothing up n = Just (n + 1) down 0 = Nothing down n = Just (n - 1) \end{lstlisting} 関数 up と down を定義した。 4以上はこれ以上、上がれないため失敗、 0以下はこれ以上、下がれないため失敗と考える。 3 という値にdown, down, up、up 繰り返していく時、モナドを使わない場合以下のように定義することになる。 case 式は、caseとofの間の式を評価し、その値によっ評価を分岐させることができる。 case を受け取る $->$ の左の部分はパターンマッチを行うこともできる。 また、case は式のため、$->$ の右の部分の全ての型は一意である必要がある。 Haskell では分岐によって返ってくる値の型が異なるということはできない。 \begin{lstlisting}[caption=Maybeモナドを使わずにupとdownを行う] updown :: Maybe Int updown = case down 3 of Nothing -> Nothing Just place1 -> case down place1 of Nothing -> Nothing Just place2 -> case up place2 of Nothing -> Nothing Just place3 -> up place3 \end{lstlisting} 毎回、失敗したか成功したか確認するために非常に煩雑なコードとなってしまった。 これを文脈を保ったまま、関数を繋げられる モナドを使えば以下のように記述できる。 \begin{lstlisting}[caption=Maybeモナドを用いてupとdownを行う] return 3 >>= down >>= down >>= up >>= up \end{lstlisting} Maybe モナドを使うことで、この計算は失敗しているかもしれないという文脈を扱うことができる。 \subsubsection{IO モナド} Haskellで副作用を持つ処理を実行するには、IO モナドを利用する。 IO モナド自体は単なる命令書であり、命令ではない。 bind を使って、小さな命令書を合成して大きな命令書を作成できる。 最終的に、mainという名前をつけることで初めてランタイムにより実行される。 Haskell の関数には副作用がないと述べたが、IO モナドを返す関数にも副作用は存在しない。 例えば、getChar という関数がある。 呼び出した状況によって、返ってくる文字が違うため副作用があるようにみえる。 しかし、実際にこの関数が返すのは、「一文字読み込む」という命令書である。 どんな状況においても同じ命令書を返すため、副作用はない。 \clearpage \section{並列実行} Haskellはデフォルトではシングルスレッドで走る。 並列に実行したい場合は、-threaded 付きでコンパイルし、RTS の -N オプションを付けて実行する。 -N オプションで指定された数だけ、OSのスレッドが立ち上がり実行される。 \begin{lstlisting}[language=bash, label=concurrent, caption=並列実行の様子] $ ghc -O2 par.hs -threaded $ ./par +RTS -N2 \end{lstlisting} 当然これだけでは並列に動かず、並列に実行できるようにプログラムを書く必要がある。 Control.Parallel.Strategies モジュールにある、 Eval モナドを用いた並列化について説明する。 Eval モナドは並列処理を行うためのモナドである。 Eval モナドで並列処理を行う使用例を示す。 %% 完全に動くプログラム \begin{lstlisting}[caption=Evalモナドの使用例] import Control.Parallel.Strategies main = print (runEval test) num :: Integer num = 1000000 test :: Eval (Integer, Integer) test = do a <- rpar (sum' 0 num) b <- rpar (sum' num (num*2)) return (a,b) sum' :: Integer -> Integer -> Integer sum' begin end = if begin < end then begin + (sum' (begin + 1) end) else begin \end{lstlisting} まず、Eval モナドが定義された、Control.Parallel.Strategies をロードし、Eval モナドを利用できるようにしている。 Haskell のプログラムはmainという名前と実行したい関数を関連付けることで実行される。 今回は、print (runEval test)が実行される。 \begin{lstlisting}[label=eval, caption=Eval モナド] data Eval a instance Monad Eval runEval :: Eval a -> a rpar :: a -> Eval a \end{lstlisting} 並列処理を行うには、rpar を使う。 rpar で挟んだ関数は並列に実行される。 Eval モナドの関数の型をみると、rpar は、a を モナドに包み、逆にrunEval はモナドから a を取り出している。 rpar で並列化可能計算を示したあと、runEvalで実行する。 test の = のすぐ後にあるdoはモナドのためのシンタックスシュガーであり、do 構文と呼ばれる。 Haskell では、モナドを単一のモナドとするために、do構文を使う。 $>>$= (bind)を使ってまとめることもできるが、do 構文を使うことでbindの入れ子構造を手続き言語のような書き方で書くことができる。 do 構文を用いない場合、以下の様な式になる。 \begin{lstlisting}[caption=do構文を使わない場合] test :: Eval (Integer, Integer) test = rpar (sum' 0 num) >>= (\a -> rpar (sum' num (num*2)) >>= (\b -> return (a,b))) \end{lstlisting} sum' は2つの引数をとって、開始点から終了点までの値をすべて足し合わせる関数である。 並列処理に負荷を与えるために使う。 ifで、開始点が終了点を超えてないか調べ、超えてなければ再帰的に呼び出して足し合わせを行う。 test で返ってくる型は、Eval (Integer, Integer)で、その後 runEval 関数を適用することで、(Integer, Integer)となる。 そして最後にprint で出力される。 Haskell は遅延評価を行うため、必要となるまで式の評価が遅延される。 今回の場合、最後のprintがなければそもそも計算が行われない。 並列に動くように処理を分割した後は、Haskell の遅延評価に気をつける必要がある。 値が必要となるprintなどを行えば、並列に実行可能な部分が並列に実行される。 rpar を使用する際に気をつけるのは、別の計算の値に依存する計算がある場合、その2つの計算は並列実行できないということである。 例えば、以下のような場合は並列実行ができない。 \begin{lstlisting}[caption=前の計算に依存した計算] test2 :: Eval (Integer, Integer) test2 = do a <- rpar (sum' 0 num) b <- rpar (sum' num (if a < num then a else (num*2))) return (a,b) \end{lstlisting}