Zenn
🍋

HaskellでStateパターンを実装する (Haskell初心者が電卓アプリを作る 3)

に公開

この記事は連載記事「Haskell初心者が電卓アプリを作る」の3回目の記事です。
本記事では、Haskell での State パターンの実装について記載します。

  1. Haskell初心者が電卓アプリを作る (1)

  2. GUIと計算部分の実装 (Haskell初心者が電卓アプリを作る 2)

  3. Haskellでstateパターンを実装する (Haskell初心者が電卓アプリを作る 3) ← 今ここ

  4. hspecでHaskellのテストコードを書く (Haskell初心者が電卓アプリを作る 4)

ソースコードは以下にアップしています。

https://github.com/daikon-oroshi/haskell-calculator-sample

State パターンに State モナドを使わなかった

実装前は State パターンと State モナドには深い関係があると思っていたのですが、今回の実装では State モナドを使いませんでした。また、実装してみて、少なくともコーディングする上ではこの二つはあまり関係ないと感じました。その理由を述べる前に、モナドと State モナドについて軽く説明します。

モナドとは

モナドを知らない方は、モナドとは「合成できる仕組み」の事だと思ってください。ここでの合成は関数の合成 gfg \circ f のようなもののことです。普通の (集合論における) 関数の場合は ff の値域と gg の定義域が一致する必要がありますが、それらが一致しなくても合成のようなものが考えられる場合があります。そのような場合には背後にモナド (に付随するクライスリ圏) が潜んでいます。

少し凝った例だと、例えば XX, YY を有限集合とし、確率的に定まる写像 f:XYf: X \to Y があるとしましょう。これは集合論的には xXx \in X に対して YY 要素を対応させるのではなく、 YY 上の確率分布 f(x)=μf(x) = \mu を対応させるものです。このとき xxyy に写される確率は μ(y)\mu(y) で表されます。確率的に定まる写像 g:YZg: Y \to Z との合成は、ff の値域が YY の上の確率分布であるため集合論的には定義できませんが、

gf(x)(z):=yYf(x)(y)g(y)(z)g \circ f (x)(z) := \sum_{y \in Y} f(x)(y)g(y)(z)

と定義すると gf(x)g \circ f (x)ZZ 上の確率分布であり、ffgg の"合成" とみなすことができます。この背後には Giry モナドが潜んでいます。

プログラムでは ff の返り値と gg の引数が一致すれば "合成" できます。厳密には副作用があるので数学的な (集合論的な) 意味での合成ではありませんが、"合成" は実際に可能であり、その背後には IO モナドが潜んでいます。

副作用以外にも、モナドを用いて表現できる合成があります。

モナドの定義のおさらい

モナドの定義について軽くおさらいすると、モナド TT とは任意の型 A,BA, B と任意の関数 f:ABf: A\to B に対して別の型 TATA, TBTB と別の関数 Tf:TATBTf: TA \to TB を対応させるもので、さらに任意の型 AA に対して関数

μA:TTATA\mu_A: TTA \to TA
ηA:ATA\eta_A: A \to TA

が存在するものでした (本当はもう少し条件があります)。

例えば List モナドは任意の型 AA に対して、 AA の配列 List A\mathrm{List} \ A という型を対応させ、関数 f:ABf: A \to B に対して、配列の各要素に ff を適用する関数 mapf:List AList B\textrm{map} f: \mathrm{List} \ A \to \mathrm{List} \ B を対応させます。

また、aAa \in A に対して ηA(a)\eta_A(a) は一つの要素を持つ [a][a] であり、配列の配列 aseq=[[a11,a12],[a21,a22]]a_{seq} = [[a_{11}, a_{12}],[a_{21}, a_{22}]] に対して、μA(aseq)\mu_A(a_{seq}) は配列をくっつけた [a11,a12,a21,a22][a_{11}, a_{12}, a_{21}, a_{22}] になります。

このとき、f:ATBf: A \to TBg:BTCg: B \to TC の"合成"を以下の図式の赤線で定義するのでした。

また、普通の関数 f:ABf: A \to B に対して f=ηBff^{\prime} = \eta_B \circ f を考えることで、普通の関数も上記の仕組みで"合成"できるのでした。

State モナドとは

SS に対して、モナド State S\mathrm{State} \ S

State S A=(A×S)S(Aは任意の型)\mathrm{State} \ S\ A = (A \times S)^S \quad (A \textrm{は任意の型})

と定義されます。右辺の (A×S)S(A \times S)^SSS から A×SA \times S への関数全体の集合と思って良いです。ここで

ηA:AState S A\eta_A: A \to \mathrm{State} \ S\ A

idA×S:A×SA×S\mathrm{id}_{A \times S}: A \times S \to A \times S

をカリー化したもの、つまり

ηA(a)(s)=idA×S(a,s)=(a,s)\eta_A(a)(s) = \mathrm{id}_{A \times S}(a, s) = (a, s)

で与えられ、

μA:State S (State S A)State S A\mu_A: \mathrm{State} \ S\ (\mathrm{State} \ S\ A) \to \mathrm{State} \ S\ A

(S×(S×A)Seval)S(S×A)S(\underbrace{S \times (S \times A)^S}_{\mathrm{eval}})^S \to (S \times A)^S

