계정: 로그인
AA 📝
7. 입출력

A Gentle Introduction to Haskell, Version 98
이전 다음 위로


Haskell의 입·출력 체계는 전통적인 프로그래밍 언어에서 찾을 수 있는 모든 표현력을 다 갖추고 있으면서도 순수한 함수형입니다. 절차형 언어에서 프로그램은 현재 상태를 검사하고 수정하는 동작(action)을 통해 진행됩니다. 전형적인 액션에는 전역 변수를 읽고 설정하는 것, 파일을 쓰는 것, 입력을 읽는 것과 창을 띄우는 것 등이 있습니다. Haskell에는 물론 이런 액션들도 들어있습니다만, 대신 이들은 언어의 순수한 함수형 핵심(core)과는 전적으로 분리되어 있습니다.

Haskell의 입·출력 체계는 모나드(monad)라는, 뭔가 좀 위협적인 수학적 기반 위에 만들어져 있습니다. 그러나, 입·출력 체계를 사용한 프로그래밍을 위해서 저변에 깔려 있는 monad 이론을 이해할 필요는 없습니다. 오히려, monad는 입·출력이 우연히 맞아떨어지는 개념적 구조일 뿐입니다. 간단한 산술 연산을 위해서 그룹 이론을 이해할 필요가 없는 것 이상으로, Haskell로 입·출력을 수행하기 위해 monad 이론을 이해할 필요가 없습니다. Monad에 관한 자세한 설명은 9장에 나옵니다.

입·출력 체계의 기반이 되는 모나드 연산들은 다른 목적으로도 사용됩니다: 나중에 monad에 관해서 더 자세히 알아볼 것입니다. 지금은 monad라는 용어를 자제하고 입·출력 체계의 용법에 대해서만 집중하겠습니다. 입·출력 monad를 그저 추상 자료 구조(ADT)로 생각하는게 가장 좋습니다.

Haskell의 표현 언어에서는 액션들이 발효된다기보다 정의되는 것입니다. 액션의 정의를 평가하는 것으로는 실제로 액션이 일어나지 않습니다. 액션의 발효는 우리가 지금까지 고려해온 표현식 평가 작업의 밖에서 일어납니다.

액션은 시스템의 원시 정의 대로 atomic할 수도 있고, 혹은 다른 액션들의 연속된 결합일 수도 있습니다. 다른 언어에서 ‘;’을 써서 문(statement)들을 순서 있게 연이어주는 것과 비슷하게, 입·출력 monad는 복합 액션을 만드는 primitive를 포함합니다. 이렇게 프로그램에 액션들을 결합시키는 풀 역할을 합니다.

7.1 기본적인 입·출력 작업

모든 입출력 액션은 값을 리턴합니다. 타입 시스템 내에서, 이 리턴값에는 액션을 다른 값이랑 구분시켜주는 IO 타입이라는 ‘표지’가 붙습니다. 예를 들어, 함수 getChar의 타입은 다음과 같습니다:

IO Char는 이것이 발효되면 문자를 리턴하는 어떤 액션을 수행한다는 의미입니다. 관심을 가질만한 값을 리턴하지 않는 액션은 단위타입(unit type)인 ()을 사용합니다. 예를 들어 아래에 나오는 함수 putChar는:

문자 하나를 인수로 받지만 별다르게 리턴하는 것이 없습니다. 단위타입은 다른 언어의 void와 비슷한 것입니다.

액션들은 약간 암호같은 이름을 가진 연산자인 >>= (혹은 ‘bind’)를 이용해서 연이어집니다. 이런 순차화 연산자를 전통적인 언어와 비슷한 구문 속에 숨기기 위해서, 이 연산자를 직접 쓰는 대신 syntactic sugar인 do 표현법을 사용합니다. Haskell Report §3.14에 나와있듯이, do 표현법은 당연히 (trivial하게) >>=로 확장될 수 있습니다.

키워드 do는 순서대로 실행되는 일련의 문(statement)들을 갖습니다. 문(文)은 액션일 수도 있고, <-를 사용해서 액션의 결과에 바인드된 패턴일 수도 있고, 혹은 let을 사용해서 만든 로컬 정의들의 집합일 수도 있습니다. do 표현법은 let이나 where와 같은 방식으로 레이아웃을 사용하므로, 적절한 들여쓰기를 통해 중괄호와 세미콜론을 생략할 수 있습니다. 문자 하나를 읽고 인쇄하는 간단한 프로그램은 다음과 같습니다:

   1 main    :: IO ()
   2 main    =  do c <- getChar
   3               putChar c

