티스토리 뷰

14장. 모나드

14장. 모나드

도입부

7장 입출력에서, 우린 IO 모나드에 대해 얘기는 했지만 의도적으로 바깥 세계와 소통하는 것에만 초점을 맞춰 논의했습니다. 우린 모나드가 무엇인지 말하진 않았습니다.

우린 이미 7장 입출력에서 IO 모나드로 쉽게 작업할 수 있는 것을 보았습니다. 표기법의 차이는 제쳐두고, IO 모나드로 코드를 짜면 다른 절차 지향 언어와도 많이 차이가 나지 않았습니다.

이전 장들에서 우리가 실용적인 문제를 다룰 때 도입한 구조도, 이제 곧 보겠지만 사실 모나드입니다. 우리는 모나드가 문제를 해결하는 데 명백유용한 도구라는 걸 보여주려고 합니다. 모나드 사용이 얼마나 쉬운지 보여주기 위해 이 장에서 모나드를 몇가지 정의할 것입니다.

이전 코드 예제 되새기기

Maybe 연쇄

10장 코드 예제 분석: 이진 자료 파싱에서 짰던 parseP5 함수를 다른 측면에서 살펴봅시다.

-- file: ch10/PNM.hs
matchHeader :: L.ByteString -> L.ByteString -> Maybe L.ByteString

-- "nat" here is short for "natural number"
getNat :: L.ByteString -> Maybe (Int, L.ByteString)

getBytes :: Int -> L.ByteString
         -> Maybe (L.ByteString, L.ByteString)

parseP5 s =
  case matchHeader (L8.pack "P5") s of
    Nothing -> Nothing
    Just s1 ->
      case getNat s1 of
        Nothing -> Nothing
        Just (width, s2) ->
          case getNat (L8.dropWhile isSpace s2) of
            Nothing -> Nothing
            Just (height, s3) ->
              case getNat (L8.dropWhile isSpace s3) of
                Nothing -> Nothing
                Just (maxGrey, s4)
                  | maxGrey > 255 -> Nothing
                  | otherwise ->
                      case getBytes 1 s4 of
                        Nothing -> Nothing
                        Just (_, s5) ->
                          case getBytes (width * height) s5 of
                            Nothing -> Nothing
                            Just (bitmap, s6) ->
                              Just (Greymap width height maxGrey bitmap, s6)

이 함수를 소개할 땐, 함수가 복잡해질수록 페이지의 오른쪽으로 진행해서 곤란했습니다. 우린 이 난간 계단같은 코드를 (>>?) 함수로 제어할 수 있었습니다.

-- file: ch10/PNM.hs
(>>?) :: Maybe a -> (a -> Maybe b) -> Maybe b
Nothing >>? _ = Nothing
Just v  >>? f = f v

우린 Maybe 값을 반환하는 함수를 연쇄할 수 있도록 주의깊게 (>>?)의 타입을 골랐습니다. 그 결과 한 함수의 결과 타입이 다음 함수의 인자 타입과 일치하는 한, Maybe로 감싸서 반환하는 함수들을 무한히 연쇄할 수 있었습니다. (>>?)의 본체는 우리가 짠 함수 연쇄chain, 이하 체인에서 Nothing이나 완전히 평가한 값을 반환하는 것으로 내부적으로 단축 평가short-circuit하는 것을 숨기고 있습니다.

암시적 상태

(>>?)parseP5를 가지런히 하는데 유용했던 것과 동시에, 우리는 파싱하면서 문자열 조각을 조금씩 읽어야 했습니다. 이 때문에 현재 문자열 값을 터플로 감싸 Maybe체인 아래로 넘겨야 했습니다. 체인 안의 각각의 함수는 파싱한 결과와 아직 파싱하지 않은 나머지 문자열을 각각 터플의 원소로 넣었습니다.

-- file: ch10/PNM.hs
parseP5_take2 :: L.ByteString -> Maybe (Greymap, L.ByteString)
parseP5_take2 s =
    matchHeader (L8.pack "P5") s      >>?
    \s -> skipSpace ((), s)           >>?
    (getNat . snd)                    >>?
    skipSpace                         >>?
    \(width, s) ->   getNat s         >>?
    skipSpace                         >>?
    \(height, s) ->  getNat s         >>?
    \(maxGrey, s) -> getBytes 1 s     >>?
    (getBytes (width * height) . snd) >>?
    \(bitmap, s) -> Just (Greymap width height maxGrey bitmap, s)

skipSpace :: (a, L.ByteString) -> Maybe (a, L.ByteString)
skipSpace (a, s) = Just (a, L8.dropWhile isSpace s)

또 다시, 반복적인 작업 패턴을 맞닥뜨립니다. 문자열을 읽고, 결과를 반환하고, 나머지 문자열을 다음 파싱 함수에 넘겨줍니다. 하지만, 이 패턴은 더 까다롭습니다. 만약 우리가 또 다른 정보를 체인의 아래로 건네고 싶다면, 각각의 2터플을 3터플로 바꿔야 하므로 체인의 거의 모든 원소를 수정해야만 하게 됩니다.

우리는 이 문제를 현재 문자열을 다루는 책임을 체인 안의 각각의 함수가 아니라 체인을 엮는 함수가 지게 해 해결했습니다.

-- file: ch10/Parse.hs
(==>) :: Parse a -> (a -> Parse b) -> Parse b

firstParser ==> secondParser = Parse chainedParser
  where chainedParser initState =
          case runParse firstParser initState of
            Left errMessage ->
                Left errMessage
            Right (firstResult, newState) ->
                runParse (secondParser firstResult) newState

뿐만 아니라 자세한 파싱 상태를 ParseState 안에 숨겼습니다. getStateputState 함수조차 파싱 상태를 조사하진 않아, ParseState를 아무리 바꿔도 이미 작성한 코드에 아무런 영향이 없게 되었습니다.

공유하는 패턴 찾기

위 예제들을 자세히 봐도 공통점이 많아 보이진 않습니다. 일단, 둘 다 함수 연쇄에 관련이 있고, 세부사항을 숨겨 더 간결한 코드를 짜도록 도와줍니다. 하지만, 한 걸음 물러나 넓게 봅시다.

먼저, 타입 정의를 살펴 봅시다.

-- file: ch14/Maybe.hs
data Maybe a = Nothing
             | Just a
-- file: ch10/Parse.hs
newtype Parse a = Parse {
      runParse :: ParseState -> Either String (a, ParseState)
    }

두 타입의 공통점은 오른쪽 어딘가에 나타나는 타입 인자 하나가 정의 왼쪽에 있다는 것입니다. 그러므로 두 타입은 어떤 타입을 담을지 모르는 일반적generic 타입입니다.

다음으로, 두 타입을 위해 우리가 작성한 체인 함수를 살펴보겠습니다.

ghci> :type (>>?)
(>>?) :: Maybe a -> (a -> Maybe b) -> Maybe b
ghci> :type (==>)
(==>) :: Parse a -> (a -> Parse b) -> Parse b

이 함수들은 감쪽같이 비슷한 타입을 가지고 있습니다. 우리가 이 타입 생성자를 타입 변수로 바꾼다면, 좀 더 추상화된 한 타입을 얻게 됩니다.

-- file: ch14/Maybe.hs
chain :: m a -> (a -> m b) -> m b

마지막으로, 둘 다 각각 “평범한” 값을 받아 대상 타입에 “주입”하는 함수를 가지고 있습니다. Maybe는 단순히 Just 타입 생성자지만, Parse의 주입자injector는 더 복잡합니다.

-- file: ch10/Parse.hs
identity :: a -> Parse a
identity a = Parse (\s -> Right (a, s))

다시, 세부 사항이나 복잡도를 보자는 것이 아닙니다. 우리가 보려는 것은 각각의 타입이 다음과 같은 “주입자” 함수를 가지고 있다는 사실입니다.

-- file: ch14/Maybe.hs
inject :: a -> m a

이 세 가지 속성과, 이것들을 어떻게 같이 사용할 지에 관한 규칙이, 하스켈에서의 모나드를 정의합니다. 위 목록을 압축한 형태로 상기해봅시다.

  • 타입 생성자 m.

  • 한 함수의 출력을 다른 함수의 인자로 연쇄할 m a -> (a -> m b) -> m b 타입 함수

  • 평범한 값을 체인 안으로 주입할 a -> m a 타입 함수. 즉, 타입 a를 타입 생성자 m으로 감싸는 함수.

Maybe 타입을 모나드로 만드는 세 가지 속성은 그 타입 생성자 Maybe a, 우리의 연쇄 함수 (>>?), 주입자 함수 Just입니다.

Parse의 경우, 대응하는 각각의 속성은 타입 생성자 Parse a, 연쇄 함수 (==>), 주입자 함수 identity입니다.

연쇄 함수와 주입자가 어떻게 동작해야 암시적 상태별로 중요하지 않기 때문에, 일부러 언급하지 않았습니다. 사실, 모나드는 매우 간단하기 때문에 하스켈 코드 곳곳에서 쓰고 있습니다. 수많은 일반적인 프로그래밍 패턴이 모나드 구조를 가지고 있는데, 암시적 데이터 전달이나, 첫번째가 실패했을 때 두번째를 보지 않고 선택하는 체인의 단축 평가의 예가 있습니다.

모나드 타입클래스

연쇄와 주입의 개념과 모나드에 넣고 싶은 타입을 하스켈 타입클래스에서 확인할 수 있습니다. 표준 프렐류드Prelude는 이미 Monad라는 이름을 가진 그러한 타입클래스를 정의했습니다.

-- file: ch14/Maybe.hs
class Monad m where
    -- chain
    (>>=)  :: m a -> (a -> m b) -> m b
    -- inject
    return :: a -> m a

여기서, (>>=)는 체인 함수입니다. 우린 이걸 이미 “시퀀싱”장에서 봤습니다. 이 함수는 왼쪽의 계산 결과를 오른쪽의 인자로 묶기 때문에 보통 “바인드bind”라고 부릅니다.

주입자 함수는 return입니다. “리턴의 속성”에서 봤듯이, return을 이름으로 고른 것은 좀 안타깝습니다. 이 이름은 이미 절차 지향 언어에서 쉽게 이해할 수 있는 뜻을 가지고 널리 쓰고 있기 때문입니다. 하스켈에선, 이 함수는 동작에 훨씬 제약을 덜 받습니다. 심지어, return을 함수의 중간에서 호출해도 체인을 일찍 종료하지도 않습니다. 이 함수의 이름에서 동작을 연상하는 한 가지 유용한 방법은 이 함수가 (a 타입의) 순수한 값을 (m a 타입의) 모나드 값으로 되돌려준다고 이해하는 것입니다.

(>>=)returnMonad 타입클래스의 핵심 함수지만, 모나드 타입클래스는 다른 두 함수를 추가로 정의합니다. 첫째는 (>>)then입니다. (>>=)와 마찬가지로 연쇄를 하지만, 왼쪽의 결과 값을 무시합니다.

-- file: ch14/Maybe.hs
    (>>) :: m a -> m b -> m b
    a >> f = a >>= \_ -> f

우리는 액션을 특정 순서로 실행시키고 싶지만 그 결과는 신경쓰지 않을 때 이 함수를 씁니다. 이건 좀 의미없어 보일 수 있습니다. 왜 우리가 함수의 리턴 값에 신경을 안 쓸가요? 하지만, 우리가 딱 이것을 나타내는 (==>&) 컴비네이터를 이미 정의했었단 걸 떠올려보세요. 아니면, print같은 조사할 필요가 없는 빈 자리를 채우기만 하는 값을 반환하는 함수를 생각하셔도 됩니다.

ghci> :type print "foo"
print "foo" :: IO ()

만약 (>>=)를 쓴다면, 우변 함수로 반드시 인자를 무시하는 함수를 제공해야 합니다.

ghci> print "foo" >>= \_ -> print "bar"
"foo"
"bar"

하지만 (>>)을 쓴다면, 우리는 쓸모없는 함수를 생략할 수 있습니다.

ghci> print "baz" >> print "quux"
"baz"
"quux"

위에서 봤듯이, (>>)의 기본 구현은 (>>=)를 사용해서 정의합니다.

둘째 곁다리 Monad 함수는 에러메시지를 받고 함수 체인을 실패시키는 fail입니다.

-- file: ch14/Maybe.hs
    fail :: String -> m a
    fail = error

fail을 조심하세요

상당한 Monad 인스턴스가 지금 본 fail의 기본 구현을 재정의하지 않았고, 그런 모나드는 failerror를 사용합니다. error를 호출하면 호출자가 붙잡지 못 하거나 예상하지 못한 예외를 던지기 때문에 보통 매우 안 좋습니다.

비록 독자가 당장 fail이 잘 구현된 모나드 안에 있다는 걸 알더라도, 여전히 쓰지 않는 걸 추천합니다. 나중에 코드를 리팩토링하다가 이전에 안전했던 fail이 새 문맥에서 위험해질 수 있다는 걸 잊어버리고 스스로 문제를 일으키기 매우 쉽기 때문입니다.

이건 10장. 코드 예제 분석: 이진 데이터 파싱에서 개발했던 파서를 떠올려보기 위한, 그 파서의 Monad 인스턴스입니다.

-- file: ch10/Parse.hs
instance Monad Parse where
    return = identity
    (>>=) = (==>)
    fail = bail

잠깐의 전문 용어 시간

아마도 독자가 낯설어 할 용어가 여럿 있습니다. 정식 용어는 아니지만, 일반적으로 쓰이므로 알아두면 좋습니다.

  • 모나딕”은 단순히 “모나드와 관계된”이란 의미입니다. 모나딕 타입Monad 타입클래스의 인스턴스고, 모나딕 은 모나딕 타입을 가지고 있습니다.

  • 우리가 어떤 타입이“모나드다”라고 말할 땐, 실제론 이 타입이 Monad 타입클래스의 인스턴스라고 말하는 것을 줄인 것입니다. Monad의 인스턴스가 되는 건 우리에게 타입 생성자, 주입자 함수, 체인 함수 모나딕 3박자를 제공합니다.

  • 마찬가지로, “Foo 모나드”라는 말은 Foo라는 타입과, 그 타입의 Monad 인스턴스를 말하는 것입니다.

  • 액션”은 모나딕 값의 다른 이름입니다. 이 단어는 아마도 print "foo"같은 모나딕 값이 관측할 수 있는 부수 효과를 가질 수 있는 I/O 모나드의 도입에서 유래한 것일 겁니다. 조금 드물지만 모나딕 값을 반환하는 함수도 액션이라고 부를 수도 있습니다.

새 모나드 사용하기: 직접 만들어 봅시다!

도입부에서, 이전에 작성한 일부 코드가 이미 모나딕 형태인 것을 확인했습니다. 모나드가 무엇인지 알아가기 시작했고, Monad 타입클래스도 봤으니, 우리가 모나드로 무엇을 할지 예상하며 만들어봅시다. 첫 시작으로 모나드 인터페이스를 정의하고, 그걸 사용할 것입니다. 한번 이 작업을 끝마치면, 마지막으로 그걸 만들 것입니다.

순수한 코드는 작성하기 놀라울 정도로 깔끔하지만, 물론 입출력을 수행할 수 없습니다. 때때로, 우린 파일에 로그 정보를 저장하지 않고 우리가 내린 판단의 기록을 가지고 싶을 수 있습니다. 이 작업을 도와줄 작은 라이브러리 하나를 개발해봅시다.

“glob 패턴을 정규 표현식으로 변환하기”장에서 만든 globToRegex 함수를 떠올려봅시다. 우린 그 함수가 변환한 특별한 패턴 시퀀스들의 기록을 유지하도록 수정할 것입니다. 익숙한 코드를 다시 다루는 이유는 같은 코드의 모나딕이 아닌 버전과 모나딕 버전을 비교하기 위해섭니다.

시작으로, 우리는 결과 타입을 Logger 타입 생성자로 감쌀 것입니다.

-- file: ch14/Logger.hs
globToRegex :: String -> Logger String

정보 은폐

우린 Logger 모듈 내부를 일부러 추상적으로 유지할 겁니다.

-- file: ch14/Logger.hs
module Logger
    (
      Logger
    , Log
    , runLogger
    , record
    ) where

이렇게 세부 사항을 감추는 덴 두 가지 장점이 있습니다. 우리가 모나드를 구현하는 방법에 상당한 유연성을 허용하고, 사용자에겐 단순한 인터페이스를 제공합니다.

Logger 타입은 단지 타입 생성자입니다. 우린 사용자가 이 타입의 값을 만들 필요가 없도록 생성자를 드러내지 않을 겁니다. 사용자가 쓸 수 있는 건 타입 시그니처를 작성할 때 필요한 Logger 뿐입니다.

Log 타입은 시그니처를 더 읽기 쉽게 하기 위해 만든, 그냥 문자열 리스트의 동의어입니다. 구현을 단순하게 유지하기 위해 문자열 리스트를 사용할 것입니다.

-- file: ch14/Logger.hs
type Log = [String]

라이브러리 사용자에게 값 생성자를 주는 것 대신, runLogger라는 기록을 첨부한 액션을 평가하는 함수를 제공할 겁니다. 이 함수는 액션의 결과와 결과가 계산되는 동안 기록한 것들을 반환합니다.

-- file: ch14/Logger.hs
runLogger :: Logger a -> (a, Log)

제어 가능한 탈출

Monad 타입클래스는 값을 모나드 족쇄에서 빼낼 수 있는 어떤 수단도 제공하지 않습니다. 우리는 단지 return으로 모나드에 갑을 주입할 수 있습니다. (>>=)로 모나드에서 값을 빼낼 수 있지만, 이 빼낸 값을 사용하는 오른쪽 함수 또한 자신의 결과를 모나드로 감싸야 합니다.

대부분의 모나드는 runLogger같은 함수를 가지고 있습니다. 두드러진 예외는 프로그램을 종료하는 것으로만 탈출할 수 있는 IO 모나드입니다.

모나드 실행 함수는 코드를 모나드 안에서 실행하고 그 결과를 꺼냅니다. 제공하는 방법으로는 대개 이런 함수들로만 값에서 모나드 래퍼를 벗겨낼 수 있습니다. 그러므로 모나드 제작자는 모나드 안에 있는 것을 꺼내는 데 완전한 제어권을 가집니다.

일부 모나드는 실행 함수를 여럿 가지고 있습니다. 우리 경우엔, runLogger를 대체할 만한 함수를 생각해볼 수 있습니다. 로그 메시지만 리턴하는 함수와, 로그메시지는 버리고 단지 결과만 반환하는 함수로도 만들 수 있을 겁니다.

흔적 남기기

Logger 액션을 실행하면, 사용자 코드는 무언가 기록하기 위해 record를 호출합니다.

-- file: ch14/Logger.hs
record :: String -> Logger ()

기록이 모나드 배관 안에서 일어나기에, 이 액션의 결과는 어떤 정보도 주지 않습니다.

일반적으로 모나드는 하나나 그 이상의 record같은 도우미 함수를 제공합니다. 이런 도우미 함수들은 우리가 그 모나드의 특수한 작용에 접근하는 수단이 됩니다.

우리 모듈은 LoggerMonad또한 정의하고 있습니다. 사용자 모듈이 이 모나드를 사용하려면 이 정의만 있으면 충분합니다.

아래는 ghci에서, 우리가 만든 모나드가 어떻게 동작하는지 보는 시연입니다.

ghci> let simple = return True :: Logger Bool
ghci> runLogger simple
(True,[])

기록을 첨부한 액션을 runLogger로 실행하면, 쌍pair 하나를 돌려받습니다. 첫째 원소는 우리 코드의 결과이고, 둘째 원소는 액션이 실행되는 동안 기록된 항목의 목록입니다. 우리는 아무것도 기록하지 않았으므로, 목록이 비어있습니다. 이걸 고쳐봅시다.

ghci> runLogger (record "hi mom!" >> return 3.1337)
(3.1337,["hi mom!"])

Logger 모나드 사용하기

아래는 우리가 Logger 모나드 안에서 glob 패턴을 정규식으로 바꾸는 걸 시작하는 방법을 보여줍니다.

-- file: ch14/Logger.hs
globToRegex cs =
    globToRegex' cs >>= \ds ->
    return ('^':ds)

언급할 가치가 있는 몇가지 코딩 스타일 특징이 있습니다. 함수 본체가 이름의 아랫줄에서 시작하는데 이러면 수평 여백을 확보할 수 있습니다. 또한 줄 끝에 익명 함수의 인자를 “매달아”놨습니다. 이건 모나딕 코드의 흔한 관습입니다.

(>>=)의 타입을 기억하세요. 바인드는 Logger 래퍼 안에서 값을 끄집어내, 이 꺼낸 값을 오른쪽 함수에 전달합니다. 오른쪽에 있는 함수는 반대로 자신의 결과를 Logger로 감쌉니다. 이것이 바로 return의 역할입니다. 순수한 값을 받아서 모나드 타입 생성자로 감쌉니다.

ghci> :type (>>=)
(>>=) :: (Monad m) => m a -> (a -> m b) -> m b
ghci> :type (globToRegex "" >>=)
(globToRegex "" >>=) :: (String -> Logger b) -> Logger b

심지어 거의 아무것도 하지 않는 함수를 작성했더라도, 반드시 return으로 결과를 맞는 타입이 되도록 감싸야합니다.

-- file: ch14/Logger.hs
globToRegex' :: String -> Logger String
globToRegex' "" = return "$"

record를 로그 항목을 저장하기 위해 호출하려면, 다음 액션을 연쇄하기 위해 (>>=) 대신 (>>)을 사용합니다.

-- file: ch14/Logger.hs
globToRegex' ('?':cs) =
    record "any" >>
    globToRegex' cs >>= \ds ->
    return ('.':ds)

이게 (>>=)에서 왼쪽의 결과만 무시하는 변종이라는 걸 떠올립시다. 우린 record의 결과가 항상 ()인 걸 알고있고, 그걸 입력받아도 별 의미가 없습니다.

우린 코드를 더 가지런히 하기 위해 “시퀀싱” 절에서 본 do 표기법을 쓸 수 있습니다.

-- file: ch14/Logger.hs
globToRegex' ('*':cs) = do
    record "kleene star"
    ds <- globToRegex' cs
    return (".*" ++ ds)

대부분의 경향은 두 줄이 넘어가면 do 표기법을 쓰는 거지만, do 표기법을 쓸지 (>>=)를 명시하고 익명 함수를 쓸지는 거의 취향 문제입니다. 두 스타일간에 한 가지 중요한 차이점이 있긴 하지만, “do 블록 해체하기” 절에서 다시 다룰 것입니다.

문자 클래스도 비슷한 맥락으로 파싱합니다.

-- file: ch14/Logger.hs
globToRegex' ('[':'!':c:cs) =
    record "character class, negative" >>
    charClass cs >>= \ds ->
    return ("[^" ++ c : ds)
globToRegex' ('[':c:cs) =
    record "character class" >>
    charClass cs >>= \ds ->
    return ("[" ++ c : ds)
globToRegex' ('[':_) =
    fail "unterminated character class"

순수 코드와 모나딕 코드 섞기

지금까지 본 카드로 볼 때, 모나드는 중대한 결정이 있는 듯 합니다. 모나딕 값을 감싸는 타입 생성자가 순수하고 평범한 일반 함수를 모나딕 래퍼 안에 갇힌 값에 적용하는 걸 방해합니다. 아래는 명백한 문제의 간단한 묘사입니다. Logger 모나드 안에서 돌아가는 문자열을 반환하는 사소한 코드 몇 줄이 있다고 생각해봅시다.

ghci> let m = return "foo" :: Logger String

문자열의 길이를 알고 싶다면 단순히 length를 호출하면 되지만, 문자열이 감싸여 있기 때문에 타입이 일치하지 않습니다.

ghci> length m
<interactive>:1:7:
    Couldn't match expected type `[a]'
           against inferred type `Logger String'
    In the first argument of `length', namely `m'
    In the expression: length m
    In the definition of `it': it = length m

우리가 지금까지 이 문제를 우회한 방법은 다음과 같습니다.

ghci> :type m >>= \s -> return (length s)
m >>= \s -> return (length s) :: Logger Int

문자열을 빼내기 위해 (>>=)을 쓰고, length를 호출하는 작은 익명 함수 하나를 만들어 다시 return으로 그 결과를 감쌉니다.

하스켈 코드에선 자주 이럴 필요가 생깁니다. 지름길이 이미 존재한다고 해도 별로 놀랍지 않을 겁니다. “펑터 소개” 절에서 펑터를 위해 도입한 리프팅 기법을 여기서 사용합니다. 순수 함수를 펑터로 리프팅하는 건 대개 펑터 안의 값을 꺼내고, 함수를 호출한 뒤, 그 결과를 다시 같은 생성자로 감싸는 작업을 포함합니다.

우리는 모나드에서 정확히 같은 일을 하고 있습니다. Monad 타입클래스가 이미 값을 감싸고 풀 줄 아는 (>>=)return 함수를 제공하고, liftM 함수는 모나드 구현의 세부 사항은 몰라도 되기 때문입니다.

-- file: ch14/Logger.hs
liftM :: (Monad m) => (a -> b) -> m a -> m b
liftM f m = m >>= \i ->
            return (f i)

우리가 어떤 타입이 Functor 타입클래스의 인스턴스라고 선언하려면, 그 타입에 맞는 fmap을 작성해야만 합니다. 대조적으로, liftM는 모나드 내부의 어떤 것도 알 필요가 없습니다. 그것들은 (>>=)return으로 추상화되었기 때문입니다. 우리는 적절한 타입 제한과 함께 딱 한번만 인스턴스를 작성하면 됩니다.

Control.Monad 모듈에서 liftM 함수를 이미 정의해놓았습니다.

liftM이 어떻게 가독성을 높이는지 보기 위해, 두 코드를 비교할 것입니다. 먼저, liftM을 안 쓰는 익숙한 종류의 코드입니다.

-- file: ch14/Logger.hs
charClass_wordy (']':cs) =
    globToRegex' cs >>= \ds ->
    return (']':ds)
charClass_wordy (c:cs) =
    charClass_wordy cs >>= \ds ->
    return (c:ds)

(>>=)와 익명 함수 쪼가리를 liftM으로 제거할 수 있습니다.

-- file: ch14/Logger.hs
charClass (']':cs) = (']':) `liftM` globToRegex' cs
charClass (c:cs) = (c:) `liftM` charClass cs

fmap이 그랬듯이, 대개 liftM은 연산자 형태로 사용합니다. 이런 표현식을 읽는 쉬운 방법은 “왼쪽의 순수 함수를 오른쪽의 모나딕 액션의 결과에 적용한다” 입니다.

liftM 함수는 Control.Monad가 더 긴 체인을 짤 수 있도록 제공하는 곁다리 함수만큼이나 유용합니다. 우리의 globToRegex' 함수 마지막 구문에서 하나를 볼 수 있습니다.

-- file: ch14/Logger.hs
globToRegex' (c:cs) = liftM2 (++) (escape c) (globToRegex' cs)

escape :: Char -> Logger String
escape c
    | c `elem` regexChars = record "escape" >> return ['\\',c]
    | otherwise           = return [c]
  where regexChars = "\\+()^$.{}]|"

위에서 사용한 liftM2 함수 정의는 다음과 같습니다.

-- file: ch14/Logger.hs
liftM2 :: (Monad m) => (a -> b -> c) -> m a -> m b -> m c
liftM2 f m1 m2 =
    m1 >>= \a ->
    m2 >>= \b ->
    return (f a b)

liftM2는 첫째 액션을 실행하고 둘째 액션을 실행한 다음 두 결과를 순수 함수 f로 조합해 그 결과를 감쌉니다. Control.Monad에는 liftM2의 변종이 liftM5까지 있습니다.

몇가지 오해 바로잡기

어떻게 돌아가는지 느끼기 위해 모나드 사용 예제를 충분히 보았습니다. 계속하기 전에, 곧 소개할 몇가지 반복되는 모나드에 관한 미신이 있습니다. 독자는 이런 주장들을 “고스란히” 듣게 될 테니, 잘 벼린 반박으로 준비할 필요가 있습니다.

  • 모나드는 이해하기 어려울 수 있다. 우린 이미 모나드가 몇가지 문제에서 “자연스럽게 떨어진” 것을 봤습니다. 우린 몇가지 구체적인 예시를 들고 거기서 어떤 공통점을 찾을 수 있는지 고찰하는 것이 모나드를 이해하는 데 최선의 핵심인 것을 알고 있습니다.

  • 모나드는 입출력과 절차 지향 코딩에만 유용하다. 우리가 하스켈에서 모나드를 입출력에 사용하는 것 외에도, 모나드는 수많은 용도로 애용됩니다. 우린 이미 모나드를 연쇄 계산의 단축 평가나, 복잡한 상태 숨기기, 로깅에 써봤습니다. 그럼에도, 아직 수박의 겉을 핥은 것에 불과합니다.

  • 모나드는 하스켈에서만 쓴다. 하스켈은 아마 모나드를 가장 드러내놓고 쓰는 언어입니다. 하지만 다른 언어로 모나드를 쓰는 사람들은 C++에서 Ocaml까지 걸쳐 있습니다. 모나드는 do 표기법과, 강력한 타입 시스템과 타입 추론, 언어 문법에 힘입어 하스켈에서 특히 다루기 쉬울 뿐입니다.

  • 모나드는 평가의 순서를 제어하기 위한 것이다.
    역자 주: 아래와 이어집니다.

Logger 모나드 작성하기

Logger 타입의 정의는 매우 간단합니다.

-- file: ch14/Logger.hs
newtype Logger a = Logger { execLogger :: (a, Log) }

이건 첫째 원소가 액션의 결과고 둘째 원소가 액션 실행 중 기록한 메시지 목록인 쌍입니다.

이 터플을 구별되는 타입으로 만들기 위해 newtype으로 감쌌습니다. runLogger 함수는 터플을 래퍼에서 꺼냅니다. 기록을 첨부한 액션을 실행하기 위해 드러낸 함수 runLogger는 단지 execLogger의 동의어입니다.

-- file: ch14/Logger.hs
runLogger = execLogger

record 도우미 함수는 우리가 넘긴 메시지 하나만 원소로 가진 리스트를 만듭니다.

-- file: ch14/Logger.hs
record s = Logger ((), [s])

우리가 결과가 들어가는 자리에 넣은 대로 이 액션의 결과는 ()입니다.

return으로 Monad 인스턴스 작성을 시작합시다. 매우 간단합니다. return은 아무것도 기록하지 않고, 인자를 튜플의 결과 자리에 넣어줍니다.

-- file: ch14/Logger.hs
instance Monad Logger where
    return a = Logger (a, [])

모나드의 심장인 (>>=)는 좀 더 흥미롭습니다. 바인드는 새 결과와 로그를 반환하기 위해 액션과 모나딕 함수를 결합합니다.

-- file: ch14/Logger.hs
    -- (>>=) :: Logger a -> (a -> Logger b) -> Logger b
    m >>= k = let (a, w) = execLogger m
                  n      = k a
                  (b, x) = execLogger n
              in Logger (b, w ++ x)

어떻게 되는 건지 하나씩 짚어봅시다. runLogger를 액션에서 m에서 결과 a를 얻는 데 사용하고, a를 모나딕 함수 k에 넘겨줍니다. 마찬가지로 결과 b를 추출하고, 마지막 액션의 결과 자리에 집어넣습니다. 로그 wx를 연결해 새 로그를 반환합니다.

순차적 로깅, 비순차적 평가

(>>=)의 정의는 왼쪽에서 기록한 메시지가 오른쪽에서 기록한 것보다 새 로그를 만들 때 더 앞에 나오는 걸 보장합니다. 하지만, 값 ab를 언제 평가하는진 알 수 없습니다. (>>=)는 게으릅니다.

모나드의 여러 다른 측면과 마찬가지로, 엄격함 또한 모나드의 구현자에게 달려있습니다. 이 엄격함은 모든 모나드가 공유하는 상수가 아닙니다. 실제로, 일부 모나드는 제각각의 엄격함 수준을 가진 다양한 특성을 제공하기도 합니다.

Writer 모나드

Logger 모나드는 mtl패키지의 Control.Monad.Writer 모듈에서 찾을 수 있는 표준 Writer 모나드의 특수화된 버전입니다. 우린 Writer를 사용한 예제를 “타입 클래스 사용하기” 절에서 보여줄 것입니다.

Maybe 모나드

Maybe 타입은 거의 가장 단순한 Monad 인스턴스입니다. 이 모나드는 결과가 없을 수도 있는 계산을 나타냅니다.

-- file: ch14/Maybe.hs
instance Monad Maybe where
    Just x >>= k  =  k x
    Nothing >>= _ =  Nothing

    Just _ >> k   =  k
    Nothing >> _  =  Nothing

    return x      =  Just x

    fail _        =  Nothing

많은 수의 계산식을 Maybe(>>=)(>>)으로 체인을 만들면, 그 중 하나만 Nothing을 반환해도, 나머지 계산식은 일절 평가하지 않습니다.

하지만 그 체인이 완전히 단축 평가되진 않는다는 점에 주의하세요. 체인 안의 (>>=)(>>)은 일단 왼쪽의 Nothing을 비교하고, Nothing을 오른쪽에 넘기고, 이걸 끝까지 반복합니다. 이 점을 잊어버리기 쉽습니다. 체인 안의 계산식이 실패할 때, 이어지는 Nothing의 생성, 연쇄, 소모는 비용이 싸긴 하지만, 공짜는 아닙니다.
역자 주: 원문 댓글에 따르면 2012년부터 체인에서도 단축 평가가 지원된다고 한다.

Maybe 모나드 실행하기

Maybe 모나드를 실행하기 적절한 함수는 maybe입니다. (모나드를 “실행하는 것”은 모나드를 평가하고 모나드 래퍼가 제거된 결과를 반환하는 걸 말한다는 걸 기억하세요.)

-- file: ch14/Maybe.hs
maybe :: b -> (a -> b) -> Maybe a -> b
maybe n _ Nothing  = n
maybe _ f (Just x) = f x

첫째 인자는 결과가 Nothing일 때 반환할 값입니다. 둘째 인자는 결과가 Just로 감싸여있을 때 그 안의 값을 꺼내 적용할 함수입니다. 함수를 적용한 결과를 돌려줍니다.

Maybe 타입이 너무 단순하기 때문에, 단순히 Maybe 값에 패턴 매칭을 시도하는 방법도 maybe를 호출하는 것 만큼이나 일반적입니다. 상황에 따라 어느 한 쪽이 더 가독성이 높습니다.

Maybe 적용과 좋은 API 디자인

아래는 Maybe를 모나드로 사용하는 예제입니다. 고객의 이름이 주어졌을 때, 그들의 통신사의 청구서 수령지를 찾고 싶습니다.

-- file: ch14/Carrier.hs
import qualified Data.Map as M

type PersonName = String
type PhoneNumber = String
type BillingAddress = String
data MobileCarrier = Honest_Bobs_Phone_Network
                   | Morrisas_Marvelous_Mobiles
                   | Petes_Plutocratic_Phones
                     deriving (Eq, Ord)

findCarrierBillingAddress :: PersonName
                          -> M.Map PersonName PhoneNumber
                          -> M.Map PhoneNumber MobileCarrier
                          -> M.Map MobileCarrier BillingAddress
                          -> Maybe BillingAddress

첫번째 버전은 군더더기 case 식을 가지고 오른쪽으로 진행하는 끔찍한 사다리입니다.

-- file: ch14/Carrier.hs
variation1 person phoneMap carrierMap addressMap =
    case M.lookup person phoneMap of
      Nothing -> Nothing
      Just number ->
          case M.lookup number carrierMap of
            Nothing -> Nothing
            Just carrier -> M.lookup carrier addressMap

Data.Map 모듈의 lookup 함수는 모나딕 리턴 타입을 가지고 있습니다.

ghci> :module +Data.Map
ghci> :type Data.Map.lookup
Data.Map.lookup :: (Ord k, Monad m) => k -> Map k a -> m a

다르게 보면, 받은 키가 맵에 있으면, lookup은 찾은 값을 return으로 모나드 안에 넣습니다. 아니면 fail을 호출합니다. 우리 생각에 형편없지만, 그럼에도 API 디자인에서 흥미로운 부분입니다.

  • 장점으론, lookup을 호출한 모나드에 따라 성공했을 때와 실패했을 때의 동작을 재정의할 수 있습니다. 더욱이, lookup 그 자체는 그 동작이 무엇인지 모르고 신경쓰지도 않습니다.

    위쪽의 case 식은 lookup의 결과를 Maybe 타입과 비교하므로 타입 검사에 문제가 없습니다.

  • 문제는, 물론 fail을 부적절한 모나드에서 사용하면 귀찮은 예외를 던진다는 겁니다. 우린 이미 fail 사용에 대해 경고했으니, 여기서 반복하지 않을 겁니다.

실제론, 모두들 Maybelookup의 결과 타입으로 사용합니다. 저렇게 개념적으로 간단한 함수의 결과 타입에 필요없는 일반화를 제공하고 있습니다. lookupMaybe을 반환하도록 짰어야 했습니다.

API에 대한 의심은 제껴두고, 못생긴 우리 코드를 생각합시다. Maybe가 모나드임을 활용해 더 감각적으로 사용할 수 있습니다.

-- file: ch14/Carrier.hs
variation2 person phoneMap carrierMap addressMap = do
  number <- M.lookup person phoneMap
  carrier <- M.lookup number carrierMap
  address <- M.lookup carrier addressMap
  return address

찾기가 하나라도 실패하면 처음의 case식처럼 (>>=)(>>)의 정의로 함수 전체의 결과가 Nothing이 된다고 유도됩니다.

이 버전은 훨씬 깔끔하지만, return은 필요하지 않습니다. 미적으론 코드를 더 가지런히 만들어주고, 아마 절차 지향 프로그래머의 눈엔 더 친숙하겠지만, 의미만 따지면 이건 필요없습니다. 아래는 동등한 코드입니다.

-- file: ch14/Carrier.hs
variation2a person phoneMap carrierMap addressMap = do
  number <- M.lookup person phoneMap
  carrier <- M.lookup number carrierMap
  M.lookup carrier addressMap

“부분 적용의 어색함” 절에서 Data.Map 모듈의 타입 시그니처는 부분 적용하기가 좀 성가셨습니다. lookup 함수가 좋은 예입니다. 인자를 flip 해주기만 해도, 함수를 한 줄로 짤 수 있게 됩니다.

-- file: ch14/Carrier.hs
variation3 person phoneMap carrierMap addressMap =
    lookup phoneMap person >>= lookup carrierMap >>= lookup addressMap
  where lookup = flip M.lookup

리스트 모나드

Maybe 타입이 값이 없거나 하나인 상황을 표현할 수 있는 반면에, 몇 개가 될 지 모르는 결과를 넘겨주고 싶은 상황이 많이 있습니다. 분명 리스트가 이 목적에 딱 알맞습니다. 리스트의 타입은 우리가 리스트를 모나드로 쓸 수 있을 거라고 암시합니다. 리스트의 타입 생성자가 자유 변수 하나를 가졌기 때문입니다. 그리고 아니나다를까, 우린 리스트를 모나드로 쓸 수 있습니다.

단순히 프렐류드의 리스트 타입의 Monad 인스턴스를 보여주기보다, 인스턴스가 어떤 모양을 가져야만 할지 생각해봅시다. 이건 하기 쉽습니다. (>>=)return의 타입을 찾고, 조금 치환한 다음에 익숙한 리스트 함수를 사용할 수 있는지 확인할 겁니다.

둘 중에 좀 더 명백한 건 return입니다. 우리는 return이 타입 a를 받고, 그걸 타입 생성자 m으로 감싸 m a 타입을 주는 걸 압니다. 또한 우리는 여기서 타입 생성자가 []인 걸 압니다. 타입 변수 m을 이 타입 생성자로 치환하면 타입 [] a를 얻습니다. (물론, 실제 적법한 표기입니다!), 그리고 더 친숙한 형태인 [a]로 다시 쓸 수 있습니다.

우린 이제 리스트를 위한 returna -> [a] 타입을 가져야만 하는 것을 압니다. return을 구현하는 데 몇가지 가능성이 있습니다. return은 빈 리스트나 원소 1개짜리singleton, 싱글턴 리스트, 혹은 무한 리스트를 반환할 수 있습니다. 지금까지의 모나드 지식에 의하면 가장 그럴 듯한 동작은 싱글턴 리스트입니다. 이건 정보를 버리지 않고, 무한히 반복하지도 않습니다.

-- file: ch14/ListMonad.hs
returnSingleton :: a -> [a]
returnSingleton x = [x]

만약 (>>=) 타입에 return에서 했던 것과 같은 치환을 하면, 바인드가 [a] -> (a -> [b]) -> [b] 타입을 가져야만 하는 걸 알 수 있습니다. 이건 map의 타입과 가까워 보입니다.

ghci> :type (>>=)
(>>=) :: (Monad m) => m a -> (a -> m b) -> m b
ghci> :type map
map :: (a -> b) -> [a] -> [b]

map의 인자 타입 순서가 일치하지 않지만, 고치기 쉽습니다.

ghci> :type (>>=)
(>>=) :: (Monad m) => m a -> (a -> m b) -> m b
ghci> :type flip map
flip map :: [a] -> (a -> b) -> [b]

아직 문제가 남아있습니다. flip map의 두번째 인자는 a -> b지만, 리스트 (>>=)의 두번째 인자는 a -> [b]입니다. 여기선 무엇을 해야할까요?

조금 더 치환을 해보고 타입에 무슨 일이 일어나는지 살펴봅시다. flip map 함수는 어떤 타입 b를 결과로 반환할 수 있습니다. 만약 flip map의 타입 시그니처에 나오는 두 b[b]로 바꾸면 타입 시그니처는 a -> (a -> [b]) -> [[b]]가 될 것입니다. 즉, 리스트에 리스트를 반환하는 함수를 매핑하면, 우리는 리스트의 리스트를 받게 됩니다.

ghci> flip map [1,2,3] (\a -> [a,a+100])
[[1,101],[2,102],[3,103]]

흥미롭게도, 우린 아직 얼마나 타입 시그니처가 밀접한지 확인하지 못했습니다. (>>=)의 타입은 [a] -> (a -> [b]) -> [b]이지만, flip map에 리스트를 반환하는 함수를 짝지은 타입은 [a] -> (a -> [b]) -> [[b]]입니다. 아직 타입 하나가 불일치합니다. 우린 단지 불일치하는 타입을 타입 시그니처 맨 끝으로 옮겼을 뿐입니다. 하지만, 우리의 저글링은 헛된 게 아니었습니다. 이제 [[b]]를 받아 [b]를 반환하는 함수가 필요하고, concat이 써달라고 우릴 부르고 있습니다.

ghci> :type concat
concat :: [[a]] -> [a]

타입은 map의 인자를 뒤집고 그 결과를 concat해 1중 리스트로 만들어야 한다고 암시합니다.

ghci> :type \xs f -> concat (map f xs)
\xs f -> concat (map f xs) :: [a] -> (a -> [a1]) -> [a1]

이게 바로 리스트의 (>>=)입니다.

-- file: ch14/ListMonad.hs
instance Monad [] where
    return x = [x]
    xs >>= f = concat (map f xs)

바인드는 리스트 xs의 모든 원소에 f를 적용하고, 그 결과를 1중리스트로 만들기 위해 접합합니다.

두 핵심 Monad 정의로, 남은 비핵심 함수인 (>>)fail의 구현은 명백해 보입니다.

-- file: ch14/ListMonad.hs
    m1 >> m2 = concat (map (\_ -> m2) m1)
    fail _ = []

리스트 모나드 이해하기

리스트 모나드는 익숙한 하스켈 도구인 리스트 조건제시식List comprehension과 유사합니다. 이 유사성을 두 리스트의 데카르트 곱을 계산해서 보겠습니다. 먼저, 리스트 조건제시식입니다.

-- file: ch14/CartesianProduct.hs
comprehensive xs ys = [(x,y) | x <- xs, y <- ys]

이번만, 모나딕 코드에 레이아웃 표기법 대신 괄호 표기법을 쓰겠습니다. 이러면 모나딕 코드가 리스트 조건제시식과 얼마나 비슷한지 확 보일 겁니다.

-- file: ch14/CartesianProduct.hs
monadic xs ys = do { x <- xs; y <- ys; return (x,y) }

유일한 차이점을 조립하는 값이 리스트 조건제시식처럼 앞에 온 게 아니라 연속되는 식의 맨 끝에 온다는 점입니다. 두 함수의 결과조차 같습니다.

ghci> comprehensive [1,2] "bar"
[(1,'b'),(1,'a'),(1,'r'),(2,'b'),(2,'a'),(2,'r')]
ghci> comprehensive [1,2] "bar" == monadic [1,2] "bar"
True

초기의 리스트 모나드 때문에 납득하기 힘들 수 있으니, 데카르트 곱 예제로 돌아가 자세히 살펴봅시다. 이번엔, 괄호대신 들여쓰기를 쓰겠습니다.

-- file: ch14/CartesianProduct.hs
blockyDo xs ys = do
    x <- xs
    y <- ys
    return (x, y)

xs의 각각의 원소로, x를 바꿔가면서 한번씩 함수의 나머지 부분을 평가합니다. ys도 마찬가지로 y에 각각의 원소를 바인딩하고 나머지 부분을 한번씩 평가합니다.

실제 여기 있는 건 2중 루프입니다! 이건 모나드의 중요한 사실을 조명합니다. 어떤 모나드를 사용할 지 알기 전까지 모나드 코드 블록이 어떤 동작을 할 지 예측할 수 없습니다.

이제 코드를 더 명시적으로 살필 텐데, 일단 밑바탕 구조를 명확히 하기 위해 먼저 do 표기법을 없애봅시다. 중첩 루프를 명백히 하기 위해 일부러 독특하게 들여쓰기를 했습니다.

-- file: ch14/CartesianProduct.hs
blockyPlain xs ys =
    xs >>=
    \x -> ys >>=
    \y -> return (x, y)

blockyPlain_reloaded xs ys =
    concat (map (\x ->
                 concat (map (\y ->
                              return (x, y))
                         ys))
            xs)

만약 xs[1,2,3]이라면, 그 다음 두 줄은 x1인 채로 평가되고, 그 다음 2, 마지막으로 3인 채 평가합니다. 만약 ys[True, False]면, 마지막 줄은 여섯 번 평가됩니다. x1이고 yTrue인 한 번, 다시 x1이고 yFalse인 한 번... 으로 쭉. return 식은 각각의 터플을 원소 1개짜리 리스트로 감쌉니다.

리스트 모나드 동작시키기

이건 간단한 무차별 대입 제약조건 검사기입니다. 정수 하나를 주면, 곱해서 그 정수가 나오는(이게 제약조건입니다) 모든 쌍을 찾아줍니다.

-- file: ch14/MultiplyTo.hs
guarded :: Bool -> [a] -> [a]
guarded True  xs = xs
guarded False _  = []

multiplyTo :: Int -> [(Int, Int)]
multiplyTo n = do
  x <- [1..n]
  y <- [x..n]
  guarded (x * y == n) $
    return (x, y)

ghci에서 돌려봅시다.

ghci> multiplyTo 8
[(1,8),(2,4)]
ghci> multiplyTo 100
[(1,100),(2,50),(4,25),(5,20),(10,10)]
ghci> multiplyTo 891
[(1,891),(3,297),(9,99),(11,81),(27,33)]

do 블록 해체하기

하스켈의 do 구문은 설탕 구문syntactic sugar의 예입니다. do 구문은 (>>=)과 익명 함수를 쓰지 않고 모나딕 코드를 짜는 대안을 제공합니다. 설탕 털기Desugaring는 설탕 구문에서 핵심 언어로의 변환입니다.

do 블록의 설탕을 터는 규칙은 이해하기 쉽습니다. 우린 컴파일러가 do 키워드가 없을 때까지 기계적으로 이 규칙을 do 블록에 적용한다고 생각해도 됩니다.

do 키워드 뒤에 액션 하나만 오면 액션 그 자체로 바뀝니다.

-- file: ch14/Do.hs
doNotation1 =
    do act
-- file: ch14/Do.hs
translated1 =
    act

액션이 하나 이상 이어진 do 키워드는 첫번째 액션으로 바꾸고, (>>)do를 나머지 액션 앞에 넣습니다. 이 규칙을 계속 적용하면 do 전체가 (>>)으로 이어집니다.

-- file: ch14/Do.hs
doNotation2 =
    do act1
       act2
       {- ... etc. -}
       actN
-- file: ch14/Do.hs
translated2 =
    act1 >>
    do act2
       {- ... etc. -}
       actN

finalTranslation2 =
    act1 >>
    act2 >>
    {- ... etc. -}
    actN

<- 표기법 변환은 눈여겨 볼 가치가 있습니다. <-의 왼쪽은 일반적인 하스켈 패턴입니다. 변수 하나일 수도 있고, 더 복잡한 무언가일 수도 있습니다. 가드 표현식은 쓸 수 없습니다.

-- file: ch14/Do.hs
doNotation3 =
    do pattern <- act1
       act2
       {- ... etc. -}
       actN
-- file: ch14/Do.hs
translated3 =
    let f pattern = do act2
                       {- ... etc. -}
                       actN
        f _     = fail "..."
    in act1 >>= f

이 왼쪽 패턴을 let 바인딩에서 (위의 예에선 f인) 고유한 이름을 가진 지역 함수로 바꿉니다. <-의 오른쪽 액션은 (>>=)로 아까의 지역함수와 연결합니다.

여기서 놀라운 부분은, 만약 패턴 매칭이 실패하면 지역 함수는 모나드의 fail 구현을 호출한다는 겁니다. 아래는 Maybe 모나드를 사용한 예입니다.

-- file: ch14/Do.hs
robust :: [a] -> Maybe a
robust xs = do (_:x:_) <- Just xs
               return x

Maybe 모나드의 fail 구현은 단순히 Nothing을 반환합니다. 위의 패턴 매칭이 실패하면 우리는 Nothing을 결과로 얻습니다.

ghci> robust [1,2,3]
Just 2
ghci> robust [1]
Nothing

마지막으로, 우리가 let 표현식을 do 블록 안에서 사용하면, 대개 in 키워드는 생략할 수 있습니다. 블록 안의 이어지는 액션은 반드시 let 키워드에 맞춰 정렬해야 합니다.

-- file: ch14/Do.hs
doNotation4 =
    do let val1 = expr1
           val2 = expr2
           {- ... etc. -}
           valN = exprN
       act1
       act2
       {- ... etc. -}
       actN
-- file: ch14/Do.hs
translated4 =
    let val1 = expr1
        val2 = expr2
        valN = exprN
    in do act1
          act2
          {- ... etc. -}
          actN

프로그래밍 가능한 세미콜론으로서의 모나드

“오프사이드 규칙은 필수가 아닙니다” 절에서, 들여쓰기를 사용하는 게 하스켈에서의 관례라고 했지만, 필수는 아닙니다. do 블록을 들여쓰기 대신 명시적 구조를 사용해 작성할 수 있습니다.

-- file: ch14/Do.hs
semicolon = do
  {
    act1;
    val1 <- act2;
    let { val2 = expr1 };
    actN;
  }
-- file: ch14/Do.hs
semicolonTranslated =
    act1 >>
    let f val1 = let val2 = expr1
                 in actN
        f _ = fail "..."
    in act2 >>= f

이런 식으로 명시적 구조를 쓰는 경우가 드물긴 하지만, 식을 구분하기 위해 세미콜론을 사용한다는 사실을 적절한 표어를 제시합니다. 모나드는 일종의 “프로그래밍 가능한 세미콜론”입니다. (>>)(>>=)의 동작이 모나드마다 다르기 때문입니다.

왜 설탕을 털어내나요?

(>>=)를 명시적으로 코드에 적음으로써, 단순히 액션을 연속하는 게 아니라, 컴비네이터로 함수를 엮어내고 있다는 사실을 상기할 수 있습니다.

독자가 모나드에 대해 초보자라고 느낀다면, 설탕 구문인 do 표기법보다 (>>=)를 명시적으로 적는 게 바람직할 겁니다. 대부분의 프로그래머에게, 실제 무엇이 일어나는지 반복적으로 강화를 하는 건 상황을 명료하게 유지하도록 도와줍니다. (절차 지향 프로그래머에겐 IO 모나드에서 좀 떨어져서, do 블록이 액션의 연속에 불과하다고 가정하는 게 쉬울 수 있습니다.)

모나드에 좀 더 익숙해지면, 함수를 짤 때 더 적절한 스타일을 고를 수 있습니다. 실제로, 독자가 다른 사람의 모나딕 코드를 읽게 되면, 일반적이진 않지만 드물지도 않게 do 표기법과 (>>=) 둘 다 함수 하나에서 혼용한 것을 보게 될 겁니다.

do 표기법을 쓰든 안 쓰든 간에 (=<<) 함수를 자주 볼 수 있습니다. 이건 (>>=)를 뒤집은 버전입니다.

ghci> :type (>>=)
(>>=) :: (Monad m) => m a -> (a -> m b) -> m b
ghci> :type (=<<)
(=<<) :: (Monad m) => (a -> m b) -> m a -> m b

(=<<)로 오른쪽에서 왼쪽으로 진행하는 일반적인 하스켈 스타일에서 모나딕 함수를 편하게 합성할 수 있습니다.

-- file: ch14/CartesianProduct.hs
wordCount = print . length . words =<< getContents

상태 모나드

이 장의 앞 부분에서 10장. 코드 사례 분석: 바이너리 데이터 포맷 파싱에 나온 Parse가 모나드라는 걸 알아냈습니다. Parse는 두 가지 논리적으로 구별되는 면모가 있습니다. 하나는 파싱 실패와 자세한 메시지를 제공하는 것이고, 이건 Either 타입으로 나타냈습니다. 다른 하나는 암시적 상태를 전달하는 것이고, 우리 경우엔 파싱하고 남은 ByteString이었습니다.

하스켈 프로그램에선 표준 라이브러리가 State라는 이름의 딱 이 목적을 위한 모나드를 제공할 정도로 상태를 읽고 쓸 필요가 있었습니다. 이 모나드는 Control.Monad.State 모듈에 있습니다.

우리의 Parse 타입이 ByteString을 상태로 전달한 데 비해, State 모나드는 아무 타입이나 전달할 수 있습니다. 이 미정 타입을 s라고 부르겠습니다.

우리가 상태로 하려는 명백하고 일반적인 작업이 뭘까요? 상태를 건네고, 그걸 조사하고, 결과와 새 상태값을 생성합니다. 결과가 어떤 타입 a가 될 수 있다고 합시다. 아까의 생각을 타입 시그니처로 표현하면 s -> (a, s)입니다. 상태 s를 받고, 그걸로 무언가 하고, 결과 a와 아마 새 상태일 s를 반환합니다.

유사 상태 모나드

유사 State 모나드를 짜보고, 진짜를 확인해봅시다. 위에서 설명한 대로 명백한 타입을 가진 타입 정의부터 시작합시다.

-- file: ch14/SimpleState.hs
type SimpleState s a = s -> (a, s)

우리 모나드는 결과를 산출하기도 하며 한 상태를 다른 상태로 바꾸는 함수입니다. 이 때문에, 상태 모나드는 때때로 상태 변환기 모나드라고도 부릅니다.

예. 이건 타입 동의어이지, 새 타입이 아닙니다. 고로 살짝 꼼수를 쓰겠습니다. 지금은 참고 기다려주세요. 이건 이어지는 설명을 간단하게 만듭니다.

이 장의 앞에서 모나드는 타입 변수 하나를 받는 타입 생성자 하나를 가진다고 했습니다. 그리고 아직 우리는 인자를 2개 받는 타입을 가졌습니다. 여기에서 핵심은 우리가 함수를 부분 적용 할 수 있듯이 타입도 부분 적용이 가능하다는 걸 이해하는 겁니다. 예제를 따라가면 이해하기 쉽습니다.

-- file: ch14/SimpleState.hs
type StringState a = SimpleState String a

여기서 타입 변수 sString을 묶었습니다bind. 그래도 StringState 타입은 아직 타입 인자 a를 가지고 있습니다. 이제 우리가 모나드에 알맞은 타입 생성자를 가진 게 드러났습니다. 즉, 우리의 모나드 타입 생성자는 SimpleState 혼자가 아니라 SimpleState s입니다.

모나드를 만드는 데 필요한 다음 재료는 return 함수입니다.

-- file: ch14/SimpleState.hs
returnSt :: a -> SimpleState s a
returnSt a = \s -> (a, s)

이 함수는 현재 결과와 상태를 받아 “터플로 만들” 뿐입니다. 독자는 이제 여러 인자를 받는 하스켈 함수는 단일 인자 함수가 엮인 거라는 개념에 할 익숙할 텐데, 안 그런 경우를 대비해 returnSt가 얼마나 간단한지 친숙한 방법으로 보겠습니다.

-- file: ch14/SimpleState.hs
returnAlt :: a -> SimpleState s a
returnAlt a s = (a, s)

우리 모나딕 퍼즐의 마지막 조각은 (>>=)의 정의입니다. 여기는, 표준 라이브러리 State(>>=)에서 실제 변수 이름을 따왔습니다.

-- file: ch14/SimpleState.hs
bindSt :: (SimpleState s a) -> (a -> SimpleState s b) -> SimpleState s b
bindSt m k = \s -> let (a, s') = m s
                   in (k a) s'

저 한 글자짜리 변수 이름들은 딱히 가독성에 도움이 안 되니, 더 의미 있는 이름으로 바꿀 수 있는지 봅시다.

-- file: ch14/SimpleState.hs
-- m == step
-- k == makeStep
-- s == oldState

bindAlt step makeStep oldState =
    let (result, newState) = step oldState
    in  (makeStep result) newState

이 정의를 이해하기 위해, steps -> (a, s) 타입의 함수라는 걸 기억하세요. 우리가 이걸 평가하면 터플을 받고, 이 터플을 s -> (a, s) 타입의 새 함수를 반환하는 데 사용해야 합니다. 아마도 bindAlt의 타입 시그니처에서 SimpleState 타입 동의어를 지우고 인자와 결과의 타입을 확인하는 게 더 파악하기 쉬울 겁니다.

-- file: ch14/SimpleState.hs
bindAlt :: (s -> (a, s))        -- step
        -> (a -> s -> (b, s))   -- makeStep
        -> (s -> (b, s))        -- (makeStep result) newState

상태 읽고 수정하기

상태 모나드의 (>>=)return 정의는 배관처럼 동작합니다. 이 함수들은 상태 조각을 전달하지만, 어떤 식으로든 건드리진 않습니다. 상태로 유용한 작업을 하기 위해 몇가지 또다른 간단한 함수가 필요합니다.

-- file: ch14/SimpleState.hs
getSt :: SimpleState s s
getSt = \s -> (s, s)

putSt :: s -> SimpleState s ()
putSt s = \_ -> ((), s)

getSt 함수는 단순히 현재 상태를 얻어 결과로 반환합니다. 반면 putSt는 현재 상태를 무시하고 상태를 입력으로 갈아치웁니다.

진짜 상태 모나드를 만들 수 있을까?

이전 절에서 썼던 유일한 단순화 요령은 SimpleState 타입 정의 대신 타입 동의어를 사용한 것입니다. 만약 우리가 동시에 newtype 래퍼를 사용했다면, 추가적인 감싸기와 풀기가 우리 코드를 파악하기 힘들게 했을 겁니다.

Monad 인스턴스를 정의하기 위해, (>>=)return 말고도 타입 생성자도 제공해야 합니다. 이 사실은 우리를 State진짜 정의로 이끕니다.

-- file: ch14/State.hs
newtype State s a = State {
      runState :: s -> (a, s)
    }

우리의 s -> (a, s) 타입을 State 생성자로 감쌌을 뿐입니다. 하스켈의 레코드 구문으로 타입을 정의해서, State 값을 생성자에서 꺼내는 runState 함수를 자동으로 만들었습니다. runState의 타입은 State s a -> s -> (a, s)입니다.

return의 정의는 함수를 State 생성자로 감쌌다는 것만 빼면 SimpleState와 거의 동일합니다.

-- file: ch14/State.hs
returnState :: a -> State s a
returnState a = State $ \s -> (a, s)

(>>=)의 정의는 약간 더 복잡해졌습니다. State 래퍼를 없애기 위해 runState를 써야 하기 때문입니다.

-- file: ch14/State.hs
bindState :: State s a -> (a -> State s b) -> State s b
bindState m k = State $ \s -> let (a, s') = runState m s
                              in runState (k a) s'

이 함수는 이전 버전 bindSt와 몇몇 값을 감싸고 푸는 점에서만 다릅니다. 장부 업무에서 “실제 업무”를 분리함으로써, 정말 무엇이 일어나는지 더 명백하게 만들었습니다.

마찬가지로 약간의 감싸기를 추가해서 상태를 읽고 수정하는 함수도 수정했습니다.

-- file: ch14/State.hs
get :: State s s
get = State $ \s -> (s, s)

put :: s -> State s ()
put s = State $ \_ -> ((), s)

상태 모나드 사용하기: 랜덤 값 생성

우린 이미 이진 데이터를 파싱하기 위한 상태 모나드의 선배 격인 Parse를 사용해 봤습니다. 그 땐 우리가 조작하는 상태의 타입을 Parse 타입에 직접 연결했습니다.

대조적으로 State 모나드는 임의 타입의 상태를 인자로 받습니다. State ByteString같이 상태의 타입을 제공합니다.

상태 모나드는 절차 지향 언어를 배운 경험이 있다면 다른 모나드보다 더 친숙할 겁니다. 무엇보다도, 절차 지향 언어는 암시적인 상태를 전달하고, 일부분을 읽고, 할당으로 다른 것을 변경하는 것이 주이고, 이건 또한 상태 모나드의 용도이기도 합니다.

그러므로 쓸데없이 상태 모나드를 사용하라고 응원하는 것보다, 유사 난수 생성을 예로 들어 상태 모나드를 어떻게 간단히 적용할 수 있는지 보여줄 겁니다. 절차 지향 언어에선, 대개 고르게 분포한 유사 난수를 얻기 쉽습니다. 예를 들어 C에선, 스스로 갱신하는 전역 상태를 이용해 난수를 생성하는 rand 함수가 있습니다.

하스켈의 표준 난수 생성 모듈은 System.Random입니다. 이 모듈은 숫자 뿐만 아니라 임의 타입의 난수를 만들 수 있습니다. 이 모듈은 IO 모나드 안에 있는 몇가지 간편한 함수를 담고 있습니다. 예를 들어, C의 rand 함수를 허술하게 따라하면 다음이 됩니다:

-- file: ch14/Random.hs
import System.Random

rand :: IO Int
rand = getStdRandom (randomR (0, maxBound))

(randomR 함수는 생성할 난수 값의 폐구간(양 끝을 포함하는 범위)을 인자로 받습니다.)

System.Random 모듈은 Int 값 난수의 근원을 정의하도록 돕는 RandomGen 타입클래스를 제공합니다. StdGen 타입은 RandomGen의 인스턴스입니다. StdGen은 유사 난수를 생성합니다. 만약 우리가 진짜 난수인 외부 원천을 가졌다면 그걸 RandomGen의 인스턴스로 만들어 유사 난수 대신 진짜 난수 값을 얻을 수 있을 겁니다.

또다른 타입클래스 Random은 어떻게 특정 타입의 랜덤 값을 앋는지 지정합니다. 랜덤 모듈은 모든 일반적인 단순한 타입에 Random 인스턴스를 정의했습니다.

덧붙이자면, 위의 rand 정의는 IO 모나드 안에 있는 내장 전역 난수 생성기를 읽고 수정합니다.

순수를 위한 첫번째 시도

IO 모나드를 가능한 한 피하라고 누누히 강조했는데, 단지 난수 값을 얻기 위해 IO 모나드로 끝어들인다면 부끄러울 겁니다. 실제로, System.Random은 순수한 난수 생성 함수를 가지고 있습니다.

순수함의 전통적인 단점은, 우리가 난수 생성기를 얻거나 만든 후, 우리가 만든 곳에서 필요한 것까지 운반해야 한다는 것입니다. 마침내 호출하면, 그건 난수 생성기를 반환합니다. 기억하세요. 우린 순수한 코드를 짜기 때문에 이미 존재하는 생성기의 상태를 바꿀 수 없습니다.

만약 불변성을 잊어버리고 함수 안에서 같은 생성기를 계속 쓴다면, 우린 정확히 같은 “난수”를 매번 받게 됩니다.

-- file: ch14/Random.hs
twoBadRandoms :: RandomGen g => g -> (Int, Int)
twoBadRandoms gen = (fst $ random gen, fst $ random gen)

말할 것도 없이, 행복할 수 없는 결과를 초래합니다.

ghci> twoBadRandoms `fmap` getStdGen
Loading package old-locale-1.0.0.0 ... linking ... done.
Loading package old-time-1.0.0.0 ... linking ... done.
Loading package random-1.0.0.0 ... linking ... done.
Loading package mtl-1.1.0.0 ... linking ... done.
(945769311181683171,945769311181683171)

randomrandomR에 넘겨주는 범위로 사용자가 제공한 범위대신 미리 정해진 범위를 사용합니다. getStdGen 함수는 IO 모나드에서 전역 표준 난수 생성기의 현재 값을 받아옵니다.

유감스럽게도, 정확히 전달하고 생성기를 연속적으로 사용하는 것은 별로 보기 좋지 않습니다. 여기 간단한 예입니다.

-- file: ch14/Random.hs
twoGoodRandoms :: RandomGen g => g -> ((Int, Int), g)
twoGoodRandoms gen = let (a, gen') = random gen
                         (b, gen'') = random gen'
                     in ((a, b), gen'')

하지만 이제 우린 생성기를 숨기는 데 좋은 후보로 보이는 상태 모나드를 알고 있습니다. 상태 모나드는 우리가 변할 수 있는 상태를 깔끔히 관리하게 하는 동시에 파일 수정이나 네트워크 연결 같은 예측 불가능한 부수 효과로부터 우리 코드가 안전함을 보장합니다. 상태 모나드의 순수함은 우리 코드의 동작을 추론하기 쉽게 만듭니다.

상태 모나드 안의 난수 값

이건 StdGen을 상태로 삼고 전달하는 상태 모나드입니다.

-- file: ch14/Random.hs
type RandomState a = State StdGen a

타입 동의어는 물론 필수는 아니지만, 편리합니다. 이건 타이핑을 다소 줄여주고, 우리가 StdGen을 다른 생성기로 바꾸고 싶을 때, 바꿔야 할 타입 시그니처 수를 줄여줍니다.

난수를 생성하는 건 이제 현재 생성기를 가져오고, 사용하고, 상태를 새 생성기로 교체하는 문제입니다.

-- file: ch14/Random.hs
getRandom :: Random a => RandomState a
getRandom =
  get >>= \gen ->
  let (val, gen') = random gen in
  put gen' >>
  return val

우린 이제 한 쌍의 난수를 얻는 함수를 이전에 봤던 모나딕 도구를 사용해 훨씬 간결하게 작성할 수 있습니다.

-- file: ch14/Random.hs
getTwoRandoms :: Random a => RandomState (a, a)
getTwoRandoms = liftM2 (,) getRandom getRandom

연습문제

  1. getRandomdo 표기법으로 다시 짜 보세요.

상태 모나드 실행하기

이미 말했듯이, 각각의 모나드는 자기만의 특별한 평가 함수를 가지고 있습니다. 상태 모나드에선 아래 몇가지에서 선택할 수 있습니다.

  • runState는 결과와 마지막 상태를 반환합니다.

  • evalState는 결과만 반환하고, 마지막 상태는 버립니다.

  • execState는 결과는 버리고, 마지막 상태만 반환합니다.

evalStateexecState함수는 단순히 fstsnd를 각각 runState에 합성한 겁니다. 때문에 셋 중에서 runState가 가장 중요합니다.

이건 getTwoRandoms 함수를 어떻게 구현하는지에 관한 완전한 예제입니다.

-- file: ch14/Random.hs
runTwoRandoms :: IO (Int, Int)
runTwoRandoms = do
  oldState <- getStdGen
  let (result, newState) = runState getTwoRandoms oldState
  setStdGen newState
  return result

runState 호출은 표준 패턴을 따르고 있습니다. runState에 상태 모나드 안에 있는 함수와 처음 상태를 넘겨줍니다. 그러면 함수의 결과와 최종 상태를 반환합니다.

runState 호출을 감싸는 코드는 단지 전역 StdGen 값을 구하고, runTwoRandoms나 다른 난수 발생 함수가 바뀐 상태를 쓸 수 있도록 전역 StdGen을 바꿉니다.

조금 더 많은 상태는 어떤가요?

상태 하나만 전달하면서 그럴 듯한 코드를 짜는 건 좀 상상하기 힘듭니다. 한 번에 상태 여러 개를 끌어오고 싶다면, 일반적인 요령은 데이터 타입으로 관리하는 것입니다. 아래는 우리가 구한 난수의 개수를 기억하는 예제입니다.

-- file: ch14/Random.hs
data CountedRandom = CountedRandom {
      crGen :: StdGen
    , crCount :: Int
    }

type CRState = State CountedRandom

getCountedRandom :: Random a => CRState a
getCountedRandom = do
  st <- get
  let (val, gen') = random (crGen st)
  put CountedRandom { crGen = gen', crCount = crCount st + 1 }
  return val

이 예제는 호출할 때마다 원래 상태의 두 원소를 소모하고, 완전히 새로운 상태를 조립합니다. 우리는 일부분만 더 자주 읽거나 변경하고 싶을 듯 합니다. 이 함수는 우리가 지금까지 생성한 난수의 개수를 가져옵니다.

-- file: ch14/Random.hs
getCount :: CRState Int
getCount = crCount `liftM` get

이 예제는 왜 우리가 CountedRandom 상태를 레코드 구문으로 정의했는지 보여줍니다. 레코드 구문이 제공하는 접근자를 get과 연결해 상태의 일부분만 읽을 수 있습니다.

상태를 부분적으로 갱신하고 싶을 때, 코드가 매력적으로 보이진 않습니다.

-- file: ch14/Random.hs
putCount :: Int -> CRState ()
putCount a = do
  st <- get
  put st { crCount = a }

여기서 함수 대신에 레코드 갱신 구문을 사용했습니다. 식 st { crCount = a }crCount 필드만 a로 다른 st의 복제를 생성합니다. 이건 구문을 사용한 꼼수기 때문에, 함수를 사용할 때의 유연성은 얻을 수 없습니다. 레코드 구문은 하스켈의 일반적인 우아함을 드러내진 않지만, 최소한 돌아가게 만들긴 합니다.

getput을 합친 modify라는 이름의 함수가 있습니다. 이 함수는 인자로 상태 변환 함수를 받지만, 만족스럽지 못합니다. 우린 아직 레코드 갱신 구문의 어색함에서 빠져나오지 못 했습니다.

-- file: ch14/Random.hs
putCountModify :: Int -> CRState ()
putCountModify a = modify $ \st -> st { crCount = a }

모나드와 펑터

펑터와 모나드는 밀접한 연관이 있습니다. 이 용어들은 수학의 한 갈래인 범주론에서 빌려왔지만, 손상 없이 고스란히 의미를 가지고 있습니다.

범주론에서 모나드는 펑터로 만듭니다. 독자는 그러면 하스켈에서 Monad 타입클래스가 Functor 타입클래스의 서브클래스가 되어야 하지 않을까하고 기대할 수 있지만, 표준 프렐류드에는 그런 정의가 없습니다. 불행한 실수입니다.
역자 주: 고쳐질 것 같다고 함.

하지만 하스켈 라이브러리 제작자들은 우회법을 쓰고 있습니다. 누군가 Monad 인스턴스를 정의해 놓았다면, 그들은 거의 반드시 Functor 인스턴스도 정의해 놓습니다. Functor 타입클래스의 fmap 함수를 모나드에 사용할 수 있다고 기대해도 좋습니다.

fmap의 타입 시그니처를 일부 우리가 이미 본 표준 모나드 함수의 시그니처와 비교해보면, fmap이 모나드에서 뭘 하는지 힌트를 얻을 수 있습니다.

ghci> :type fmap
fmap :: (Functor f) => (a -> b) -> f a -> f b
ghci> :module +Control.Monad
ghci> :type liftM
liftM :: (Monad m) => (a1 -> r) -> m a1 -> m r

짐작했겠지만, fmap은 순수 함수를 liftM처럼 모나드 안으로 리프팅합니다.

모나드를 보는 다른 방법

이제 모나드와 펑터의 관계를 알았으니, 리스트 모나드를 다시 보면 흥미로운 걸 찾을 수 있을 겁니다. 특히 리스트의 (>>=)정의를 보세요.

-- file: ch14/ListMonad.hs
instance Monad [] where
    return x = [x]
    xs >>= f = concat (map f xs)

f의 타입이 a -> [a]라는 걸 떠올리세요. map f xs를 호출하면, concat으로 “평탄화” 해야 하는 [[a]] 타입의 값을 얻게 됩니다.

MonadFunctor의 서브클래스였다면 뭘 할 수 있었을지 상상해보세요. 리스트의 fmapmap으로 정의했기에, (>>=)의 정의에서 mapfmap으로 바꿀 수 있습니다. 이 자체로는 신기하지 않지만, 더 진행할 수 있다고 가정해봅시다.

[[a]] -> [a] 타입의 concat 함수는 알다시피 리스트의 중첩을 평탄화합니다. 우리는 이 타입 시그니처를 리스트에서 모나드로 일반화해서 “한 단계 중첩을 줄여주는m (m a) -> m a 타입 시그니처를 얻을 수 있습니다. 이 타입을 가진 함수는 관습적으로 join이라고 이름이 붙었습니다.

joinfmap의 정의를 가지고 있다면, 모든 모나드에서 (>>=)가 매우 일반적이 돼 정의할 필요가 없어집니다. 이건 (>>=)의 일반적 정의를 가진 Monad 타입클래스의 다른 정의로 볼 만한 겁니다.

-- file: ch14/AltMonad.hs
import Prelude hiding ((>>=), return)

class Functor m => AltMonad m where
    join :: m (m a) -> m a
    return :: a -> m a

(>>=) :: AltMonad m => m a -> (a -> m b) -> m b
xs >>= f = join (fmap f xs)

두 모나드 정의 중 어느 것도 “더 낫지”는 않습니다. join이 있으면 (>>=)를 작성할 수 있고, 반대도 가능하기 때문입니다. 하지만 다른 관점은 참신할 수 있습니다.

모나딕 래핑을 한 단계 벗기는 건 사실 실제 상황에서 유용할 수 있습니다. Control.Monad 모듈에서 join의 일반적 정의를 찾을 수 있습니다.

-- file: ch14/MonadJoin.hs
join :: Monad m => m (m a) -> m a
join x = x >>= id

이건 join이 뭘 하는지에 관한 예입니다.

ghci> join (Just (Just 1))
Just 1
ghci> join Nothing
Nothing
ghci> join [[1],[2,3]]
[1,2,3]

모나드 법칙과 좋은 코딩 스타일

“펑터에 대해 더 생각해보기” 절에서, 펑터가 항상 어떻게 동작해야만 하는지 두 가지 규칙을 제시했습니다.

-- file: ch14/MonadLaws.hs
fmap id        ==   id 
fmap (f . g)   ==   fmap f . fmap g

모나드가 지켜야 하는 규칙도 있습니다. 아래에 있는 세가지 규칙을 모나드 법칙이라고 부릅니다. 하스켈 구현은 이 규칙을 강제하지 않습니다. 이 규칙을 지키는 건 Monad 인스턴스를 작성자에게 달렸습니다.

모나드 규칙은 단순히 “모나드는 날 놀래키지 말아야 한다”를 딱딱하게 말하는 방법입니다. 이론적으론 우린 이 절 전체를 넘어갈 수도 있을 겁니다. 하지만 그러면 부끄러울 겁니다. 법칙이 자칫 간과할 수도 있는 지혜를 담고 있기 때문입니다.

법칙 읽기

아래에 있는 각각의 법칙을 “=== 왼쪽의 식과 오른쪽의 식이 같다”고 읽을 수 있습니다.

첫째 모나드 법칙은 return(>>=)좌항등원이라는 것입니다.

-- file: ch14/MonadLaws.hs
return x >>= f            ===   f x

이걸 표현하는 다른 방법은 (>>=)로 곧바로 꺼낼 거라면 굳이 return으로 순수한 값을 싸맬 필요가 없다는 겁니다. return으로 감싸고 같은 함수에서 몇 줄 후에 (>>=)로 꺼내는 건 사실 모나드에 처음인 하스켈 프로그래머가 자주 저지르는 스타일 에러입니다. 아래는 do 표기법으로 작성한 같은 법칙입니다.

-- file: ch14/MonadLaws.hs
do y <- return x
   f y                    ===   f x

이 규칙은 우리 코딩 스타일에 실용적인 영향을 줍니다. 우리는 필요없는 코드를 짜고 싶지 않고, 이 법칙은 우리가 간결한 코드가 더 장황한 버전하고 동작이 동일할 거라고 짐작해도 좋도록 합니다.

둘째 모나드 법칙은 return(>>=)우항등원 이라는 것입니다.

-- file: ch14/MonadLaws.hs
m >>= return              ===   m

이 법칙도 (특히 절차 지향 언어에서 온 경우) 실제 프로그램에서 스타일에 영향을 줍니다. 블록의 마지막 액션이 제대로 된 결과를 반환했다면, return을 사용할 필요가 없습니다. 이 법칙을 do 표기법으로 봅시다.

-- file: ch14/MonadLaws.hs
do y <- m
   return y               ===   m

다시 한번, 모나드가 이 법칙을 지킨다고 가정하면, 더 긴 코드와 같은 효과를 가진다는 사실을 알고 더 짧은 코드로 짤 수 있습니다.

마지막 법칙은 결합 법칙과 관계가 있습니다.

-- file: ch14/MonadLaws.hs
m >>= (\x -> f x >>= g)   ===   (m >>= f) >>= g

이 법칙은 약간 이해하기 어려울 수 있기 때문에, 양쪽 식의 괄호 안을 봅시다. 우리는 왼쪽 식을 다음과 같이 재작성할 수 있습니다.

-- file: ch14/MonadLaws.hs
m >>= s
  where s x = f x >>= g

오른쪽 식도, 마찬가지로 재배열 할 수 있습니다.

-- file: ch14/MonadLaws.hs
t >>= g
  where t = m >>= f

이제 우리는 다음 두 식이 같다고 말할 수 있습니다.

-- file: ch14/MonadLaws.hs
m >>= s                   ===   t >>= g

이게 의미하는 것은 액션을 더 작은 부분으로 나눴을 때, 우리가 순서만 액션 순서만 지킨다면 어느 부분 액션을 새 액션으로 만들지는 중요하지 않다는 겁니다. 우리가 액션 세 개를 연쇄하고 있다면, 앞 두 개 액션이나, 뒤 두 개 액션 중 한 쪽을 먼저 치환하는 것 어느 것이든 가능합니다.

이 더 복잡한 법칙은 실용성이 있습니다. “메서드 추출”은 코드 뭉치를 잘라내 함수로 바꾼 다음, 잘라낸 곳에서 그 함수를 호출하는 기법을 칭하는 깜찍한 소프트웨어 리팩토링 용어입니다. 셋째 법칙으로 모나딕 하스켈 코드에 메서드 추출을 쓸 수 있단 걸 알 수 있습니다.

우린 각각의 모나드 법칙이 어떻게 더 나은 모나딕 코드를 짜는 데 필요한 통찰을 주는지 보았습니다. 처음 두 법칙은 return의 남용을 어떻게 피하는지 보여줬습니다. 셋째 규칙은 복잡한 액션을 여러 단순한 액션으로 안전하게 리팩토링 할 수 있다는 걸 알려줬습니다. 이제 제대로 된 모나드를 사용할 때 “내가 의도한 동작”을 할 거라는 직관이 깨지지 않을 거라는 지식을 가지고 자세한 내용은 신경 끌 수 있습니다.

그런데, 하스켈 컴파일러는 모나드가 실제 모나드 법칙을 따르는지는 보장하지 않습니다. 작성한 코드가 모나드 법칙을 만족하도록—혹은 가능하면 만족하는 걸 증명하도록— 하는 건 모나드 제작자의 책임입니다.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함