人気ブログランキング | 話題のタグを見る

ErrorT モナド・トランスフォーマーの使い方

hackage.haskell.org の Control.Monad.Error 文書には Error モナドのモナド・トランスフォーマーである ErrorT の使い方の例が紹介されている。次のプログラムがそれだ。原著からのコピペでそのまま動いた。

import Control.Monad.Error

-- An IO monad which can return String failure.
-- It is convenient to define the monad type of the combined monad,
-- especially if we combine more monad transformers.
type LengthMonad = ErrorT String IO

main = do
  -- runErrorT removes the ErrorT wrapper
  r <- runErrorT calculateLength
  reportResult r

-- Asks user for a non-empty string and returns its length.
-- Throws an error if user enters an empty string.
calculateLength :: LengthMonad Int
calculateLength = do
  -- all the IO operations have to be lifted to the IO monad in the monad stack
  liftIO $ putStrLn "Please enter a non-empty string: "
  s <- liftIO getLine
  if null s
    then throwError "The string was empty!"
    else return $ length s

-- Prints result of the string length calculation.
reportResult :: Either String Int -> IO ()
reportResult (Right len) = putStrLn ("The length of the string is " ++ (show len))
reportResult (Left e) = putStrLn ("Length calculation failed with error: " ++ (show e))

実行例は次のようになる。前回のプログラム例と同じように入力した文字列の文字数を数えるプログラムだ。空文字列が入力されるとエラーになる。

*Main> :l errorT.hs
[1 of 1] Compiling Main ( errorT.hs, interpreted )
Ok, modules loaded: Main.
*Main> main
Please enter a non-empty string:
hello
The length of the string is 5
*Main> main
Please enter a non-empty string:

Length calculation failed with error: "The string was empty!"

比較的短いプログラムなので、逐次解読してみる。最初に必要なライブラリをインポートするが Control.Monad.Error だけだ。

import Control.Monad.Error

このプログラムでは、Error クラスのエラー識別子は特に定義してない。それは、Control.Monad.Error.Class モジュールで String 型が既に Error クラスのインスタンスとして宣言されているからだ。String 型の Error クラスのインスタンス宣言は次のようになっている。

instance Error String where
    noMsg = ""
    strMsg = id

したがって、エラー識別子には String 型を使うことにして、Error クラス関係の記述は省略してある。そのため、すぐに lengthError モナドの定義がしてある。ErrorT モナド・トランスフォーマーと IO モナドを使った複合モナドだ。したがって、LengthMonad モナドの do ブロック内から、liftIO 関数を用いて IO モナドの関数を呼び出すことができる。

-- An IO monad which can return String failure.
-- It is convenient to define the monad type of the combined monad,
-- especially if we combine more monad transformers.
type LengthMonad = ErrorT String IO

つぎはいきなり main 関数だ。このプログラムではトップダウンのプログラム方式でコーディングされている。

main = do
  -- runErrorT removes the ErrorT wrapper
  r <- runErrorT calculateLength
  reportResult r

上のプログラムでは、最初に、runErrorT 関数が利用されているが、これは ErrorT ラッパーのコンテナの中身を取り出すデータアクセサで ErrorT トランスフォーマーに付随する関数だ。ErrorT String IO モナドのモナド値のコンテナの中には、IO モナドでラッピングされた Either 型のデータが入っているので、その IO 型のデータを取り出している。

calculateLength 関数が入力された文字列の文字数を計算する本体のプログラム(関数)だが、その戻値は LengthMonad という複合モナド値になるからだ。

そうして、取り出された IO モナド値から、さらに <- 記法を使って、IO モナドにラッピングされている Either 型のデータを取り出している。

このように複合モナドでは、モナド値がネストしているので注意が必要だ。たとえば、このプログラム例では、

LengthMonad ( IO ( Either e a) )

のように箱のなかにまた箱が入っているようなデータ構造になっている。

r に束縛された Either 型のデータは reportResult 関数に渡され、文字列計算の結果が表示されてプログラムが終了する。

このあとは、上の main プログラムに現れた関数が順に定義されている。まず、 calculateLength 関数の定義で、これがプログラムの本体だ。

-- Asks user for a non-empty string and returns its length.
-- Throws an error if user enters an empty string.
calculateLength :: LengthMonad Int
calculateLength = do
  -- all the IO operations have to be lifted to the IO monad in the monad stack
  liftIO $ putStrLn "Please enter a non-empty string: "
  s <- liftIO getLine
  if null s
    then throwError "The string was empty!"
    else return $ length s

