M.Hiroi's Home Page

お気楽 Haskell プログラミング入門

応用編 : 書き換え可能な変数

Copyright (C) 2013-2021 Makoto Hiroi
All rights reserved.

はじめに

Haskell は純粋な関数型言語なので、関数の引数や let で定義した局所変数の値を書き換えることはできません。ところが、Haskell には値を書き換えることができるデータ型 IORef がモジュール Data.IORef に用意されています。

ただし、IORef は IO モナドの中でしか使用することができません。このほかにも、ST モナドの中で使う STRef というデータ型がありますが、本稿では取り上げません。配列や IORef を使うと、手続き型言語のように「副作用」を伴う操作を Haskell でも行うことができます。

●IORef の使い方

書き換え可能な変数のデータ型は IORef a で表されます。a は型変数です。IORef a は関数 newIORef で生成します。newIORef の型を示します。

newIORef :: a -> IO (IORef a)

newIORef x は初期値が x の IORef を生成します。返り値は IO に格納されて返されます。

データの参照と更新は関数 readIORef と writeIORef で行います。IORef の値に関数を適用して値を書き換える関数 modifyIORef もあります。

readIORef :: IORef a -> IO a
writeIORef :: IORef a -> a -> IO ()
modifyIORef :: IORef a -> (a -> a) -> IO ()

返り値は IO に格納されて返されます。簡単な使用例を示します。

ghci> :m + Data.IORef
ghci> a <- newIORef 0
ghci> :t a
a :: IORef Integer
ghci> readIORef a
0
ghci> writeIORef a 10
ghci> readIORef a
10
ghci> modifyIORef a (*2)
ghci> readIORef a
20

newIORef で初期値が 0 の IORef を生成します。この場合、変数 a の型は IORef Integer になります。readIORef で a の値を参照すると 0 になります。

次に、writeIORef で a の値を 10 に書き換えます。再度、readIORef で a の値を参照すると 10 になり、変数の値が書き換えられていることがわかります。modifyIORef で a に関数 (*2) を適用すると、変数 a の値は 10 * 2 = 20 に書き換えられます。

●配列によるスタックの実装

Haskell の場合、スタックはリストを使って簡単に実現できます。関数型言語の場合、データ構造の主役はリストですが、配列を使ってもスタックを実装することができます。実際のところ、Haskell でこのようなスタックを使うことはないと思いますが、IORef の簡単な例題ということで、ご容赦くださいませ。

配列でスタックを実現する場合、データを格納するための配列本体と、スタックのトップを表す変数が必要になります。この変数のことを「スタックポインタ (stack pointer)」と呼びます。次の図を見てください。

 buffer 0  1  2  3  4  5    top
 (1) [                    ]  0    空の状態 
 (2) [  A                 ]  1    PUSH A
 (3) [  A  B              ]  2    PUSH B
 (4) [  A  B  C           ]  3    PUSH C
 (5) [  A  B              ]  2    POP => C 
 (6) [  A                 ]  1    POP => B 
 (7) [                    ]  0    POP => A 

    図 : 配列によるスタックの実現

まず、配列 buffer とスタックポインタ top を用意します。top の値は 0 に初期化しておきます。データをプッシュするときは buffer の top 番目にデータを格納してから、top の値をインクリメントします。逆にポップするときは、top の値をデクリメントしてから、buffer の top 番目にあるデータを取り出します。スタックを操作するたびに、top の値は上図のように変化します。

データをプッシュしていくと、top の値は配列の大きさと等しくなります。配列はリストと違って大きさに限りがあるので、これ以上データを追加することはできません。つまり、スタックは満杯となります。したがって、データをプッシュするとき、スタックに空きがあるかチェックする必要があります。また、top の値が 0 のときはスタックが空の状態なので、ポップすることはできません。このチェックも必要です。

●プログラムの作成

プログラムは次のようになります。

リスト : 配列によるスタックの実装

import Data.Array.IO
import Data.IORef

-- データ型の定義
data Stack a = S Int (IORef Int) (IOArray Int a)

-- スタックの生成
makeStack :: Int -> IO (Stack a)
makeStack n = do
  buff <- newArray_ (0, n - 1)
  cnt  <- newIORef 0
  return (S n cnt buff)

-- スタックにデータを追加する
push :: Stack a -> a -> IO ()
push (S size cnt buff) x = do
  n <- readIORef cnt
  if n >= size
    then error "Full Stack"
    else do
      writeArray buff n x
      writeIORef cnt (n + 1)

-- スタックからデータを取り出す
pop :: Stack a -> IO a
pop (S _ cnt buff) = do
  n <- readIORef cnt
  if n <= 0
    then error "Empty Stack"
    else do
      writeIORef cnt (n - 1)
      readArray buff (n - 1)

