読者です 読者をやめる 読者になる 読者になる

てくのろじーたのしー

Haskellぺろぺろ

いまさらhaskellの状態管理に関して一言いっとくか

今回の記事はパッと出の思いつきで書いたものなのでぶっちゃけ読まないでいいです。例が面白くないです。

タイトルはそろそろFreeモナドに関して一言いっとくかパクリオマージュです。

「そろそろhaskellの状態管理に関して一言いっとくか」ではありません、「いまさら」です。要するに特に目新しいことではありません。もっと言うと状態管理というかほとんどモナドトランスフォーマーの話です。

導入

純粋関数型言語であるhaskellは「状態がないから状態管理が不得意だ」なんて言われることがあったりなかったりします。かく言う私も、昔はそんなことを思っていました。

しかし実際はhaskellはそんじょそこらのプログラミング言語よりも状態管理が得意かもしれません。少なくともある点においては明確に得意と言えるでしょう。

Stateモナド

状態と言えば英語でStatehaskellのStateモナドはその名前の通り、状態を扱うためのモナドです。

準備運動も兼ねて少し遊んでみましょう。後でStateTを使うのでControl.Monad.Trans.State.Lazyに定義してあるStateを使います。

import Control.Monad.Trans.State

addOne :: State Int ()
addOne = modify succ

doubleEven :: State Int ()
doubleEven = do
  n <- get 
  if even n
  then put $ n * 2
  else return ()
  
main :: IO ()
main = do
  print $ execState (addOne >> doubleEven) 5 -- 12
  print $ execState (doubleEven >> addOne) 5 -- 6
  print $ execState (doubleEven >> addOne) 6 -- 13

状態を裏配線して、getputmodifyで状態を更新したり取得しながら計算を行っています。

StateT

ここからが本題です。StateモナドStateモナド以上でも以下でもありません。初期値を元に計算を行って、結果を受け取る以上のことはできませんし、アプリを作る際に広い範囲で使うようなことは難しいでしょう。

そこでモナドトランスフォーマーの出番です。モナドトランスフォーマーを使えば、モナドの軛を逃れ、更なる力を得ることができます。ほぼどこでも好きなところで状態を扱えるにも関わらず、非純粋な言語のグローバル関数のようにどこでそれが使われているか分からない無秩序なものではありません。

骨組み

ある程度具体的な例を使って説明しましょう。

あなたはあるアプリケーションを作りたくなりました。そのアプリでは最初に設定ファイルから設定を読み込み、時折設定を変更しながら様々な処理をします。現時点で設定をどこかで変更したり、また別のどこかで設定を使うことは予想できていますが、プログラムの全容はまだ見えていません。

今、我々が問題としているのは設定の扱い方についてです。このプログラムが何をするかは誰も知りませんが、状態に関わる部分を作っていきましょう。

とりあえず設定を保持するStateTに加えてExceptTも使うとして、アプリ専用のモナドを構築します。

type App e s a = ExceptT e (StateT s IO) a

runApp :: App e s a -> s -> IO (Either e a, s)
runApp app s = runStateT (runExceptT app) s

evalApp :: App e s a -> s -> IO (Either e a)
evalApp app s = evalStateT (runExceptT app) s

ついでに結果だけを取り出すevalAppも作っておきました。

いろいろ

さて、今回はただの例なので扱う状態は簡単にIntということにして、このアプリで使うであろう色々な関数を書いてみます。

import Data.Char

-- 状態をインクリメントする関数
incState :: Monad m => StateT Int m ()
incState = modify succ

-- 今の状態を出力する関数
printState :: StateT Int IO ()
printState = get >>= lift . print

-- 失敗するかもしれない状態を扱う関数
doubleNonZero :: Monad m => ExceptT String (StateT Int m) ()
doubleNonZero = do
  n <- lift get
  case n of
    0 -> throwE "zero!"
    n -> lift . put $ n * 2

-- ユーザから入力を受け付ける失敗するかもしれない関数
echo :: (MonadTrans t, Monad (t IO)) => ExceptT String (t IO) ()
echo = do
  lift . lift $ putStr "type 'haskell': "
  cs <- lift . lift $ getLine
  if cs == "haskell"
    then lift . lift $ putStrLn "yes, haskell!" 
    else throwE "boo, not haskell"

-- ユーザに指示を仰ぐ失敗するかもしれない状態を扱う関数
tellNumber :: Monad m => ExceptT String (StateT Int IO) ()
tellNumber = do
    lift . lift $ putStr "tell a number: "
    s <- lift . lift $ getLine
    if isNumbers s
      then lift . put $ read s
      else throwE "not number"
  where isNumbers = all isNumber

-- ユーザをバカにする関数
fool :: IO ()
fool = putStrLn "Hey! You fool!!"

一体何に使うのか分からない関数もありますが、とりあえずこんなところでしょうか。

これらの関数は適切に持ち上げることでAppにすることができます

lift incState :: App e Int ()
lift printState :: App e Int ()
doubleNonZero :: App String Int ()
echo :: App String Int ()
tellNumber :: App Int ()
lift . lift $ fool :: App e s ()

感想

聡明な皆さんならば既にお気付きのことでしょう。これらの関数は型を見るだけでその関数が状態を扱えるかどうかが分かります。実際に型注釈にStateTが入っているincStateprintStatedoubleNonZerotellNumberは状態を扱っています。逆に型注釈にStateTが入っていないechofoolは状態を扱っていません。IOが入っているかどうかも同様です。

StateTが入っていれば必ず状態を扱うわけではないですが、確実に言えることは「StateTが入っていない関数は絶対に状態を扱えない」ということです。こうすれば実質グローバルな状態でもわざわざ使わないStateTを書かない限りはどこで状態を使っているかが一目瞭然です。変な場所で状態が変わっているかもしれない心配とはサヨナラ!

最後に

注意するべきことはこのグローバル変数を本当にグローバル変数として良いか考えることです。モナドトランスフォーマーを積み重ねすぎるとパフォーマンスに影響が出ますし、高く積もったモナドトランスフォーマーは見通しが悪く、バグの元になります。状態を扱う部分をモジュールとして切り出せないか考えるべきです。