본문 바로가기

Haskell

기본적인 입출력과 모나드

대부분의 프로그래밍 언어는 입출력에 관련한 '함수'를 제공한다. 그리고 이런 함수들은 기본 중의 기본으로 취급되면서 '있는 그대로 쓰면 되는 것'으로 취급된다. 예를 들어 파이썬의 경우에 입출력함수는 input(), print()가 있다. 특히 input()과 같이 표준 입력을 받아오는 함수의 경우, 함수를 평가한 결과가 키보드등으로 입력된 문자열 값이 된다.

물론 하스켈의 경우에도 입출력 함수가 있다.(그것도 여러개) 하지만 다른 언어에서 가장 기본중의 기본이 되는 입출력이 하스켈에서는 그리 간단한 일이 아니다. 그것은 하스켈이 순수 함수형 언어라는 디자인 특성을 가지고 있기 때문인데, 따라서 입출력 자체는 어렵지 않은 일이나, 그것을 다루는 방법이 제법 까다롭다.

입출력을 담당하는 기본 함수

하스켈은 순수 함수형 언어이다. 순수함수형 언어에서는 '상태값'이라는 것이 없고 모든 함수는 순수하다. 함수가 순수하다는 말은 외부 상태에 영향을 주고받지 않으며 입력이 동일하다면 그 출력이 동일하다는 것을 뜻한다. 하스켈에서 표준 입력에서 문자열을 입력받는 함수는 getLine 이다. 이 함수는 인자를 받지 않는다. 즉 수학적으로는 f(x) = 3과 같은 항등함수이다. 그러나 함수가 평가될 때 마다, 키보드로부터 들어오는 문자열이 결코 같지는 않을 것이므로 getLine은 순수한 함수가 될 수 없다. 이러한 모순을 해결하기 위해서 하스켈은 IO 모나드라는 것을 도입한다.

IO 모나드는 모나드의 일종인데 모나드가 무엇인지를 여기서 설명하기는 어려울 듯 하다. 단지 '특정한 값이 어떠한 계산맥락(computational context)상에 존재한다'는 개념 정도로 알고 넘어가면 되겠다. 대신에 이러한 모나드가 리스트나 Maybe 처럼 어떤 값을 감싸는 컨테이너와 비슷하게 동작한다는 것만 이해하면 된다.

입력을 받는 함수인 getLine의 타입은 IO String이다. 즉 IO라는 맥락 안에서 String타입의 값을 내포한다는 것이다. 이 때 String 타입의 값은 하스켈의 순수한 함수 영역에서 다룰 수 있는 값의 타입이다. 그리고 IO 컨테이너는 리스트와 Maybe와 비슷하게 어떤 함수를 그 내부의 값에 사상할 수 있는 성질을 가지고 있다.

fmap을 써서 함수를 컨테이너 내부의 값에 사상할 수 있는 컨테이너를 함자(functor)라 한다. 모든 모나드는 함자이다. 참고로 fmap은 연산자로 표기하면 <$>라고 쓴다.

또 다른 한가지 중요한 연산은 바로 바인딩이다. 바인딩은 연산자 >>=를 써서 표현하는데, 모나드를 순수한 값을 인자로 받는 함수(정확하게는 순수한 값을 인자로받아 모나드를 리턴하는 함수)에 연결하는 기능을 수행한다. 바인딩에 적용되는 대표적인 함수로는 putStrLn이 있다. 이 함수는 문자열을 입력받아서 이를 화면에 출력한다. 출력이라는 프로세스에 의해서 프로그램의 외부 세계 (콘솔출력)의 상태가 변경되는 부작용이 발생하기 때문에 이 역시 순수한 함수가 되지 못한다. putStrLn의 타입은 String -> IO () 가 된다. 여기에 문자열을 넘겨주면 화면으로 출력되는데, 만약 문자열이 IO 모나드속에 들어있다면 >>=를 써서 연결하면 된다.

에코, 리버스 에코

자 그러면 간단한 에코 프로그램을 작성하는 방법에 대해서 생각해보자. 이 프로그램의 동작 방식은 단순하다. 키보드로부터 한 줄의 텍스트를 입력 받고, 이를 그대로 화면에 다시 출력하는 것이다. 키보드에 의한 입력은 getLine으로 처리할 수 있고, putStrLn으로 문자열을 출력할 수 있다. 이 때 getLineIO String 타입이고, putStrLnString -> IO () 타입이다. 따라서 두 함수를 >>=로 연결하면 다음과 같은 프로그램 코드가 얻어진다.

