Haskell入門ハンズオン! #5 - 当日用資料 (3/5)
IOモナド
Haskellでは入出力や状態変化をともなう処理は、IOモナドというかたちで、あつかわれる。「モナド」という難しげな言葉に、おどろく必要はない。理解すれば、どうということはない概念だ。圏論とかを理解する必要はない。さまざまな処理を抽象化してくれる便利な道具だ。今回は「モナド」については、あまりふれない。IOモナドを「組み立て」という側面から説明する。
手続き
つぎのような「手続き」をみてみよう。
puts "hello"
s = gets
puts s
これは、いちおうRubyの例だ。puts "hello"をしたあとに、getsをして、キーボード入力を変数sに代入し、その代入された値sをputsしている。この記法では、「AのつぎにBをする」という「関係」が上下の位置によって暗黙に示されている。
Rubyなどでは「暗黙に」示されている関係が、Haskellでは明示的に演算子になる。
putStrLn "hello" >>
getLine >>=
\s -> putStrLn s
「AのつぎにBをする」はA >> Bと表される。「Cの結果を利用してDをする」はC >>= Dだ。ここでDは「Cの結果」を引数とする関数になる。
do記法
Haskellでは入出力は、演算子(>>)や(>>=)によって「明示的に」組み合わされる。これは、Haskellの美しさでもあり、コードを安全に、きれいに書ける性質でもある。ただ、上から下に流れる処理というのは、メタファとして、わかりやすい。Haskellでは「美しさ」「安全性」を犠牲にすることなく、あたかも、暗黙の前後関係をもつような書きかたができる。それがdo記法だ。前述のコードを再掲する。
putStrLn "hello" >>
getLine >>=
\s -> putStrLn s
これをdo記法を使って書くと、つぎのようになる。
do putStrLn "hello"
s <- getLine
putStrLn s
今回は、時間がないので、このdo記法のかたちだけ覚えておこう。そのうらがわに、明示的な演算子による結合があると、知っておけばいい。do記法で入出力処理を書いてみよう。
% vim samples/do.hs
io1 :: IO ()
io1 = do
putStrLn "hello"
s <- getLine
putStrLn s
IOモナドの型は、入力される値がなければ、つぎのようになる。
IO()
変数io1を、「do記法によって組み立てた入出力」を表す値で束縛した。試してみる。
> :load samples/do.hs
> io1
hello
(適当に入力してエンター)world
world
do記法のなかで使える予約語letを使った構文がある。
% vim samples/do.hs
io2 :: IO ()
io2 = do
n <- getLine
let msg = "Hello, " ++ n ++ "!"
putStrLn msg
このように、do記法のなかで変数を定義できる。試してみる。
> :reload
> io2
(名前を入力)Yoshio
Hello, Yoshio!
標準入出力
標準入出力への入出力について学ぶ。標準入力はデフォルトではキーボードからの入力。標準出力はデフォルトでは画面への出力になる。このふたつは、すでに出てきたが、もういちど確認する。対話環境で試してみる。
> putStrLn "hello"
hello
> getLine
(適当に入力)world
"world"
関数putStrLnは文字列を引数にとり、その文字列を標準出力に出力する。入出力getLineは標準入力から1行入力し、それを「つぎの」入出力にわたす。型をみてみよう。
> :type putStrLn
putStrLn :: String -> IO ()
> :type getLine
getLine :: IO String
つぎにわたす値がないとき、入出力の型は、つぎのようになる。
IO ()
つぎにわたすのがString型の値のとき、つぎのようになる。
IO String
改行を追加しないで出力するには、つぎのようにする。
> putStr "hello"
hello>
このように、関数putStrを使う。文字列以外の値を表示するには関数printを使う。
> print 123
123
関数printは、関数showによって文字列化したものを、関数putStrLnによって表示するのと、おなじことだ。
> putStrLn (show 123)
123
ファイル入出力
つぎに、ファイルへの入出力をみてみよう。
> writeFile "tmp_hnh.txt" "Haskell Handson!\n"
> readFile "tmp_hnh.txt"
"Haskell Handson!\n"
writeFileは第1引数のファイルに、第2引数の文字列を書き込む。readFileは第1引数のファイルの内容を読み込む。
現在時刻
現在の時刻を取得する。時刻は入力値として取得される。
> :module Data.Time
> getCurrentTime
2018-10-15 05:54:10.471924992 UTC
:moduleコマンドでモジュールData.Timeを、対話環境に導入している。入出力getCurrentTimeによって現在時刻が得られる。入出力getCurrentTimeの型は、つぎのようになる。
> :type getCurrentTime
getCurrentTime :: IO UTCTime
分岐
順接、分岐、反復の3つで、すべてのアルゴリズムが記述可能だ。「順接」は、「AのつぎにB」ということだ。すでにみた。つぎは「分岐」をみてみよう。
標準出力に表示するか、ファイルに保存するこわ選ぶ例だ。つぎのようなコードを書く。
% vim samples/currentTime.hs
import Data.Time
currentTime :: IO ()
currentTime = do
putStrLn "現在時刻を取得します"
now <- getCurrentTime
putStrLn "ファイルに保存しますか?(Y/N)"
s <- getLine
if s == "Y" || s == 'y'
then writeFile "currentTime.txt"
(show now ++ "\n")
else putStrLn (show now)
if式を使って分岐を記述することができた。試してみよう。
> :load samples/currentTime.hs
> currentTime
現在時刻を取得します
ファイルに保存しますか?(Y/N)
(nを入力)n
2018-11-21 04:34:15.538628867 UTC
反復
反復、つまり「くりかえし」は再帰で書くことができる。
% vim samples/sum.hs
total :: Integer -> IO Integer
total s = do
ln <- getLine
let n = read ln
if n < 0
then return s
else total (s + n)
入力値が0より小さければ、sの値をかえし、そうでなければ、s + nを引数に自身を呼び出す。試してみる。
> :load samples/sum.hs
> total 0
3
24
11
- 1
38
3, 24, 11を入力し、終了させるために- 1を入力した。3 + 24 + 11 = 38なので、38が表示される。
独立して実行できるプログラム
ここまで、対話環境で試してきた。ここで、コンパイルして独立実行できる実行可能形式の作りかたを示す。Haskellでは、変数mainを束縛した入出力が実行される。sum.hsに入出力mainを追加する。
% vim samples/sum.hs
main :: IO ()
main = do
s <- total 0
print s
つぎのようにしてコンパイルして、実行する。
% stack ghc -- samples/sum.hs -o sum
% ./sum
3
24
11
-1
38
IOモナドのまとめ
Haskellでは入出力や状態変化をともなう処理には、IOモナドという仕組みが使われる。IOモナドはモナドの一種だが、ここでは「モナド」については説明しない。手続き型言語では「暗黙に」されていた「組み立て」が、明示的な「演算子」によって、おこなわれる。手続き型言語のような書きかたのできるdo記法がある。順接、分岐、反復の実装をみた。Haskellでは、コンパイルされた実行可能形式は、変数mainを束縛した入出力を実行する。
追加の知識
このあと、プロジェクト例を示すが、そのために必要な追加の知識を、ここで学ぶ。まずは型Intを紹介する。つぎに、Haskellにおいて、かつては、標準的だった乱数生成ライブラリについて学ぶ。
整数型Int
Haskellには多倍長整数Integerのほかに、固定長整数Intがある。Int型の値は処理系依存の最小値、最大値をもつ。
> minBound :: Int
-9223372036854775808
> maxBound :: Int
9223372036854775807
minBound, maxBoundは多相的な値であり、それぞれの型について、その最小値、最大値を示す。うえの例は64ビットOSでの値であり、32ビットOSでは異なった値になる。
乱数生成
かつて標準的だったモジュールSystem.Randomはパッケージrandomに含まれている。
> :module System.Random
> randomIO :: IO Integer
7070170094128852650
> randomIO :: IO Double
0.19573673642621692
毎回ちがう乱数値を得るには、「なんらかの状態変化」が必要なので、randomIOは「入出力」になっている。randomIOは多相的なので、型注釈で型を指定している。randomIOで乱数値を生成するには、生成される値の型が、型クラスRandomのインスタンスである必要がある。型クラスRandomは、だいたい、つぎのように定義されている。
class Random a where
randomR :: RandomGen g =>
(a, a) -> g -> (a, g)
random :: RandomGen g => g -> (a, g)
RandomGen gは型クラス制約であり、型gが型クラスRandomGenのインスタンスであることを示している。型gが型クラスRandomGenのインスタンスならば、型gにたいして、つぎのような関数が存在する。
next :: g -> (Int, g)
これは、g型の値が「乱数の種」であり、関数nextによって、Int型の乱数値と新しい「乱数の種(g型の値)」とが生成される、ということだ。
じゃんけん型
じゃんけん型を定義する。
% vim samples/janken.hs
import System.Random
data Janken = Rock | Paper | Scissors
deriving Show
じゃんけん型を型クラスRandomのインスタンスにする。
% vim samples/janken.hs
instance Random Janken where
randomR = undefined
random g = let (n, g') = next g in
case n `mod` 3 of
0 -> (Rock, g')
1 -> (Paper, g')
2 -> (Scissors, g')
_ -> error "never occur"
関数randomRは「範囲を指定して乱数値を得る」関数だ。いまは使わないので、とりあえずundefinedにしておく。関数randomは「乱数の種」から、乱数値と新しい種をつくる。関数nextでInt型の値をつくり、それを3通りの値に変換している。試してみる。
> :load samples/janken.hs
> randomIO :: IO Janken
Scissors
> randomIO :: IO Janken
Paper
> randomIO :: IO Janken
Scissors
> randomIO :: IO Janken
Rock
バッファリング
標準入出力はバッファリングされている。標準入出力のバッファリングのモードを表示する。
% vim samples/buffer.hs
import System.IO
main :: IO ()
main = do
bi <- hGetBuffering stdin
bo <- hGetBuffering stdout
print bi
print bo
コンパイルして実行する。
% stack ghc -- samples/buffer.hs -o buffer
% ./buffer
LineBuffering
LineBuffering
標準入力、標準出力ともにLineBufferingだ。つまり、改行が入力(出力)されるまで入力(出力)はバッファリングされる。標準出力への出力は改行の出力まで、おくらされる。これは、たとえば「質問」の出力のあとで、改行したくないようなとき、こまる。
% vim samples/yesno0.hs
main :: IO ()
main = do
putStr "どうする?(Y/N)"
a <- getLine
putStrLn $ "Your answer is " ++ a ++ "."
コンパイルして実行する。
% stack ghc -- samples/yesno0.hs -o yesno
% ./yesno
(入力)foo
どうする?(Y/N): Your answer is foo.
質問が表示されるまえに、入力が必要になる。バッファをフラッシュする必要がある。yesno.hsの先頭にimport文を追加する。
% vim samples/yesno1.hs
import System.IO
質問文の表示のあとにhFlushを追加する。
% vim samples/yesno1.hs
...
putStr "どうする?(Y/N): "
hFlush stdout
...
コンパイルして実行する。
% stack ghc -- samples/yesno1.hs -o yesno
% ./yesno
どうする?(Y/N): (入力)Y
Your answer is Y.
hFlushをすることで、改行を待たずに表示される。