main이라는 이름의 용법은 매우 중요합니다: main은 Haskell 프로그램의 시작 지점으로 정의되며 (C언어의 main 함수와 비슷함), 반드시 IO 계열의 타입이어야만 하고, 대개 IO ()입니다. (main이라는 이름은 Main 모듈에서만 특별한 의미를 갖습니다. 모듈에 대해서는 나중에 더 이야기합니다.) 상기 프로그램은 두 개의 액션을 순서대로 수행합니다: 첫번째는 문자 하나를 읽어서 그 결과를 변수 c에 바인딩하는 것이고, 그 다음은 그 문자를 인쇄하는 것입니다. 변수들이 모든 정의들에 대해서 스코프를 넓히는 let 포현식과는 달리, <-에 의해서 정의된 변수들은 뒤따라 나오는 문들 속에서만 스코프를 넓힙니다.

아직도 짚고 넘어가야할 부분이 있습니다. 우리는 이제 do를 사용해서 액션을 유발시키고 그 결과를 검사할 수 있습니다. 하지만, 일련의 액션들로부터 어떻게 하면 값을 리턴받을 수 있을까요? 예를 들어, 문자 하나를 읽고 만일 그 문자가 'y'라면 True를 리턴하는 함수 ready에 대해서 생각해봅시다:

   1 ready    :: IO Bool
   2 ready    =  do c <- getChar
   3                c == 'y'   -- Bad!!!

do 블럭 속의 두 번째 문이 액션이 아니라 단지 논리값이기 때문에, 상기 함수는 제대로 동작하지 않습니다. 이 논리값을 취해서, 다시 이 논리값을 리턴하는 것 외에 아무 것도 하지 않는 액션 하나를 만들 필요가 있습니다. 함수 return이 바로 이런 일을 합니다:

함수 return이 일련의 primitive들의 집합을 끝냅니다. 함수 ready의 마지막 줄은 return (c == 'y')이어야 합니다.

이제 좀 더 복잡한 입출력 함수를 살펴보도록 합니다. 먼저, 함수 getLine은 다음과 같습니다:

   1 getLine    :: IO String
   2 getLine    =  do c <- getChar
   3                  if c == '\n' then return ""
   4                               else do l <- getLine
   5                                       return (c:l)

Else 절 속에 있는 두 번째 do에 주목하십시오. 각각의 do는 단일 사슬의 문들을 만듭니다. if처럼 중간에 간섭해 들어가는 모든 구성 요소들은 또다른 일련의 액션들을 유발시키려면 새로운 do를 사용해야만 합니다.

함수 return은 논리값 같은 일반적인 값들을 입출력 액션 영역에 허용합니다. 그럼 다른 방향으로는 어떨까요? 평범한 표현식 속에서 입출력 액션을 유발시킬 수 있을까요? 예를 들어, x + print y라는 표현식 평가 과정에서 y가 인쇄되도록 하려면, 이걸 표현식 속에서 어떻게 표현해야 할까요? 이런건 불가능 하다는 것이 대답입니다. 순수한 함수형 코드의 세계에서 살짝 절차형 코드의 영역으로 숨어드는 것은 불가능합니다. 절차형 언어의 영향으로 오염된 모든 값들은 그렇게 태그되어야만 합니다. 다음과 같은 함수는:

리턴 타입에 IO가 나타나지 않았기 때문에 결코 어떠한 입출력도 수행할 수 없습니다. 디버깅 과정 중 코드 전체에 걸쳐 자유롭게 프린트 문을 삽입하는 데에 익숙한 프로그래머에게 있어서 이런 사실은 상당히 괴로운 일입니다. 사실은 이런 문제를 해결해주는 안전하지 못한 함수들이 존재하지만, 이건 숙련된 프로그래머를 위한 것일 뿐입니다. (Trace 같은) 디버깅 패키지들이 종종 이런 ‘금지된 함수들’을 절대 안전한 방법으로 자유롭게 사용할 수 있게끔 해주기도 합니다.

7.2 액션을 사용한 프로그래밍

