知り合いの主催する勉強会イベント「孤独のHaskell 第二話」http://www.zusaar.com/event/502008 があったので、参加してきました。 みなさん自由な感じで特に発表などもなく、好きに過ごす感じです。
そんな中、Haskell初心者な私は
- 「すごいHaskellたのしく学ぼう」を教科書に
- 「今日はIOをできるようになろう!」を目標に
8章から9章のあたりを中心に、もくもくと写経しつつ、わからないことを @khibino師 https://twitter.com/khibino に都度尋ねるというスタイルで過ごしておりました。
do
の中に適当にIO
な式を並べたり、入力が必要な場面では <-
で生の値(?)を取り出したり、あとは if
や再帰呼び出しとかも結構ふつうにできるという感じで(要するにIO
な式になりさえすればよい)、入出力の組み立て方がなんとなくつかめてきました。
9章で紹介されている例に「todo管理」を一応の目的とした、「テキストファイルに1行足す」プログラムと「特定の1行を取り除いて書き戻す」プログラムがあります。
「1行足す」方のプログラムは、ごく単純だったこともありますし、写経して特に問題なくコンパイルが通り、期待通り動作しました。
一方で、「取り除いて書き戻す」方は何も見ずに書いてみることにしました。そろそろ自分で考えてみたくなりましたし。 単純だけど重大な書き間違い(引数の順番とか)がちゃんと型エラーになってくれることに感動しつつやっとコンパイルが通り、動かしてみたところ、さっそくハマりました。 ファイル todo.txt の 中身が、すべて消えてしまった のです。消えてほしいのは特定の1行だけだったのに。
問題のプログラムのソースコードは次のようなものです(見ないで書いたため、仕様が9章の deletetodo.hs とは異なるので注意してください)。
{- 標準入力から読み込んだ文字列と完全一致する行をtodo.txtから取り除いて上書き保存する -}
import System.IO
todoFilePath :: FilePath
todoFilePath = "todo.txt"
type Item = String
type Items = [String]
hParseToDo :: Handle -> IO Items
hParseToDo h = do
contents <- hGetContents h
return $ lines contents
parseToDoFile :: FilePath -> IO Items
parseToDoFile path = do
withFile path ReadMode hParseToDo
hWriteToDo :: Handle -> Items -> IO ()
hWriteToDo h items = do
hPutStr h $ unlines items
writeToDoFile :: FilePath -> Items -> IO ()
writeToDoFile path items = do
withFile path WriteMode $ flip hWriteToDo $ items
removeItem :: Items -> Item -> Items
removeItem items itemToRemove = filter (/= itemToRemove) items
main = do
currentItems <- parseToDoFile todoFilePath
itemToRemove <- getLine
writeToDoFile todoFilePath $ removeItem currentItems itemToRemove
hoge
顔洗う
スマホ充電する
ねる
シェルで runghc removetodo.hs
としてこのプログラムを起動し、削除したい行を入力すると、todo.txt は更新されたのですが、、、中身は空でした。
(あとで別の環境で追試してみたところ、完全に空になるのではなく、1文字だけのファイルになりました。)
plaster@chloe:~/work/sugoih/9> runghc removetodo.hs
hoge
plaster@chloe:~/work/sugoih/9> cat todo.txt
plaster@chloe:~/work/sugoih/9>
たすけてひびのさーん
答えは「遅延IO」なるものにありました。hGetContents
で読み出す文字列は、 その中身を使おうとしない限り、実際には読まれない のだそうです。
ところで、今回は hGetContents
の呼び出しを withFile
でラップしてしまっています。
withFile
アクションは次のアクションを行う前にハンドルを閉じてしまうので、hGetContents
の結果を使う頃には、もう読み出せなくなっているのです。
つまり、hGetContents
を使うつもりであれば、読みだした結果はすべてファイルを閉じる前に - 今回の場合は withFile
の中で - 使いきらなければいけなかったのです。
使いきるとはどういうことか。ファイルに書き出すということです。読んでるファイルを閉じる前に、同じファイルに書き出す? そんなのいやです。
実際に9章の deletetodo.hs を確認すると、(readFile
を使っていて、これも遅延IOだそうです)テンポラリファイルに書き出してからrenameするという回避策を(何も言わずに)とっていました。ずるい!!
ハンドルから全部を読み出す hGetContents
とは異なり、1文字を読み出す hGetChar
なら、遅延I/Oになりません。
今回はすべてメモリに持っても特に問題なさそうなので、
- 遅延IOしない
hGetContents'
を実装する - それを呼ぶように
hParseToDo
を修正する
ことにしました。
hGetContents' :: Handle -> IO String
hGetContents' h = do
eof <- hIsEOF h
if eof
then return ""
else do
c <- hGetChar h
cs <- hGetContents' h
return $ c : cs
hParseToDo :: Handle -> IO Items
hParseToDo h = do
contents <- hGetContents' h
return $ lines contents
これにて期待通り動きました。
どういしてこういう嫌なハマリ方をするのかしばらく考えたり尋ねたりしたところ、私の至った結論は以下のとおりです。
currentItems
の型は Items
つまり [String]
です。Haskellでは、こういう普通の型がついた値であれば、いつどのようにアクセスしても、本来同じ値が得られるはずです。
ところが、hGetContents
で読みだした String
は「ファイルを閉じる前に使う」か「ファイルを閉じた後で使う」かで値が変わってしまいます。
つまり、 参照透明性が成り立たっていない のではないでしょうか?
遅延IOコワイ! hGetContents
コワイ!!
とはいうものの、これをうまく使ってインタラクティブなプログラムを手軽に書けるんじゃないかなという気もしますし(というか「ふつうのHaskellプログラミング」あたりにいきなりそんな例があった気がする)、先は長そうですが、なんとかうまくつきあっていきたいなあ。
お題が出るかもと聞いていたのをちょっと楽しみにしてたのですが、代わりにこんなに収穫あったので、満足してます。 主催の @amedama さん @vvakame さん、ありがとうございました。次回も(ありますよね?(ぉ))ぜひよろしくお願いします。