bitterharvest’s diary

A Bitter Harvestは小説の題名。作者は豪州のPeter Yeldham。苦闘の末に勝ちえた偏見からの解放は命との引換になったという悲しい物語

通常のプログラムをHaskellで記述する(1)

11 通常のプログラムをHaskellで記述する

モナドの大きな目的は手続き型プログラミング言語で書かれたプログラムを副作用のない純関数型のプログラムで記述できるようにすることである。

通常のプログラミング言語は多くの場合手続きが他である。これには、C言語FortranJavaPythonなどが含まれる。手続き型プログラミング言語は部分関数、例外、状態などがあることが特徴であるが、これらの特徴が信頼性の低いプログラムを生み出す原因となっている。

そこで、信頼性の高いプログラムを提供するために、副作用のない純関数型のプログラムに書き直すことが望まれる。それは、命令型のプログラムを小さな部分に分けて、その部分を純関数型のプログラムで実装し、それらを接続することで実現できる。

ここではその例を示すために、Pythonで記述されたプログラムをHaskellで記述することを考えてみよう。

11.1 部分関数と例外の問題を解決する

次のプログラムは、円錐の体積を求めるものである。

Python 3.6.2 (v3.6.2:5fd33b5, Jul  8 2017, 04:57:36) [MSC v.1900 64 bit (AMD64)] on win32
Type "copyright", "credits" or "license()" for more information.
>>> from math import pi
>>> rc=input('底面の半径は?')
底面の半径は?5.0
>>> r=float(rc)
>>> a = pi*r*r
>>> print('底面積は:'+str(a))
底面積は:78.53981633974483
>>> hc=input('円錐の高さは?')
円錐の高さは?2.0
>>> h=float(hc)
>>> v=h*a/3.0
>>> print('体積は:'+str(v))
体積は:52.35987755982989
>>> 

インターアクティブにプログラムを作成しているのでわかりにくいが、コードの部分だけを取り出すと以下のようになる。

from math import pi
rc=input('底面の半径は?')
r=float(rc)
a = pi*r*r
print('底面積は:'+str(a))
hc=input('円錐の高さは?')
h=float(hc)
v=h*a/3.0
print('体積は:'+str(v))

上記のプログラムは底辺の面積と円錐の体積を求める二つのプログラムから成り立っていることが分かる。それでは、底辺の面積\(area\)と円錐の体積\(volume\)を計算するプログラムをHaskellで定義してみよう。

Pythonのプログラムの中では省いたが、半径も高さも正の値でなければならない。半径も高さも実数ということにすると、\(area\)も\(volume\)も負の値が来たときは例外処理を行わなければならない。上記のPythonのプログラムではこの判定を行わなかったが、信頼性の高いプログラムを実現しようとするときは、負の値が入ってきたときの例外処理を行わなければならない。

プログラムはどのような実数も入力可能であるのに、受け付けることができる値は正数に限定されていることに上記のプログラムの問題がある。数学での関数は全関数であるのに対して、命令型のプログラムでは部分関数になっていることからこのような問題が生じている。

上記の問題を解決してくれるのがモナドである。Haskellでは\(Maybe,Either\)でこのような問題を解決できるようにしている。\(Maybe\)では受け付けられない入力に対しては\(Nothing\)を返す。受け付けられる入力に対しては、それを用いて計算しその結果に\(Just\)を付加して出力する。

Haskellでは\(area\)と\(volume\)の関数は次のようで定義できる。

area :: (Ord a, Floating a) => a -> Maybe a
area a = if a > 0 then return (pi * a * a) else Nothing

volume  :: (Ord a, Floating a) => a -> a -> Maybe a
volume a b = if a > 0 && b > 0 then return (a * b / 3.0) else Nothing

実行例は次のようになる。

*Main> area 5.0
Just 78.53981633974483
*Main> volume 2.0 78.5
Just 52.333333333333336

それでは、この二つの関数を接続することを考えよう。これは前の記事で説明したようにフィッシュ・オペレータを用いる。これは次のようになっている。

(>=>) :: (Monad m) => ( a -> m b) -> ( b -> m c) -> (a -> m c)
f >=> g = \a -> let mb = f a
                in mb >>= g

フィッシュ・オペレータを定義するためにはモナドを定義する必要があるが、幸いに、Maybeはモナドとして定義されているのでそのまま用いることにする。

それでは\(area\)と\(volume\)を接続しよう。

接続して、実行した例は次のようになる。

*Main> (area >=> volume 2.0) 5.0
Just 52.35987755982989
*Main> (area >=> volume 2.0) (-5.0)
Nothing
*Main> (area >=> volume (-2.0)) 5.0
Nothing
*Main> (area >=> volume (-2.0)) (-5.0)
Nothing

あるいは、次のように定義した後で実行してもよい。

*Main> g = \a b -> (area >=> volume a) b
*Main> g 2.0 5.0
Just 52.35987755982989
*Main> g 2.0 (-5.0)
Nothing
*Main> g (-2.0) 5.0
Nothing
*Main> g (-2.0) (-5.0)
Nothing

注:

\(Maybe\)をモナドとするためには(>>=)と\(return\)の対か\(join\)と\(return\)の対を定義する必要がある。(>>=)と\(return\)の対はHasekllで定義されているが、次のようになっている。

(>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b
(Just a) >>= f = Just (f a)
_ >>= f = Nothing
return :: a -> Maybe a
a = Just a

上記の定義で

_ >>= f = Nothing

のところの_には実際には\(Nothing\)が来ることに注意。例外事象が生じたときに\(Nothing\)が出力されるので、\(f\)に先行するプログラムで例外が発生したときは、\(f\)のプログラムを実行せずに\(Nothing\)をパスすると解釈することができる。このため、前の方で起こった例外事象は後の方にあるプログラムを起動することなく伝えられることとなる。このため、Haskellでは例外事象を適切に処理していると言える。

\(join\)と\(return\)の対を用いたいのであれば次のようにする。

join :: Maybe (Maybe a) -> Maybe a
join Just (Just a) = Just a
join _ = Nothing
return :: a -> Maybe a
a = Just a