4bitにっき

ぽよ

気持ちだけで書けるようになりたいHaskell

※この記事は気持ちだけで書かれています

まえがき

実践的な道具としてのHaskellに興味があったので株式会社HERPでインターンさせていただきました。その間に学んだHaskell知見を主に自分用にまとめておきます。

同じ境遇のHaskell初心者に向けて、Haskellを実践する上でなるべく敷居の低い順路を提案したい意図もあります。 この記事をたまに眺めながら逐一入門書を読むような状況を想定しています。

Haskellってどうなん

Haskellをやって実感した個人的な感想だけ書いておく。 より客観的な意見は他をあたってください。

メリット

メリットとしては

副作用が起こせないこと、純粋性

  • 副作用はたいていの事故の元
  • Haskellでは、「純粋であるもの/そうでないもの」「宣言的(関数的)であるもの/手続き的であるもの」をはっきり区別する
    • 直接的に許容されるのは前者側のみであり、後者が望むものは前者を使って間接的に再定義される
  • プログラムの状態が定式化され、バカでも書けるようになる
  • 各関数を読むために必要なドメイン知識が自然と少なくなる

型による強力な保証

  • 型レベルで様々な情報を持たせプログラムを静的に定義/抽象化していくスタイルが言語レベルでサポートされる。かなり潔癖
  • バカが書いても失敗しづらい(動的型付け言語と違って)
  • 型を見れば多くのことが分かる。型はそのままドキュメントとなるし、型から必要な関数を検索することができる

純粋関数の扱いやすさ

  • Haskellでは純粋関数しか扱えない
  • すべては純粋関数であり、それらを統一的かつ強力な方法で組み合わせることができる。結果一つ一つの関数が小さく単純になる

抽象的

  • 概念がとにかく抽象的なので、最近抽象性に飢えてるな~という人におすすめ

デメリット

一方で、デメリットは

学習コスト/初期投資

  • 学習コストが重い。実践的に書けるようになるまでが長い
    • 手続き型に支配された世の中でなんとなく共有財産化している概念が通用しないことがある
  • 学習を進めていると結構早い段階で日本語情報や体系的な情報が少なくなってくる
    • 英語ってむずかしい
  • なお圏論まで学ぶ必要はない

開発環境

  • 開発環境がつらい
  • Haskell IDE Engineというのが知られているが、これは大きなプロジェクトではまともに動かない、不安定
  • ghcideというやつが頑張っているっぽい?現時点(2020年4月)ではビルドを複数構成にしたりすると動かない?
    • よく分からない
  • 補完やリアルタイムの解析には頼らず書いていく強い心が必要
    • もともと記述量が少ない言語なので、対話環境ghciを使って型を確認しつつ頑張ればそれなりに書ける
    • まあghciもプロジェクトが大きくなると結構重いが…
  • 開発環境だけでなく、色々と実験的な部分が多めな印象

頭がおかしい

  • たぶん根本的に頭がおかしい
  • なかなか言語の限界が見えないので実装時の妥協点が分からない

最初にやるといいこと

書籍を買います。おわり。 おすすめは

初心者の場合で、敢えてお試しで片方だけ買うならすごいH本? 「プログラミングHaskell 第2版」という良書もあるらしいけど読んだことがない。

環境構築などについては書籍だと古くなっていたりするのでてきとうにググるといい。 (少し前にQiitaを退会する流れがあったのでちょっとあれかも…)

書籍を読んでいると常人ならばモナドが出てきたあたりで意味が分からなくなるので深呼吸をしながら書籍を読んだり読まなかったりこの記事を読んだり読まなかったりする。

モナドとの向き合い方について

モナドが何かということについてはみんな困りがちなので、日本語で調べても色々説明がある。複数の解説を比較してみるのもいい。 もし個人的にアドバイスするなら、具体的なものに例えて理解するのは過度に分かった気になるのでやめた方がいい。 Haskellを書く上で「モナドが何であるか」というのはあまり重要じゃない。 オブジェクト指向にプログラミングしたことのある人が、”オブジェクト”がなんであるか説明できるか?ほとんどの人はできないし、する必要もない。 足し算がなんであるか説明できるか?ほとんどの人はできないし、する必要もない。

