Haskell を勉強して面白いと思ったところ
しばらく個人的に忙しい時期が続いたが,一時的に余裕が生まれたので以前から気になっていた Haskell を少し勉強した. Lisp や OCaml で関数型言語の経験はあったが,型クラスやモナドといった単語になんとなく興味があり Haskell に至った.
- Haskell入門 関数型プログラミング言語の基礎と実践
- すごいHaskellたのしく学ぼう! (通称 "すごいH本")
の2冊を中心に読んだ.
最初に"Haskell入門 関数型プログラミング言語の基礎と実践"のほうをあまり手を動かさずにざっと読み,後から"すごいH本"をコードを実行しながら読んだ. (読んだ後から思えば,本の内容などからするとむしろ逆の順番のほうが良さそうな気はする.)
勉強してみて1週間ぐらい経ったので、面白いと思ったところなどをまとめてみる.
Haskell に関しては本当に初心者なので,記述の正確性は保証しない. おかしいところがあれば遠慮なく指摘してほしい.
型システム
いわゆる Hindley-Milner 型システムに基づくもので,Ocaml のような ML 系の言語と基本的な使用感は同じである.
Haskell で特徴的なのは,型クラスによる型に対する制約の記述だろう.
型クラスはclass
から始まる宣言によって記述し,そのクラスに属する型の満たすべきインターフェースをメソッドとして書き並べる.
例えば,次の例はモノイドを表す型クラスの(簡略化した)例である.
class Monoid m where mempty :: m (<>) :: m -> m -> m
型m
がモノイドであるとき,あるm
型の値が単位元mempty
となり,モノイド上の(結合的な)演算を<>
という演算子によって表現する.
具体的な型が型クラスの要求する制約を満たすことは,instance
宣言でメソッドに対する具体的な実装を与えることで記述する.
たとえば,リストは++
による結合演算についてモノイドになり,単位元は空リスト([]
)である.
instance Monoid [a] where mempty = [] (<>) = (++)
このように型クラスという形でインターフェースを記述し実装は具体型ごとに別々に与えることで自然な形でアドホック多相を実現できる.
型クラスという抽象化を通して多くの型に共通する本質的な部分を取り出すことに注意が向けられるし,具体型による特殊な実装に依存しすぎない再利用性の高いコードを記述する助けになるのではないかと感じた.
モナドという抽象化
Haskell の特徴としてよく語られるのはモナドという概念であると思う. モナドは"文脈付きの計算"のようなものを表す抽象化といえる.
上のような説明だけではあまり要領を得ないが,定式化は非常に単純であり,以下のたった3行の宣言に集約される.
class Monad m where return :: a -> m a (>>=) :: m a -> (a -> m b) -> m b
>>=
(bind operator とも呼ばれる) が肝であり,
x >>= f
により,"x
からモナド(にともなう文脈)を剥がして素の値だけを取り出し,この値に対する計算f
を行って新しくモナド値(文脈付きの値)を生成する" という操作を表現する.
これによって"文脈"を引き継ぎながら計算するということが表現できるのである.
この説明だけでは正直あまり嬉しさは伝わってこない思う. しかし,下のように様々な計算がモナドという抽象化の上で表現できるということを理解すると,ありがたみが感じられてくる.
- 途中で失敗しうる計算 (Maybe モナド, Either モナド)
- IO 付きの(逐次)計算 (IO モナド)
- 値の候補が複数ある非決定的な計算 (リストモナド)
- 状態の操作をともなう計算 (State モナド, ST モナド)
- etc...
特に,IO モナドは注目に値する. IO をともなう計算は本質的に外界とのやりとりをともなう"副作用"付きの計算であり,これをモナドというモデルの上で表現できるということは面白い発見である. また,IO による影響範囲が型のレベルではっきりと表現されることで可視化されるので,IO の影響範囲を局所化しようという動機も自然と発生するように思われる.
遅延評価
Haskell は"同じ関数を同じ引数で評価すれば必ず同じ値が得られる"という参照等価性を徹底した純粋関数型言語である. これはつまり関数をどのタイミングで評価しても結果が変わらないということになるから,必要になるギリギリまで評価を遅延させる遅延評価を可能にする.
実際には使われない引数が評価されたりすることがないので効率的である.
また,無限要素のリストのようなメモリ上に事前に展開することが不可能な対象を表現することが非常に自然かつ容易に行える.
次の例は無限リスト[1..]
から最初の100要素を取り出している.
ghci> take 100 [1..] [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100]
数学的な構造をその全体の大きさを気にすることなく素直に表現できるということは大きなメリットであると感じる.
構文的な簡潔さ
Haskell は実際にコードを記述するときの簡潔さにも大いに注意して設計されていると感じる.
例えば,先ほどの例でも登場したように無限リストは range により [1..]
などのように非常に簡潔に書ける.
一風変わった演算子たちも特徴的である.
- 関数合成:
f . g = \x -> f (g x)
- 関数適用:
f $ x = f x
($
を使うと結合順位が下がる)
セクション記法といって中置演算子(+
など)の引数をひとつ固定した関数を書く記法も面白い.
例えば,(+3)
は \x -> x + 3
と等価である.
これらの記法は,関数型言語でありがちな括弧だらけの記述を簡潔にするのに役立つ.
例として,整数の無限リストから10要素取り出して2倍し,5より大きい値だけを取り出すという操作を考える.
map
やfilter
を用いて次のように書けるが,やや冗長である.
ghci> filter (\x -> x > 5) (map (\x -> x*2) (take 10 [1..])) [6,8,10,12,14,16,18,20]
.
,$
やセクション記法をうまく使うとだいぶ見やすくなる.
ghci> filter (>5) . map (*2) $ take 10 [1..] [6,8,10,12,14,16,18,20]
モナドによる計算の記述を簡潔にするための構文要素としては do 式やリスト内包表記がある. ここでは特に do 式に注目する.
do 式は,>>=
の連鎖に対する syntax sugar を提供する.
例えば,次は標準入力から2行入力を受け取り,それらの文字列を連結して出力する関数である.
getTwoLines :: IO () getTwoLines = getLine >>= \x -> getLine >>= \y -> putStrLn (x ++ y)
>>=
の挙動に十分慣れていないと読むのが難しい.
一方,do 式を用いた場合は次のように書ける.
getTwoLines :: IO () getTwoLines = do x <- getLine y <- getLine putStrLn (x ++ y)
見た目はまるで命令型のプログラミングである. こちらのほうがだいぶ直観的に感じる人が多いだろう.