LoginSignup
56
36

More than 3 years have passed since last update.

Haskell: Monadクラスのこれまでとこれから

Last updated at Posted at 2018-03-22

returnpure って同じじゃないの?」「Monad クラスの fail って数学的なモナドにはなくない?」

Haskell初心者が引っかかるポイントの一つが、 Monad クラスと Applicative クラスの関係だろう。他にも、 Haskell 2010 の Monad クラスには数学的なモナドと照らし合わせると奇妙な点がいくつかある。

この記事では、近年行われている Monad クラスへの破壊的変更をまとめ、変化の途上にある Monad クラスの理解の一助としたい。

Haskell 2010 での Monad クラスとその問題点

Haskell 2010 Language Report では、 Monad クラスは次のように定義されている:

Prelude
class Monad m where
  (>>=)  :: m a -> (a -> m b) -> m b
  (>>)   :: m a -> m b -> m b
  return :: a -> m a
  fail   :: String -> m a

  m >> k = m >>= \_ -> k
  fail s = error s

しかし、 Haskell 2010 の Monad クラスの定義には、次のような問題点がある:

  • 数学的にはモナドというのは自己関手の一種であるが、 Haskell の型クラスの階層はそれを反映していない。つまり、 Monad は Functor/Applicative のサブクラスとなっているべきだが、そうなっていない。
    • というわけで Applicative を Monad のスーパークラスにしたい (Functor-Applicative-Monad Proposal または AMP)
    • Applicative が Monad のスーパークラスとなった暁には、
      • return は Applicative クラスの pure と同一なので、 return を Monad クラスから取り除きたい (Monad of no return Proposal または MRP)
      • (>>) も Applicative クラスの (*>) と同一なので、 (>>) も Monad クラスから取り除きたい (これも Monad of no return Proposal の一部として扱われている)
  • 数学的なモナドの定義と照らし合わせると、 fail が余計である。
    • というわけで fail を別のクラスに分離したい (MonadFail Proposal または MFP)
    • ちなみに、 fail は do 構文におけるパターンマッチの脱糖に利用される。

このほかに「join :: Monad m => m (m a) -> m a を Monad クラスのメソッドにしたい」という動きもあるようだが、互換性を壊す変更でもないし、ここでは扱わない1

なお、 Monad クラスから削除した return(>>) は普通の関数として return = pure, (>>) = (*>) と定義し、 Monad クラスの利用者からは従来通り使えるようにする。

理想の Monad クラス

というわけで、上記の問題点を解決した「理想の」 Monad クラスの定義は次のようになる:

Prelude
class Functor f where
  fmap :: (a -> b) -> f a -> f b

class Functor f => Applicative f where
  pure :: a -> f a
  (<*>) :: f (a -> b) -> f a -> f b
  (*>) :: f a -> f b -> f b
  -- 他にもメソッドがあるけど省略

  -- 場合によってはより効率的な定義を与えることができる
  a1 *> a2 = (id <$ a1) <*> a2

-- Applicative がスーパークラスとなる
class Applicative m => Monad m where
  (>>=)  :: m a -> (a -> m b) -> m b
  -- (>>): クラスの外へ移動
  -- return: クラスの外へ移動
  -- fail: MonadFail へ移動
  -- このほか、 join がデフォルト実装付きで Monad クラスに追加されるかも

class Monad m => MonadFail m where
  fail :: String -> m a

return :: Applicative m => a -> m a
return = pure

(>>) :: Applicative m => m a -> m b -> m b
(>>) = (*>)

しかし、実際にこういう風に定義を変更してしまうと、今まで動いていた Monad クラスのインスタンスが動かなくなってしまう。具体的には、

  • 既存の Monad インスタンスに対して Applicative のインスタンスが定義されていない場合にエラーとなる。
  • Monad のインスタンスで return が定義されているとエラーとなる。
  • Monad のインスタンスで fail が定義されているとエラーとなる。
  • MonadFail のインスタンスでない型について fail を使う(ようなdo記法を使う)とエラーとなる。

したがって、一気に移行するのではなく、段階的に移行できるような措置が必要となる。

以下では、 Monad クラスの変更点を次の3点にわけ、どのように「段階的な」移行を実現するか、見ていこう。

  • Applicative を Monad のスーパークラスにしたい (Functor-Applicative-Monad Proposal または AMP)
  • return(>>) を Monad クラスから取り除きたい (Monad of no return Proposal または MRP)
  • fail を別のクラスに分離したい (MonadFail Proposal または MFP)