입출력 액션들은 정상적인 Haskell 값입니다: 이들은 함수에 인수로 전달될 수 있고, 자료 구조 속에 들어갈 수 있으며, 모든 다른 Haskell 값들처럼 사용될 수 있습니다. 다음과 같은 액션들의 리스트를 생각해봅시다:

   1 todoList    :: [IO ()]
   2 todoList    =  [putChar 'a',
   3                 do putChar 'b'
   4                    putChar 'c',
   5                 do c <- getChar
   6                    putChar c]

이 리스트는 실제로 아무런 액션도 유발시키지 않습니다. — 단지 액션들을 담고 있을 뿐입니다. 이런 액션들을 하나의 액션으로 합치려면 sequence_ 같은 함수가 필요합니다:

   1 sequence_           :: [IO ()] -> IO ()
   2 sequence_ []        =  return ()
   3 sequence_ (a:as)    =  do a
   4                           sequence as

do x;yx >> y로 확장된다는 점을 이용해서 간소화할 수 있습니다. (9.1절을 보시기 바랍니다.) 이런 재귀 패턴은 함수 foldr에 의해서 캡춰됩니다. (foldr의 정의는 Prelude를 참조하십시오.) sequence_의 보다 나은 정의는 다음과 같습니다:

   1 sequence_    :: [IO ()] -> IO ()
   2 sequence_    =  foldr (>>) (return ())

do 표현법은 분명히 유용한 도구지만, 이 경우에는 저변에 깔려 있는 모나드 연산자인 >>이 더 적절합니다. Haskell 프로그래머는 do가 기반하고 있는 연산자에 대해서 이해해두는 것이 좋습니다.

함수 sequence_를 사용해서 putChar로부터 putStr을 만들 수 있습니다:

   1 putStr      :: String -> IO ()
   2 putStr s    =  sequence_ (map putChar s)

putStr에서 Haskell과 전통적인 절차형 프로그래밍의 차이점 중 하나를 확인할 수 있습니다. 절차형 언어에서는 절차식 putChar를 문자열에 대응시키는 것만으로도 문자열을 충분히 인쇄할 수 있습니다. 그러나 Haskell에서는 함수 map이 전혀 아무런 액션도 수행하지 않습니다. 대신, 이 함수는 문자열의 각각의 문자 하나 하나에 대응하는 액션들의 리스트를 만듭니다. sequence_ 속의 폴딩 연산은 각각의 액션들을 하나의 액션으로 결합시키기 위해서 함수 >>을 사용합니다. 여기서 사용한 return ()는 꼭 필요합니다. — foldr은 (특히 문자열 속에 문자가 없을 경우!) 자신이 만드는 액션의 사슬 맨 끝에 null 액션을 필요로합니다.

Prelude와 라이브러리에는 입출력 액션을 순차화시키는데 유용한 함수들이 많이 들어있습니다. 이들은 대개 임의의 모나드로 일반화됩니다; Context 속에 Monad m =>을 포함하는 모든 함수들은 IO 타입과 잘 동작합니다.

7.3 예외 처리 (Exception Handling)

지금까지는 입출력 과정의 예외 문제에 대해서는 언급을 회피했었습니다. 만일 getChar가 파일의 끝에 다다르면 무슨 일이 일어날까요? (에 대해서는 오류라는 용어를 사용합니다: 종결불가 상태나 패턴 매칭 실패처럼 복구 불가능한 상태는 오류입니다. 반면에 예외는 입출력 모나드 안에서 잡아내고 처리할 수 있습니다.) 입출력 모나드 내에서 ‘file not found’ 같은 예외적 상황을 처리하기 위해, 기능상 Standard ML의 것과 비슷한 처리 기전을 사용합니다. 특수 구문이나 특수 의미는 사용되지 않습니다; 예외 처리는 입출력 순차 연산 정의의 일부입니다.

오류는 IOError라는 특수한 자료형을 써서 인코드됩니다. 이 타입은 입출력 모나드 내에서 발생할 수 있는 예외들 모두를 나타냅니다. 이것은 추상 자료형입니다: 사용자가 쓸 수 있는 IOError용 구성자는 없습니다. 술어(predicate)를 써서 IOError 타입의 값을 확인해볼 수 있습니다. 예를 들어, 아래와 같은 함수는:

오류가 end-of-file 상황때문에 발생했는지를 판별합니다. IOError의 추상을 만듦으로써, 기존 자료형에 큰 변화를 일으키지 않으면서 새로운 종류의 오류를 시스템에 추가할 수 있습니다. 함수 isEOFError는 별도의 라이브러리인 IO에 정의되어 있으며, 프로그램에 명시적으로 수입(import)되어야합니다.

