ズンドコ手続型 Haskell


2016年 03月 25日

巷では「ズンドコキヨシ」というプログラミング課題が流行っているらしい。そりゃなんぞや、というところだが、要点としては《「ズン」「ドコ」のいずれかをランダムで出力し続け、「ズン」「ズン」「ズン」「ズン」「ドコ」の配列が出たら「キ・ヨ・シ!」と出力して終了するプログラムを作成せよ》ということになるだろうか。既に多くの方が挑戦しているようで、以下にまとめられている。

ズンドコキヨシまとめ

すっかり波には乗り遅れている感じではあるが、それはともかくとしてもプログラミング課題としてとてもセンスが良い。ざっと思いつくプログラミング上のポイントは

  • 文字列出力
  • 乱数利用
  • 状態管理
  • 繰り返し、およびその中断

あたりだろうか。ツボを押さえたとても良い課題である。

というわけで、是非やってみたい。そう、敢えて、「手続き型」 Haskell で。

ソースコード全体

で、書いてみたのがコレ。

import Control.Applicative (empty)
import Control.Monad (forever)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Maybe (runMaybeT)
import Control.Monad.Trans.State (evalStateT, get, put, modify)
import System.Random (randomRIO)

main = (`evalStateT` 0) . runMaybeT . forever $ do
  r <- liftIO $ randomRIO (0, 1 :: Int)
  if r == 0 then do
    liftIO $ putStr "ズン"
    lift $ modify (+ 1)
  else do
    liftIO $ putStr "ドコ"
    cnt <- lift get
    if 4 <= cnt then do
      liftIO $ putStr "キ・ヨ・シ!"
      empty
    else do
      lift $ put 0

「ほう、これが噂の Haskell による関数型プログラミングか…」

いや、違う。 関数型プログラミングスタイルを得意とする Haskell では、このような問題に対してこうした書き方はふつうしない。しかし一方で、 Haskell は do という構文があり、それによって手続き型のプログラミングスタイルを容易に含めることができるというのも特長のひとつなのだ。

そんなわけで、今回はふつうはそうしないであろう部分にも do 構文を導入し、敢えて手続き型プログラミングスタイルを Haskell でやってみたという、ある種のネタである。が、冒頭で述べたとおり課題のセンスがとても良く、敢えてこう書いてみることでプログラミングポイントが浮き彫りになってくる。順番に見てみよう。

出力、および乱数層

とりあえず必要なのが出力と乱数である。これらはまとめて IO モナドがその能力を提供してくれる (※1)。プログラム実行エントリポイントである mainIO モナドアクションであり、これは処理系から常に提供されるものだ。

文字列出力に関しては putStr :: String -> IO () でできるし、出力後に改行する putStrLn などもある。このあたりは問題ないだろう。

乱数生成に関しては、 randomRIO :: Random a => (a, a) -> IO a という、引数の範囲内でランダムに値を生成してくれる関数がある。

例えば次のプログラムは、 1 から 6 の値をランダムに表示する。

main = do
  dice <- randomRIO (1, 6 :: Int)
  putStr (show dice)

状態管理層

「ズンドコキヨシ」では、それまでのランダムな出力結果によって挙動を変える必要がある。ということは、それまでに何を出力したかという「状態」を持っていなければならない。少なくとも、《これまでに何回「ズン」が連続したか?》くらいのことは必要だろう。

そんなわけで、状態を管理する層を追加しているのが evalStateT :: Monad m => StateT s m a -> s -> m a である。これは任意の型 s で表される「状態」を自由に読み書きする能力を与える。具体的には、 get で現在の状態を取得し、 put で書き込みができるようになる。また、「読んで、変更して、書く」を一度に行うショートカット関数 modify もある。

例えば次のプログラムは、ユーザから 1 行ずつ入力を受け取りながらその長さを累積させ、 20 文字を超えたら、ちょうど超えたそのときの入力を表示する。

main = putStrLn =<< evalStateT go 0
  where
    go = do
      input <- liftIO getLine
      len <- get
      let newlen = len + length input
      if 20 < newlen then do
        return input
      else do
        put newlen
        go

中断可能繰り返し層

Haskell での繰り返しは、先の例のように再帰呼び出しをしたり、あるいは mapfoldr などの高階関数を使って実現するが、今回使っているのは単純に無限ループを実現する forever :: Monad m => m a -> m b である。第一引数のモナドアクションを無限に繰り返す、極めてシンプルな関数だ。

例えば次のプログラムは、無限に Hello, World! を出力し続ける。

main = forever (putStrLn "Hello, World!")

しかし無限にアクションを実行されてもそれはそれで困る。ふつうのプログラムは条件によって繰り返しを終了したい。その「計算中断能力」を授けているのが、「失敗するかもしれない計算」を実現する Maybe モナドだ。 runMaybeT :: MaybeT m a -> m (Maybe a) を使って、 Maybe をモナド階層に積み上げている。

