あどけない話

インターネットに関する技術的な話など

ファーストクラスIOと部品プログラミング

Haskell 界隈では、生産者/消費者のモデルとして、Enumerator/Iteratee (EI)が流行っている。しかし、EI を理解するのは難しい。

そこでこの記事では、もっと簡単な生産者/消費者モデルを示す。EI は Enumerator が Iteratee に push する push 型だが、紹介するモデルは pull 型である。

僕が書いたEI のチュートリアルA tutorial on the enumerator library」から例を取る。すなわち、UNIX の find コマンドを実装する。

命令型 find

命令型プログラミング言語から Haskell にやってきた初心者プログラマーなら、こんなコードを書くだろう。

import Control.Monad
import Control.Applicative
import Data.List
import System.Directory
import System.FilePath

getValidContents :: FilePath -> IO [String]
getValidContents path = 
    filter (`notElem` [".", "..", ".git", ".svn"])
    <$> getDirectoryContents path

isSearchableDir :: FilePath -> IO Bool
isSearchableDir dir =
    (&&) <$> doesDirectoryExist dir
         <*> (searchable <$> getPermissions dir)

findImperative :: FilePath -> String -> IO ()
findImperative dir pattern = do
  cnts <- map (dir </>) <$> getValidContents dir
  forM_ cnts $ \path -> do
    when (pattern `isInfixOf` path) $ putStrLn path
    isDirectory <- isSearchableDir path
    when isDirectory $ findImperative path pattern

このコードもの問題は、findImperative が仕事をし過ぎていることである。関数プログラマーなら、この一枚岩な関数を分割し、合成することで find を実装したい。

期待外れの関数型find

初心者関数プログラマーなら、以下のように生産者(getRecursiveContetns)と消費者(grep)をそれぞれ別に実装し、>>= で合成するだろう。

getRecursiveContents :: FilePath -> IO [FilePath]
getRecursiveContents dir = do
  cnts <- map (dir </>) <$> getValidContents dir
  cnts' <- forM cnts $ \path -> do
    isDirectory <- isSearchableDir path
    if isDirectory
      then getRecursiveContents path
      else return [path]
  return . concat $ cnts'

grep :: String -> [FilePath] -> [FilePath]
grep pattern = filter (pattern `isInfixOf`)

findFunctional :: FilePath -> String -> IO ()
findFunctional dir pattern = 
    grep pattern <$> getRecursiveContents dir
    >>= mapM_ putStrLn

残念ながら、このコードは期待通りには動かない。IO [a] という型は、(getContetents とその仲間たちを除いて)正格に動く。リストは遅延しない。つまり、一度に [a] の全体が帰ってくると考えてよい。

よって、findFunctional を走らせると利用者をしばらく待たせ、すべての結果を得られた時点で、一度に表示する。我々が欲しいのは、ファイルを見つけた時点で表示してくれるプログラムである。

ファーストクラスIOを利用した関数型find

上記の問題を解決するために、IO がファーストクラスであることを利用する。IO a は、命令ではなく、命令書であることを思い出そう。命令書は、値、つまりファーストクラスである。

以下のコードのポイントは、Contents というデータである。定義を見れば、IO を値として扱っていることが分かるだろう。

data Contents = File FilePath | Dir [IO Contents]

getRecursiveContents :: FilePath -> IO [IO Contents]
getRecursiveContents dir = do
    cnts <- map (dir </>) <$> getValidContents dir
    return $ map toDir cnts
  where
    toDir :: FilePath -> IO Contents
    toDir path = do
        isDirectory <- isSearchableDir path
        if isDirectory
            then Dir <$> getRecursiveContents path
            else return $ File path

grep :: String -> [IO Contents] -> IO ()
grep _       [] = return ()
grep pattern (act:acts) = do
    cnt <- act
    case cnt of
        File file
            | pattern `isInfixOf` file -> putStrLn file
            | otherwise                -> return ()
        Dir dir -> grep pattern dir
    grep pattern acts

このプログラムは期待通りに動く。どう動いているのか分からない人のために、図示してみる。ディレクトリの構造が、

.
  file01
  dir02
    file11
    dir12
      file31
  file03
  dir04
    file21
    file22

であるとすると、以下のように動いている。

getR "." >>= grep
    file01
    getR "diir02" >>= grep
        file11
	getR "dir12" >>= grep
            file31
    file03
    getR "dir04" >>= grep
        file21
        file22

高階関数にしてみる

N 個表示したら、終了するようにするために、消費者側を以下のように変更してみる。

type Action = FilePath -> IO Bool

takeM :: Int -> Action -> [IO Contents] -> IO ()
takeM lim act as = takeM' 0 lim act as >> return ()

takeM' :: Int -> Int -> Action -> [IO Contents] -> IO Int
takeM' n _  _ [] = return n
takeM' n lim act (order:orders) = do
    cnt <- order
    m <- case cnt of
        File file -> do
            took <- act file
            if took
               then return (n + 1)
               else return n
        Dir dir   -> takeM' n lim act dir
    if m == lim
       then return m
       else takeM' m lim act orders

grep :: String -> Action
grep patt file
  | patt `isInfixOf` file = putStrLn file >> return True
  | otherwise             = return False

find :: FilePath -> String -> Int -> IO ()
find dir pattern n = getRecursiveContents dir >>= takeM n (grep pattern)

まとめと今後の課題

EI は柔軟であるが、理解は容易ではない。

一方、このモデルは、理解が簡単であるが、消費者はやはり一枚岩として動くので、柔軟性に欠ける。間にもう一人の登場人物を挟んだりはできない。今後の課題は、このモデルを unfold/fold のようにもっと抽象化できるか考えることである。