-- スタックは空か
isEmpty :: Stack a -> IO Bool
isEmpty (S _ cnt _) = do
  n <- readIORef cnt
  return (n == 0)

-- スタックは満杯か
isFull :: Stack a -> IO Bool
isFull (S size cnt _) = do
  n <- readIORef cnt
  return (n == size)

-- データ数を求める
len :: Stack a -> IO Int
len (S _ cnt _) = readIORef cnt

スタックのデータ型は Stack a とし、データ構築子を S としました。S の第 1 引数がスタックの大きさを表す整数値 (Int) で、第 2 引数がスタックポインタを表す変数です。値を書き換えるので IORef Int とします。なお、このプログラムでは、スタックポインタはスタックに格納されているデータ数を表すことになります。第 3 引数がスタック本体を表す配列です。

関数 makeStack n は大きさ n のスタックを生成して、IO に格納して返します。newArray_ で大きさ (0, n - 1) の配列を生成し、newIORef で初期値 0 の変数 (スタックポインタ) を生成し、それらを S に格納して返します。

関数 push s x はスタック s に x を追加します。最初に readIORef でスタックポインタの値を取り出して変数 n にセットします。n がスタックの大きさ size 以上であれば、スタックは満杯なのでエラーを送出します。そうでなければ、配列の n 番目の位置に writeArray で x を書き込み、writeIORef でスタックポインタの値を +1 します。

関数 pop s はスタックからデータを取り出して IO に格納して返します。最初にスタックポインタの値を取り出して変数 n にセットします。n が 0 以下であれば、スタックは空なのでエラーを送出します。そうでなければ、writeIORef でスタックポインタの値を -1 して、readArray で buff の n - 1 番目の値を取り出して返します。

isEmpty s はスタック s が空ならば True を返します。関数 isFull s はスタック s が満杯であれば True を返します。関数 len はスタックに格納されているデータの個数を返します。これらの関数は返り値を IO に格納して返すことに注意してください。

●実行例

それでは簡単な実行例を示します。

ghci> a <- makeStack 10 :: IO (Stack Integer)
ghci> isEmpty a
True
ghci> mapM_ (push a) [1..10]
ghci> isEmpty a
False
ghci> isFull a
True
ghci> pop a
10
ghci> pop a
9
ghci> pop a
8
ghci> pop a
7
ghci> pop a
6
ghci> pop a
5
ghci> pop a
4
ghci> pop a
3
ghci> pop a
2
ghci> pop a
1
ghci> isEmpty a
True

●配列によるキューの実装

拙作のページ「モジュール」では、リストを使ってキュー (queue) を実現しました。キューは配列を使っても簡単に実現できます。先頭位置を示す front と末尾を示す rear を用意し、front と rear の間にあるデータをキューに格納されているデータとするのがポイントです。次の図を見てください。

           0  1  2  3  4  5  6  7  8  9
rear = 0  ↓
QUEUE    [                              ]  : QUEUE は空
front= 0  ↑

rear = 3           ↓
QUEUE    [10 20 30                      ]  : データの追加
front= 0  ↑

rear = 3           ↓
QUEUE    [10 20 30                      ]  : 10を取り出す
front= 1     ↑

rear = 3           ↓
QUEUE    [10 20 30                      ]  : 20,30を取り出す
front= 3           ↑

                    図 : キューの動作

まずキューは空の状態で、rear, front ともに 0 です。データの追加は、rear が示す位置にデータを書き込み、rear の値をインクリメントします。データ 10, 20, 30 を追加すると、図のようにデータが追加され rear は 3 になります。このとき front は 0 のままなので、先頭のデータは 10 ということになります。

次に、データを取り出す場合、front の示すデータを取り出してから front の値をインクリメントします。この場合、front が 0 なので 10 を取り出して front の値は 1 となり、次のデータ 20 が先頭になります。データを順番に 20, 30 と取り出していくと、3 つしかデータを書き込んでいないので当然キューは空になります。このとき front は 3 になり rear と同じ値になります。このように、front と rear の値が 0 の場合だけが空の状態ではなく、front と rear の値が等しくなると、キューは空になることに注意してください。

rear, fornt ともに値は増加していく方向なので、いつかは配列の範囲をオーバーします。このため、配列を先頭と末尾がつがっているリング状と考え、rear, front が配列の範囲を超えたら 0 に戻すことにします。これを「循環配列」とか「リングバッファ」と呼びます。一般に、配列を使ってキューを実現する場合は、リングバッファとするのがふつうです。

●プログラムの作成

