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

本記事では、GUI部分の実装と計算部分の実装について書いていきます。この記事は連載記事「Haskell初心者が電卓アプリを作る」の2回目の記事です。

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

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

GUI の実装

GUI ライブラリ

GUI は GTK のバインディングである gi-gtk を用いました。gi-gtk を使うには依存パッケージのインストールが必要です。インストール方法は gi-gtk の README に書いてあります。mac なら以下を実行してください。

brew install gobject-introspection gtk+ gtk+3

その後、stack の package.yaml の dependencies に haskell-gi-base と gi-gtk を追加すると stack build 時にコンパイルしてくれます。

dependencies:
- base >= 4.7 && < 5
- haskell-gi-base
- gi-gtk

電卓画面

電卓アプリの画面は以下の画像の様になりました。配置の構造について解説します。 (見た目の調整には手を付けていません。)

要素の配置構造は概ね以下の画像のようになっています。

Box という要素があり、その中に要素を配置していく仕組みで、Box の小要素の配置の方向を水平か垂直のどちらか指定できます。以下の様にして Box に個要素を追加していきます。

import qualified GI.Gtk as Gtk

...

root_box <- new Gtk.Box [#orientation := Gtk.OrientationVertical]

text_view <- new Gtk.Entry [ #maxLength := 11, #editable := False]
keys_box <- new Gtk.Box [#orientation := Gtk.OrientationHorizontal]

#add root_box text_view
#add root_box keys_box

root_box は上画像には明示していませんが一番外側の Box で、子要素を垂直方向に配置します。text_view は上の赤枠の要素で、keys_box は下の赤枠の要素です。追加した要素から順に上から配置されていきます。key_box は青枠の2つの要素を小要素として持っており、水平方向に配置します。

表示の変更

以下の様にして変更できます。

buffer <- Gtk.getEntryBuffer text_view
Gtk.setEntryBufferText buffer some_text

イベントハンドラの登録

Button に対して以下の様にしてイベントハンドラを登録できます。callback_func の型は IO() です。

btn <- new Gtk.Button [ #label := "tmp" ]
_ <- on btn #clicked callback_func

ちなみに

_ <- after btn #clicked callback_func

もあるようで、default handler の前に実行されるか後に実行されるかの違いがあるようです。

その他

gi-gtk のサンプルコードは検索しても引っかからないので、使いたい場合は hackagehoogle をよく読む必要があります。慣れればこの2つあれば十分です。

計算部分の実装

基本的な考え方

電卓では普通の表示だけでなく、”0.” や “0.00” のような入力途中のものを表示する必要があります。なので、普通の数値を扱うだけでは不十分です。文字列のまま保持しつつ、計算の時に read で数値に変換する方法も考えられますが、あまり美しくないので、電卓用の新しい数値の型を作りました。

data ExpNotation = ExpNotation {
    _exponent :: Maybe Int,
    _significand :: Int
}

これは指数表記の様なもので、

$$ \begin{cases} \textrm{_significand} & (\textrm{_exponent} = \textrm{Nothing}) \\ \textrm{_significand}\ /\ 10^{e} & (\textrm{_exponent} = \textrm{Just} \ e) \end{cases}$$

を表します。_exponent を Maybe Int にしているのは、”0″ と “0.” を区別するためです。

実装

電卓プログラムを動かすためには数値の型が 4 つのメソッドを持てば十分です。

data Operation = Plus | Sub | Prod | Div deriving (Show, Eq)

class (Num a, Fractional a) => CalcValue a where
    -- |
    -- 数字を末尾に追加する
    addDigit :: a -> Int -> a
    -- |
    -- 小数点をつける
    dot:: a -> a
    -- |
    -- 表示する
    display :: a -> String
    -- |
    -- 計算する
    calculate :: a -> a -> Maybe Operation -> a
    calculate _ _ Nothing = 0
    calculate dv1 dv2 (Just x)
        | x == Plus = dv1 + dv2
        | x == Sub = dv1 - dv2
        | x == Prod = dv1 * dv2
        | otherwise = dv1 / dv2

calculate は Num クラスかつ Fractional クラスであれば定義できるので、個別に定義する必要はありません。具体的な実装は以下のとおりです。

instance CalcValue ExpNotation where
    dot :: ExpNotation -> ExpNotation
    dot ExpNotation {_exponent = Nothing, _significand = x}
        = ExpNotation {_exponent = Just 0, _significand = x}
    dot dv = dv

    addDigit :: ExpNotation -> Int -> ExpNotation
    addDigit dv a
        | numOfDigits dv > limitOfDigits = dv
        | isNothing (_exponent dv)
            = dv {
                _significand = addDigitToLast (_significand dv) a
            }
        | isJust (_exponent dv) && a == 0 && _significand dv == 0
            = dv {
                _exponent = fmap (+1) (_exponent dv),
                _significand = 0
            }
        | otherwise =
            ExpNotation {
                _exponent = fmap (+1) (_exponent dv),
                _significand = addDigitToLast (_significand dv) a
            }
    display :: ExpNotation -> String
    display ExpNotation {
            _exponent = Nothing,
            _significand = x
        } = show x
    display ExpNotation {
            _exponent = Just e,
            _significand = x
        }
        | x == 0 =
            "0." ++ concat (replicate e "0")
        | otherwise =
            let
                sign_str = if x > 0 then "" else "-"
                abs_x = abs x
                digits = numOfDigits abs_x
            in
                if digits > e
                then
                    let
                        (int_part, deci_part) = splitAt (digits - e) $ show abs_x
                    in sign_str ++ int_part ++ "." ++ deci_part
                else
                    sign_str ++ "0." ++ concat (replicate (e - digits) "0") ++ show abs_x

numOfDigits は ExpNotation の桁数を取る関数です。パターンマッチのおかげで書きやすいです。

0 で割る

0 での割り算は Nothing にする方法が考えられますが、(/) の型の問題があり ExpNotation のままではできません。

ghci> :t (/)
(/) :: Fractional a => a -> a -> a

Haskell には Infinity が定義されており (1 / 0 = Infinity)、それを使う方法も考えましたが、-Infinity と NaN も扱わないといけないのでめんどくさくてやめました。別の割り算メソッドを定義する方法もありますが、今回はやめました。excetion を使う方法は、exception の扱い方がわからなのでやめました。

そこで、Maybe ExpNotation を CalcValue クラスにすることで、zeroDivisionError を回避しました。コードを一部抜粋します。

type MExpNotation = Maybe ExpNotation

instance Fractional MExpNotation where
    (/) :: MExpNotation -> MExpNotation -> MExpNotation
    Nothing / _ = Nothing
    _ / Nothing = Nothing
    _ / (Just 0) = Nothing
    (Just dv1) / (Just dv2) = Just (dv1 / dv2)

    fromRational :: Rational -> MExpNotation
    fromRational a = Just (fromRational a)

instance CalcValue MExpNotation where
    dot :: MExpNotation -> MExpNotation
    dot = fmap dot

    addDigit :: MExpNotation -> Int -> MExpNotation
    addDigit Nothing _ = Nothing
    addDigit (Just dv) a = Just (addDigit dv a)

    display :: MExpNotation -> String
    display Nothing = "Error"
    display (Just dv) = display dv

display Nothing = “Error” としているで、一度 Nothing になるとどのような計算をしても Error と表示されます。Error状態を追加して計算をできなくしても良かったのですが、面倒なのでやめました。

ちなみに上記のような書き方はそのままではコンパイルが通りません。以下のおまじないを追加するとコンパイルが通ります。

{-# OPTIONS_GHC -fno-warn-orphans #-}
{-# LANGUAGE FlexibleInstances, InstanceSigs #-}

桁あふれ

計算が12桁を超えた場合は、先頭の12桁を取るようにしました。これをエラー扱いするなら exception を用いた方がいいように思います。