Functor-Applicative-Monad Proposal (AMP)

概要: Applicative を Monad のスーパークラスとする。(ついでに、 Alternative を MonadPlus のスーパークラスとする)

現状:完了している。具体的には、GHC 7.8 で移行のための警告が導入され、 GHC 7.10 (2015年3月リリース)で実際の変更が行われた。

互換性のための警告

AMP は既存のコードを壊しうる変更なので、実際に移行する前に「将来的に壊れるようなコード」に対して警告が出ると有益である。

そのため、 AMP 前夜の GHC 7.8 では -fwarn-amp という警告(デフォルトでON)が導入された2

-fwarn-amp では、 Monad のインスタンスが Applicative のインスタンスになっていない場合、および MonadPlus のインスタンスが Alternative のインスタンスになっていない場合に、警告が出る。

AMP 後の Monad クラスの定義

Applicative を Monad のスーパークラスにして、 return のデフォルト実装を与える3

Prelude
-- GHC 7.10 (base-4.8.0.0) 以降の定義

class Functor f where
  fmap :: (a -> b) -> f a -> f b
  -- 他にもメソッドがあるけど省略

class Functor f => Applicative f where
  pure :: a -> f a
  (<*>) :: f (a -> b) -> f a -> f b
  -- 他にもメソッドがあるけど省略

-- Applicative がスーパークラスとなる
class Applicative m => Monad m where
  (>>=)  :: m a -> (a -> m b) -> m b
  (>>)   :: m a -> m b -> m b
  return :: a -> m a
  fail   :: String -> m a

  m >> k = m >>= \_ -> k
  return = pure  -- デフォルト実装追加
  fail s = error s

どういうコードを書けば良い?(Monad を実装する側)

移行前(GHC 7.8 まで)と移行後(GHC 7.10 以降)の両方に通用する書き方は、次のようになる:

-- AMP 移行前と移行後の両対応コード
import Control.Applicative

-- Functor/Applicative のインスタンスも用意する
instance Functor Foo where
  fmap = ...

instance Applicative Foo where
  pure = ... -- return を使わずに定義する
  (<*>) = ...

instance Monad Foo where
  (>>=) = ...
  return = pure -- pure として定義する

Monad のインスタンス (Foo) に対して、 Applicative のインスタンスも用意するのがポイントである。Monad のインスタンスに対しては、 return の実装も一応用意しておく。

purereturn は等価だし、 Applicative の purepure = return と定義すれば良いのでは」と思われるかもしれないが、 return は将来的にクラスから削除したいという意図(後述の Monad of no return Proposal)があるので、 pure = return ではなくて return = pure とするのが適切である。

AMP に完全に移行した後は、 return 関数のデフォルト実装があるため、 return = pure は不要になる。また、 Applicative クラスの定義が Prelude に入るので、インスタンスを書くためだけに明示的に import Control.Applicative をする必要はなくなる。

特に、2018年現在、AMP以前を考慮するコードを新規に書くことはないと思われるので、 return = pure は必要ない。

-- AMP 完全移行後のコード(2018年に新規に書くならこれ)
-- Applicative(pure,(<*>),(*>),(<*)) 以外を使わないのであれば import Control.Applicative は必要ない

instance Functor Foo where
  fmap = ...

instance Applicative Foo where
  pure = ... -- return を使わずに定義する
  (<*>) = ...

instance Monad Foo where
  (>>=) = ...
  -- return は必要ない

Monad of no return Proposal (MRP)

概要:Monad クラスから return 関数を排除し、クラスの外で return = pure と定義する。

現状:進行中。GHC 8.0〜8.8 の時点では、「将来的に壊れるコード」に対しての警告が実装されている (Phase 1)。

MRP Phase 1

Phase 1 では「将来的に壊れるコード」に対しての警告が実装される。つまり、 Monad のインスタンスが明示的に return, (>>) を実装しており、しかも望ましい定義 return = pure, (>>) = (*>) とは異なる場合に、警告を出す。

GHC 8.0 で実装された -Wnoncanonical-monad-instances (デフォルトでOFF, -Wall には含まれない)がこの警告オプションである。

MRP Phase 2 以降

Phase 2 以降(GHC 8.10 またはそれ以降)では、次の順番で、時間をかけて移行を促していく。

  1. return(>>) を Monad クラスから削除する。これで return(>>) を明示的に定義した場合は基本的にエラーとなるが、例外として、 return = pure, (>>) = (*>) と定義されていた場合はエラーにしない。
  2. return = pure, (>>) = (*>) と定義されている場合であっても警告を出す。
  3. return = pure, (>>) = (*>) と定義されている場合であってもエラーとする。(完了)