しかし「オブジェクトはなんだと解釈していますか?」と聞くと人それぞれ言葉を持っていて、その言葉にはある程度共通点があるのでなんやかんや世界が上手く回っている。 モナドの場合も、目指すところはそこにある。 自分なりに色々な解釈をしてみるうちにだんだんいい感じの感覚が得られて、そのいい感じの感覚があれば実用上はやっていける。

ちなみに僕の場合「モナドは文脈である」と毎晩唱えた後「モナドは手続きである」と毎晩唱えていた。

モナド

ここから先は入門書レベルの前提知識を要求します。

モナドを説明する前のとっかかりとしてモナドが例えば何をするためのものなのか予め知りたい、という人情があると思う。 個人的に言葉を選ぶなら、「モナドは手続きを再定義するもの」

Haskellと純粋関数について初めて知った手続き型プログラマは「手続きも副作用もなかったら困るんじゃないの?」と思う。 実際、(手続き型プログラマは暴力的に手続きと副作用を使いがちだしその多くは避けられるが)本質的に手続きや副作用が必要な場面がある。 モナドを導入するとHaskellの信念を崩さないまま手続き全般が書けるようになって、おまけに副作用を扱う方法(IOモナド)も付いてくる。

そういう感じ。知らんけど。

モナドをやっていく。

だいたい書籍で出てくるMaybeモナドとかListモナドとかStateモナドとかの定義を見ると分かるが、モナドと呼ばれるやつらには共通する構造がある。 つまりモナド m は一つの具体型を引数に取って具体型を返す型(リストみたいな。 * -> * と書く)であって、m a に対して (>>=)(bind) と return (pureとも言う) という演算が使えるということ。 型クラスとしてちゃんと書くと

class (Functor m, Applicative m) => Monad m where
  return :: a -> m a
  (>>=) :: m a -> (a -> m b) -> m b

すごいH本に書いてあるようなことだが、これらの解釈としては

  • 前提として、m はFunctorとApplicative Functorである必要がある
    • 個人的にはApplicative Functorの理解は後回しでもいい気がする…?
  • m aa に何かしら文脈がくっついた値の型
  • m がFunctorなので文脈がくっついた値 :: m a は関数で写せる(fmap演算)が、ただ a -> b と写す関数でなく文脈をくっつけて返すような関数 :: a → m b で写すことを考える。普通に fmap で写すとm が二重になった :: m m b な値が返ってきてしまうが、 >>=(bind) で写せば”文脈を結合”して m b を返すようにできる
  • return は文脈のついてない値を最小の文脈に入れる

“文脈を結合”できるという感覚が大事で、これによってモナドは手続きの再定義と思うことができる。 m a のaは手続き処理の結果。

ほんまか? 手続きって厳密にはなんやねんという気持ちになるが、我々は手続きが何か知らなくても手続き型言語を書けるのでそのノリを大事にしていきたい。 とにかく文脈を結合したらそれはもう手続き的な何かなんだ。shirankedo…

つまりこの文脈っていうのは手続き型言語で言うと変数aに値3が入っていてそれがあるタイミングで更新されるとかされないとか他にも状態があるだとかそういう感じ、それをもっと抽象化したやつ。 この”文脈”を並べて結合していくことができて、>>= (bind) はその結合を行う…みたいな感じです。 実際ある種のモナド(StateやIO)を使うと「変数aに値3が入っていてそれをこうやって更新して次に~…」という書き方もできて、その気になれば手続き型となんら変わりがない書き方でなんでも書けちゃうが、なんでもは書かないほうがいい。

モナドを定義することで、いわば各目的に特化した小さな手続き型言語を定義できる。 数多の言語が実証している通り手続きというものは表現力的には強いので、モナドも表現力的に強い。 「各目的に特化した手続き型言語を作る」というのもいかにも関数型らしくていい。

