メインコンテンツへ移動

確実に不純な IO_Haskell ノート 5

無料2018-05-19#Functional_Programming#Haskell IO#Haskell获取用户输入#Haskell IO Action#Haskell文件读写#Haskell readFile

純粋関数型言語において、確実に不純なシナリオをどのように処理するか?

前置き

ずっと疑問に思っていたことがあります。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'

型から見ると、IOMaybe :: * -> * と似ており、どちらも具体的な型パラメータを受け取り、具体的な型(例えば IO ())を返します。

P.S. その中で、newtypedata 型宣言と似ており、構文と使い方も基本的に同じです。newtype はより厳格な型宣言です(直接 data に置き換えても正常に使用できますが、datanewtype に置き換えるとは限りません)。具体的な違いは:

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 dowhen condition do など、いくつかのシナリオを簡素化できます。

return

returnvalue を 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(IOApplicative クラスに属す)を受け取れます。ブール値が 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 のシナリオ(mIO に、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

之前使用的 getLineputStrLn 都是 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();

非常に形象的です。getContentsmap 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 ()

openFileFilePathIOMode パラメータを受け取り、IO Handle を返します。この Handle を持っていれば hGetContents または hGetLine にファイル内容を要求できます。最後に hClose を通じてファイルポインタ関連のリソースを解放します。その中で FilePathString です(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 などの一般的なパターン抽象を含みます

  • 重要なロジック以外の部分を簡素化します。例えば withFilemap, 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 ()

注意してください。その中で removeFilerenameFileSystem.Directory モジュールで定義されています(System.IO ではなく)。ファイルの増減改査、権限管理などの関数はすべて System.Directory モジュールにあります。例えば doesFileExist, getAccessTime, findFile などです。

P.S. より多くのファイル操作関数は System.Directory を参照してください。

参考資料

コメント

コメントはまだありません

コメントを書く