どういうコードを書けば良い?(Monad を実装する側)

AMPのところにも書いたように、

-- AMP を前提にした、 MRP 移行前と移行後の両対応コード
-- Applicative(pure,(<*>),(*>),(<*)) 以外を使わないのであれば import Control.Applicative は必要ない

instance Functor Foo where
  fmap = ...

instance Applicative Foo where
  pure = ... -- return を使わずに定義する
  (<*>) = ...

instance Monad Foo where
  (>>=) = ...
  -- return は書かない

となる。

GHC オプションとして -Wnoncanonical-monad-instances も有効にしておくと良いだろう。

修正前:

MRPTest.hs
{-# LANGUAGE DeriveFunctor #-}

newtype Foo a = Ok a
              deriving (Functor,Show)

instance Applicative Foo where
  pure x = Ok x
  Ok f <*> Ok x = Ok (f x)

instance Monad Foo where
  Ok m >>= f = f m
  return x = Ok x -- 警告

main :: IO ()
main = return ()
$ stack ghc --compiler ghc-8.8.1 -- -Wnoncanonical-monad-instances MRPTest.hs
[1 of 1] Compiling Main             ( MRPTest.hs, MRPTest.o )

MRPTest.hs:12:3: warning: [-Wnoncanonical-monad-instances]
    Noncanonical ‘return’ definition detected
    in the instance declaration for ‘Monad Foo’.
    Either remove definition for ‘return’ or define as ‘return = pure’
   |
12 |   return x = Ok x
   |   ^^^^^^^^^^^^^^^

return x = Ok x の行を return = pure に変えるか、あるいは完全に取り去ってしまうと、警告は出なくなる。

MonadFail Proposal (MFP)

概要:fail を別のクラス MonadFail に分離する。

現状:GHC 8.8 でほぼ完了。

  • GHC 8.0〜8.4 の時点では、 MonadFail クラスが導入され、do記法で MonadFail クラスを使うためのGHC拡張 (MonadFailDesugaring) が実装されている。将来壊れるコードに対しての警告も実装されている。
  • GHC 8.6 の時点では、do記法でデフォルトで MonadFail が使われるようになっている(MonadFailDesugaringが有効)。
  • GHC 8.8 (base-4.13) では、 Monad クラスの定義から fail が取り除かれ、 MonadFail(fail) が Prelude からexportされるようになった。
  • 将来 Control.Monad.Fail モジュールがdeprecateになるかもしれない(ただし執筆時点で prime.haskell.org の証明書の期限が切れているので確認できない)。

互換性のための警告

GHC 8.0 で -Wnoncanonical-monadfail-instances-Wmissing-monadfail-instances の2つの警告が実装された。

-Wnoncanonical-monadfail-instances は Monad を実装する側への警告で、 Monad クラスの fail メソッドが明示的に実装されており、しかも望ましい定義 fail = Control.Monad.Fail.fail とは異なる場合に、警告を出す。この警告は GHC 8.0〜8.6 ではデフォルトでOFFであり、 -Wall にも含まれない。

(GHC 8.6 の時点で -Wnoncanonical-monadfail-instances-Wcompat に含めるべきだったと筆者は思うのだが……)

-Wmissing-monadfail-instances は Monad の利用者側への警告で、do記法で fail が必要とされるのに MonadFail のインスタンスが見つからない場合に、警告を出す。この警告は GHC 8.0〜8.4 ではデフォルトでOFFであるが、 -Wcompat によって有効化される。

GHC 8.8 の時点では -Wnoncanonical-monadfail-instances を指定すると警告が出るようになった。

MFP Phase 1

GHC 8.0 で実施。

Control.Monad.Fail モジュールを新規に追加し、 MonadFail クラスをそこで提供する。

Prelude
-- 変更なし
module Prelude where

class Functor f -- 略
class Functor f => Applicative f -- 略

class Applicative m => Monad m where
  (>>=)  :: m a -> (a -> m b) -> m b
  (>>)   :: m a -> m b -> m b
  return :: a -> m a
  fail   :: String -> m a

  m >> k = m >>= \_ -> k
  return = pure
  fail s = error s
Control.Monad.Fail
-- 追加
module Control.Monad.Fail where

class Monad m => MonadFail m where
  fail :: String -> m a

この時点ではdo記法の脱糖には相変わらず Monad クラスの fail が使用される。ただし、GHC拡張の MonadFailDesugaring が有効な場合は、do記法の脱糖に MonadFail クラスの fail が使われるようになる。

MFP Phase 2 以降

GHC 8.6 で、

  • GHC拡張 MonadFailDesugaring がデフォルトで有効になる。つまり、do記法で MonadFail クラスの fail が使用されるようになる。

GHC 8.8 で、

  • Monad クラスから fail が削除され、 Control.Monad.Fail.failPrelude.fail となる。(MFP 完了)

なお、MFPが完了すると Control.Monad.Fail というモジュールの存在価値はなくなる。

どういうコードを書けば良い?(Monad を実装する側)

モナド実装者の側として、まともな fail を提供できない場合(fail = error とするしかないような場合)は、 MFP の前も後も「fail を明示的に実装しない」。

fail を実装できるモナドの場合は、次のように書く:

import qualified Control.Monad.Fail as Fail

instance Functor Foo -- 略
instance Applicative Foo -- 略

instance Monad Foo where
  (>>=) = ...
  -- GHC 8.8 以降をサポートする場合: fail を定義しない。または CPP を使って GHC 8.8 以降の場合は fail を定義しないようにする。
  -- GHC 8.6 までの場合:
  fail = Fail.fail

instance Fail.MonadFail Foo where
  fail = ...

ただし、 GHC 8.0 よりも前のバージョンをサポートする必要がある場合は、別途 fail パッケージを利用してControl.Monad.Fail モジュールを使えるようにしておく。

ポイントは次の点である:

  • GHC 8.8 以降(base 4.13 以降)では Monad クラスで fail を定義しているとまずい。fail の定義をやめる(GHC 8.6 以前で fail = error となるのを許容する)か、 CPP を使って切り分ける。
  • MonadMonadFail の両方に対して fail を実装する。ただし、 Monad の方の fail から MonadFailfail へ処理を丸投げする。
  • MonadfailMonadFailfail は名前が同じ別物なので、区別するために qualified import を使う。

なお、モナドトランスフォーマーの類(State モナドなど、下位のモナドの上に構築されるモナド)を書く場合は、

  • Monad インスタンスを書く際に下位のモナドに対する MonadFail を使えないので fail = Fail.fail とは書けない

ので、緩やかな移行パスは存在しない(はず)。CPP を使おう。

どういうコードを書けば良い?(do記法の利用者)

do記法において「失敗しうるパターンマッチ」を使っているかどうか、検討する。

  • do記法で「失敗しうるパターンマッチ」を使っていない場合は、問題ない。
  • 使っているモナドが MonadFail のインスタンスである場合は、問題ない。
  • それ以外、つまり、使っているモナドが MonadFail のインスタンスでないのに、do記法で「失敗しうるパターンマッチ」を使っている場合。
    • モナドに MonadFail のインスタンスを実装できそうな場合は、実装する。(あるいは、ジェネリックな関数の場合は型制約を MonadFail に変える)
    • そうでない場合は、 irrefutable pattern を使うか、明示的に error を呼ぶ。(暗黙のうちに error が呼ばれないようにするのがポイント)

なお、do記法における「失敗しうるパターンマッチ」とは、 <- を使った do {...; <pattern> <- <expression> ;...} の形のパターンマッチのうち、失敗しうるものである。let や case によるパターンマッチは関係ない。

よく使われるモナドの例を挙げると、リスト [], Maybe, IOMonadFail のインスタンスで、 Either eIdentityMonadFail のインスタンスではない4

よくわからなかったら、GHCの警告 -Wmissing-monadfail-instances (または -Wcompat でも良い)を有効にするか、あるいはGHC拡張 MonadFailDesugaring を有効にして、無事にコンパイルできるか確かめると良いだろう。

なお、筆者の私見だが、ユーザーが明示的に(do記法の脱糖以外で) fail を呼ぶことはなるべく避けたほうが良いように思う。そもそも fail は失敗に関する情報を String しか受け取れない。まともなエラー処理がしたかったら Control.Monad.Except の throwError や IO の throwIO など、そのモナドに適した例外送出方法を使うべきである。リストや Maybe の場合は mzero (もしくは Alternative クラスの empty)を使うのが適切である。

修正前:

MFPTest.hs
{-# LANGUAGE DeriveFunctor #-}

data Foo a = Ok a
           | Failed String
           deriving (Functor,Show)

instance Applicative Foo where
  pure x = Ok x
  Ok f <*> Ok x = Ok (f x)
  Failed s <*> _ = Failed s
  _ <*> Failed s = Failed s

instance Monad Foo where
  Ok m >>= f = f m
  Failed s >>= _ = Failed s
  fail s = Failed s -- 警告:fail = Control.Monad.Fail.fail とするべき

main :: IO ()
main = print $ do
  Just x <- return (Just 123) :: Foo (Maybe Int) -- 警告/エラー:Foo に MonadFail のインスタンスがない
  return x

GHC 8.4.1 で、警告を有効にした場合:

$ stack ghc --compiler ghc-8.4.1 -- -Wmissing-monadfail-instances -Wnoncanonical-monadfail-instances MFPTest.hs
[1 of 1] Compiling Main             ( MFPTest.hs, MFPTest.o )

MFPTest.hs:16:3: warning: [-Wnoncanonical-monadfail-instances]
    Noncanonical ‘fail’ definition detected
    in the instance declaration for ‘Monad Foo’.
    Either remove definition for ‘fail’ or define as ‘fail = Control.Monad.Fail.fail’
   |
16 |   fail s = Failed s
   |   ^^^^^^^^^^^^^^^^^

MFPTest.hs:20:3: warning: [-Wmissing-monadfail-instances]
    • No instance for (Control.Monad.Fail.MonadFail Foo)
        arising from the failable pattern ‘Just x’
        (this will become an error in a future GHC release)
    • In a stmt of a 'do' block:
        Just x <- return (Just 123) :: Foo (Maybe Int)
      In the second argument of ‘($)’, namely
        ‘do Just x <- return (Just 123) :: Foo (Maybe Int)
            return x’
      In the expression:
        print
          $ do Just x <- return (Just 123) :: Foo (Maybe Int)
               return x
   |
20 |   Just x <- return (Just 123) :: Foo (Maybe Int)
   |   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

GHC 8.4.1 で、 MonadFailDesugaring 拡張を有効にした場合:

$ stack ghc --compiler ghc-8.4.1 -- -XMonadFailDesugaring MFPTest.hs
[1 of 1] Compiling Main             ( MFPTest.hs, MFPTest.o ) [flags changed]

MFPTest.hs:20:3: error:
    • No instance for (Control.Monad.Fail.MonadFail Foo)
        arising from a do statement
        with the failable pattern ‘Just x’
    • In a stmt of a 'do' block:
        Just x <- return (Just 123) :: Foo (Maybe Int)
      In the second argument of ‘($)’, namely
        ‘do Just x <- return (Just 123) :: Foo (Maybe Int)
            return x’
      In the expression:
        print
          $ do Just x <- return (Just 123) :: Foo (Maybe Int)
               return x
   |
20 |   Just x <- return (Just 123) :: Foo (Maybe Int)
   |   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

修正後:

MFPTest.hs
{-# LANGUAGE DeriveFunctor #-}
import qualified Control.Monad.Fail as Fail

data Foo a = Ok a
           | Failed String
           deriving (Functor,Show)

instance Applicative Foo -- 略

instance Monad Foo where
  Ok m >>= f = f m
  Failed s >>= _ = Failed s
  fail = Fail.fail -- 変更

-- MonadFail 追加
instance Fail.MonadFail Foo where
  fail s = Failed s

main :: IO ()
main = print $ do
  Just x <- return (Just 123) :: Foo (Maybe Int)
  return x

その他

Haskell の Prelude の関数を一般化したり、スーパークラスを追加したりする変更については、この記事に書いた Monad 関連のもの以外にも

などがある。


  1. join を入れる動機としては、 1. 数学的なモナドの定義には >>= よりもむしろ join が使われる 2. モナドによっては join に効率的な定義を与えることができるかもしれない の2点が考えられる。しかし、 join を Monad クラスに入れるにあたっては、 GeneralizedNewtypeDeriving との食い合わせが悪いという問題があるらしい。 

  2. GHC 7.10 以降は -fwarn-amp は用済みとなるので、 -fwarn-amp オプション自体の使用に対して "it has no effect" 的な警告が出る。 

  3. 関連して MonadPlus クラスも Alternative をスーパークラスとするように変更されているが、この記事では省略する。 

  4. これに対し、 GHC 8.8 (base-4.13) の時点では、STモナドは MonadFail のインスタンスになっているようである。IOモナドと違ってSTモナドは定義が fail = error なのに! 

56
36
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
56
36