calculateLength 関数の定義も do で始まっているので、モナドによるプログラムだが、IO モナドではない。この do ブロックは LenghtMonad モナドによるプログラムのブロックだ。したがって、calculateLength の最終的な値は LengthMonad 型になる。

LengthMonad は複合モナドなので、LengthMonad の do ブロックの中に liftIO 関数で IO モナドを LengthMonad に変換することで、IO モナドの関数を使うことができる。つまり、この do ブロックの中のプログラムは全て LengthMonad モナドである。IO モナドの関数が使われているが、その場合は liftIO 関数で IO モナドを LengthMonad にラッピングしている。

したがって、これは LengthMonad の関数だ、これは IO モナドの関数だと区別せずに読むことができる。関数が何のモナドかを気にせずに読むと、上のコーディングは、putStrLine でメッセージを表示し、getLine で文字列を読み込んで s にバインドして、s が空文字列だったら throwError 関数でエラー例外を発生させ、それ以外の場合は s の文字数を length 関数で計算して返すというプログラムだということが分かる。手続き型のプログラムだったら、ごく普通のプログラムだ。

ポイントはエラーが発生した場合は throwError 関数を持ちいて例外を発生し、処理が成功した場合はその値を return 関数で返すというところだ。直接に値を返すのではなく、throwError や return 関数を利用することによって、生の値を LengthMonad に簡単にラッピングすることができる。例外の発生と言っても特別な処理をしているわけではなく、Maybe 型で Nothin 値はそれ以後の >>= 演算子の値を全て Nothing にしてしまう処理に似た、普通のモナドの仕組みを利用しているだけだ。

モナドを利用したプログラムを読むようになって、return 関数の良さが分かってきた。複合モナドのような複雑なデータ構造のモナドを使ってプログラムする場合も return 関数を使いさえすれば簡単にデータをモナド型にラッピングすることができるからだ。

話が少しそれるが、どのような複雑なタイプのモナドも、do ブロック内でプログラムする場合には、全て m a 型の単純なデータだとみなしていい。

do ブロック内で明示的に扱う必要のあるデータは a だけで、たとえば、State モナドを使っていても状態の情報は、モナドのプログラムには現れてこない、モナド内では単に get や put を使うことによって中からはブラックボックスになっている外部情報を取り出しているだけだと考えることができる。そうして、do ブロックの中で行った処理を外部に返すには return a を実行するだけでよい。複雑なラッピングの操作などは全て処理系側でやってくれる。

モナド内部の処理に関する限り、プログラムの際の不必要な複雑さを極力押さえることができる。

さて、最後は reportResult 関数だ、この関数は Either 型のデータの種類によって、正常処理の内容の表示や、エラー処理の内容の表示をやってくれる。

-- Prints result of the string length calculation.
reportResult :: Either String Int -> IO ()
reportResult (Right len) = putStrLn ("The length of the string is " ++ (show len))
reportResult (Left e) = putStrLn ("Length calculation failed with error: " ++ (show e))

小さい、プログラムだが、複合モナドの作り方や、その構造、リフト操作の使い方、モナドでプログラムすることの利点などを端的に見せてくれる。

モナドの do ブロックの中に記述できるのは単一のモナド値を返す関数だけだ。そのため、モナドの中のプログラムは他のプログラムから隔離され、外部から中への干渉や、内部から外部への干渉をプロックすることができる。

そのため、do ブロックの中のプログラムはモジュール化されて、より安全で、より独立性の高いプログラムになる。

IO モナドや State モナドのように単一のモナドでプログラムしているときはこのようなモナドのありがたさを感じることができる。ところが、実際のプログラムではモナドによって提供される、IO 処理や、状態の利用、エラー処理などは一緒に使いたい場合が多い。しかし、モナドの do ブロックの中には単一のモナド値しか入れられないため、そのようなプログラムは複雑で使いづらいものになってしまう。

複合モナドは、それらのモナドを入れ子にしてデータ化することによって、複数のモナドを共存させる事ができるが、そのため、使用法がやや複雑になってしまう。しかし、それも、リフト操作による外部モナドへのラッピングや、runErrorT 関数などのアクセサ関数による内部モナドの取り出しに慣れれば、それほど頭を悩ませないで使えるようになるのだろう。

複合モナドの取り扱いの煩雑さに悩むと、モナドを出来るだけ避けたい気持ちになる。しかし、上で述べたように、プログラムをモナド化にすることによって、魔法のようにモジュール化したプログラムが作れるのを考えると、やはり、頑張って沢山の小さなモナドのプログラムに触れることで、結局は強力な道具を手に入れることになるような気がする。
by tnomura9 | 2013-08-18 17:33 | Haskell | Comments(0)
<< 知識の理解の仕方 Error モナドの使い方 >>