do構文

モナドは手続きなので、楽に手続き的に書くためのdo構文というものが用意されている。 doはただの糖衣構文であって簡単に関数バージョンに変換できるが、初見だと <- とかがたくさん出てくる場合少し分かりづらい。

do
  x <- f
  let a = 3
  y <- g
  h x y a
  r

f >>= (\x ->
let a = 3 in (
g >>= (\y ->
h x y a >>= (\_ ->
r
))))

に等しくなる。 このdoを見てどの関数(値)がどういう型か思い浮かぶようにしておくといい。

モナド

モナドは以下の性質を満たす必要がある。

return x >>= f  = f x (左恒等性)
m >>= return    = m (右恒等性)
(m >>= f) >>= g = m >>= (\x -> f x >>= g) (結合則)

気持ちで言うと、上2つがreturnが最小の文脈であることを要求していて、一番下はdoを書くときに非直感的な挙動をしないためのもの。 厳密な理由を問うと圏論をやれと言われて泣きながら圏論をやることになる。

どこが「右恒等性」で「結合則」なんだよ?と思うかもしれないが、>>= の代わりに a -> m b 上の二項演算子 >=> を使って記述すると意味がわかる。 これは a -> m b な関数の合成演算みたいな雰囲気。

(>=>) :: Monad m => (a -> m b) -> (b -> m c) -> (a -> m c)
f >=> g = (\x -> f x >>= g)

頑張ってモナド則を変形すると

return >=> f    = f
f >=> return    = f
(f >=> g) >=> h = f >=> (g >=> h)

になる。 確かに、左恒等性だし結合則だ。

Freeモナド

典型的なモナド(MaybeとかListとかStateとかReaderとかWriterとかだよ)の実装例を見て、ウゲッこれモナド自作したかったら毎回書くのかよ…という気持ちになる。 朗報ですが、書かなくてもいい。そう、Freeモナドならね。

FreeモナドはFunctorをMonadに変換できる。Monadとしての性質をデータ構造内に溜め込んでおくことで。

Freeモナドを使うとFunctorがMonadであるかのようにdo構文が書ける。 このときFreeモナドは手続きの解釈(文脈の解釈)を後回しにして、一連の手続きをとりあえずデータ構造として溜めておくことで >>=(bind) や return の使用を可能にする。 溜め込んだ手続きをどう解釈するか後から与えてやれば、手続きが実行できる。

もともとモナドというのが手続きの本質的な部分を抽象化した概念(知らんけど)だったが、Freeモナドを使うと実装的にも”手続きの本質的な部分”を独立させることができる。 “手続きの本質的な部分”、つまりモナドの共通性質はFreeモナドに押し付けられ、具体的なそれぞれのモナド実装は

  1. Functorを定義する(syntaxの定義)
  2. 手続きの解釈を与える(semanticsの定義)

だけでよくなる。syntax(構文)とsemantics(意味)というのはモナド手続き型言語として見た場合の話で、このsyntaxとsemanticsが分離できるという意味でも嬉しいらしい。

Freeモナドの実装は次のようになっている。 Free はFunctorな型をラップしてMonadに変換する。 FunctorとApplicativeも満たさなきゃいけないので一応書いておく。

data Free t a
  = Pure a
  | Join (t (Free t a))

instance Functor t => Functor (Free t) where
  f `fmap` Pure x  = Pure $ f x
  f `fmap` Join tx = Join $ fmap (fmap f) tx
instance Functor t => Applicative (Free t) where
  pure = Pure
  Pure f  <*> m = fmap f m
  Join tf <*> m = Join $ fmap (<*> m) tf
instance Functor t => Monad (Free t) where
  Pure x  >>= f = f x
  Join tx >>= f = Join $ fmap (>>= f) tx

-- tをFreeモナドに乗せる便利関数
toFree :: t a -> Free t a
toFree = Join . (fmap Pure)