예외 처리기(exception handler)의 타입은 IOError -> IO a입니다. 함수 catch는 예외 처리기를 액션이나 액션 집합에 연계시킵니다:

catch의 인수는 액션 하나와 예외 처리기 하나입니다. 만일 액션이 성공하면 예외 처리기를 발동시키기 않고 결과가 리턴됩니다. 만일 오류가 발생하면 그 오류는 IOError 타입의 값으로서 예외 처리기에 전해지고 예외 처리기와 관련된 액션이 유발됩니다. 예를 들어, getChar의 아래 버젼은 오류를 만났을 때 개행문자를 리턴합니다:

   1 getChar'    :: IO Char
   2 getChar'    =  getChar `catch` (\e -> return '\n')

이것은 모든 오류들을 같은 방식으로 취급하므로 조금 허술합니다. 단지 end-of-file이 인식되려는 때에도 오류 값이 반드시 제공되어야 합니다:

   1 getChar'    :: IO Char
   2 getChar'    =  getChar `catch` eofHandler   where
   3     eofHandler e    =  if isEofError e then return '\n' else ioError e

여기서 사용한 함수 ioError는 다음번 예외 처리기에 예외를 하나 던져줍니다. ioError의 타입은 다음과 같습니다:

다음번 입출력 액션으로 진행하는 대신 예외 처리기에 대한 제어권을 전달한다는 점만 빼고는 return과 비슷합니다. 함수 catch는 중첩되어 호출될 수 있으며, 중첩된 예외 처리기를 만들어냅니다.

getChar'를 사용하면 중첩된 예외 처리기의 용법을 보여주도록 getLine을 다시 정의할 수 있습니다:

   1 getLine'    :: IO String
   2 getLine'    =  catch getLine'' (\err -> return ("Error: " ++ show err))   where
   3     getLine''    =  do c <- getChar'
   4                        if c == '\n' then return ""
   5                                     else do l <- getLine'
   6                                             return (c:l)

중첩된 오류 처리기는 getChar'로 하여금 파일의 끝을 잡아낼 수 있게끔 하며, 반면 getLine'으로부터 발생한 다른 모든 오류들은 "Error: "로 시작하는 문자열을 발생시킵니다.

편의를 위해, Haskell은 예외를 인쇄하고 프로그램을 종료시키는 최상위 수준의 기본(default) 예외 처리기를 제공합니다.

7.4 Files, Channels, and Handles

입출력 모나드와 예외 처리 기전을 제외하면, Haskell의 입출력 설비는 전반적으로 다른 언어들의 것과 비슷합니다. 이런 함수들의 대부분은 Prelude가 아니라 IO 라이브러리에 들어있고, 따라서 스코프 내에 두기 위해서는 반드시 명시적으로 수입(import)해야만 합니다. (모듈과 수입에 대해서는 11장에서 논의합니다.) 게다가, 이런 함수들의 대부분은 Haskell Report가 아니라 Library Report에서 논의하고 있습니다.

파일을 열면 입출력 작업에 사용될 (Handle 타입의) 핸들(handle)이 만들어집니다. 핸들을 닫으면 관련된 파일이 닫힙니다:

   1 type  FilePath    =  String    -- path names in the file system
   2 openFile          :: FilePath -> IOMode -> IO Handle
   3 hClose            :: Handle -> IO () 
   4 data  IOMode      =  ReadMode | WriteMode | AppendMode | ReadWriteMode

핸들은 채널(channels)과도 연관될 수 있습니다: 통신 포트는 파일에 직접 붙지 않습니다. stdin (표준 입력), stdout (표준 출력), stderr (표준 오류)를 포함하는 몇가지 채널 핸들이 정의되어 있습니다. 문자 수준의 입출력 연산에는 hGetCharhPutChar가 포함되며, 이들은 인수로 핸들 하나를 받아들입니다. 전에 사용했던 getChar 함수는 다음과 같이 정의할 수 있습니다:

   1 getChar    =  hGetChar stdin

Haskell에서는 파일이나 채널의 전체 내용이 하나의 문자열서 리턴될 수 있습니다:

언뜻 생각하기에는 경우에 따라 getContents가 공간상으로나 시간상으로나 상당히 비효율적으로 파일 혹은 채널 전체를 즉시 읽어들이는 것으로 보일 수도 있습니다만, 실제로는 그렇지 않습니다. 여기서 요점은 getContents가 문자들의 ‘게으른’ (즉, non-strict한) 리스트를 리턴하며 (Haskell에서 문자열은 단지 문자들의 리스트일 뿐이라는 점을 상기하시기 바랍니다), 이 리스트의 원소들은 다른 모든 리스트에서와 마찬가지로 ‘요구될 때에만 (by demand)’ 읽혀진다는 사실입니다. 컴파일러나 인터프리터들은 이런 요구기반 (demand-driven) 특성을 구현함에 있어서, 계산에 필요할 때에만 파일로부터 한 글자씩 읽어들이도록 구현하리라 생각할 수 있습니다.

이 예제에서는 Haskell 프로그램이 파일 하나를 복사합니다:

   1 main    =  do fromHandle <- getAndOpenFile "Copy from: " ReadMode
   2               toHandle   <- getAndOpenFile "Copy to: " WriteMode 
   3               contents   <- hGetContents fromHandle
   4               hPutStr toHandle contents
   5               hClose toHandle
   6               putStr "Done."
   7 
   8 getAndOpenFile                :: String -> IOMode -> IO Handle
   9 getAndOpenFile prompt mode    =
  10     do putStr prompt
  11        name <- getLine
  12        catch (openFile name mode)
  13              (\_ -> do putStrLn ("Cannot open "++ name ++ "\n")
  14                        getAndOpenFile prompt mode)

게으른 함수 getContents를 사용하면 파일 전체 내용을 한꺼번에 주기억장치에 읽어들일 필요가 없습니다. 만일 hPutStr이 일정한 크기의 문자 블럭에 문자열을 쓰는 방식으로 출력을 버퍼링하고자 한다면, 입력 파일 중에서 한 번에 딱 한 블럭씩만 메모리에 기억하면 됩니다. 마지막 문자를 읽고나면 파일은 암묵적으로 닫힙니다.

7.5 Haskell과 절차형 프로그래밍

마지막으로, 입출력 프로그래밍은 대단히 중요한 쟁점을 던져줍니다: 이런 스타일은 평범한 절차형 프로그래밍과 비슷한게 아닌가 의심스럽습니다. 예를 들어, 아래 getLine 함수는:

   1 getLine    =  do c <- getChar
   2                  if c == '\n' then return ""
   3                               else do l <- getLine
   4                                       return (c:l)

다음과 같은 절차형 코드와 놀라운 유사성이 있습니다 (실존하는 언어의 코드는 아닙니다):

function getLine() {
    c := getChar();
    if c == `\n` then return ""
                 else {l := getLine();
                       return c:l}
}

그래서, 결국 Haskell은 절차형 세계에서 이미 발명해놓은 바퀴를 이제서야 재발명한 것 뿐일까요?

어떤 면에서는 그렇습니다. 입출력 모나드는 Haskell 내부에 자그마한 절차형 보조언어를 구성하며, 따라서 프로그램 중 입출력 부분은 평범한 절차형 코드와 비슷해보일 수 있습니다. 하지만 한 가지 중요한 차이점이 있습니다: 사용자가 다뤄야만하는 특수한 의미적 요소(semantics)가 존재하지 않습니다. 특히, Haskell에서의 등식에 의한 추론은 전혀 타협하지 않았습니다. 프로그램 중의 모나드적 코드가 주는 절차형 언어같은 느낌은 Haskell의 함수형 언어적 측면을 조금도 손상시키지 않습니다. 숙련된 함수형 프로그래머라면 최소한의 최상위수준 절차를 위한 입출력 모나드만을 사용하여, 프로그램 중의 절차형 부분을 최소화활 수 있어야합니다. 모나드는 프로그램의 각 부분들을 함수형 부분과 절차형 부분으로 깔끔하게 구별해줍니다. 반면에, 함수적 부분집합을 갖는 절차형 언어들은 일반적으로 순수한 함수형 세계와 절차형 세계를 경계지어주는 잘 정의된 격벽을 갖고 있지 않습니다.


이전 다음 위로
A Gentle Introduction to Haskell, Version 98

Copyright © 1999 Paul Hudak, John Peterson and Joseph Fasel

Permission is hereby granted, free of charge, to any person obtaining a copy of “A Gentle Introduction to Haskell” (the Text), to deal in the Text without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Text, and to permit persons to whom the Text is furnished to do so, subject to the following condition: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Text.