これでこの関数に渡されるモナドアクションは、「失敗する」能力を得る (※2)。要は繰り返しを終了したくなったら「失敗」すればいいわけだ。失敗自体は emptymzero などでできる。

例えば次のプログラムは、ユーザから quit と入力されるまで、入力をエコーバックし続ける (※3)。

main = runMaybeT . forever $ do
  input <- liftIO getLine
  if input == "quit" then
    empty
  else
    liftIO $ putStrLn input

各層の能力の引き出し

さて、元々 main が持っている IO に加え、 (`evalStateT` 0) . runMaybeT で「状態管理能力」と「計算中断能力」を積み上げた。従ってこの 3 層のモナド階層の上ではそれら能力を自由に使えるわけだが、「どの層の」能力を使うのかによって、呼び出し方を変える必要がある。

「計算中断能力」に関しては、最も上に積まれており、直接その能力を利用できる。即ち、そのまま empty などと書けばその能力を発揮する。

「状態管理能力」に関しては、「計算中断能力」よりも下にあるので、一旦カーペットを引っぺがして床下配線を引き出さなければならない。それを行うのが lift だ。従って lift getlift (put 3) などとすればその能力を発揮する。

「出力能力/乱数能力」に関しては更にその下にあるので、 2 回 カーペットを引っぺがして床下配線を引き出さなければならない。ということはつまり、 lift を 2 回使って lift . lift $ putStr "TEST" などとすればその能力を発揮する…、のだが、如何せん IO に関しては「頻繁に使用する」「モナドスタックの最下段である」などの理由から、 lift をいつもいつも複数回重ねるのはとても面倒である。そんなわけで、どんな「高層」からでも一発で IO 能力を引っ張り出せる liftIO というショートカットがある (※4)。通常はこいつを使うと良いだろう。

ソースコード再掲

というわけでソースコードを再掲する。

import Control.Applicative (empty)
import Control.Monad (forever)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Maybe (runMaybeT)
import Control.Monad.Trans.State (evalStateT, get, put, modify)
import System.Random (randomRIO)

main = (`evalStateT` 0) . runMaybeT . forever $ do
  r <- liftIO $ randomRIO (0, 1 :: Int)
  if r == 0 then do
    liftIO $ putStr "ズン"
    lift $ modify (+ 1)
  else do
    liftIO $ putStr "ドコ"
    cnt <- lift get
    if 4 <= cnt then do
      liftIO $ putStr "キ・ヨ・シ!"
      empty
    else do
      lift $ put 0

最初の行で「必要な能力を選んで積み上げ」て、その do アクション内では lift 等によって「積み上げたどの層の能力を使うか」を選択しているのがわかるだろうか。つまり、

  • liftIO しているところは「乱数」「出力」
  • lift しているところは「状態」
  • 何もしていないところは「中断」

の能力を利用しているというわけだ。

まとめ

関数型プログラミングスタイルが得意な Haskell で、敢えて手続き型のプログラミングスタイルを選択してみた。今回のような課題に適用するのが適切かといえばそうではないだろうが、それでも入出力などは手続き型のスタイルのほうが親和性が高いことも多く、「そう書く選択肢もある」のが Haskell の魅力だ。

また、モナドという、「何かしらの能力を与える層」を積み上げ、その上で各層の能力を選択的に使っていくスタイルは、通常の Haskell プログラミングにおいても見かける。そういう使い方をするとき、課題に対して「どのような能力が必要か」「今使っている能力は何か」を意識するのは重要になるし、「ズンドコキヨシ」はそういう観点も養えるという、とてもセンスの良いプログラミング課題だと言えるだろう。

手続き型のスタイルに慣れた人にとって、関数型のスタイルはなかなか馴染まないかもしれない。しかし Haskell なら do とモナドを使って手続き的なスタイルで書くこともできる。関数的なスタイルにするのが難しいなら、無理にそうせず、いったん慣れた手続き的スタイルで記述してみるのもアリかもしれない。

脚注

(※1) IO モナドを使わずに「乱数生成能力」を提供する RandT というものもある。Haskell では IO モナドは「強力すぎて危険」なので避けるようにするのがベタープラクティスとなっていて、それに従う場合はこちらも検討すると良いだろう。

(※2) Maybe を積む MaybeT は、単に「失敗する」能力を与えるが、その理由については気にしない。「失敗した際にその理由を通知したい」場合は、 Either を積む ExceptT を利用すると良い。

(※3) このサンプルプログラムは guard :: Alternative f => Bool -> f () を使うともう少しすっきりする。これは、引数が True だったら成功し、 False だったら失敗する関数である。

main = runMaybeT . forever $ do
  input <- liftIO getLine
  guard $ input /= "quit"
  liftIO $ putStrLn input

「条件を満たさなければ、この先には進めない」と読むといいだろう。通行証の提示を求める「ガード」なわけである。

(※4) ほかのモナドトランスフォーマーでも類似のショートカットを用意してくれていたり、 lift せずに直接使えるよう工夫してくれていたりするものもあるので、そういう場合は有効活用させてもらおう。