Haskell の場合、リングバッファを使うことはほとんどないと思いますが、配列と IORef の簡単な例題ということで、実際に作ってみることにしましょう。最初に、キューを表すデータ型を定義します。

リスト : キューの定義と生成

-- データ型の定義
data Queue a = Q Int (IORef Int) (IORef Int) (IORef Int) (IOArray Int a)

-- キューの生成
makeQueue :: Int -> IO (Queue a)
makeQueue n = do
  a <- newIORef 0
  b <- newIORef 0
  c <- newIORef 0
  d <- newArray_ (0, n - 1)
  return (Q n a b c d)

データ型は Queue a とし、データ構築子は Q としました。第 1 引数がキューの大きさを表す整数値 (Int) で、第 2, 3, 4 引数が front, rear, cnt を表します。cnt はキューに格納されているデータ数を表します。これらの変数は値を書き換えるので IORef Int を使っています。最後の引数がキュー本体を表す配列です。

配列を生成する関数 makeQueue も簡単です。newIORef で front, rear, cnt 用の変数を、newArray_ でキュー本体を生成し、それらを Q に格納して返すだけです。

次はデータを追加する関数 enqueue を作ります。

リスト : キューにデータを追加する

enqueue :: Queue a -> a -> IO ()
enqueue (Q size _ rear cnt buff) x = do
  c <- readIORef cnt
  if c >= size
    then error "Full Queue"
    else do
      r <- readIORef rear
      writeArray buff r x
      writeIORef cnt (c + 1)
      if r + 1 >= size
        then writeIORef rear 0
        else writeIORef rear (r + 1)

まず、cnt の値と readIORef で取り出して変数 c にセットします。c がキューの大きさ size 以上の場合、キューは満杯なのでエラーを送出します。そうでなければ、readIORef で rear の値を取り出して r にセットします。そして、writeArray で buff の r 番目に x を書き込み、writeIORef で cnt の値を c + 1 に書き換えます。最後に、r + 1 が size 以上になるかチェックします。その場合は rear を 0 に書き換えます。そうでなければ rear の値を +1 します。

次は、キューからデータを取り出す関数 dequeue を作ります。

リスト : キューからデータを取り出す

dequeue :: Queue a -> IO a
dequeue (Q size front _ cnt buff) = do
  c <- readIORef cnt
  if c <= 0
    then error "Empty Queue"
    else do
      f <- readIORef front
      x <- readArray buff f
      writeIORef cnt (c - 1)
      if f + 1 >= size
        then writeIORef front 0
        else writeIORef front (f + 1)
      return x

最初に readIORef で cnt の値を取り出して変数 c にセットします。c が 0 以下であればキューは空なのでエラーを送出します。そうでなければ、front の値を readIORef で取り出して変数 f にセットし、buff の f 番目のデータを readArray で取り出して変数 x にセットします。あとは、writeIORef で cnt の値を -1 して、f + 1 が size 以上になるかチェックします。そうであれば、front の値を 0 に書き換えます。そうでなければ、front の値を +1 します。最後に、x を IO に格納して返します。

あとの関数 isEmpty, isFull, clear は簡単なので説明は省略します。プログラムリストをお読みくださいませ。

リスト : キューの操作関数

-- キューは空か
isEmpty :: Queue a -> IO Bool
isEmpty (Q _ _ _ cnt _) = do
  c <- readIORef cnt
  return (c == 0)

-- キューは満杯か
isFull :: Queue a -> IO Bool
isFull (Q _ _ _ cnt _) = do
  c <- readIORef cnt
  return (c /= 0)

-- キューを空にする
clear :: Queue a -> IO ()
clear (Q _ front rear cnt _) = do
  writeIORef front 0
  writeIORef rear  0
  writeIORef cnt   0

●実行例

これでプログラムは完成です。それでは、簡単な実行例を示しましょう。

ghci> a <- makeQueue 8 :: IO (Queue Integer)
ghci> isEmpty a
True
ghci> isFull a
False
ghci> mapM_ (enqueue a) [1..8]
ghci> isEmpty a
False
ghci> isFull a
True
ghci> dequeue a
1
ghci> dequeue a
2
ghci> dequeue a
3
ghci> dequeue a
4
ghci> dequeue a
5
ghci> dequeue a
6
ghci> dequeue a
7
ghci> dequeue a
8
ghci> isEmpty a
True
ghci> isFull a
False

makeQueue でキューを作成して変数 a にセットします。mapM_ でキューにデータを 8 個セットします。これでキューは満杯になるので、これ以上データを追加することはできません。次に、dequeue でデータを取り出します。先に入れたデータから順番に取り出されていることがわかりますね。これでキューは空の状態になります。


初版 2013 年 5 月 5 日
改訂 2021 年 8 月 8 日