본문으로 건너뛰기

확실히 불순한 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 은 한 줄의 입력을 가져와 IO String 을 반환하며, <- 연산자를 통해 String 을 꺼내 line 변수에 할당합니다. 비어 있으면 아무것도 하지 않고 (IO () 를 반환하여 종료), 그렇지 않으면 해당 줄의 내용을 putStrLn 로 표준 출력에 출력하고 줄바꿈한 후 main 을 재귀적으로 실행합니다.

그 중에서, main 은 진입점 함수를 나타내며 (C 언어와 유사), do 는 여러 I/O Action 을 하나로 결합하는 데 사용되며, 결합된 마지막 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

returnvalue 를 I/O Action 으로 패키징하는 데 사용되며, 함수에서 빠져나가는 것이 아닙니다. return<- 의 작용은 반대입니다 (상자 포장/상자 개봉의 느낌):

main = do
  a <- return "hell"
  b <- return "yeah!"
  putStrLn $ a ++ " " ++ b

두 가지 용도가 있습니다:

  • 아무것도 하지 않는 I/O Action 을 만드는 데 사용합니다. 예를 들어 echo 예시의 then 부분

  • do 문 블록의 반환 값을 커스터마이즈하는 데 사용합니다. 예를 들어 I/O Action 을 직접 do 문 블록의 반환 값으로 하고 싶지 않고, 2 차 가공을 하고 싶은 시나리오

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 에 사용할 경우, 두 번째 파라미터의 반환 타입은 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 의 첫 번째 파라미터는 a 를 입력하여 IO b 를 출력하는 함수, 두 번째 파라미터는 [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
[(),(),()]

형식적인 차이일 뿐입니다. 두 번째 파라미터로 전달되는 함수가 복잡한 경우, 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 에 매우 적합한 시나리오가 하나 있습니다:

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

더 이상 한 줄씩 처리하지 않고, 모든 내용을 가져와 한 번에 모두 변환합니다. 하지만 이 함수를 컴파일하여 실행하면, 줄별로 처리되고 있음을 알 수 있습니다:

$ ./toUpperCase
abc
ABC
efd
EFD

이는 입력 버퍼와 관련이 있습니다. 자세한 내용은 Haskell: How getContents works? 를 참조하십시오.

지연 I/O

문자열 자체는 지연 List 이며, getContents 도 지연 I/O 입니다. 내용을 한 번에 메모리로 읽어들입니다.

toUpperCase' 의 예시에서는 한 줄씩 읽어들여 대문자 버전을 출력합니다. 출력할 때에야실제로 이러한 입력 데이터가 필요하기 때문입니다. 그 이전의 작업은 모두 일종의 약속에 불과하며,不得不做할 때에야 약속 이행을 요구합니다. 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

모두 정상이며, 파일의 처음 두 줄을 읽고 출력합니다. 이 포인터는 확실히 이동 가능합니다.

P.S. 유사한 hGet/Putxxx 가 많이 포함되어 있습니다. 예를 들어 hPutStr, hPutStrLn, hGetChar 등, h 가 없는 버전과 유사하지만 handle 파라미터가 하나 더 많습니다. 예를 들어:

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
  )

더 깔끔해졌습니다. 점점 더 많은 함수형 일반적인 패턴은, 하는 일은无非 두 가지:

  • 일반적인 패턴을 추상화합니다. 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 ()

주의하세요. 그 중에서 removeFilerenameFileSystem.Directory 모듈에서 정의됩니다 (System.IO 가 아님). 파일 추가/삭제/수정/조회, 권한 관리 등의 함수는 모두 System.Directory 모듈에 있습니다. 예를 들어 doesFileExist, getAccessTime, findFile 등입니다.

P.S. 더 많은 파일 조작 함수는 System.Directory 를 참조하십시오.

참고 자료

댓글

아직 댓글이 없습니다

댓글 작성