🎅この記事は、Haskell Advent Calendar 2020 25日目の記事です。
🎄Happy Christmas!!🎄
今回は先日(といっても元の質問の投稿からもう何ヶ月も経ってしまいましたが…)StackOverflowに上がったこちら👇の質問に対する回答の、続きっぽい話を書こうと思います。長いし、質問の回答からスコープが大きく外れてしまうので記事にしました。
haskell - モナド則を崩してしまう例が知りたい - スタック・オーバーフロー
Monad
とMonoid
にある重要な繋がりを説明した後、それを応用したWriter
Monad
がどうMonoid
を使ってMonad
則を満たしているのか証明します。そして、Writer
のそうした性質を用いて簡単にMonad
則を破る例を紹介することで、読者のみなさんがMonad
則のみならずdo
記法やMonad
そのものの性質について、よりはっきりとした理解が得られることを目指します。
Link to
hereサンプルコードについて
本記事のサンプルコードは、Haskellの構文に準拠していないものを除いて、すべてreadme-testというツールの2020年12月13日時点の開発版でテストしました。こちらのツールはまだ開発中で、今後も仕様が大きく変わる可能性がありますが、この記事のサンプルコードをテストするのに必要な機能は十分にそろっています。このreadme-test自体についてはいつか改めて共有します。
また、テストの際に用いた環境は以下の通りです:
- Windows 10 Pro 20H2
- GHC 8.10.1
- Stackage nightly-2020-08-15
Link to
hereMonad
とMonoid
の切っても切り離せない関係
「モナドは単なる自己関手の圏におけるモノイド対象だよ。何か問題でも?」というフレーズ(原文「A monad is a monoid in the category of endofunctors, what’s the problem?」が示すとおり、モナドとモノイド、Haskellの識別子で言うところのMonad
とMonoid
には密接な関係があります。ぶっちゃけ、このフレーズの正確な意味を私は理解していないのですが、少なくともMonad
とMonoid
には重要な共通点があることは知っています。それは、どちらも単位元と結合則がある、ということです!
具体的にMonad
とMonoid
の単位元・結合則を見てみましょう:
Monoid
の単位元: 単位元であるmempty
は、どんな値x
に<>
で足しても結果が変わらない!
<> mempty = x
x mempty <> x = x
Monad
の単位元: return
は>>=
の前に使っても後ろに使っても、m
やk a
の結果を変えない!
return a >>= (\a -> k a) = k a
>>= (\a -> return a) = m m
Monoid
の結合則: x <> y <> z
の結果は、y <> z
を先に計算しようとx <> y
を先に計算しようと変わらない!
<> (y <> z) = (x <> y) <> z x
Monad
の結合則: m >>= \x -> k x >>= h
の結果は、\x -> k x >>= h
を先に計算しようと、m >>= (\x -> k x)
を先に計算しようと変わらない!
>>= (\x -> k x >>= h) = (m >>= (\x -> k x)) >>= h m
※Monad
の単位元・結合則の式についてはわかりやすさのために引用元から少し形を変えています。
HaskellにおけるMonad
・Monoid
とは、値がそれぞれの単位元・結合則をを満たす型です1。それ以上でも、それ以下でもありません。
それぞれの単位元・結合則を表す式は、一見して異なるものに見えるかも知れませんが、表す性質自体はよく似ています。なので、式を読んでもよく分からないという方は、上記に書いた日本語の説明をざっと眺めて覚えておいてください。特に、結合則における「~を先に計算しようと、~を先に計算しようと変わらない!」の部分がこの後とても重要になります。
Link to
hereMonoid
の例
ここまで読んで、Monad
はなんか聞いたことがあるけどMonoid
は初めて聞くよ、という方向けに補足すると、Monoid
とは例えば次のような型の値(と、それに対する処理)です。
Sum
型: 数値(Num型クラスのインスタンス)に対する、足し算を表すMonoid
のインスタンス
-- これから紹介する処理に必要なモジュールのimport
import Data.Monoid
-- Sum aに対する <> は + と同等なので、
> getSum (Sum 1 <> Sum 2 <> mempty)
-- は、
1 + 2 + 0
-- と同じ。
mempty
が各Monoid
のインスタンスにおける単位元を返す、という点に注意してください。上記のとおり足し算の場合は0
です。
Product
型: 数値(Num型クラスのインスタンス)に対する、かけ算を表すMonoid
のインスタンス
-- Product aに対する <> は * と同等なので、
> getProduct (Product 1 <> Product 2 <> mempty)
-- は、
1 * 2 * 1
-- と同じ。
リスト型: リスト型の値に対する、結合 (++)
を表すMonoid
のインスタンス
-- [a] に対する <> は ++ と同等なので、
> [1, 2] <> [3] <> mempty
-- は、
1, 2] ++ [3] ++ []
[-- と同じ
All
型: Bool
型の値に対する論理積&&
を表すMonoid
のインスタンス
> getAll (All True <> All False)
-- は、
True && False
-- と同じ
-- これが何を返すかは、想像してみてください!
mempty getAll
Any
型: Bool
型の値に対する論理和||
を表すMonoid
のインスタンス
> getAny (Any True <> Any False)
-- は、
True || False
-- と同じ
-- これも何を返すかは、想像してみてください!
mempty getAny
このように、Monoid
は他のプログラミング言語でもおなじみの、多くの二項演算を表しています。これらのインスタンスはすべて、先ほど紹介した「単位元」や「結合則」のルールを守っているので、気になった方はぜひチェックしてみてください2。
Link to
hereMonoid
とWriter
の切っても切り離せない関係
実はそんなMonad
とMonoid
の固い絆を象徴するようなMonad
が、この世にはあります。そう、Writer
です!Writer
はMonoid
の単位元・結合則をそのまま活かすことによってMonad
の単位元・結合則を満たしたMonad
であり、Writer
がどうやってMonad
則を満たしているのか知れば、Monad
則がどうやって成立するものなのかが、すっきりクリアになることでしょう。
手始めにWriter
の定義と、Writer
がMonad
の各メソッドをどのように実装しているか見てみましょう。「モナドのすべて」におけるWriter
の紹介ページから、少しリファクタリングしつつ引用します3。
-- Writer型の定義
newtype Writer w a = Writer { runWriter :: (a, w) }
タプルに対してnewtype
していることから分かるとおり、Writer
の実態はただのタプルです。ただのタプルがどうやってMonad
になるのでしょう?その答えがこちら👇:
-- WriterのMonad型クラスの実装
-- 実際のところFunctor, Applicativeのインスタンス定義も必要だけどここでは省略
instance Monoid w => Monad (Writer w) where
return a = Writer (a, mempty)
Writer (a, w1) >>= f =
let Writer (b, w2) = f a
in Writer (b, w1 <> w2)
return
の定義は比較的シンプルですね。mempty
を受け取った値a
と一緒にタプルに入れて返すだけです。Monad
の単位元であるreturn
では、Monoid
の単位元であるmempty
を使うのです。
一方、>>=
はどう読めばいいでしょう?let ... in ...
の結果にあたるWriter (b, w1 <> w2)
に注目してください。
まず、b
は>>=
の右辺であるf
が返した結果です。Writer
の>>=
が返す、Writer
がラップしたタプルの一つ目の要素は、ここでf
が返した値の型と一致していなければなりません。Writer
において>>=
の型はWriter w a -> (a -> Writer w b) -> Writer w b
であり、右辺にあたるf
は(a -> Writer w b)
という型なので、>>=
全体の戻り値Writer w b
とf
の戻り値が一致している必要があることがわかりますよね?
さらに重要なのがw1 <> w2
です。ここであのMonoid
の演算子<>
が出てきました!Writer
は>>=
の中で<>
を使うMonad
なんですね!一体何と何を<>
しているのでしょう?まず、<>
の左辺であるw1
は、左辺にあたるWriter
がタプルに保持していたMonoid
型クラスのインスタンスの値です。そして右辺のw2
は、>>=
の右辺に渡した関数f
がb
と一緒に返したw2
です。
以上のことをまとめると、Writer
の>>=
は、
- 左辺の
(a, w1)
におけるa
をf
に渡して、 f
が返した(b, w2)
におけるb
を、w1
とw2
と一緒に<>
でくっつけつつ返す、
という処理を行っています。Writer
は、「b
を返すついでにw1
とw2
を<>
でくっつける」と覚えてください。
Writer
は、
Monad
の単位元return
でMonoid
の単位元mempty
を使って、Monad
の結合則を満たす>>=
で、これまたMonoid
の結合則を満たす<>
を使っているのです。
やっぱりWriter
はMonoid
あってのMonad
と言えますね。
Link to
heredo
と<>
さて、この「b
を返すついでにw1
とw2
を<>
でくっつける」というWriter
の振る舞いが象徴するように、大抵のMonad
のインスタンスにおける>>=
は、何かしら値を返すついでに、何らかの処理を行うよう実装されています。この「ついでに行われる処理」はMonad
のインスタンスをdo
記法の中で扱うと、ますます静かに身を隠すようになります。
こちらもWriter
を例に説明しましょう。まず、例示用にWriter
を作るアクションを適当に定義します。
addLogging :: Int -> Int -> Writer [String] Int
=
addLogging x y Writer (x + y, ["Adding " ++ show x ++ " to " ++ show y ++ "."])
multLogging :: Int -> Int -> Writer [String] Int
=
multLogging x y Writer (x * y, ["Multiplying " ++ show x ++ " with " ++ show y ++ "."])
addLogging
とmultLogging
はそれぞれ、引数として受け取った整数を足し算したりかけ算したりしつつ、「足したよ」「かけたよ」という内容の文字列を一緒に返します。Writer [String] Int
における[String]
にログとして書き込んでいるようなイメージで捉えてください。
これらをdo
の中で使ってみると、よりaddLogging
やmultLogging
が「足し算やかけ算をするついでに、ログとして書き込んでいる」っぽいイメージが伝わるでしょう:
testDo :: Writer [String] Int
= do
testDo <- addLogging 3 4
result1 <- multLogging 5 2
result2 addLogging result1 result2
⚠️申し訳なくもdo
記法自体の解説、つまり>>=
がどのようにdo
記法に対応するかはここには書きません。お近くのHaskell入門書をご覧ください。
👆では、3 + 4
した結果result1
と、5 * 2
した結果result2
を足す処理を行っています。それに加えて、「足したよ」「かけたよ」というログを表す文字列のリスト[String]
も一緒に返しています。do
記法が>>=
に変換されるのに従い、Writer
の>>=
が内部で<>
を使い、addLogging 3 4
・multLogging 5 2
・addLogging result1 result2
が返した文字列のリスト[String]
を結合することによって、あたかもaddLogging
やmultLogging
が「値を返しつつ、ログとして書き込む」かのような処理を実現できるのがWriter
におけるdo
記法の特徴です。
能書きはここまでにして、実際にどのような結果になるか見てみましょう:
> runWriter testDo
17,["Adding 3 to 4.","Multiplying 5 with 2.","Adding 7 to 10."]) (
はい、3 + 4
と5 * 2
の結果を足し算した結果17
と、addLogging 3 4
・multLogging 5 2
・addLogging result1 result2
が一緒に返していた文字列のリスト[String]
が、書いた順番どおりに結合されて返ってきました。Writer
はdo
記法の中に書いたWriter
の値(a, w)
のうち、Monoid
のインスタンスであるw
を<>
で都度結合させているということが伝わったでしょうか?
Link to
hereWriter
Monad
の結合則とMonoid
の結合則
ここまでで、Writer
Monad
がどのように<>
を使っているのか、それによって>>=
やdo
記法がどのように振る舞っているのか、具体例を示して説明いたしました。ここからは、Writer
がMonoid
の<>
の結合則をどう利用することで、Monad
としての>>=
の結合則を満たしているのかを示しましょう。長いので「めんどい!」という方はこちらをクリックしてスキップしてください。
そのために、Monad
の結合則における>>=
を、Writer
の>>=
として展開してみます。
(0) Monad
の結合則:
>>= (\x -> k x >>= h) = (m >>= (\x -> k x)) >>= h m
(1) m
は>>=
の左辺なのでWriter (a, w1)
に置き換える:
※ここからは、比較しやすくするために等式=
の左辺と右辺を別々の行に書きます。
let Writer (a, w1) = m in Writer (a, w1) >>= (\x -> k x >>= h)
=
let Writer (a, w1) = m in Writer (a, w1) >>= (\x -> k x) >>= h
(2) 一つ目の>>=
をWriter
における>>=
の定義で置き換える:
let Writer (a, w1) = m
Writer (b, w2) = (\x -> k x >>= h) a
in Writer (b, w1 <> w2)
=
let Writer (a, w1) = m in Writer (a, w1) >>= (\x -> k x) >>= h
(3) 等式=
の右辺における一つ目の>>=
も同様に変換する:
let Writer (a, w1) = m
Writer (b, w2) = (\x -> k x >>= h) a
in Writer (b, w1 <> w2)
=
let Writer (a, w1) = m
Writer (b, w2) = (\x -> k x) a
in Writer (b, w1 <> w2) >>= h
(4) 無名関数である(\x -> k x >>= h)
と(\x -> k x)
に、a
を適用する:
let Writer (a, w1) = m
Writer (b, w2) = k a >>= h
in Writer (b, w1 <> w2)
=
let Writer (a, w1) = m
Writer (b, w2) = k a
in Writer (b, w1 <> w2) >>= h
(5) 等式=
の左辺における二つ目の>>=
をWriter
における>>=
の定義で置き換える:
let Writer (a, w1) = m
Writer (b, w2) =
let Writer (c, w3) = k a
Writer (d, w4) = h c
in Writer (d, w3 <> w4)
in Writer (b, w1 <> w2)
=
let Writer (a, w1) = m
Writer (b, w2) = k a
in Writer (b, w1 <> w2)) >>= h
(6) 等式=
の右辺における二つ目の>>=
も同様に変換する:
let Writer (a, w1) = m
Writer (b, w2) =
let Writer (c, w3) = k a
Writer (d, w4) = h c
in Writer (d, w3 <> w4)
in Writer (b, w1 <> w2)
=
let Writer (a, w1) = m
Writer (b, w2) = k a
in let Writer (c, w3) = Writer (b, w1 <> w2)
Writer (d, w4) = h c
in Writer (d, w3 <> w4)
(7) Writer
は、Writer
と(a, w)
を切り替えるだけで実質何もしていないので削除する:
let (a, w1) = m
=
(b, w2) let (c, w3) = k a
= h c
(d, w4) in (d, w3 <> w4)
in (b, w1 <> w2)
=
let (a, w1) = m
= k a
(b, w2) in let (c, w3) = (b, w1 <> w2)
= h c
(d, w4) in (d, w3 <> w4)
(7.5) (7)の等式をよく見ると、=
の左辺においては(b, w2)
と(d, w3 <> w4)
が、=
の右辺においては(c, w3)
と(b, w1 <> w2)
が等しい。
let (a, w1) = m
= -- ここの(b, w2)は、
(b, w2) let (c, w3) = k a
= h c
(d, w4) in (d, w3 <> w4) -- ここの(d, w3 <> w4)を代入したもの!
in (b, w1 <> w2)
=
let (a, w1) = m
= k a
(b, w2) in let (c, w3) = (b, w1 <> w2) -- ここで代入している!
= h c
(d, w4) in (d, w3 <> w4)
(8) (7.5)から、=
の左辺ではb = d
でw2 = w3 <> w4
、=
の右辺ではc = d
でw3 = w1 <> w2
であることがわかる。なのでそれぞれ置き換える:
let (a, w1) = m
= k a
(c, w3) = h c
(d, w4) in (d, w1 <> (w3 <> w4))
=
let (a, w1) = m
= k a
(b, w2) = h b
(d, w4) in (d, (w1 <> w2) <> w4)
(9) a
~d
・w1
~w4
の変数名を、登場した順番に振り直す:
let (a, w1) = m
= k a
(b, w2) = h b
(c, w3) in (c, w1 <> (w2 <> w3))
=
let (a, w1) = m
= k a
(b, w2) = h b
(c, w3) in (c, (w1 <> w2) <> w3)
等式=
の左辺と右辺がそっくりな式になりましたね!
ここで、Monoid
の結合則を思い出してみましょう:
<> (y <> z) = (x <> y) <> z x
そう、x <> y <> z
などと書いて3つのMonoid
型クラスのインスタンスの値を<>
でくっつけるときは、カッコで囲って(y <> z)
を先に計算しようと、(x <> y)
を先に計算しようと、結果が変わらない、というものでした!
それを踏まえて、(9)の等式=
の両辺をよく見比べてみてください。異なっているのはw1 <> (w2 <> w3)
と(w1 <> w2) <> w3)
の箇所だけですね!つまり、Writer
Monad
における>>=
の結合則は、w1 <> (w2 <> w3)
と(w1 <> w2) <> w3)
が等しいから、すなわちMonoid
における<>
の結合則が成り立つからこそ成立するのです。これがまさしく「Monoid
とWriter
の切っても切り離せない関係」なのです!
Link to
here関係を壊してみる
それではいよいよ、「Monoid
とWriter
の切っても切り離せない関係」を利用して、Monad
則を破ってみましょう💣
Link to
here<>
とMonoid
の結合則
前述のとおり、Writer
における>>=
が結合則を満たすのは、Writer
がラップしているMonoid
な値の<>
が結合則を満たしてこそ、なのでした。これは言い換えれば、その、ラップしているMonoid
な値の<>
が結合則を破れば、自然にWriter
の>>=
も結合則を破るはずです。この方法は、結合則を満たさない>>=
っぽい処理をゼロから探すより遥かに簡単です。>>=
のようなm a -> (a -> m b) -> m b
というややこしい型の関数よりも、<>
のようなa -> a -> a
という型の関数の方がずっと身近ですしね!
Monoid
の<>
のようなa -> a -> a
という型の関数で、結合則を満たさない処理 — といえば、引き算-
や割り算/
を思い浮かべる方が多いのではないでしょうか。と、いうわけでMonoid
の例で紹介したSum
やProduct
のように、数値に対する引き算を表すnewtype
、Difference
を定義してみましょう:
newtype Difference a = Difference { getDifference :: a }
それから、Difference
を(実際には間違いですが)Monoid
のインスタンスにします。最近のGHCでは、Monoid
のインスタンスを定義する前にSemigroup
のインスタンスにする必要があるのでご注意ください。説明しやすさのために敢えてこれまで触れてきませんでしたが、これまで何度も使った<>
は実際のところMonoid
の関数ではなくSemigroup
の関数なんですね。Monoid
は「<>
で(結合則を備えた)二項演算ができるだけでなく、mempty
という単位元もある」という性質の型クラスなので、「単に『<>
で(結合則を備えた)二項演算ができる』だけの型クラスも欲しい!」というニーズから、Monoid
の<>
はSemigroup
の関数となり、Monoid
はSemigroup
のサブクラスという関係に変わったのでした。
何はともあれ、Difference
をSemigroup
のインスタンスにしましょう:
instance Num a => Semigroup (Difference a) where
Difference a <> Difference b = Difference (a - b)
はい、単に両辺を-
で引き算するだけですね。
今度こそDifference
をMonoid
のインスタンスにします。本記事ではmempty
を直接使うことはないので何でもいいはずですが、とりあえずSum
と同様に0
ということにしておきます:
instance Num a => Monoid (Difference a) where
mempty = Difference 0
😈これで<>
が結合則を満たさないおかしなMonoid
のインスタンス、Difference
ができました!早速試して結合則を破っていることを確認してみましょう:
-- こちらは 1 - (2 - 3) と同じ
> getDifference $ Difference 1 <> (Difference 2 <> Difference 3)
2
-- こちらは (1 - 2) - 3 と同じなので...
> getDifference $ (Difference 1 <> Difference 2) <> Difference 3
-4 -- <- 当然 1 - (2 - 3) とは異なる結果に!
バッチリ破れてますね!このように<>
における結合則は、引き算などおなじみの演算で、簡単に破ることができます💪
Link to
here>>=
とMonad
の結合則
<>
における結合則を破ることができたと言うことは、Writer
の>>=
による結合則も、もはや破れたも同然です。先ほど定義したDifference
型を使えば、>>=
は途端に結合則を満たさなくなるでしょう。
例を示す前に、Writer
を使う際しばしば用いられる、ユーティリティー関数を定義しておきます。実践でWriter
を使いたくなったときにも大変便利なので、是非覚えておいてください:
tell :: Monoid w => w -> Writer w ()
= Writer ((), w) tell w
このtell
関数は、受け取ったMonoid
な値をそのまま「ログとして書き込む」関数です。結果として返す値はただのユニット()
なので、気にする必要がありません。tell
のみを使ってWriter
を組み立てれば、「ログとして書き込む」値のみに集中することができます。これから紹介する例でもやはり関心があるのは「ログとして書き込む」値だけなので、ここでtell
を定義しました。
それではtell
を使って、Writer
の>>=
における結合則も破ってみましょう:
-- こちらは Difference 1 <> (Difference 2 <> Difference 3) と同じ
> getDifference . snd . runWriter $ tell (Difference 1) >>= (\_ -> tell (Difference 2) >>= (\_ -> tell (Difference 3)))
2
-- こちらは (Difference 1 <> Difference 2) <> Difference 3 と同じなので...
> getDifference . snd . runWriter $ (tell (Difference 1) >>= (\_ -> tell (Difference 2))) >>= (\_ -> tell (Difference 3))
-4 -- <- 当然 1 - (2 - 3) とは異なる結果に!
予想どおり一つ目のWriter
と二つ目のWriter
とで異なる結果となりました。1 - (2 - 3)
と(1 - 2) - 3
をWriter
を使って遠回しに言い換えているだけなので、当然と言えば当然です。
しかしtell (Difference 1) >>= (\_ -> tell (Difference 2) >>= \_ -> tell (Difference 3))
などのWriter
型の式がMonad
の結合則m >>= (\x -> k x >>= h) = (m >>= (\x -> k x)) >>= h
にどう対応するのか、ちょっと分かりづらいですかね?(式も長いし)一つずつ注釈を加えます:
-- こちらは m >>= (\x -> k x >>= h) = (m >>= (\x -> k x)) >>= h の前半、
-- m >>= (\x -> k x >>= h) に相当する
> tell (Difference 1) >>= (\_ -> tell (Difference 2) >>= (\_ -> tell (Difference 3)))
-- ^^^^^^^^^^^^^^^^^^^ ^ ^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^
-- m x k h
--
-- こちらは m >>= (\x -> k x >>= h) = (m >>= (\x -> k x)) >>= h の後半、
-- (m >>= (\x -> k x)) >>= h に相当する
> (tell (Difference 1) >>= (\_ -> tell (Difference 2))) >>= (\_ -> tell (Difference 3))
-- ^^^^^^^^^^^^^^^^^^^ ^ ^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^
-- m x k h
ラムダ式の引数x
は実際には使われていない点に注意してください。これでもconst
を使って\x -> const (tell (Difference 2)) x
と書き換えれば、const (tell (Difference 2))
がk
に厳密に対応するので、上記の二組の式は>>=
の結合則を破るペアだと言えます。
Link to
heredo
記法とMonad
の結合則
前の節では、Monoid
の結合則を守っていない値をラップしているWriter
を作ることで、>>=
の結合則を破る例を簡単に作り出せることを紹介しました。ここでは本記事の最後として、>>=
の結合則を破った結果、do
記法がいかに直感に反する挙動となるか紹介して、>>=
の結合則を守ることが私たちにどのようなメリットをもたらすのか解説します。
例として、先ほど>>=
の結合則を破るのに使った1 - 2 - 3
を再利用しましょう。Difference
をラップしたWriter
で1 - 2 - 3
を計算させると、次のような式になります:
Difference 1) >>= (\_ -> tell (Difference 2)) >>= (\_ -> tell (Difference 3)) tell (
これをdo
記法に変換すると、次のようになります:
do
Difference 1)
tell (Difference 2)
tell (Difference 3) tell (
do
記法における各行の間に>>=
が隠れたことで、すっきりしましたね!
この状態から、do
記法を使って1 - (2 - 3)
と(1 - 2) - 3
を表すWriter
の式にするには、次のように書き換えます:
-- こちらが 1 - (2 - 3) を表す
=
do_1minus'2minus3' do
Difference 1)
tell (do
Difference 2)
tell (Difference 3)
tell (
-- こちらが (1 - 2) - 3 を表す
=
do_'1minus2'minus3 do
do
Difference 1)
tell (Difference 2)
tell (Difference 3) tell (
コメントに書いたとおり、do_1minus'2minus3'
が1 - (2 - 3)
、do_'1minus2'minus3
が(1 - 2) - 3
と同等なWriter
です。Haskellはシングルクォートを変数の名前に含めることができるので、シングルクォートでカッコを表すことにしました(まさかこんなところで役に立つとはね!)。
上記の二つの式では、カッコ()
で囲う代わりにもう一つのdo
記法に収めることで、do
記法における各行を実行する順番をいじっています。
本当にこれで1 - (2 - 3)
や(1 - 2) - 3
と同等な式になっているのでしょうか?試しにrunWriter
して結果を確かめてみましょう:
-- こちらが 1 - (2 - 3) を表す
> getDifference . snd $ runWriter do_1minus'2minus3'
2
-- こちらが (1 - 2) - 3 を表す
> getDifference . snd $ runWriter do_'1minus2'minus3
-4
バッチリ👌想定どおり、do_1minus'2minus3'
が1 - (2 - 3) = 2
を計算し、do_'1minus2'minus3
が(1 - 2) - 3 = -4
を計算していますね!
さてこれまでで、Writer
Monad
はMonoid
の結合則を利用することで>>=
の結合則を満たしていることを示し、ラップしているMonoid
な値が結合則を満たしていなければ、必然的にWriter
も結合則を破ってしまうことを、>>=
やdo
記法を使って具体的に示しました。それでは今挙げた、do
記法で結合則を破った例は、一体何を示唆しているのでしょうか?普通にHaskellでコードを書いていて、前述のような書き換え、すなわち、
do
Difference 1)
tell (do
Difference 2)
tell (Difference 3) tell (
から、
do
do
Difference 1)
tell (Difference 2)
tell (Difference 3) tell (
への書き換え(あるいはその逆)は、一見するとそんな機会ないように思えます。しかしこれが、do
記法をカッコ代わりに使うという変な方法ではなく、次のように変数に代入することで切り出していた場合、いかがでしょうか?
= tell (Difference 1)
someSingleAction
= do
someSequence Difference 2)
tell (Difference 3)
tell (
= do
someCompositeAction
someSingleAction someSequence
上記👆のような三つのWriter
の値を、下記👇の三つの値にリファクタリングする場合です。
= do
refactoredSequence Difference 1)
tell (Difference 2)
tell (
= tell (Difference 3)
splitOutSingleAction
= do
refactoredCompositeAction
refactoredSequence splitOutSingleAction
あるいは、たった3行しかありませんし、一つの値に統合する方がいいかも知れません:
= do
flattenedAction Difference 1)
tell (Difference 2)
tell (Difference 3) tell (
これらの書き換えは、いずれもdo
記法が内部で使っている>>=
の結合則を前提とすれば、可能であってしかるべきです。do
記法は、適当にMonad
のインスタンスの値(「アクション」などとも呼ばれます)を上から下まで列挙すれば、自動で>>=
を使ってつなげてくれる、というものです。なので、適当に並べたアクションがどういう形に結合されるのか気にする必要があるのでは、安心して使えません。一方、上記の3組の式は、Writer Difference
、すなわち引き算を表す「偽Monoid
」をラップしているが故に、>>=
の結合則を満たしておりません。結果、do
記法に変えたときに並べたアクションをどこで切り出すかで、結果が変わってしまいます。これでは安心して列挙できません!
Link to
hereまとめ
以上です。これまでで、Monad
則のうち結合則がなぜ重要なのか、結合則を実際に破ってみることを通じて説明しました。Monad
と同様に結合則を持ったMonoid
は、Monad
以上にインスタンスを見つけるのが簡単で、なおかつ、例えば引き算のように「二項演算だけど結合則を満たしていない」処理を見つけるのが簡単です。本記事ではMonoid
のそうした性質と、Monoid
の性質でもってMonad
則を満たしているWriter
Monad
に注目することで、簡単にMonad
則を破る例を提示することができました。それから、Monad
の結合則を実際に破った例を使って、Monad
の結合則がdo
記法を自然に書けるようにするために必要であることを示しました。これらの実例から主張したいことを一般化すると、次のとおりです:
do
記法の各行の間で、値を返すついでに何かを行うのがMonad
のインスタンスdo
記法の各行の間で、値を返すついでに行っている処理が結合則を満たす型が、Monad
則を満たすと言えるMonad
則を守らない型をdo
記法で使うと、do
記法の結合を気にして書かなければならなくなる
それでは、2021年も🎁Happy Haskell Hacking with Monad🎁!
一応、
Monad
についてはそのスーパークラスであるApplicative
の則、Functor
の則がありますが、Monad
則を満たしていればそれらは自動的に満たせるので、ここでは省略します。↩︎残念ながら実際のところ、
Float
型・Double
型などの浮動小数点数に対するSum
やProduct
は結合則を満たさない場合があります。これは他の多くのプログラミング言語にもある、浮動小数点数の悩ましい問題です。詳しくは「情報落ち」で検索してみてください。↩︎ここでの定義は、実際に使われているtransformersパッケージの
Writer
の定義とは大きく異なっているのでご注意ください。実際のWriter
はパフォーマンス上の都合やMonad Transformerとの兼ね合いで、幾分工夫された定義となっています。↩︎