7장. 기본 입출력
7장. 기본 입출력 (Basic Input/Output)
Haskell 2010 Language Report의 입출력 장을 번역한 페이지예요. 이 장에서는 Haskell이 순수 함수형 언어라는 특성을 유지하면서도, 일반적인 프로그래밍 언어가 가진 입출력 기능을 어떻게 모두 담아내는지 설명해요. 그 중심에 있는 개념이 바로 monad(모나드) 죠.
출처: https://www.haskell.org/onlinereport/haskell2010/haskellch7.html
본문
도입
Haskell의 입출력 시스템은 순수 함수형이면서도, 일반적인 프로그래밍 언어에서 만날 수 있는 표현력을 모두 갖추고 있어요. 이걸 가능하게 하기 위해 Haskell은 monad를 이용해서 입출력 연산을 순수 함수형 맥락 안으로 끌어들입니다.
여기서 말하는 I/O 모나드는 함수형 언어에 자연스러운 값(value) 과, 입출력 연산과 일반적인 명령형 프로그래밍을 특징짓는 액션(action) 사이를 중개하는 역할을 해요. Haskell에서 식(expression)의 평가 순서는 오직 데이터 의존성에 의해서만 제약을 받아요. 그래서 구현체는 이 순서를 고르는 데 상당한 자유를 갖습니다. 하지만 액션은 프로그램 실행 — 특히 입출력 — 이 의미를 갖기 위해서는 잘 정의된 순서를 따라야 해요. Haskell의 I/O 모나드는 액션을 순차적으로 연결하는 방법을 사용자에게 제공하고, 구현체는 이 순서를 반드시 지켜야 합니다.
monad라는 용어는 수학의 한 분야인 범주론(category theory) 에서 온 말이에요. 다만 Haskell 프로그래머의 관점에서는 모나드를 추상 데이터 타입(abstract datatype)으로 생각하는 게 가장 좋아요. I/O 모나드의 경우 그 추상적인 값들이 바로 앞서 말한 액션들이에요. 어떤 연산은 일반적인 입출력 연산에 해당하는 원시 액션(primitive action)이고, 다른 특별한 연산들(클래스 Monad의 메서드, 6.3.6절 참고)은 명령형 언어의 순차 연산자(예를 들면 세미콜론)처럼 액션을 순차적으로 조합합니다.
7.1 표준 입출력 함수 (Standard I/O Functions)
Haskell은 IO 라이브러리에 상당히 정교한 입출력 기능을 제공하지만, 실제로 많은 Haskell 프로그램은 이 절에서 설명할 Prelude에서 내보내는 몇 가지 간단한 함수들만으로도 쓸 수 있어요.
여기서 정의하는 모든 입출력 함수는 문자(character) 중심이에요. 줄바꿈(newline) 문자의 처리 방식은 시스템마다 달라질 수 있어요. 예를 들어 입력에서 carriage return과 linefeed 두 문자가 하나의 줄바꿈 문자로 읽힐 수 있죠. 따라서 이 함수들은 이진(binary) 입출력에는 이식성 있게 사용할 수 없습니다.
이어지는 내용에서 String은 [Char]의 동의어라는 점을 기억해 두세요 (6.1.2절).
출력 함수 (Output Functions)
이 함수들은 표준 출력 장치(보통 사용자의 터미널)에 씁니다.
putChar :: Char -> IO ()
putStr :: String -> IO ()
putStrLn :: String -> IO () -- adds a newline
print :: Show a => a -> IO ()
print 함수는 출력 가능한(printable) 타입의 값을 표준 출력 장치에 내보내요. 출력 가능한 타입이란 클래스 Show의 인스턴스인 타입을 말해요. print는 show 연산으로 값을 문자열로 바꾼 뒤 줄바꿈을 덧붙여 출력합니다.
예를 들어 처음 20개의 정수와 그 2의 거듭제곱을 출력하는 프로그램은 이렇게 쓸 수 있어요.
main = print ([(n, 2^n) | n <- [0..19]])
입력 함수 (Input Functions)
이 함수들은 표준 입력 장치(보통 사용자의 터미널)에서 읽습니다.
getChar :: IO Char
getLine :: IO String
getContents :: IO String
interact :: (String -> String) -> IO ()
readIO :: Read a => String -> IO a
readLn :: Read a => IO a
getChar 연산은 파일 끝(end-of-file)에서 예외를 발생시켜요 (7.3절). 이 예외를 식별하는 isEOFError 술어는 IO 라이브러리에 정의되어 있어요. getLine 연산은 IO 라이브러리에 정의된 hGetLine과 같은 조건에서 예외를 발생합니다.
getContents 연산은 모든 사용자 입력을 하나의 문자열로 돌려주는데, 필요할 때 지연(lazily) 되어 읽혀져요. interact 함수는 String->String 타입의 함수를 인자로 받아요. 표준 입력 장치에서 들어온 전체 입력이 이 함수의 인자로 전달되고, 그 결과로 나온 문자열이 표준 출력 장치에 출력됩니다.
보통은 클래스 Read의 read 연산을 사용해서 문자열을 값으로 바꿔요. readIO 함수는 프로그램을 종료하는 대신 파싱 실패를 I/O 모나드에 알려준다는 점만 빼면 read와 비슷해요. readLn 함수는 getLine과 readIO를 합친 것입니다.
다음 프로그램은 표준 입력에서 ASCII가 아닌 문자를 모두 제거하고 그 결과를 표준 출력에 그대로 되돌려줘요. (isAscii 함수는 라이브러리에 정의되어 있어요.)
main = interact (filter isAscii)
파일 (Files)
이 함수들은 문자 파일(file of characters)을 대상으로 동작해요. 파일은 문자열로 이름을 붙이는데, 문자열을 파일 이름으로 해석하는 방법은 구현체에 따라 달라집니다.
writeFile과 appendFile 함수는 두 번째 인자인 문자열을 첫 번째 인자인 파일에 쓰거나(write) 이어 붙여요(append). readFile 함수는 파일을 읽고 그 내용을 문자열로 돌려줍니다. 파일은 getContents처럼 요청에 따라 지연되어 읽혀져요.
type FilePath = String
writeFile :: FilePath -> String -> IO ()
appendFile :: FilePath -> String -> IO ()
readFile :: FilePath -> IO String
writeFile과 appendFile은 파일에 문자 그대로의(literal) 문자열을 쓴다는 점을 기억하세요. print처럼 출력 가능한 타입의 값을 쓰려면 먼저 show 함수로 값을 문자열로 바꿔야 합니다.
main = appendFile "squares" (show [(x,x⋆x) | x <- [0,0.1..2]])
7.2 입출력 연산 순차화 (Sequencing I/O Operations)
타입 생성자 IO는 Monad 클래스의 인스턴스예요. Monad 클래스의 메서드인 두 모나딕 결합(binding) 함수가 일련의 입출력 연산을 조합하는 데 쓰여요. >> 함수는 첫 번째 연산의 결과가 별로 중요하지 않을 때 — 예를 들어 그 값이 ()일 때 — 사용해요. >>= 연산은 첫 번째 연산의 결과를 두 번째 연산의 인자로 전달합니다.
(>>=) :: IO a -> (a -> IO b) -> IO b
(>>) :: IO a -> IO b -> IO b
예를 들어,
main = readFile "input-file" >>= \ s ->
writeFile "output-file" (filter isAscii s) >>
putStr "Filtering successful\n"
이 코드는 앞선 interact 예제와 비슷하지만, 입력을 "input-file"에서 받고 출력을 "output-file"에 써요. 프로그램이 끝나기 전에 표준 출력에 메시지 하나를 찍습니다.
do 표기법을 쓰면 더 명령형에 가까운 문법 스타일로 프로그래밍할 수 있어요. 앞선 예제를 조금 더 정교하게 만든 버전은 이렇게 되겠네요.
main = do
putStr "Input file: "
ifile <- getLine
putStr "Output file: "
ofile <- getLine
s <- readFile ifile
writeFile ofile (filter isAscii s)
putStr "Filtering successful\n"
return 함수는 입출력 연산의 결과를 정의하는 데 사용해요. 예를 들어 getLine은 getChar로 정의되는데, 결과를 만들기 위해 return을 사용합니다.
getLine :: IO String
getLine = do c <- getChar
if c == '\n' then return ""
else do s <- getLine
return (c:s)
7.3 I/O 모나드에서의 예외 처리 (Exception Handling in the I/O Monad)
I/O 모나드는 간단한 예외 처리 시스템을 포함해요. 어떤 입출력 연산이든 결과를 돌려주는 대신 예외를 발생시킬 수 있습니다.
I/O 모나드의 예외는 IOError 타입의 값으로 표현돼요. 이 타입은 추상 타입이라서 그 생성자(constructor)는 사용자에게 숨겨져 있어요. IOError 값을 만들고 검사하는 함수는 IO 라이브러리가 정의합니다. IOError 값을 만드는 유일한 Prelude 함수는 userError예요. 사용자 오류 값은 오류를 설명하는 문자열을 포함합니다.
userError :: String -> IOError
예외는 다음 함수들로 발생시키고 잡아요.
ioError :: IOError -> IO a
catch :: IO a -> (IOError -> IO a) -> IO a
ioError 함수는 예외를 발생시켜요. catch 함수는 catch로 보호되는 액션 안에서 발생한 예외를 받는 핸들러(handler)를 설정합니다. 예외는 가장 최근에 설정된 catch 핸들러가 잡아요. 이 핸들러들은 선택적이지 않아서 모든 예외를 잡아냅니다. 따라서 예외 전파(propagation)는 핸들러가 원치 않는 예외를 다시 발생시킴으로써 명시적으로 제공되어야 해요. 예를 들어
f = catch g (\e -> if IO.isEOFError e then return [] else ioError e)
이 코드에서 함수 f는 g에서 파일 끝 예외가 발생하면 []를 돌려주고, 그 외의 경우에는 예외를 다음 바깥쪽 핸들러로 전파합니다. isEOFError 함수는 IO 라이브러리의 일부예요.
예외가 main 프로그램 바깥으로 전파되면, Haskell 시스템은 관련된 IOError 값을 출력하고 프로그램을 종료합니다.
Monad 클래스의 IO 인스턴스 (6.3.6절) 에서 fail 메서드는 userError를 발생시켜요. 이렇게요.
instance Monad IO where
...bindings for return, (>>=), (>>)
fail s = ioError (userError s)
Prelude의 입출력 함수들이 발생시키는 예외들은 42장에 정의되어 있어요.