再帰的な構造になっていることが分かる。 コンストラクタが少し見づらいので、GADTs(個人的最初に覚えるべき言語拡張1位。ググってね)を使ったほうが見やすいかもしれない。

{-# LANGUAGE GADTs #-}

data Free t a where
  Pure :: a -> Free t a
  Join :: t (Free t a) -> Free t a

ちなみに Free t a のtに直接Functor制約を加えたらいいのではと思うかもしれないがこれはGADTsによって可能になる: https://wiki.haskell.org/Data_declaration_with_constraint つまり次のように書くと Free はFunctorなるtしか受け取らなくなる。

{-# LANGUAGE GADTs #-}

data Free t a where
  Pure :: Functor t => a -> Free t a
  Join :: Functor t => t (Free t a) -> Free t a

本来こちらのほうが自然なはずだし型推論も強力になるので実践的なコード書く時は使っていこう。

さて、コンストラクタを眺めても結局よく分からないかもしれない。 結局Freeがどういう値を持つのかと言うと、例えば

data T a = T1 a

なる定義のFunctorがあったとして、こいつに対するFreeモナドをbindしまくったりすると

    Join (T1 (Join (T1 (Join (T1 ( .. ))))))

みたいな値が出来上がる。

bindは「二重になった文脈を結合する」役割があるみたいな話をしたが、実はbindは

  • m a に対して a -> m b な関数を適用して(fmap)
  • 二重になった文脈を m m b -> m b と結合する(join)

の2つの関数に分けることができる。 fmap の定義はFunctorの定義でもあるので、つまりモナドfmapjoinreturn の3つで定義されるとも言える。

このjoinが Free のコンストラクJoin に対応している。Pure はreturn(pure)に対応している。 Freeモナドがラップする型はFunctorだからfmapは元からできる一方で、joinとreturnはできない。 そこでそれらの操作をデータ構造として溜め込んでおいて後から誰かに解釈させよう的な魂胆。

例えばリスト [] をFreeモナドに乗せてモナド化することを考える。(元からモナドなので必要ないが、例のため)

program :: Free [] Int
program = do
  x <- toFree [1, 2]
  y <- toFree [3, 4]
  f x y
 where
  f :: Int -> Int -> Free [] Int
  f = ...

と書くと、(余談: このwhereの書き方便利なのに最初気づきにくい) toFree [1, 2] = Join [Pure 1, Pure 2] だからdoブロックは

Join [Pure 1, Pure 2] >>= (\x ->
Join [Pure 3, Pure 4] >>= (\y ->
f x y
))

と書き換えられ、 >>= を展開していくと

Join [Pure 1, Pure 2] >>= (\x ->
Join [Pure 3, Pure 4] >>= (\y ->
f x y
))
↓ 1つ目の(>>=)を展開
Join [
  Pure 1 >>= (\x -> Join [Pure 3, Pure 4] >>= (\y -> f x y)),
  Pure 2 >>= (\x -> Join [Pure 3, Pure 4] >>= (\y -> f x y))
]
↓ 左2つの(>>=)を展開
Join [
  Join [Pure 3, Pure 4] >>= (\y -> f 1 y),
  Join [Pure 3, Pure 4] >>= (\y -> f 2 y)
]
↓ (>>=)を全部展開
Join [
  Join [Pure 3 >>= (\y -> f 1 y), Pure 4 >>= (\y -> f 1 y)],
  Join [Pure 3 >>= (\y -> f 2 y), Pure 4 >>= (\y -> f 2 y)]
]
↓ (>>=)を全部展開
Join [
  Join [f 1 3, f 1 4],
  Join [f 2 3, f 2 4]
]

となる。

あとはこのデータ構造を自由に解釈すれば結果の値が取り出せる。 典型的なリストモナド、つまり「複数の可能性がある」という文脈として解釈するなら

runList :: Free [] a -> [a]
runList (Pure x) = [x]
runList (Join tx) = concat $ fmap runList tx
-- runList program = [f 1 3, f 1 4, f 2 3, f 2 4]

となる。 concat[[a]] → [a] なPrelude関数。

望むならば文脈を「世界は全部空リスト」だと解釈してしまってもいい。ごFreeに。

runList _ = []

Free Functor

Coyoneda(あるいはFree Functor)というのがあるモナ。 何ができるかというと、* -> * な任意の型をFunctorにすることができる。

Free Monadが任意のFunctorをMonadにするのと同じように、Coyoneda(Free Functor)は * -> * な任意の型をFunctorにする。 やり口もFreeモナドと同じで、Functorとしての性質をデータ構造内に溜め込んでおくことで擬似的に(疑似ではないが) fmap を可能にする。

つまりFree FunctorとFree Monadを組み合わせたらFree Freeで最強モナ?

というわけでまずCoyoneda(Free Functor)の実装を見る。 Coyoneda* -> * な型をラップしてFunctorに変換する。

data Coyoneda t a = forall x . Coyoneda (t x) (x -> a)

instance Functor (Coyoneda t) where
  fmap f (Coyoneda tx g) = Coyoneda tx $ f . g

-- tをCoyonedaに乗せる便利関数
coyoneda :: t a -> Coyoneda t a
coyoneda = (`Coyoneda` id)

ちなみにGADTsで書くと

{-# LANGUAGE GADTs #-}

data Coyoneda t a where
  Coyoneda :: forall x . t x -> (x -> a) -> Coyoneda t a

Coyoneda は中の値に実際に関数を適用する方法を知らないので、適用される予定の関数を合成して溜め込んでおき後で Coyoneda t a -> t a しようとした誰かに適用してもらう。

forall x . のついたコンストラクタのところが少しトリッキーだが、これは任意の型xを取ってくることを明示する構文で、「元の型はなんでもいいけどとにかくaに変換する関数をこのコンストラクタが持っているはずだよ」と言っている。

Freerモナド

Free FunctorとFree MonadをあわせてFreerモナド爆誕する。 * -> * な任意の型を取ってMonadにできる。

type Freer t a = Free (Coyoneda t) a

toFreer :: t a -> Freer t a
toFreer = toFree . coyoneda

なお型を一から定義しなおす(Free中にCoyonedaを展開すればいい)と

{-# LANGUAGE GADTs #-}

data Freer t a where
  Pure :: a -> Freer t a
  Join :: forall x . t x -> (x -> Freer t a) -> Freer t a

Maybe

Freerモナドを使うと、Maybeモナドは以下のように実装できる。 MyMaybe の文脈:「計算に失敗する可能性がある」

{-# LANGUAGE GADTs #-}

data MyMaybe a where
  MyNothing :: MyMaybe a
  MyJust :: a -> MyMaybe a

runMyMaybe :: Freer MyMaybe a -> MyMaybe a
runMyMaybe (Pure x) = MyJust x
runMyMaybe (Join (Coyoneda MyNothing _)) = MyNothing
runMyMaybe (Join (Coyoneda (MyJust x) k)) = runMyMaybe (k x)

もはやFunctorを定義する必要すらない。 データ型を定義し、解釈を与えるだけだ。 Maybeとしてのすべての振る舞いがrunMyMaybeだけで簡潔に定義できる。

Reader

ReaderモナドReader e の文脈:「あるe型の値を参照できる」

{-# LANGUAGE GADTs #-}

data Reader e a where
  Ask :: Reader e e

runReader :: e -> Freer (Reader e) a -> a
runReader _ (Pure x) = x
runReader e (Join (Coyoneda Ask k)) = runReader e (k e)

最初見たときMaybeのJustみたいな a -> Reader e a 型のコンストラクタは要らないの?と思ったが、実は要らない。 というか、実はMaybeでも要らない。 Justは最小の文脈pureに等しくて、それは本来FreerモナドのPureに任せることができる。 ただMaybeを実行した後にJustという文脈が残っていたほうがそのまま実行結果にできて便利なので、敢えて冗長に定義していると見ることができる。 実際Justを使わない(My)Maybeモナドは次のように定義できる。

{-# LANGUAGE GADTs #-}

data MyMaybe a where
  MyNothing :: MyMaybe a

runMyMaybe :: Freer MyMaybe a -> Maybe a
runMyMaybe (Pure x) = Just x
runMyMaybe (Join (Coyoneda MyNothing _)) = Nothing

State

StateモナドState s の文脈: 「s型の状態を伴う」

{-# LANGUAGE GADTs #-}

data State s a where
  Get :: State s s -- 状態を手に入れる
  Modify :: (s -> s) -> State s () -- 状態を更新する

runState :: s -> Freer (State s) a -> a
runState s (Pure x) = Just x
runState s (Join (Coyoneda Get k)) = runState s (k e)
runState s (Join (Coyoneda (Modify f) k)) = runState (f s) $ k ()

余談 run内で何回も出てくる、Coyonedaコンストラクタの第2引数に入っている関数 k :: x -> Freer t a は”各時点での残りの計算”の表現になっていることが分かるが、こういう残りの計算的なもの(というか”残りの計算”そのもの)を継続と言うらしい。 関数型言語ではこうして継続を容易に扱えるのが強いんだとかなんとか。 抽象的な概念すぎて何が嬉しいかよく分からないがちゃんと嬉しさがあるらしいね…

IOモナドと純粋性

IOモナドは「副作用が起きる」という文脈を持つモナドである。 Haskellでは副作用はすべてIOモナドで扱う。(ちなみにこれが少し乱暴すぎるという話もある)

IOモナドを実行すると副作用が起きるので、bindする度に副作用が起こってしまうのでは?本当にHaskellは純粋と言えるの?という気持ちになってくる。

IOモナドfmap, return, join で定義できるはずで、fmapとreturnは明らかに純粋に書けそうだが、joinが怪しい。 do構文はbind、つまりjoinを含むので、もしIOモナドのjoinに副作用があったらIOモナドのdoが唯一純粋でないということになってしまう。

しかし実際にはIOモナドのbindは副作用を含まない形で実装されている。 Freeモナドもやっているように、実際に文脈をjoinしていく処理はモナドとしての演算とは完全に独立した場所で書くことができる。

気持ちとしてはFreeモナドと同じで、IOモナドのdoでは実際のjoinは実行しないまま”joinされる予定”が溜め込まれており、予定を溜め込むだけなら純粋なままできる、という感じ。 後から溜め込まれたものを見て副作用を起こし、IOモナドを剥がす runIO 的な部分は鉄格子とかで囲まれた全く別の場所に隔離されている。噛みついてくることはありません。

最強?

Haskellの難関たるモナドをなんとか攻略すると(難解なアルゴリズムなどはともかく)実践レベルのプログラミングができるのではないか? と思いきや、実はまだ結構困った問題がある。

モナドは各目的に特化した手続き言語なので、これを自作し、組み合わせて使いたいという当然のニーズがある。 もう少し発展させたテクとして組み合わせたモナドを連携させる、つまりあるモナドを他のより一般的なモナドに翻訳したいという気持ちもある。 しかし実はこの問題の解決は容易でも完璧でもなく(←知らんけどポイント)、この辺からHaskellは初心者に牙を剥いてくる。 副作用がお前に噛みつかなくてもHaskellがお前に噛みつく。

詳しい話は次の記事に書く。

次回予告

モナドを組み合わせる解決策の一つはモナド変換子(Monad Transformer)であるが、この手法には嬉しくない問題がいくつかある。 代替として、Extensible Effectsという手法があり、更にこれを発展したMore Extensible Effectsがある。 モナドを組み合わせるという抽象的かつ簡潔な目的に対していくつも手法を使い分けたりしたくないので、できればMore Extensible Effectsで殴り倒したいところ… あと例外の取り扱い、言語拡張とか?

頑張っていきましょう

初心者おすすめ記事

入門書に載ってなさそうだけどHaskell感覚を養う上で割と大事なやつ。

https://haskell.jp/blog/posts/2017/10-about-kind-system-part1.html

(続く)