Skip to content

Instantly share code, notes, and snippets.

@plaster
Last active December 12, 2015 07:48
Show Gist options
  • Save plaster/4739570 to your computer and use it in GitHub Desktop.
Save plaster/4739570 to your computer and use it in GitHub Desktop.

孤独のHaskell 第二話 参加記録

知り合いの主催する勉強会イベント「孤独のHaskell 第二話」http://www.zusaar.com/event/502008 があったので、参加してきました。 みなさん自由な感じで特に発表などもなく、好きに過ごす感じです。

そんな中、Haskell初心者な私は

  • 「すごいHaskellたのしく学ぼう」を教科書に
  • 「今日はIOをできるようになろう!」を目標に

8章から9章のあたりを中心に、もくもくと写経しつつ、わからないことを @khibino師 https://twitter.com/khibino に都度尋ねるというスタイルで過ごしておりました。

do の中に適当にIOな式を並べたり、入力が必要な場面では <- で生の値(?)を取り出したり、あとは if や再帰呼び出しとかも結構ふつうにできるという感じで(要するにIOな式になりさえすればよい)、入出力の組み立て方がなんとなくつかめてきました。

hGetContents 関数でハマった

9章で紹介されている例に「todo管理」を一応の目的とした、「テキストファイルに1行足す」プログラムと「特定の1行を取り除いて書き戻す」プログラムがあります。

「1行足す」方のプログラムは、ごく単純だったこともありますし、写経して特に問題なくコンパイルが通り、期待通り動作しました。

一方で、「取り除いて書き戻す」方は何も見ずに書いてみることにしました。そろそろ自分で考えてみたくなりましたし。 単純だけど重大な書き間違い(引数の順番とか)がちゃんと型エラーになってくれることに感動しつつやっとコンパイルが通り、動かしてみたところ、さっそくハマりました。 ファイル todo.txt の 中身が、すべて消えてしまった のです。消えてほしいのは特定の1行だけだったのに。

問題のプログラムのソースコードは次のようなものです(見ないで書いたため、仕様が9章の deletetodo.hs とは異なるので注意してください)。

removetodo.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

todo.txt

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

答えは「遅延IO」なるものにありました。hGetContents で読み出す文字列は、 その中身を使おうとしない限り、実際には読まれない のだそうです。 ところで、今回は hGetContents の呼び出しを withFile でラップしてしまっています。 withFileアクションは次のアクションを行う前にハンドルを閉じてしまうので、hGetContents の結果を使う頃には、もう読み出せなくなっているのです。

つまり、hGetContents を使うつもりであれば、読みだした結果はすべてファイルを閉じる前に - 今回の場合は withFile の中で - 使いきらなければいけなかったのです。 使いきるとはどういうことか。ファイルに書き出すということです。読んでるファイルを閉じる前に、同じファイルに書き出す? そんなのいやです

実際に9章の deletetodo.hs を確認すると、(readFile を使っていて、これも遅延IOだそうです)テンポラリファイルに書き出してからrenameするという回避策を(何も言わずに)とっていました。ずるい!!

遅延IOしない hGetContents' を実装した

ハンドルから全部を読み出す 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 さん、ありがとうございました。次回も(ありますよね?(ぉ))ぜひよろしくお願いします。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment