gimmesilver's blog

Agbird.egloos.com

포토로그



(번역) A Gentle Introduction to Haskell - 9. 모나드 하스켈 스프링노트

 하스켈을 처음 접하는 많은 사람들이 모나드의 개념에 대해 헷갈려한다. 모나드는 하스켈에서 자주 등장하는 개념인데 입출력 시스템이 모나드를 사용해서 만들어 졌으며 특히 모나드를 위한 별도의 문법이 제공된다. 게다가 모나드 전용 모듈이 표준 라이브러리에 포함되어 있다. 이번 장에서는 모나드 프로그래밍에 대해 보다 자세히 알아보도록 하겠다.


 이번 장은 아마도 이전 장들에 비해 덜 친절할지 모르겠다. 여기서 우리는 단지 모나드를 포함한 언어적 특성뿐만이 아니라 보다 큰 그림 즉, 모나드가 왜 그렇게 중요한 도구이며 그들을 어떻게 사용해야 하는가를 다루고자 한다. 모나드에 대해서 모든 사람을 만족시킬만한 설명방법은 없다. 더 많은 설명 문서들이 haskell.org 에 있다. 모나드를 사용한 실전 프로그래밍에 대한 또다른 좋은 설명서로는 Wadler 가 쓴 Monads for Functional Programming이 있다.


9.1 모나드 클래스

 Prelude 에는 하스켈에서 사용되는 많은 모나드 클래스의 정의가 들어 있다. 이들 클래스들은 범주론(category theory) 에 나오는 모나드 개념을 기반으로 하고 있다. 모나드 클래스와 연산자의 이름은 범주론에 나오는 용어들에서 따왔다. 하지만 모나드 클래스의 사용법을 이해하기 위해 추상적인 수학 개념에 깊이 파고들 필요는 없다.


 모나드는 IO 같은 다형(polymorphic) 타입의 꼭대기에 구성된다. 모나드 자체는 모나드 클래스인 Functor, Monad, MonadPlus 의 전부 혹은 일부를 특정 타입과 연관지어 인스턴스로 선언함으로써 정의된다. 어떤 모나드 클래스도 유도될 수 없다.  Prelude 에는 IO 를 포함하여 리스트와 Maybe 타입이 모나드 클래스의 멤버이다.


 수학적으로 볼때, 모나드는 모나드 연산자들이 지켜야 할 규칙들의 집합으로 관리된다. 모나드만 이런 규칙이라는 개념을 가진 것은 아니다. 하스켈에는 최소 한도내에서 지켜야 하는 규칙을 가진 다른 연산자들이 있다. 예를 들어 x /= y 와 not (x == y) 는 비교가 가능한 어떤 타입이든지 같은 값을 가져야 한다. 하지만 이것이 꼭 보장되는 것은 아니다. == 와 /= 는 Eq 클래스에서 별도의 메소드이며 따라서 == 와 /= 가 이런 관계를 가져야 한다고 보장해줄 만한 방법은 없다. 마찬가지로 모나드 법칙 역시 하스켈에서 강제할 수 있는 방법이 없다. 하지만 모나드 클래스의 인스턴스라면 모두 이 규칙을 지켜야 한다. 모나드 법칙은 모나드 구조체에 대한 통찰력을 준다. 이 법칙을 살펴봄으로써 모나드를 어떻게 사용해야 할지 감을 잡을 수 있기 바란다.


 Functor 클래스는 이미 5장에서 살펴본 적이 있는데 fmap 이라는 연산자 하나로 정의되어 있다. map 함수는 컨테이너안에 있는 각각의 객체들에게 특정 연산을 적용해서 그 결과로 같은 타입의 컨테이너를 반환한다. 이들 규칙은 Functor 클래스에 있는 fmap 함수에 적용된다.

  1. fmap id = id
  2. fmap (f . g) = fmap f . fmap g

 위 규칙들은 컨테이너의 형태가 fmap 에 의해 변하지 않으며 컨테이너의 내용이 대응되는 연산에 의해 재 정렬되지 않는다는 것을 보장한다.

 모나드 클래스는 두가지 기본 연산자인 >>=(bind) 와 return 을 정의한다.

  1. infixl 1 >>, >>=
  2. class Monad m where
  3.     (>>=) :: m a -> (a -> m b) -> m b
  4.     (>>) :: m a -> m b -> m b
  5.     return :: a -> m a
  6.     fail :: String -> m a
  7.     m >> k = m >>= \_ -> k

 결합(bind) 연산자인 >> 와 >>= 는 두개의 모나드 값을 결합시켜준다. 그리고 return 연산자는 어떤 값을 모나드 안에 집어 넣는다. >>= 함수의 시그니처는 이 연산자를 이해하는데 도움이 된다. ma >>= \v -> mb  라는 함수는 a 타입의 값을 보관하는 ma 라는 모나드와,  a타입의 값 v 를 받아 결과로 mb 모나드를 반환하는 함수를 결합시켜준다. 그 결과 ma 와 mb 는 b 타입의 값을 가진 모나드로 결합된다. >> 함수는 첫번째 모나드 연산에서 나온 결과값을 사용하지 않는 함수를 결합할 때 사용한다.


 물론 결합 연산자는 모나드의 종류에 따라 세부적인 의미가 결정된다. 예를 들어 IO 모나드에서 x >>= y 는 두 액션을 순차적으로 수행하며 이 때 첫번째 액션의 결과를 두번째 액션의 인자로 넘겨준다. 다른 내장(built-in) 모나드인 리스트나 Maybe 타입의 경우 이들 결합 모나드 연산자들을 사용하면 첫번째 연산에서 나온 하나 이상의 값을 다음에 나오는 연산의 입력 인자로 넘겨준다고 이해하면 된다. 이에 대해 간략한 예제를 살펴보도록 하자.


 do 문법을 사용하면 모나드 연산의 연결 사슬을 간단하게 요약할 수 있다. do 문법은 다음과 같은 두가지 규칙에 의해 변환된다.

  1. do e1; e2 = e1 >> e2
  2. do p <- e1; e2 = e1 >>= \p -> e2

 위 규칙중 두번째 형태의 경우 패턴이 잘못돼서 매칭이 실패하면 fail 함수가 호출된다. 그러면 (IO 모나드에서 언급한) error 함수가 호출되고 "zero" 가 반환된다. 그래서 변환과정을 좀더 자세하게 표현하면 아래와 같다.

  1. do p <- e1; e2 = e1 >>= (\v -> case v of p -> e2; _ -> fail "s")

 "s" 는 오류 메시지를 출력할 때 사용할 수 있는 do 구문의 위치를 표시해주는 문자열을 뜻한다. 예를 들어 IO 모나드에서 'a' <- getChar 와 같은 액션은 만약 입력된 문자 타입이 'a' 가 아니라면 fail 이 호출될 것이다. IO 모나드의 fail 함수는 error 를 호출하기 때문에 프로그램은 종료된다.


 >>= 와 return 함수에 적용되는 규칙은 다음과 같다.

  1. return a >>= k = k a
  2. m >>= return = m
  3. xs >>= return . f = fmap f xs
  4. m >>= (\x -> k x >>= h) = (m >>= k) >>= h


 MonadPlus 클래스는 zero 원소와 plus 연산을 갖는 모나드에서 사용된다.

  1. class (Monad m) => MonadPlus m where
  2.     mzero :: m a
  3.     mplus :: m a -> m a -> m a

 zero 원소는 다음 규칙을 따른다.

  1. m >>= \x -> mzero = mzero
  2. mzero >>= m = mzero


 리스트의 경우 zero 값은 [] 즉, 빈 리스트이다. 입출력 모나드는 zero 원소가 없으며 이 클래스의 인스턴스가 아니다.

 mplus 연산자는 다음과 같은 규칙을 따른다.

  1. m `mplus` mzero = m
  2. mzero `mplus` m = m

 리스트 모나드에서 mplus 연산자는 일반적인 리스트 연결(concatenation) 이다.


9.2 내장 모나드(Built-in Monads)

 주어진 모나드 연산자와 규칙을 이용해서 어떤 걸 만들 수 있을까? 우리는 이미 입출력 모나드를 자세히 살펴봤었고 이제 다른 두 개의 내장 모나드를 살펴보려고 한다.

 리스트에서 모나드 결합 연산은 리스트에 들어 있는 각각의 값을 계산해서 나온 집합(리스트)들을 합쳐서 하나의 리스트로 만든다. 리스트에서 >>= 연산의 시그니처는 다음과 같다.

  1. (>>=) :: [a] -> (a -> [b]) -> [b] 

 이것은 a 타입 원소를 가진 리스트가 있을 때, 각 원소들을 입력 인자로 받아 b 타입 원소를 가진 리스트를 반환하는 함수를 적용해서 나온 모든 결과를 하나의 리스트로 합치는 함수이다. return 함수는 원소 하나짜리 리스트를 생성한다. 이들 연산자는 이미 친숙할텐데 모나드 연산자를 사용하면 조건 제시법 리스트(list comprehension) 를 쉽게 표현할 수 있다. 다음 세가지 표현식은 문법은 다르지만 모두 같은 결과를 갖는다.

  1. [(x, y) | x <- [1,2,3], y <- [1,2,3], x /= y] 
  2.   
  3. do x <- [1,2,3] 
  4.    y <- [1,2,3] 
  5.    True <- return (x /= y) 
  6.    return (x, y) 
  7.   
  8. [1,2,3] >>= (\x -> [1,2,3] >>= (\y -> return (x /= y) >>= 
  9.     (\r -> case r of True -> return (x, y) 
  10.                      _ -> fail "")) 

 This definition depends on the definition of fail in this monad as the empty list. 각각의 <- 는 리스트에서 전체 모나드 연산의 나머지 부분에 전달될 값들을 생성한다. 그래서 x <- [1,2,3] 은 각 원소별로 뒷부분의 모나드 연산을 세번씩 호출한다. 반환된 표현식인 (x,y) 는 결합 가능한 모든 경우에 대해서 결과를 계산할 것이다. 이렇듯 리스트 모나드는 여러 값들을 인자에 적용하는 함수를 표현한다고 생각할 수 있다.


  1. mvLift2 :: (a -> b -> c) -> [a] -> [b] -> [c] 
  2. mvLift2 f x y = do x' <- x 
  3.                    y' <- y 
  4.                    return (f x' y') 

 위 함수는 두개의 인자를 갖는 함수 f 에 (인자 리스트로 표현된) 여러 값들을 적용해서 가능한 모든 인자 경우의 수를 적용한 결과값을 반환하는 일반 함수이다.

  1. mvLift2 (+) [1,3] [10,20,30] => [11,21,31,13,23,33] 
  2. mvLift2 (\a b -> [a,b]) "ab" "cd" => ["ac", "ad", "bc", "bd"] 
  3. mvLift2 (*) [1,2,4] [] => [] 

 이 함수는 모나드 라이브러리에 있는 LiftM2 함수의 특별 버전이다. 여러 개의 값을 받아 계산하는 리스트 모나드를 위해 만든 함수처럼 생각할 수 있다.

 Maybe 모나드 정의는 리스트 모나드와 비슷하다. Nothing 값은 [], Just x 는 [x]라 할 수 있다.


9.3 모나드 사용하기

 모나드 연산과 이에 관련된 규칙을 설명한다고 해서 모나드가 어떤 점에서 좋은지 알 수는 없다. 모나드가 진정으로 제공하는 장점은 모듈화이다. 모나드 연산을 정의하면 새로운 속성을 모나드 속에 명확하게 병합하며 모호한 상태를 숨길 수 있다. Wadler의 논문에는 모나드를 사용해서 어떻게 모듈화된 프로그램을 만들 수 있는지 좋은 예제가 나와 있다. 이 논문에 나온 모나드인 상태 모나드를 먼저 살펴보고 유사한 특성을 갖는 보다 복잡한 모나드를 만들어 보도록 하겠다.


 간략하게 S 타입의 값을 갖는 상태 모나드를 아래와 같이 정의해 보자.

  1. data SM a = SM (S -> (a, S)) -- 모나드 타입 
  2.   
  3. instance Monad SM where 
  4.     -- defines state propagation 
  5.     SM c1 >>= fc2 = SM (\s0 -> let (r, s1) = c1 s0 
  6.                                    SM c2 = fc2 r in 
  7.                                c2 s1) 
  8.     return k = SM (\s -> (k, s)) 
  9.   
  10. -- extracts the state from the monad 
  11. readSM :: SM s 
  12. readSM = SM (\s -> (s, s)) 
  13.   
  14. -- update the state of the monad 
  15. updateSM :: (S -> S) -> SM () 
  16. updateSM f = SM (\s -> ((), f s)) 
  17.   
  18. -- run a computation in the SM monad 
  19. runSM :: S -> SM a -> (a, S) 
  20. runSM s0 (SM c) = c s0 

 이 예제에서는 SM이라는 새로운 타입을 정의했는데 S 타입의 값을 암묵적으로 전송하는 계산식이다. SM t 라는 타입의 계산식은 S 타입의 상태에 상호작용(읽기/쓰기 작업)을 하는 t 타입의 값을 정의한다. SM 의 정의는 간단히 말하면 다음과 같다. 그것은 어떤 상태를 받아 반환된 값과 변경된 상태, 두개의 결과를 생성하는 함수로 구성된다. 여기서는 타입 동의어를 사용할 수 없는데 인스턴스 선언문에서 사용될 수 있는 SM 같은 타입 이름이 필요하기 때문이다. data 대신 newtype  선언을 사용하기도 한다.


덧글

댓글 입력 영역