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

tak0kadaの何でもノート

発声練習、生存確認用。

医学関連は 医学ノート

「Learn You a Haskell for Great Good!」第9章を読んだ

第9章はIOの続き。ファイルの読み書き、乱数生成、コマンドライン引数について。前の章と違って具体例が中心である。

ファイルとストリーム

ストリームはプログラムに出入りするデータの流れのことを言う。

runhaskell capslocker.hs < haiku.txt

のようにしてファイルを標準入力から取り込むことを考える。

ハスケルではファイルからの入力も遅延評価できる。

import Control.Monad
import Data.Char

main = forever $ do
    l <- getLine
    putStrLn $ map toUpper l

-- (だいたい)等価
import Data.Char
main = do
    contents <- getContents
    putStr $ map toUpper contents

getContentsは遅延評価されるので、インプットのテキストやターミナルからの入力を必要になった時に取り込むようになっている。

-- 複数行からなるインプットをとって10文字以内の行のみを出力する
main = do
    contents <- getContents
    putStr $ shorLinesOnly contents

shortLinesOnly :: String -> String
shortLinesOnly = unlines . filter (\line -> length line < 10) . lines

-- 等価
main = interact shortLinesOnly

interactはStringをとってStringを返す関数をとって、IOアクションを返す。このIOアクションは入力に引数にとった関数を適用した結果を表示する。

ファイルの読み書き

前のパートで標準入力、出力での読み書きが分かったのでファイルからの入出力を考える。ファイルからの入出力の概念は標準入出力とあまり変わらない。

import System.IO

main = do
    handle <- openFile "girlfriend.txt" ReadMode
    contents <- hGetContents handle
    putStr contents
    hClose handle

-- 等価
main = do
    withFile "girlfriend.txt" ReadMode (\handle -> do
        contents hGetContents handle
        putStr contents)

このプログラムで目新しいのはopenFile、h...である。openFileの型シグネチャFilePath -> IOMode -> IO Handle、FilePathはStringの型シノニム、IOModeはdata IOMode = ReadMode | WriteMode | AppendMode " ReadWriteModeというデータ型になっている。hGetContentsとhCloseはどのファイルを取り扱うか示すものである、ハンドルを引数に取る。

withFileの型シグネチャFilePath -> IOMode -> (Handle -> IO a) -> IO aとなっている。withFileを使えばハンドルを開放し忘れることはない。withFileはbracketを使って実装してみると

import Control.Exception

withFile :: FilePath -> IOMode -> (Handle IO a) -> IO a
withFile filename mode f = bracket (openFile filename mode)
    (\handle -> hClose handle)
    (\handle -> f handle)

のようになる。bracketはIO a -> (a -> IO b) -> (a -> IO c) -> IO cで2つ目の引数はエラーが起こった時に呼ばれる関数、3つ目の引数は問題ない時に呼ばれる関数である。

テキストファイルの読み書きはよくある操作なので専用の関数もある。readFile、writeFile、appendFileである。つまり、

import System.IO   -- readFileはPreludeに含まれているのでimport不要では?
main = do
    contents <- readFile "girlfriend.txt"
    putStr contents

コマンドライン引数

System.EnvironmentのgetArgs関数を使う。型シグネチャIO[String]で、コマンドライン引数のリストを返す。(getProgName::IO Stringというものもあり、プログラムの名前を返す。)

具体例

import System.Environment   
import System.Directory  
import System.IO  
import Data.List

-- 作業内容選択用のリスト
dispatch :: [(String, [String] -> IO ())]  
dispatch =  [ ("add", add)  
            , ("view", view)  
            , ("remove", remove)  
            ]  

-- Maybeを使う
-- lookup :: Eq a => a -> [(a, b)] -> Maybe b
main = do  
    (command:args) <- getArgs  
    let (Just action) = lookup command dispatch  
    action args  
  
add :: [String] -> IO ()  
add [fileName, todoItem] = appendFile fileName (todoItem ++ "\n")  

-- ファイルから読み取った行の先頭に数字をzipしてから表示する
view :: [String] -> IO ()  
view [fileName] = do  
    contents <- readFile fileName  
    let todoTasks = lines contents  
        numberedTasks = zipWith (\n line -> show n ++ " - " ++ line) [0..] todoTasks
    putStr $ unlines numberedTasks  

-- 一時作業用ファイルを作って作業する
remove :: [String] -> IO ()  
remove [fileName, numberString] = do  
    handle <- openFile fileName ReadMode  
    (tempName, tempHandle) <- openTempFile "." "temp"  
    contents <- hGetContents handle  
    let number = read numberString  
        todoTasks = lines contents  
        newTodoItems = delete (todoTasks !! number) todoTasks  
    hPutStr tempHandle $ unlines newTodoItems  
    hClose handle  
    hClose tempHandle  
    removeFile fileName  
    renameFile tempName fileName
-- エラーハンドリングありdispatch
dispatch :: String -> ( [String] -> IO () )
dispatch command
    | command == "add"    = add
    | command == "view"   = view
    | command == "remove" = remove
    | otherwise           = doesntExist command

doesntExist :: String -> String -> IO ()
doesntExist command _ =
    putStrLn "The " ++ command ++ " command doesn't exist."

main = do
    (command:args) <- getArgs
    dispatch comannd args
-- エラーハンドリングありremove
import Control.Exception

