前置き
ずっと疑問に思っていたことがあります。Haskell は純粋関数型言語と謳っていますが、確実に不純なシナリオ(明らかに副作用がある、または操作自体が副作用である)をどのように解決するのでしょうか?
例えば(擬似)乱数、I/O など、純粋関数の乱数発生器は明らかに存在し得ませんが、そのようなシナリオをどのように処理すればよいのでしょうか?
Haskell のアプローチは実際には React の componentDidMount() などのコンポーネントライフサイクル関数に似ています。React は render() を純粋関数に保つことを推奨し(道徳的制約)、副作用を伴う操作は componentDidMount() などのライフサイクルに移動させます。つまり、ライフサイクルフックを通じて、純粋な部分と不純な部分を区別します。Haskell は do 文ブロックを提供しており、これも不純な部分を隔離するために使用されます。
一.I/O action
まず関数の型を見てみましょう:
> :t print
print :: Show a => a -> IO ()
print 関数は Show クラスのパラメータを受け取り、IO () を返します。これを I/O Action と呼び、これも一種の型です。以下のように:
> :k IO
IO :: * -> *
> :k IO ()
IO () :: *
> :i IO
newtype IO a
= GHC.Types.IO (GHC.Prim.State# GHC.Prim.RealWorld
-> (# GHC.Prim.State# GHC.Prim.RealWorld, a #))
-- Defined in 'GHC.Types'
instance Monad IO -- Defined in 'GHC.Base'
instance Functor IO -- Defined in 'GHC.Base'
instance Applicative IO -- Defined in 'GHC.Base'
instance Monoid a => Monoid (IO a) -- Defined in 'GHC.Base'
型から見ると、IO は Maybe :: * -> * と似ており、どちらも具体的な型パラメータを受け取り、具体的な型(例えば IO ())を返します。
P.S. その中で、newtype は data 型宣言と似ており、構文と使い方も基本的に同じです。newtype はより厳格な型宣言です(直接 data に置き換えても正常に使用できますが、data を newtype に置き換えるとは限りません)。具体的な違いは:
data can only be replaced with newtype if the type has exactly one constructor with exactly one field inside it.
二.ユーザー入力
I/O Action を使用してユーザー入力を取得できます。例えば:
main = do
line <- getLine
if null line then
return ()
else do -- do用来合成action
putStrLn line
main
上記の例はシンプルな echo プログラムです。getLine は 1 行の入力を取得し、IO String を返します。<- 演算子を通じて String を取り出し、line 変数に代入します。空の場合は何もせず(IO () を返して終了)、そうでない場合は该行の内容を putStrLn で標準出力に出力して改行し、main を再帰的に実行します。
その中で、main はエントリーポイント関数を表し(C 言語と類似)、do は複数の I/O Action を 1 つに結合するために使用され、結合された最後の I/O Action を返します。また、do 文ブロック内の I/O Action は実行されるため、do 文ブロックには 2 つの役割があります:
-
複数の文を持つことができますが、最後に I/O Action を返す必要があります
-
不純な環境を区画し、I/O Action がこの環境で実行できます
JS に例えると、複数の文を結合する機能はカンマ演算子に似ており、最後の式の値を返します。不純な環境を区画するのは async function に似ており、I/O Action は do 文ブロック内でのみ出現できます。これは await に似ています。
P.S. 実際には、I/O Action を実行する方法は 3 通りあります:
-
mainにバインドする場合、エントリーポイント関数として -
do文ブロック内に配置する -
GHCi 環境で I/O Action を入力して Enter を押す、例えば
putStrLn "hoho"
実行
main を GHCi 環境で通常の関数として実行できます。例えば:
> :l echo
[1 of 1] Compiling Main ( echo.hs, interpreted )
Ok, modules loaded: Main.
> main
what?
what?
空行を入力すると終了し、その他の内容を入力すると行ごとにそのまま出力されます。
コンパイルして実行可能ファイルを取得することもできます:
$ ghc --make ./echo.hs
[1 of 1] Compiling Main ( echo.hs, echo.o )
Linking echo ...
$ ./echo
here
here
三.Control.Monad
Control.Monad モジュールは I/O シナリオに適した関数も提供しており、いくつかの固定パターンをカプセル化しています。例えば forever do、when condition do など、いくつかのシナリオを簡素化できます。
return
return は value を I/O Action にパッケージするために使用され、関数から抜け出すためではありません。return と <- の作用は逆です(箱詰め/箱開けの感覚):
main = do
a <- return "hell"
b <- return "yeah!"
putStrLn $ a ++ " " ++ b
2 つの用途があります:
-
何もしない I/O Action を作成するために使用します。例えば
echo例のthen部分 -
do文ブロックの戻り値をカスタマイズするために使用します。例えば I/O Action を直接do文ブロックの戻り値としたくない、二次加工したいシナリオ
when
when も関数です:
Control.Monad.when :: Applicative f => Bool -> f () -> f ()
ブール値と I/O Action(IO は Applicative クラスに属す)を受け取れます。ブール値が True の場合の値は I/O Action、そうでない場合の値は return () なので、以下に相当します:
when' c io = do
if c then io
else return ()
このものの型は:
when' :: Monad m => Bool -> m () -> m ()
したがって、I/O に使用する場合、2 番目のパラメータの戻り型は IO () のみ可能で、あまり便利には見えませんが、条件付き出力のシナリオには非常に適しています。結局のところ、print などの一連の出力関数はすべてこの型を満たします。
sequence
sequence :: (Traversable t, Monad m) => t (m a) -> m (t a)
この型宣言は比較的複雑に見えます:
Traversable :: (* -> *) -> Constraint
Monad :: (* -> *) -> Constraint
-- 找两个对应实例,List 和 IO
instance Traversable [] -- Defined in 'Data.Traversable'
instance Monad IO -- Defined in 'GHC.Base'
I/O List のシナリオ(m を IO に、t を [] に置き換える)では、パラメータの型制約は [IO a]、戻り値の型制約は IO [a] なので、以下に相当します:
sequence' [] = do
return []
sequence' (x:xs) = do
v <- x
others <- (sequence' xs)
return (v : others)
I/O List 内のすべての I/O 結果を収集して List を形成し、それを IO にパッケージする役割です。
P.S. Promise.all のような感覚です。一組の promise を受け取り、この一組の結果を持つ新しい promise を返します。
mapM と mapM_
Control.Monad.mapM :: (Traversable t, Monad m) => (a -> m b) -> t a -> m (t b)
Control.Monad.mapM_ :: (Foldable t, Monad m) => (a -> m b) -> t a -> m ()
I/O List のシナリオでは、mapM の 1 番目のパラメータは a を入力して IO b を出力する関数、2 番目のパラメータは [a]、戻り値は IO [b] で、戻り値の型は sequence と一致します。まず [a] をマッピングして I/O List を取得し、次に sequence を実行するのと同じです。例えば:
> mapM (\x -> do return $ x + 1) [1, 2, 2]
[2,3,3]
> mapM print [1, 2, 2]
1
2
2
[(),(),()]
mapM_ はこれと似ていますが、結果を破棄して IO () を返します。print など I/O Action の結果を気にしないシナリオに非常に適しています:
> mapM_ print [1, 2, 2]
1
2
2
forM
Control.Monad.forM :: (Traversable t, Monad m) => t a -> (a -> m b) -> m (t b)
mapM とパラメータの順序が逆で、作用は同じです:
> forM [1, 2, 2] print
1
2
2
[(),(),()]
形式的な違いに過ぎません。2 番目のパラメータに渡す関数が複雑な場合、forM の方がより明確に見えます。例えば:
main = do
colors <- forM [1,2,3,4] (\a -> do
putStrLn $ "Which color do you associate with the number " ++ show a ++ "?"
getLine)
putStrLn "The colors that you associate with 1, 2, 3 and 4 are: "
mapM putStrLn colors
P.S. 最後に forM(パラメータの順序を交換)を使用することもできますが、意味論的習慣のため、forM は I/O Action を定義するシナリオ(例えば [a] から IO [b] を生成する)でよく使用されます。
forever
Control.Monad.forever :: Applicative f => f a -> f b
I/O のシナリオでは、I/O Action を受け取り、その Action を永遠に繰り返す I/O Action を返します。したがって、echo の例は近似的に以下のように書き換えられます:
echo = Control.Monad.forever $ do
line <- getLine
if null line then
return ()
else
putStrLn' line
echo のシナリオではあまり利点がわかりません(Ctrl+C で強制的に中断しない限り、抜け出すことさえできません)が、forever do に非常に適したシナリオが 1 つあります:
import Control.Monad
import Data.Char
main = forever $ do
line <- getLine
putStrLn $ map toUpper line
つまり、テキスト処理(変換)のシナリオです。入力テキストが終了すると forever も終了します。例えば:
$ ghc --make ./toUpperCase.hs
[1 of 1] Compiling Main ( toUpperCase.hs, toUpperCase.o )
Linking toUpperCase ...
$ cat ./data/lines.txt
hoho, this is xx.
who's that ?
$ cat ./data/lines.txt | ./toUpperCase
HOHO, THIS IS XX.
WHO'S THAT ?
toUpperCase: <stdin>: hGetLine: end of file
forever do を通じてファイルの内容を徐々に大文字形式に処理します。さらに進んで:
$ cat ./data/lines.txt | ./toUpperCase > ./tmp.txt
toUpperCase: <stdin>: hGetLine: end of file
$ cat ./tmp.txt
HOHO, THIS IS XX.
WHO'S THAT ?
処理結果をファイルに書き込み、予想通りです。
四.System.IO
之前使用的 getLine、putStrLn 都是 System.IO 模块里的函数,常用的还有:
-- 输出
print :: Show a => a -> IO ()
putChar :: Char -> IO ()
putStr :: String -> IO ()
-- 输入
getChar :: IO Char
getLine :: IO String
その中で print は値を出力するために使用され、putStrLn . show に相当します。putStr は文字列を出力するために使用され、末尾に改行が含まれません。二つの違いは:
> print "hoho"
"hoho"
> putStr "hoho"
hoho
P.S. IO モジュールの詳細情報は System.IO を参照してください。
getContents
getContents :: IO String
getContents はすべてのユーザー入力を文字列として返すことができます。したがって、toUpperCase は以下のように書き換えられます:
toUpperCase' = do
contents <- getContents
putStr $ map toUpper contents
もはや 1 行ずつ処理せず、すべての内容を取得して一度に変換します。しかし、この関数をコンパイルして実行すると、行ごとに処理されていることがわかります:
$ ./toUpperCase
abc
ABC
efd
EFD
これは入力バッファに関連しています。詳細は Haskell: How getContents works? を参照してください。
遅延 I/O
文字列自体は遅延 List であり、getContents も遅延 I/O です。内容を一度にメモリに読み込みません。
toUpperCase' の例では、1 行ずつ読み込んで大文字バージョンを出力します。出力するときに初めて実際にこれらの入力データが必要になるためです。それ以前の操作はすべて一種の約束に過ぎず、不得不做のときに初めて約束の履行を要求します。JS の Promise に似ています:
function toUpperCase() {
let io;
let contents = new Promise((resolve, reject) => {
io = resolve;
});
let upperContents = contents
.then(result => result.toUpperCase());
putStr(upperContents, io);
}
function putStr(promise, io) {
promise.then(console.log.bind(console));
io('line\nby\nline');
}
// test
toUpperCase();
非常に形象的です。getContents、map toUpper などの操作は一連の Promise を作成するだけで、putStr で結果を出力する必要があるときに初めて実際に I/O を行って toUpper などの演算を行います。
interact
interact :: (String -> String) -> IO ()
文字列処理関数をパラメータとして受け取り、空の I/O Action を返します。テキスト処理のシナリオに非常に適しています。例えば:
-- 滤出少于 3 字符的行
lessThan3Char = interact (\s -> unlines $ [line | line <- lines s, length line < 3])
以下と同等です:
lessThan3Char' = do
contents <- getContents
let filtered = filterShortLines contents
if null filtered then
return ()
else
putStr filtered
where
filterShortLines = \s -> unlines $ [line | line <- lines s, length line < 3]
かなり面倒に見えます。interact 関数名は交互を意味し、この最も一般的な交互モードを簡素化する役割です:文字列を入力し、処理完毕后に結果を出力します。
五.ファイル読み書き
ファイルを読み込んで、そのまま表示します:
import System.IO
main = do
handle <- openFile "./data/lines.txt" ReadMode
contents <- hGetContents handle
putStr contents
hClose handle
形式は C 言語のファイル読み書きに似ています。handle はファイルポインタに相当し、読み取り専用モードでファイルを開いてファイルポインタを取得し、ポインタを通じてその内容を読み取り、最後にファイルポインタを解放します。直感的に、このようにしてみます:
readTwoLines = do
handle <- openFile "./data/lines.txt" ReadMode
line1 <- hGetLine handle
line2 <- hGetLine handle
putStrLn line1
putStrLn line2
hClose handle
すべて正常で、ファイルの最初の 2 行を読み込んで出力します。このポインタは確かに移動可能です。
P.S. 同様の hGet/Putxxx は多く含まれています。例えば hPutStr, hPutStrLn, hGetChar など、h なしのバージョンと似ていますが、handle パラメータが 1 つ多いです。例えば:
hPutStr :: Handle -> String -> IO ()
これらの関数の型を振り返ってみましょう:
openFile :: FilePath -> IOMode -> IO Handle
hGetContents :: Handle -> IO String
hGetLine :: Handle -> IO String
hClose :: Handle -> IO ()
openFile は FilePath と IOMode パラメータを受け取り、IO Handle を返します。この Handle を持っていれば hGetContents または hGetLine にファイル内容を要求できます。最後に hClose を通じてファイルポインタ関連のリソースを解放します。その中で FilePath は String です(String の別名)、IOMode は列挙値です(読み取り専用、書き込み専用、追加、読み書きの 4 つのモード):
> :i FilePath
type FilePath = String -- Defined in 'GHC.IO'
> :i IOMode
data IOMode = ReadMode | WriteMode | AppendMode | ReadWriteMode
-- Defined in 'GHC.IO.IOMode'
P.S. ファイルポインタはしおりと理解できます。本はファイルシステム全体を指します。この比喩は非常に形��的です。
withFile
withFile :: FilePath -> IOMode -> (Handle -> IO r) -> IO r
また一種のパターンのカプセル化のように見えます。それを使用して上記のファイル読み込み例を簡素化します:
readThisFile = withFile "./data/lines.txt" ReadMode (\handle -> do
contents <- hGetContents handle
putStr contents
)
よりすっきりしました。ますます多くの関数型の一般的なパターンは、行うことは无非 2 種類:
-
一般的なパターンを抽象化します。
Maybe/Eitherなどの型抽象、forever do, interactなどの一般的なパターン抽象を含みます -
重要なロジック以外の部分を簡素化します。例えば
withFile、map, filterなどのツール関数はボイラープレートコード(openFile, hCloseなどの定型的な操作)を分離するのに役立ち、重要なロジックに集中できます
したがって、withFile が行うことは、传入されたファイルパスと読み取りモードに従ってファイルを開き、取得した handle をファイル処理関数(3 番目のパラメータ)に注入し、最後に handle を閉じます:
withFile' path mode f = do
handle <- openFile path mode
result <- f handle
hClose handle
return result
注意してください。ここでは return の重要な役割が体现されています。結果を返す前に hClose handle する必要があるため、カスタム値を返すメカニズムが必要です。
readFile
readFile :: FilePath -> IO String
ファイルパスを入力し、IO String を出力します。Open/Close の环节がすべて省略されており、ファイル読み込みを非常に簡単にできます:
readThisFile' = do
contents <- readFile "./data/lines.txt"
putStr contents
writeFile
writeFile :: FilePath -> String -> IO ()
ファイルパスと書き込む文字列を入力し、空の I/O Action を返します。同様に handle を扱う环节が省略されています:
writeThatFile = do
writeFile "./data/that.txt" "contents in that file\nanother line\nlast line"
ファイルが存在しない場合は自動的に作成され、上書き式に書き込まれ、非常に便利です。手動で制御する面倒な方式と同等です:
writeThatFile' = do
handle <- openFile "./data/that.txt" WriteMode
hPutStr handle "contents in that file\nanother line\nlast line"
hClose handle
appendFile
appendFile :: FilePath -> String -> IO ()
型は writeFile と同じですが、内部で AppendMode を使用し、内容をファイルの末尾に追加します。
その他のファイル操作関数
-- 在 FilePath 指定的路径下,打开 String 指定的名字拼上随机串的文件,返回临时文件名与 handle 组成的二元组
openTempFile :: FilePath -> String -> IO (FilePath, Handle)
-- 定义在 System.Directory 模块中,用来删除指定文件
removeFile :: FilePath -> IO ()
-- 定义在 System.Directory 模块中,用来重命名指定文件
renameFile :: FilePath -> FilePath -> IO ()
注意してください。その中で removeFile と renameFile は System.Directory モジュールで定義されています(System.IO ではなく)。ファイルの増減改査、権限管理などの関数はすべて System.Directory モジュールにあります。例えば doesFileExist, getAccessTime, findFile などです。
P.S. より多くのファイル操作関数は System.Directory を参照してください。
コメントはまだありません