で与えられます。ここで eval\mathrm{eval}(s,f)S×XS(s, f) \in S \times X^S に対して f(s)Xf(s) \in X を与えるものです。

合成を考えましょう。

f:AState S B (=(B×S)S)f: A \to \mathrm{State} \ S \ B \ (= (B \times S)^S)

はアンカリー化すると

fˉ:A×SB×S\bar{f}:A \times S \to B \times S

であり、ffg:BState S Cg: B \to \mathrm{State} \ S \ C の合成は (証明は省略しますが) fˉ\bar{f}gˉ\bar{g} の合成

gˉfˉ:A×SC×S\bar{g} \circ \bar{f}: A \times S \to C \times S

のカリー化と一致します。つまり、型 SS の引数を順次渡していく様な場合に State モナドを使うことができます。

要素を用いて表すと (a,s)A×S(a, s) \in A \times S に対して

fˉ(a,s)=(b,s)B×S\bar{f}(a, s) = (b, s^{\prime}) \in B \times S
gˉ(b,s)=(c,s)C×S\bar{g}(b, s^{\prime}) = (c, s^{\prime\prime}) \in C \times S

となります。

State モナドを使わなかった理由 1

電卓アプリではボタン操作の度に現在の状態を更新する必要があります。State モナドは状態を保持するのではなく、状態を渡していくだけなので、実行するたびに初期値から計算されてしまい、意図した動作になりません。

状態を保持するには STRef (STモナド) を使う方法と IORef を使う方法があります。IORef を使うのなんでもできそうなのでなるべく使いたくなかったのですが、click のコールバック関数の型が IO() だったので IORef を使いました。

理由はもう一つありますが、実装上の問題なので後ほど述べます。

State パターンの実装

まず各 step の型を

data FirstInputStep = FirstInputStep
data OperationSelectedStep = OperationSelectedStep
data SecondInputStep = SecondInputStep
data ResultStep = ResultStep
data CalcStep = forall s. (ICalcStep s) => CalcStep s

と定義して、

data CalcState v = (CalcValue v) => CalcState {
    csStep :: CalcStep,
    csCurrentVal :: v,
    csPrevVal :: v,
    csOperation :: Maybe Operation
}

とします。つまり現在の状態を step と入力した値 (第 1 変数と第 2 変数) と演算の組を値として表します。step を持っているのはあまり良くないですが、今は気にしないことにします。

step のインターフェイスを

class ICalcStep a where
    actionDigit :: (CalcValue v) => a -> CalcState v -> Int -> CalcState v
    actionDot :: (CalcValue v) => a -> CalcState v -> CalcState v
    actionZeroZero :: (CalcValue v) => a -> CalcState v -> CalcState v
    actionPm :: (CalcValue v) => a -> CalcState v -> CalcState v
    actionAc :: (CalcValue v) => a -> CalcState v -> CalcState v
    actionC :: (CalcValue v) => a -> CalcState v -> CalcState v
    actionOperation :: (CalcValue v) => a -> CalcState v -> Operation -> CalcState v
    actionEq :: (CalcValue v) => a -> CalcState v -> CalcState v

と定義して、各キーの操作を実行するメソッドを持つようにします。
第1引数を無視すれば、それぞれ CalcState (+ α)を受け取って CalcState を返す関数であり、step を遷移する場合は csStep を変更することで状態遷移を実装します。

instance ICalcStep FirstInputStep where
    actionOperation :: FirstInputStep -> CalcState v -> Operation -> CalcState v
    actionOperation _ st op =
        st {
            csStep = CalcStep OperationSelectedStep,
            csOperation = Just op
        }
    ...

問題点と改善点

この実装で state パターンは実現できていますが、いくつか問題があります。

  • step を値として持っているのは気持ち悪い。型として定義すべき。

  • ファイルを step 毎に分割できない (循環 import 問題)。

  • 冗長なところがある。特に CalcStep を ICalcStep のインスタンスにするところ。

これらは data family というものを使うと解決できるようです。

State モナドを使わなかった理由 2

ICalcStep クラスのそれぞれのメソッドは CalcState (+ α) を受け取って CalcState を返す関数なので、State モナドを使うことができます。ですが、それぞれのメソッドは別個に呼び出されるため、引数を順次渡していく仕組みである State モナドを使うメリットがありません。

まとめ

Haskell でアプリを実装してみて、思っていたよりクセがないという印象です。ポリモーフィズムが手軽に実装できますし、関数の引数のパターンマッチのお陰で if 文が少なくて済みます。仮に実装に困っても IORef をつかえば大体なんとかできそうです。

一方で、関数名の被りに厳しいところが面倒に感じました。他言語であれば、あるクラスに足し算を定義したいときに + 演算だけ定義することができますが、Haskell では + は Num クラスの + と被るため、+ を定義しようとすると、Num クラスのインスタンスにするために掛け算、abs等、他のメソッドを定義する必要があります。

また、ライブラリを使うと知らない演算子が現れたり、コンパイルエラーの内容が分かりづらかったり、役に立つサンプルコードが少なかったりと結構ハードルが高かったです。

次の記事

参考文献

HaskellでStateパターンやってみた。

GitHubで編集を提案

Discussion

ログインするとコメントできます