remove :: String -> IO ()
remove [fileName, numberString] = do
    contents <- readFile filename
    let todoTasks = lines contents
        numberedTasks = zipWith (\n line -> show n ++ " - " ++ line)
                                [0..] todoTasks
    putStrLn "These are your TODO items:"
    mapM_ putStrLn numberedTasks
    let number = read numberString
        newTodoItems = unlines $ delete (todoTasks !! number) todoTasks
    bracketOnError (openTempFile "." "temp")
        (\(tempName, tempHandle) -> do
            hClose tempHandle
            removeFile tempName)
        (\(tempName, tempHandle) -> do
            hPutStr tempHandle newTodoItems
            hClose tempHandle
            removeFile fileName
            renameFile tempName fileName)

オンライン版をコピペしてきたものだが手元で印刷した古いバージョン(ハードコーディングしてあったファイル名の書き換え忘れ間違いがあった...)と違うところは最後に書き足した。(オンライン版はMaybeを使っている。自分はこちらできちんと動くならこの書き方のほうが好きと感じる。)

removeについて補足すると、openTempFileという関数が使われている。openTempFile :: FilePath -> String -> IO (FilePath, Handle)で、上の例では"."で現在のディレクトリ、"temp"でファイル名が指定され一時ファイルを作成するアクションと、返り値としてファイルの名前とハンドルを返す。Control.ExceptionのbracketOnErrorがエラーハンドリングバージョンとして作られている。bracketOnErrorはbracketと違い、エラーがなければハンドルを開放しない。

乱数生成

System.Randomをインポートして利用する。random :: (RandomGen g, Random a) => g -> (a, g)である。random関数に乱数ジェネレータgを与える。mkStdGen関数はInt -> StdGenである。

import System.Random

-- 型指定が必須
> random (mkStdGen 100) :: (Int, StdGen)
-- 乱数と乱数ジェネレータが返り値
(-3650871090684229393,693699796 2103410263)
> random (mkStdGen 100) :: (Int, StdGen)
-- 同じ値
(-3650871090684229393,693699796 2103410263)

無限長のランダム値を手に入れるにはrandomsrandoms :: (RandomGen g, Random a) => g -> [a]を使う。randomsは再帰的に定義されているので乱数ジェネレータを返すことはない。

take 5 $ randoms (mkStdGen 11) :: [Int]

ある範囲の乱数を生成するにはrandomRrandomR :: (RandomGen g, Random a) => (a, a) -> g -> (a, g)を使う。

randomR (1, 6) (mkStdGen 100)

-- 無限長ver
-- この場合だと型指定がなくても通る
take 3 $ randomRs ('a', 'z') (mkStdGen 3) :: [Char]

IOで乱数ジェネレータを作る

上の方法では乱数の種が毎回同じだったので結果が変わらないことになる。それでは困るのでIOアクションを使って違う乱数ジェネレータを手に入れたい。

import System.Random

main = do
    gen <- getStdGen
    putStrLn $ tak 3 (randomRs (0, 10) gen)
    -- 全く同じ結果
    gen <- getStdGen
    putStrLn $ tak 3 (randomRs (0, 10) gen)

getStdGenを利用すると実行時ごとに違う値を使えるが実行中はジェネレータを更新できない。newStdGenを使って乱数ジェネレータを更新する。

main = do
    gen <- getStdGen
    putStrLn $ tak 3 (randomRs (0, 10) gen)
    -- 異なる結果
    gen <- newStdGen
    putStrLn $ tak 3 (randomRs (0, 10) gen)

Bytestring

だそうです。そしてこのサンクでただの数のリストをサンクの列として処理するのは非効率。そこでbytestringを使う。普通のリストは型が[a]だがbytestringではByteString型を、aの代わりにWord8をやりとりする。Word8は8ビット符号なし整数なので0から255までの整数でNumクラスのインスタンスになっている。なので5などの数字は多層型なのでWord8としても振る舞えることからそのまま使うことが出来る。

-- bytestringには正格バージョンと遅延バージョンがあるのでimportするときに好きな方を選んで使う。
import qualified Data.ByteString.Lazy as B
import qualified Data.ByteString as S

遅延bytestringは要素ごとにサンクになるのではなく、L2キャッシュに乗るように64Kバイト単位でチャンク(chunk)としてまとめてある。正格bytestringのリストと見ると分かりやすい。

ByteStringの生成と値の取り出し方。ghciでの表示がテキストと異なるのでshowあたりの実装が変わったのかもしれない。

-- pack :: [Word8] -> ByteString
> B.pack [99, 97, 110]
-- テキストにはChunk "can" Emptyとあるが手元の環境では"can"のみが帰ってくる。
"can"
> B.unpack $ B.pack [97, 98, 110]
[97, 98, 110]

正格、遅延Bytestringの変換も可能。どちらもData.ByteString.Lazyの関数を使う。bytestring同士の連結もB.consで出来るようになっている。

-- 正格 -> 遅延
> B.fromChunks [S.pack [40, 41, 42], S.pack [43, 44, 45], S.pack [46, 47, 48]]
"()*+,-./0"
-- 遅延 -> 正格
> B.toChunks $ B.pack [40, 41, 42]
["()*"]

> B.cons 85 $ B.pack [80, 81, 82]
--  85 `B.cons` B.pack [80, 81, 82]の方が分かりやすい?
"UPQR"

普通にリストを使ったプログラムのパフォーマンスに問題があればそれから書きなおせばいい。(実際に使う場合は文字コードがらみで注意点があるらしいので日本語訳を参照する。)