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

本記事では、Haskell での State パターンの実装について記載します。

この記事は連載記事「Haskell初心者が電卓アプリを作る」の3回目の記事です。

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

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

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

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

モナドとは

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

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

$$g \circ f (x)(z) = \sum_{y \in Y} f(x)(y)g(y)(z)$$

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

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

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

モナドの定義

モナドの定義について軽く触れると、任意の型 $A, B$ と任意の関数 $f: A\to B$ に対して別の型 $TA, TB$、別の関数 $Tf: TA \to TB$ を対応させるもので、さらに任意の型 $A$ に対して関数

$$\mu_A: TTA \to TA$$

$$\eta_A: A \to TA$$

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

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

\[ \xymatrix{ & & TTC \ar@[red][d]^{\mu_C} \\ & TB \ar@[red][ru]^{Tg} & TC \\ A \ar@[red][ru]^{f} & B \ar[ru]^{g} & \\ & \Downarrow & \\ & & TC \\ A \ar[rru]^{g \circ f} & & } \]

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

\[ \xymatrix{ & TB \\ A \ar[ru]^{f^{\prime}} \ar@[red][r]_{f} & B \ar@[red][u]_{\eta_B} } \]

State モナドとは

型 $S$ に対して、モナド $\mathrm{State} \ S$ が

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

と定義されます。これは関数 $h: S \to A \times S$ の全体の集合と思って良いです。

$$\eta_A: A \to \mathrm{State} \ S\ A$$

$$\mathrm{id}_{A \times S}: A \times S \to A \times S$$

をカリー化したもの、

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

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

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

合成を考えましょう。

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

はアンカリー化すると

$$\bar{f}:A \times S \to B \times S$$

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

$$\bar{g} \circ \bar{f}: A \times S \to C \times S$$

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

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

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

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

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

State パターンの実装

実装

やりたいのは概ね以下の図の通りです。context がないパターンです。

まず各 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 を値として持っているのはあまり良くないですが、今は気にしないことにします。インターフェイスを

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

として各 step を ICalcStep のインスタンスすることで各 step での動作を別々に定義できます。第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
        }
    ...

最後に CalcStep を ICalcStep のインスタンスにすることで呼び出し方を共通化します。

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

問題点と改善点

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

  • step を値として持っているのは気持ち悪い。型として定義すべき。
  • ファイルを step 毎に分割できない (循環 import 問題)。
  • 冗長なところがある。特に CalcStep を ICalcStep のインスタンスにするところ。

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

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

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

まとめ

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

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

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

参考文献

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