앞에 쓰는 말
줄곧 의문이 있었습니다. 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 은 한 줄의 입력을 가져와 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
return 은 value 를 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(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 에 사용할 경우, 두 번째 파라미터의 반환 타입은 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 의 첫 번째 파라미터는 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 ()
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
)
더 깔끔해졌습니다. 점점 더 많은 함수형 일반적인 패턴은, 하는 일은无非 두 가지:
-
일반적인 패턴을 추상화합니다.
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 를 참조하십시오.
아직 댓글이 없습니다