--echo.hs
main :: IO ()
main = getLine >>= putStrLn

바인딩 연산자 >>=의 좌변은 IO String 타입이고 우변은 String -> IO () 타입이다. 그리고 이 때 >>= 의 타입은 IO String -> String -> IO () 이 되므로 타입상의 조합으로는 문제가 없다. 해당 코드를 컴파일하고 실행해보자.

이번에는 이 프로그램을 살짝 바꿔서 입력받은 문자열을 뒤집어서 출력하는 프로그램을 만들어보자. 문자열은 Chat 타입의 리스트이므로, 리스트를 뒤집는 reverse 함수를 입력값에 적용하면 된다. 이 때 reverse의 타입은 [a] -> [a] 이고 입력받은 문자열은 IO [Char] 이므로 이 둘을 연결하려면 바인딩이 아닌 맵핑을 사용한다. 맵핑은 주로 fmap을 통해서 적용할 수도 있지만, 연산자로는 <$>를 사용할 수 있다. 자 그러면 다음 코드를 보자.

main = reverse <$> getLine >>= putStrLn
  1. 하스켈의 연산자들은 (특별한 우선순위 관계가 정의되지 않은 이상) 왼쪽에서 오른쪽으로 평가된다.
  2. 먼저 reverse <$> getLine이 평가된다. 이는 사용자에게 입력받은 값을 뒤집는다. 하지만 그 결과 타입은 계속 IO String이다.
  3. 다시 IO String 값이 >>= putStrLn으로 넘겨지는 상황으로 돌아왔다. 뒤집힌 문자열이 출력된다.

do 문법

하스켈 초심자를 위한 여러 튜토리얼에서 리버스 에코를 구현하는 코드는 보통 위와 같이 쓰지 않는다. do 표기를 사용하여 마치 절차형 언어처럼 쓰는 경우가 많다. 이런 식이다.

main = do
  s <- getLine       -- 한줄을 입력받고 그 값을 s라 한다.
  let s' = reverse s -- s를 뒤집어서 그 값을 s'라 한다.
  putStrLn s'        -- s'를 출력한다.

이 예제 역시 나무랄대 없는 하스켈 프로그램이나, 하스켈 초심자가 익힌다는 상황에서는 조금 문제가 있다. 먼저 <-를 마치 언어의 문법의 일부인 양 약속하면서 getLine, putStrLn을 일반적인 순수함수와 동일한 모양으로 다룬다는 것과, let을 쓰는 것과 <-를 쓰는 것의 차이가 모호해져 버린다.

위와 똑같은 흐름으로 돌아가는 코드를 바인딩을 써서 다시 작성해보겠다.

main = getLine >>= (\s-> let s' = reverse s in putStrLn s')

이걸 바인딩과 in 을 기준으로 행구분을 해서 다시 써보면 아래와 같다.

main = 
  getLine >>= (\s ->
  let s' = reverse s in
  putStrLn s'

이제 바인딩 구문의 좌우변을 바꾸고, <-로 연결한다. 그리고 in을 제거한다. 그러면 다음과 같이 쓸 수 있게 된다.

main = do
  s <- getLine
  let s' = reverse s
  putStrLn s'

그러면 do 표기와 완전히 동일해진다. 즉 do 표기는 바인딩과 람다표현을 문법적인 장식을 통해서 절차지향적인 코드의 모양으로 바꿔주는 것으로 이해할 수 있다. 따라서 do 블럭 내에서의 각 행은 다음과 같이 사용한다고 보면 된다.

  1. 모나드 값을 리턴하는 함수를 이름에 바인딩하는 것은 <-를 사용한다.
  2. 순수함수를 통해서 값을 변형할 때에는 let ...을 쓴다. 단 이 때 in은 생략하여야 한다.

정리

이상으로 기본적인 입출력에 대해서 알아보았다. 프로그램의 입력과 출력은 외부 상태에 대한 부작용을 필수 불가결하게 동반하므로 순수 함수로는 직접 처리할 수 없으며, 순수 함수가 동작하는 영역 내부로 들어올 수 없어야 한다. IO 모나드는 이처럼 외부 상태와 연결되어 있는 값을 격리하는 역할을 수행하며, 이런 값들은 하스켈의 맵 및 바인드 연산을 통해서 조작하는 것이 가능하다.

그리고 do 문법은 연속적인 바인딩 연산을 라인별로 작성하여 마치 절차형 프로그래밍 언어와 비슷한 방식으로 입출력 코드를 작성할 수 있게 해주는 도구이다.