Real World Haskell 14장 번역: 모나드Real World Haskell 14장 번역: 모나드

Posted at 2015.01.25 02:31 | Posted in 지식저장소/읽은 책 요약
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의 남용을 어떻게 피하는지 보여줬습니다. 셋째 규칙은 복잡한 액션을 여러 단순한 액션으로 안전하게 리팩토링 할 수 있다는 걸 알려줬습니다. 이제 제대로 된 모나드를 사용할 때 “내가 의도한 동작”을 할 거라는 직관이 깨지지 않을 거라는 지식을 가지고 자세한 내용은 신경 끌 수 있습니다.

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

저작자 표시 비영리 동일 조건 변경 허락
신고

Real World Haskell 9장 번역Real World Haskell 9장 번역

Posted at 2015.01.16 15:10 | Posted in 지식저장소/읽은 책 요약

어쩌다 번역해 봤는데, 진 빠진다. 더 안할 듯. 원본 링크

9장. 입출력 사례 예제: 파일 시스템 검색 라이브러리

"내가 파일을 가지고 있지만, 어디에 있는 진 모르겠다"는 문제는 컴퓨터가 계층 파일 구조를 도입한 때 만큼이나 오랫동안 널리 있었습니다. 1974년 유닉스 5번째 판부터 find라는 명령어를 도입했고, find는 필수적인 명령어로 자리잡았습니다. 오랜 시간 다듬은 현재의 최신 기술로 현대 운영 체제는 발전한 문서 색인과 검색 능력을 가지게 됩니다.

프로그래머의 도구상자엔 아직 find같은 기능을 위한 중요한 장소가 있습니다. 이번 장에서, 우리는 하스켈 만으로 find의 여러 기능을 제공하는 라이브러리를 만들 것입니다. 이 라이브러리를 다양한 방법으로 만들어 보면서 제각기 어느 정도의 강력함을 가지는 지 알아볼 것입니다.

find 명령어

만약 유닉스 계열 운영 체제를 써본 적이 없거나, 열렬한 쉘 사용자가 아니라면, find를 들어본 적조차 없을 수도 있습니다. find는 디렉토리 목록을 주면 각각을 재귀적으로 검색해 표현식에 맞는 모든 항목을 출력해 주는 명령어입니다.

각각의 표현식은 "이 glob 패턴에 맞는 이름", "보통 파일", "이 날짜 이후에 수정한 파일" 등의 형식을 가지고 있습니다. 이 표현식들을 "and"나 "or" 연산자로 조합해 더 복잡한 표현식으로 조합할 수도 있습니다.

가벼운 시작: 디렉토리 재귀적으로 열거하기

라이브러리를 설계해보기 전에, 작은 문제들부터 풀어봅시다. 우리의 첫번째 문제는 디렉토리와 하위 디렉토리의 내용물을 재귀적으로 나열하는 것입니다.

-- file: ch09/RecursiveContents.hs
module RecursiveContents (getRecursiveContents) where

import Control.Monad (forM)
import System.Directory (doesDirectoryExist, getDirectoryContents)
import System.FilePath ((</>))

getRecursiveContents :: FilePath -> IO [FilePath]

getRecursiveContents topdir = do
  names <- getDirectoryContents topdir
  let properNames = filter (`notElem` [".", ".."]) names
  paths <- forM properNames $ \name -> do
    let path = topdir </> name
    isDirectory <- doesDirectoryExist path
    if isDirectory
      then getRecursiveContents path
      else return [path]
  return (concat paths)

filter 식은 디렉토리 내용에서 각각 현재 디렉토리와 부모 디렉토리를 의미하는 특별한 이름인 .과 ..을 제외해줍니다. 우리가 이걸 빼 놓는 걸 깜박한다면, 영원히 재귀하게 될 겁니다.

우리는 mapM의 인자가 뒤바뀐 것과 같은 forM을 이전 장에서 본 적이 있습니다.

ghci> :m +Control.Monad
ghci> :type mapM
mapM :: (Monad m) => (a -> m b) -> [a] -> m [b]
ghci> :type forM
forM :: (Monad m) => [a] -> (a -> m b) -> m [b]

반복문(이하 루프) 본체는 현재 디렉토리 항목이 디렉토리인지 아닌지 확인합니다. 만약 디렉토리라면, 그 디렉토리도 열거하기 위해 getRecursiveContents를 재귀적으로 호출합니다. 아니라면, 현재 항목의 이름을 담은 원소 하나짜리 리스트를 반환합니다. (return이 하스켈에서는 다른 의미를 가지는 걸 잊지 맙시다: return은 값을 모나드 타입 생성자로 감싸는 역할을 수행합니다.)

isDirectory변수를 사용했다는 점도 중요합니다. 파이썬같은 절차 지향 언어에선 일반적으로 if os.path.isdir(path)와 같이 작성합니다. 하지만 doesDirectoryExist 함수는 액션입니다; 이 함수의 리턴 타입은 IO Bool이지, Bool이 아닙니다. if 표현식이 Bool 타입 표현식을 필요로 하기 때문에 우리는 액션의 결과를 IO 타입 안에서 꺼내 Bool을 얻기 위해 <-를 써야 합니다. 그러면 이제 꺼낸 Boolif 표현식 안에 쓸 수 있게 됩니다.

루프 본체가 이름 리스트를 반환하므로 forM의 결과는 IO [[FilePath]]가 됩니다. 이걸 단일 리스트로 만들기 위해 concat을 쓸 수 있습니다.

익명 함수와 이름 있는 함수 복습

“익명 함수 (람다)”장에서, 익명 함수를 쓰면 안 되는 이유를 몇 가지 살펴봤습니다. 또한 익명 함수를 루프 본체로 써 봤습니다. 이건 하스켈에서 익명 함수의 가장 흔한 사용 예 중 하나입니다.

우리는 이미 forMmapM이 함수를 인자로 받는 것을 타입을 통해 살펴봤습니다. 대부분의 루프 본체는 프로그램에서 한 번만 나타나는 코드 블록입니다. 루프 본체를 딱 한 군데에서만 쓸 것 같다면, 그것에 이름을 줄 필요가 있을까요?

물론 때때로 정확히 같은 코드를 여러 다른 루프에 써야하는 일도 생깁니다. 이 경우에는 같은 익명 함수를 잘라내 붙여넣는 것보다, 이미 있는 익명 함수에 이름을 붙이는 것이 더 현실적입니다.

왜 mapM과 forM을 둘 다 제공하나요?

완전히 같지만 인자의 순서만 다른 두 함수가 있는 것이 좀 이상해 보일 수 있습니다. 하지만 mapM과 forM은 각각 다른 상황에서 편리합니다.

익명 함수를 루프 본체로 사용하는 위의 예를 생각해봅시다. 만약 우리가 forM대신 mapM을 사용한다면, properNames 변수를 루프 본체 뒤에 붙여야 할 것입니다. 또한 파싱이 제대로 되도록 익명 함수를 괄호로 감싸거나 한번만 쓸 이름을 붙여야 할 것입니다. 한 번 시도해보세요: 위의 코드를 복사하고, forMmapM으로 바꾸고, 코드 가독성에 어떤 영향을 주는지 확인해보세오.

반대로, 만약 루프 본체가 이미 이름을 가진 함수라면, 그리고 루프할 리스트가 복잡한 계산으로 표현된다면, 이건 mapM을 사용하면 되는 대표적인 경우가 될 것입니다.

여기서 mapM을 쓸지 forM을 쓸지 결정하는 스타일 규칙은 가장 간결한 코드가 되는 것을 고르는 것입니다. 루프 본체와 루프 데이터 표현식 계산이 둘 다 짧다면 둘 중 무엇을 써도 좋습니다. 만약 루프가 짧고, 데이터가 길다면 mapM을 쓰세요. 루프가 길고, 데이터가 짧다면, forM을 쓰세요. 둘 다 길다면, let이나 where로 하나를 짧게 만들면 됩니다. 약간의 연습으로 각각의 상황에서 어떤 접근 방법이 최선인지 쉽게 알 수 있을 것입니다.

순진한 탐색 함수

우리가 만든 getRecursiveContents 함수는 고민 없이 파일 검색기를 만드는 데 쓸 수 있습니다.

-- file: ch09/SimpleFinder.hs
import RecursiveContents (getRecursiveContents)

simpleFind :: (FilePath -> Bool) -> FilePath -> IO [FilePath]

simpleFind p path = do
  names <- getRecursiveContents path
  return (filter p names)

이 함수는 getRecursiveContents의 결과를 걸러낼 때 쓸 조건자predicate를 받습니다. 조건자로 완전한 경로가 전달되는데, 그래서 "확장자가 .c인 모든 파일을 찾아라"같은 일반적인 작업을 어떻게 시킬 수 있을까요?

System.FilePath 모듈은 파일 이름을 다루는 유용한 함수들을 많이 가지고 있습니다. 이번엔 우리는 takeExtension을 사용할 겁니다.

ghci> :m +System.FilePath
ghci> :type takeExtension
takeExtension :: FilePath -> String
ghci> takeExtension "foo/bar.c"
Loading package filepath-1.1.0.0 ... linking ... done.
".c"
ghci> takeExtension "quux"
""

이것으로 문제는 경로를 받고, 확장자를 추출해, ".c"하고 비교하는 함수를 만드는 것으로 간단해졌습니다.

ghci> :load SimpleFinder
[1 of 2] Compiling RecursiveContents ( RecursiveContents.hs, interpreted )
[2 of 2] Compiling Main             ( SimpleFinder.hs, interpreted )
Ok, modules loaded: RecursiveContents, Main.
ghci> :type simpleFind (\p -> takeExtension p == ".c")
simpleFind (\p -> takeExtension p == ".c") :: FilePath -> IO [FilePath]

simpleFind가 동작하긴 하지만, 몇가지 명백한 문제를 가지고 있습니다. 첫째로 조건자가 그다지 명확하지 못합니다. 조건자는 디렉토리 항목을 살펴볼 뿐, 그 항목이 파일인지 디렉토리인지는 구분하지 못 합니다. 즉 지금의 simpleFind.c로 끝나는 디렉토리도 결과에 포함한다는 얘깁니다.

둘째로 simpleFind는 우리에게 파일 시스템을 어떻게 순회할 지 전혀 제어권을 주지 않습니다. 이게 왜 중요한지 알기 위해, SVN으로 관리 중인 소스 파일들을 찾는 문제를 생각해봅시다. SVN은 .svn 디렉토리를 SVN이 관리하는 디렉토리마다 만들고 관리합니다. 각각의 .svn 디렉토리는 많은 파일과 하위 디렉토리를 가지고 있지만 우리의 관심사가 아닙니다. .svn을 포함한 경로를 제외하는 것으로 이 디렉토리 순회를 회피해 훨씬 효율을 올릴 수 있습니다. 예를 들어 우리가 45,000개의 파일 중 30,000개의 파일을 1,200개의 여러 .svn디렉토리에 저장한 SVN 폴더를 가지고 있다면 1,200개의 디렉토리 순회를 피하는 것이 30,000개의 파일을 제외하는 것보다 훨씬 저렴할 것입니다.

마지막으로, simpleFind는 엄격합니다. 왜냐하면 IO 모나드 안에서 실행하는 액션의 연쇄로 이루어졌기 때문입니다. 만약 우리가 백만 개의 파일을 순회한다면 꽤 긴 지연을 겪은 뒤 한꺼번에 백만 개의 이름을 받을 것입니다. 이것은 리소스 사용과 반응성 면에서 둘 다 안 좋습니다. 우리는 결과가 나오는 대로 처리하는 게으른 스트림을 더 선호할 겁니다.

이어지는 단락에서, 우리는 각각의 문제를 해결할 것입니다.

조건자: 순수함을 유지하면서, 약했지만 강력하게

우리 조건자는 파일 이름만 볼 수 있었습니다. 이건 우리가 원하는 다양한 행동을 제한합니다: 예를 들어, 주어진 크기보다 더 큰 파일만 열거하고 싶다면 어떨까요?

IO를 사용하자고 쉽게 반응할 수도 있습니다: 조건자의 타입을 FilePath -> Bool대신, FilePath -> IO Bool로 바꾸면 안 될까요? 이건 임의의 I/O작업을 조건자에서 할 수 있게 만들 겁니다. 매력적으로 보이지만, 이건 잠재적으로 문제를 가지고 있습니다: 조건자가 부수 효과를 가질 수 있다는 문제. IO 리턴 타입을 가진 함수는 어떤 부수 효과라도 일으킬 수 있습니다.

타입 시스템을 더 예측 가능하고 덜 장황한 코드를 짜는 데 활용하기 위해: "IO"를 사용하지 않는 것으로 조건자를 순수하게 유지할 것입니다. 이것은 조건자가 어떤 보기 싫은 부수효과도 가지지 않도록 합니다. 또한 위험해지지 않으면서 우리가 원하는 표현력을 얻기 위해 조건자에 좀 더 많은 정보를 제공할 것입니다.

하스켈의 이식성있는 System.Directory 모듈은 제한적이긴 하지만 유용한 메타데이터들을 제공해줍니다.

ghci> :m +System.Directory
  • doesFileExist와 doesDirectoryExist를 디렉토리 안 항목이 파일인지 디렉토리인지 확인하는 데 쓸 수 있습니다. 최근에 널리 쓰이게 된 지명 파이프나 하드 링크, 심볼릭 링크같은 다른 파일 타입들은 아직 이식성을 가지고 조사할 수 있는 방법이 없습니다.

    ghci> :type doesFileExist
    doesFileExist :: FilePath -> IO Bool
    ghci> doesFileExist "."
    Loading package old-locale-1.0.0.0 ... linking ... done.
    Loading package old-time-1.0.0.0 ... linking ... done.
    Loading package directory-1.0.0.0 ... linking ... done.
    False
    ghci> :type doesDirectoryExist
    doesDirectoryExist :: FilePath -> IO Bool
    ghci> doesDirectoryExist "."
    True
  • The getPermissions 함수로 어떤 작업이 해당 파일이나 디렉토리에 가능한지 확인할 수 있습니다.

    ghci> :type getPermissions
    getPermissions :: FilePath -> IO Permissions
    ghci> :info Permissions
    data Permissions
      = Permissions {readable :: Bool,
                     writable :: Bool,
                     executable :: Bool,
                     searchable :: Bool}
            -- Defined in System.Directory
    instance Eq Permissions -- Defined in System.Directory
    instance Ord Permissions -- Defined in System.Directory
    instance Read Permissions -- Defined in System.Directory
    instance Show Permissions -- Defined in System.Directory
    ghci> getPermissions "."
    Permissions {readable = True, writable = True, executable = False, searchable = True}
    ghci> :type searchable
    searchable :: Permissions -> Bool
    ghci> searchable it
    True

    (만약 ghci의 특별한 변수인 it이 떠오르지 않는다면 “타입으로의 첫걸음”을 돌아보세요.) 우리가 내용물을 열거할 권한이 있다면 그 디렉토리는 searchable한 것입니다. 파일은 절대 searchable할 수 없습니다.

  • 마지막으로, getModificationTime은 항목이 마지막으로 수정된 때를 알려줍니다.

    ghci> :type getModificationTime
    getModificationTime :: FilePath -> IO System.Time.ClockTime
    ghci> getModificationTime "."
    Mon Aug 18 12:08:24 CDT 2008

만약 우리가 이식성 있고 표준을 지키는 하스켈 코드를 고집한다면 이 함수들까지만 우리가 맘대로 쓸 수 있습니다. (파일 크기 또한 약간의 꼼수로 구할 수 있습니다. 아래를 보세요) 또한 이 함수들은 우리가 너무 방대한 예를 들 필요가 없이 배우고자 하는 원리를 설명하기에 충분합니다. 만약 더 요구조건이 많은 코드를 짜야 한다면, System.PosixSystem.Win32모듈이 두 주류 플랫폼에 대한 더 자세한 메타데이터를 제공해 줄 것입니다. 또한 Hackage에는 윈도에서 유닉스 계열의 API를 제공하는 unix-compat 패키지도 있습니다.

새롭고 더 강력한 조건자에는 얼마나 많은 데이터 조각이 필요할까요? 해당 항목의 Permissions를 보는 것으로 그것이 파일인지 디렉토리인지 알 수 있기 때문에, 우리는 doesFileExistdoesDirectoryExist의 결과를 참고할 필요가 없습니다. 그 결과 더 강력한 조건자가 필요로 하는 네 종류의 데이터를 알게 됩니다.

-- file: ch09/BetterPredicate.hs
import Control.Monad (filterM)
import System.Directory (Permissions(..), getModificationTime, getPermissions)
import System.Time (ClockTime(..))
import System.FilePath (takeExtension)
import Control.Exception (bracket, handle)
import System.IO (IOMode(..), hClose, hFileSize, openFile)

-- the function we wrote earlier
import RecursiveContents (getRecursiveContents)

type Predicate =  FilePath      -- path to directory entry
               -> Permissions   -- permissions
               -> Maybe Integer -- file size (Nothing if not file)
               -> ClockTime     -- last modified
               -> Bool

Predicate는 단지 4개의 인자를 가진 함수의 타입 동의어 입니다. 이것은 타이핑 작업을 줄이고 화면 공간을 절약해 우리를 도와줄 것입니다.

조건자의 리턴 타입이 IO Bool이 아니라 Bool이라는 데 주목해주세요: 조건자는 순수하고, 어떤 입출력 작업도 하지 않습니다. 이 타입으로 더 표현력 있는 조건자 함수를 잘 다듬습니다.

-- file: ch09/BetterPredicate.hs
-- soon to be defined
getFileSize :: FilePath -> IO (Maybe Integer)

betterFind :: Predicate -> FilePath -> IO [FilePath]

betterFind p path = getRecursiveContents path >>= filterM check
    where check name = do
            perms <- getPermissions name
            size <- getFileSize name
            modified <- getModificationTime name
            return (p name perms size modified)

코드를 따라가 봅시다. getFileSize는 곧 자세히 설명하므로, 지금은 넘어갑니다.

우리는 조건자 p를 호출하기 위해 filter를 사용할 수 없습니다. 왜냐하면 p의 순수성이 우리가 필요로하는 메타데이터를 얻기 위한 입출력 작업을 할 수 없게 만들기 때문입니다.

때문에 낯선 함수인 filterM을 도입합니다. 이건 기존의 filter 함수와 비슷하지만, 이 경우 filterM은 조건자가 입출력 작업을 할 수 있도록 조건자를 IO 모나드 안에서 평가합니다.

ghci> :m +Control.Monad
ghci> :type filterM
filterM :: (Monad m) => (a -> m Bool) -> [a] -> m [a]

check조건자는 순수한 조건자 p를 감싸는 입출력 가능한 래퍼wrapper입니다.check는 모든 "더러운" 입출력 작업을 p를 위해서 해주고, 그 결과 우리는 p가 원치 않는 부수효과를 낼 수 없도록 합니다. 메타데이터를 모든 다음, checkp를 호출해 return으로 p의 결과를 IO로 감쌉니다.

안전하게 파일 크기 조절하기

비록 System.Directory로 파일이 얼마나 큰지 알 수 없다지만, 유사하게 이식성 있는 System.IO 모듈을 사용해 구할 수 있습니다. 이 모듈은 hFileSize라는 연 파일의 크기를 바이트로 알려주는 함수를 가지고 있습니다. 아래는 그것을 감싸는 간단한 함수입니다.

-- file: ch09/BetterPredicate.hs
simpleFileSize :: FilePath -> IO Integer

simpleFileSize path = do
  h <- openFile path ReadMode
  size <- hFileSize h
  hClose h
  return size

이 함수는 동작하긴 하지만, 우리가 사용하기엔 아직 적절하지 않습니다. betterFind에서 우리는 getFileSize를 무차별적으로 모든 디렉토리 항목에 사용하고 있습니다; 만약 항목이 일반 파일이 아니면 이 함수는 Nothing을 반환하고, 크기는 Just로 감싸야 합니다. 그러지 않으면 함수는 일반 파일이 아니거나 (아마도 권한이 부족한 상황에서) 열 수 없을 때 예외를 던지고 감싸지 않은 크기를 반환할 겁니다.

아래는 더 안전한 버전의 함수입니다.

-- file: ch09/BetterPredicate.hs
saferFileSize :: FilePath -> IO (Maybe Integer)

saferFileSize path = handle (\_ -> return Nothing) $ do
  h <- openFile path ReadMode
  size <- hFileSize h
  hClose h
  return (Just size)

handle구문을 제외한 함수 본체는 거의 동일합니다.

위에 나온 예외 핸들러는 받은 예외를 무시하고 Nothing을 반환합니다. 뒤따라오는 함수 본체에 생긴 유일한 변화는 파일 크기를 Just로 감싼 것입니다.

saferFileSize 함수는 이제 제대로 된 타입 시그니처를 가지고, 더 이상 예외를 던지지 않을 것입니다. 하지만 아직 완전히 잘 동작하지는 않습니다. openFile은 성공하지만 hFileSize에서 예외가 생기는 디렉토리 항목이 있습니다. 이건 지명 파이프 같은 항목에서 발생할 수 있습니다. 이러한 예외는 handle에 의해 잡히지만, hClose는 일어나지 않을 것입니다.

하스켈 구현은 핸들을 더 이상 사용하지 않는 상황을 감지하면 자동으로 핸들을 닫습니다. 이것은 가비지 컬렉터가 실행되기 전까진 일어나지 않을 것이고, 다음 가비지 컬렉션이 실행될 때까지의 지연은 예측할 수 없습니다.

파일 핸들은 부족할 수 있는 리소스입니다. 밑단인 운영 체제에서 이 제한을 강제합니다. 예를 들어 리눅스에선, 한 프로세스는 기본으로 1024개 까지만 파일 핸들을 동시에 열 수 있습니다.

saferFileSize를 호출하는 betterFind를 사용하는 프로그램이 betterFind가 쓰레기 파일 핸들이 닫히기 전에 파일 핸들을 고갈내 프로그램을 크래시 낸다는 상상은 어렵지 않게 할 수 있습니다.

이건 특히 치명적이고 알아차리기 힘든 종류의 버그입니다: 이것은 합쳐지면 상당히 추적을 어렵게 하는 여러 면을 가지고 있습니다. 이 버그는 betterFind가 프로세스가 열 수 있는 파일 핸들 제한을 넘는 갯수의 비일반 파일을 맞닥뜨려야 하고 쌓인 쓰레기 파일 핸들이 닫히기 전에 다른 파일을 열려고 하는 호출자에게 반환되어야만 발생할 것입니다.

설상가상으로, 프로그램에서 더 이상 접근할 수 없는 데이터와 가비지 컬렉션되지 않은 데이터 때문에 발생하는 뒤따르는 에러가 있습니다. 이런 버그는 프로그램 구조와 파일시스템 내용, 프로그램 흐름이 가비지 컬렉터 실행과 얼마나 가까운지에 따라 갈립니다.

이런 종류의 문제는 개발 도중엔 간과하기 쉽지만, 나중에 현장에서 일어날 땐 (이런 이상한 문제가 항상 일어나듯이), 진단하기는 매우 힘들 것입니다.

다행히도, 우리는 함수를 더 짧게 만들면서도 이런 종류의 에러를 매우 쉽게 피할 수 있습니다.

획득-사용-해제 사이클

우리는 openFile이 성공하면 반드시 hClose를 호출해야 합니다. Control.Exception 모듈은 정확히 이 용도를 위한 bracket 함수를 제공합니다.

ghci> :type bracket
bracket :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c

bracket 함수는 세 액션을 인자로 받습니다. 첫째 액션은 자원을 획득합니다. 둘째 액션은 자원을 해제합니다. 셋째 액션은 자원을 획득한 사이에 실행되는 액션입니다; 이걸 "사용" 액션이라고 부릅시다. 만약 "획득" 액션이 성공하면 "해제" 액션은 항상 호출될 것입니다. 이건 자원이 항상 해제될 것을 보장합니다. "사용"과 "해제" 액션에 전달되는 자원은 "획득" 액션에서 얻은 자원입니다.

만약 예외가 "사용" 액션 실행 중에 일어났다면, bracket은 “해제” 액션을 호출하고 예외를 다시 던집니다. 만약 “사용” 액션이 성공하면, bracket은 “해제” 액션을 호출하고, “사용” 액션에서 반환한 값을 반환합니다.

우리는 이제 완전히 안전한 함수를 작성할 수 있습니다: 이 함수는 예외를 던지지 않고, 프로그램 어딘가에서 이상한 실패를 일으킬 수 있는 쓰레기 파일 핸들을 축적하지도 않을 것입니다.

-- file: ch09/BetterPredicate.hs
getFileSize path = handle (\_ -> return Nothing) $
  bracket (openFile path ReadMode) hClose $ \h -> do
    size <- hFileSize h
    return (Just size)

bracket의 인자를 자세히 보세요. 첫째 인자는 파일을 열고, 연 파일 핸들을 반환합니다. 둘째 인자는 파일 핸들을 닫습니다. 셋째 인자는 단순히 파일 핸들에 hFileSize 를 호출하고 그 결과를 Just로 강쌉니다.

우리는 이 함수를 제대로 동작시키기 위해 brackethandle을 둘 다 써야 합니다. 전자는 우리가 쓰레기 파일 핸들을 축적하지 않는 것을 보장하고, 후자는 예외를 없애는 것을 보장합니다.

연습문제

  1. brackethandle의 순서가 중요할까요? 그 이유는?

조건자를 위한 도메인 언어Domain specific language

조건자를 만들기 위한 첫발을 내디뎌봅시다. 우리가 만들 조건자는 128KB가 넘는 C++ 소스 파일을 찾아낼 겁니다.

-- file: ch09/BetterPredicate.hs
myTest path _ (Just size) _ =
    takeExtension path == ".cpp" && size > 131072
myTest _ _ _ _ = False

이건 딱히 즐겁지 않습니다. 이 조건자는 4개의 인자를 받고, 항상 그 중 2개는 무시하며, 두 개의 등식을 을 정의하는 걸 요구합니다. 물론 개량의 여지가 있습니다. 우리가 더 간결한 조건자를 작성하는 걸 도와줄 약간의 코드를 만들어 봅시다.

때때로, 이러한 종류의 라이브러리는 임베디드 도메인 언어라고 부릅니다: 프로그래밍 언어의 내재된 기능만으로 (그래서 임베디드) 우리가 더 좁은 문제를 특별히 우아하게 해결하는 코드를 짭니다. (그래서 영역 한정)

첫 단계는 인자 중 하나를 반환하는 함수를 짜는 것입니다. 아래 함수는 Predicate에 넘긴 인자 중 경로를 추출합니다.

-- file: ch09/BetterPredicate.hs
pathP path _ _ _ = path

만약 우리가 타입 시그니처를 제공하지 않는다면, 하스켈 구현은 이 함수를 매우 일반화한 타입으로 추측할 겁니다. 이건 나중에 해석하기 힘든 에러 메시지를 초래할 수 있으므로, pathP에 타입을 줘 봅시다.

-- file: ch09/BetterPredicate.hs
type InfoP a =  FilePath        -- path to directory entry
             -> Permissions     -- permissions
             -> Maybe Integer   -- file size (Nothing if not file)
             -> ClockTime       -- last modified
             -> a

pathP :: InfoP FilePath

이제 다른 비슷한 구조를 가질 함수들을 정의할 때 간편히 사용할 수 있는 타입 동의어를 만들었습니다. 이 타입 동의어는 타입 인자를 받으므로 우리는 각기 다른 결과 타입을 지정할 수 있습니다.

-- file: ch09/BetterPredicate.hs
sizeP :: InfoP Integer
sizeP _ _ (Just size) _ = size
sizeP _ _ Nothing     _ = -1

(여기서 살짝 찝찝하지만, 파일이 아니거나 열 수 없는 파일에 대해선 -1을 반환합니다.)

사실, 우리가 이 장이 시작할 때 정의한 Predicate 타입은 얼핏 보기에 InfoP Bool과 같습니다. (그러므로 우리는 합법적으로 Predicate 타입을 지울 수 있습니다.)

pathPsizeP의 용도는 뭘까요? 약간의 접착제와 함께, 우리는 이걸 조건자 안에서 사용할 수 있습니다 (각각의 이름의 P 접미사는 “조건자”라는 걸 나타내기 위함입니다). 여기서부터 재밌어지기 시작합니다.

-- file: ch09/BetterPredicate.hs
equalP :: (Eq a) => InfoP a -> a -> InfoP Bool
equalP f k = \w x y z -> f w x y z == k

equalP의 타입 시그니처는 관심을 기울일 만 합니다. pathPsizeP 둘 다와 호환되는 InfoP a를 받습니다. 이건 a를 받습니다. 그리고 이미 Predicate와 타입 동의어인 걸 확인한 InfoP Bool을 반환합니다. 다시 말하자면, equalP는 조건자를 만듭니다.

equalP 함수는 받은 인자를 f에 넘겨 그 결과를 k와 비교하는, 조건자로 인정되는 익명 함수를 반환하는 것으로 동작합니다.

equalP 등식은 우리가 이걸 두 인자를 받는 함수라고 간주하는 부분을 강조합니다. 하스켈은 모든 함수를 커리하기 때문에, equalP를 꼭 이런 방식으로 작성할 필요는 없습니다. 익명 함수를 생략하고 커리로 대신해서, 동일한 함수를 재작성할 수 있습니다.

-- file: ch09/BetterPredicate.hs
equalP' :: (Eq a) => InfoP a -> a -> InfoP Bool
equalP' f k w x y z = f w x y z == k

탐구를 시작하기 전에, 우리 모듈을 ghci에 로딩해봅시다.

ghci> :load BetterPredicate
[1 of 2] Compiling RecursiveContents ( RecursiveContents.hs, interpreted )
[2 of 2] Compiling Main             ( BetterPredicate.hs, interpreted )
Ok, modules loaded: RecursiveContents, Main.

이 함수로 만든 간단한 조건자가 잘 동작하는지 확인해 봅시다.

ghci> :type betterFind (sizeP `equalP` 1024)
betterFind (sizeP `equalP` 1024) :: FilePath -> IO [FilePath]

실제로 betterFind를 호출한 게 아니라, 그저 표현식의 타입만 확인한 것입니다. 이제 우리는 정확히 특정 크기의 파일을 모두 열거하는 더 표현력있는 방법을 가지게 되었습니다. 이 성공은 계속할 자신감을 줍니다.

리프팅으로 보일러플레이트 피하기

equalP 말고도, 우리는 다른 이항 함수들을 만들고 싶습니다. 쓸데없이 장황해질 것 같기 때문에, 각각을 완벽하게 정의하지 않는 편을 선호할 것입니다.

이 문제를 다루기 위해, 하스켈의 강력한 추상화를 이용해봅시다. equalP의 정의를 취하고(==)을 직접 호출하는 대신 우리가 원하는 이항 연산을 또 다른 인자로 넘겨줄 것입니다.

-- file: ch09/BetterPredicate.hs
liftP :: (a -> b -> c) -> InfoP a -> b -> InfoP c
liftP q f k w x y z = f w x y z `q` k

greaterP, lesserP :: (Ord a) => InfoP a -> a -> InfoP Bool
greaterP = liftP (>)
lesserP = liftP (<)

greaterP같은, (>)같은 함수를 받아서 다른 문맥에서 동작하는 다른 함수로 변환하는 행위를 그 문맥으로 리프팅한다고 말합니다. 이건 lift가 함수 이름에 붙는 이유를 설명합니다. 리프팅은 우리가 코드를 재활용하고 보일러플레이트를 줄이도록 도와줍니다. 우리는 여러 모양새로 이 책의 나머지 부분에서 리프팅을 많이 사용할 것입니다.

우리가 함수를 리프트할 때, 원래 함수와 새 함수를 각각 리프트 안 된 함수unlifted와 리프트된 함수lifted라고 부릅니다.

어쨌든, liftP의 인자로써 q(리프트할 함수)의 위치는 다분히 계획적입니다. 이것은 우리가 greaterPlesserP를 간결하게 정의하는 걸 가능하게 만듭니다. API를 설계할 때 이런 부분 적용은 “최선”의 인자 순서를 찾아내는 걸 하스켈에서 다른 언어보다 더 중요하게 만듭니다. 부분 적용이 없는 언어에서는, 인자 순서는 단지 취향과 관습의 문지입니다. 하지만 하스켈에서 인자 위치를 부적절하게 정하는 것은, 부분 적용이 주는 간결해질 수 있는 기회를 잃는 게 됩니다.

컴비네이터로 그런 간결함을 일부 회복할 수도 있습니다. 예를 들어, forMControl.Monad 모듈에 2007년까지 없었습니다. 그 이전에는, 사람들은 flip mapM을 대신 사용했습니다.

ghci> :m +Control.Monad
ghci> :t mapM
mapM :: (Monad m) => (a -> m b) -> [a] -> m [b]
ghci> :t forM
forM :: (Monad m) => [a] -> (a -> m b) -> m [b]
ghci> :t flip mapM
flip mapM :: (Monad m) => [a] -> (a -> m b) -> m [b]

조건자 이어붙이기

조건자를 합성하고 싶다면, 물론 아래의 명백한 수작업을 할 수 있습니다.

-- file: ch09/BetterPredicate.hs
simpleAndP :: InfoP Bool -> InfoP Bool -> InfoP Bool
simpleAndP f g w x y z = f w x y z && g w x y z

이제 우리는 리프팅을 알고 있으니, 위에 있는 불리언 연산자를 리프팅해서 우리가 작성해야 하는 코드의 양을 줄이는 것이 더 자연스러워집니다.

-- file: ch09/BetterPredicate.hs
liftP2 :: (a -> b -> c) -> InfoP a -> InfoP b -> InfoP c
liftP2 q f g w x y z = f w x y z `q` g w x y z

andP = liftP2 (&&)
orP = liftP2 (||)

liftP2가 이전에 있었던 liftP와 비슷하다는 점에 주목하세요. 사실, 이게 더 일반적인데, 왜냐하면 liftPliftP2로 작성할 수 있기 때문입니다.

-- file: ch09/BetterPredicate.hs
constP :: a -> InfoP a
constP k _ _ _ _ = k

liftP' q f k w x y z = f w x y z `q` constP k w x y z

컴비네이터

하스켈에서, 다른 함수를 인자로 받아 새 함수를 반환하는 함수를 컴비네이터라고 부릅니다.

이제 우리는 여러 도우미 함수를 준비했으니, 이전에 정의했던 myTest 함수로 돌아가 봅시다.

-- file: ch09/BetterPredicate.hs
myTest path _ (Just size) _ =
    takeExtension path == ".cpp" && size > 131072
myTest _ _ _ _ = False

우리가 만든 새 컴비네이터를 사용하면 이 함수가 어떻게 보일까요?

-- file: ch09/BetterPredicate.hs
liftPath :: (FilePath -> a) -> InfoP a
liftPath f w _ _ _ = f w

myTest2 = (liftPath takeExtension `equalP` ".cpp") `andP`
          (sizeP `greaterP` 131072)

마지막 컴비네이터로 liftPath를 추가했는데, 파일 이름을 조작하는 건 흔한 작업이기 때문입니다.

새 연산자 정의하고 사용하기

우리의 도메인 언어를 새 중위 연산자를 정의해 더 심화시킬 수 있습니다.

-- file: ch09/BetterPredicate.hs
(==?) = equalP
(&&?) = andP
(>?) = greaterP

myTest3 = (liftPath takeExtension ==? ".cpp") &&? (sizeP >? 131072)

대응되는 리프트 안 된 연산자와 시각적으로 일관되기 위해 (==?)같은 이름을 골랐습니다.

myText3 정의의 괄호는 필수인데, 그 이유는 우리가 아직 하스켈에게 새 연산자에 대한 우선순위나 결합 방향을 지정하지 않았기 때문입니다. 하스켈은 이런 fixity 지정이 없는 연산자들을 반드시 infixl 9로 취급한다고 명시했습니다. 즉, 이 연산자들은 왼쪽에서 오른쪽으로, 가장 높은 우선순위로 평가된다는 뜻입니다. 만약 우리가 괄호를 생략한다면, 표현식은 끔찍하게 잘못된 (((liftPath takeExtension) ==? ".cpp") &&? sizeP) >? 131072로 해석될 겁니다.

우리는 새 연산자의 fixity 선언을 넣어 대응할 수 있습니다. 첫째 단계는 리프트되지 않은 연산자의 fixity를 우리가 흉내낼 수 있도록 확인하는 것입니다.

ghci> :info ==
class Eq a where
  (==) :: a -> a -> Bool
  ...
        -- Defined in GHC.Base
infix 4 ==
ghci> :info &&
(&&) :: Bool -> Bool -> Bool      -- Defined in GHC.Base
infixr 3 &&
ghci> :info >
class (Eq a) => Ord a where
  ...
  (>) :: a -> a -> Bool
  ...
        -- Defined in GHC.Base
infix 4 >

이것을 가지고, 이제 괄호가 없으면서 myTest3와 동일하게 해석되는 함수를 짤 수 있습니다.

-- file: ch09/BetterPredicate.hs
infix 4 ==?
infixr 3 &&?
infix 4 >?

myTest4 = liftPath takeExtension ==? ".cpp" &&? sizeP >? 131072

순회 제어

파일 시스템을 순회할 때, 우리 자신에게 어떤 디렉토리를 들어갈지 말지, 혹은 언제 들어갈지에 대한 제어를 주고 싶습니다. 이걸 허용하는 쉬운 방법은 함수 안에서 주어진 디렉토리의 하위 디렉토리 리스트를 받아 다른 리스트를 반환하는 것입니다. 반환한 리스트는 원소가 제거되었을 수도 있고, 원래 리스트와 순서가 다르거나, 혹은 둘 다일 수도 있습니다. 가장 간단한 제어 함수인 id는 리스트를 있는 그대로 돌려줄 겁니다.

다양성을 위해, 표현의 일부분을 바꿔볼 것입니다. InfoP a로 일관하는 대신 내부적으로 나타내는 건 비슷하지만 일반적인 대수적 데이터 타입을 사용할 겁니다.

-- file: ch09/ControlledVisit.hs
data Info = Info {
      infoPath :: FilePath
    , infoPerms :: Maybe Permissions
    , infoSize :: Maybe Integer
    , infoModTime :: Maybe ClockTime
    } deriving (Eq, Ord, Show)

getInfo :: FilePath -> IO Info

“간단히 쓸 수 있는” 접근자를 얻기 위해 infoPath같은 레코드 구문을 사용했습니다. traverse 함수의 타입은 위에서 제안한 대로 간단합니다. 파일이나 폴더의 Info 를 얻기 위해 getInfo 액션을 호출할 겁니다.

-- file: ch09/ControlledVisit.hs
traverse :: ([Info] -> [Info]) -> FilePath -> IO [Info]

traverse의 정의는 짧지만, 빽빽합니다.

-- file: ch09/ControlledVisit.hs
traverse order path = do
    names <- getUsefulContents path
    contents <- mapM getInfo (path : map (path </>) names)
    liftM concat $ forM (order contents) $ \info -> do
      if isDirectory info && infoPath info /= path
        then traverse order (infoPath info)
        else return [info]

getUsefulContents :: FilePath -> IO [String]
getUsefulContents path = do
    names <- getDirectoryContents path
    return (filter (`notElem` [".", ".."]) names)

isDirectory :: Info -> Bool
isDirectory = maybe False searchable . infoPerms

여기서 어떤 기법도 소개하진 않을 테지만, 이건 우리가 맞닥뜨린 함수 중 가장 밀도 높은 함수 중 하나입니다. 줄 단위로 짚어가면서 어떻게 되는지 설명해 봅시다. 처음 두 줄은 우리가 이미 본 코드를 말 그대로 복사한 것과 같기에 어떤 까다로운 점도 없습니다.

contents 변수에 할당할 때부터 흥미진진해집니다. 이 줄을 오른쪽에서 왼쪽으로 읽어보세요. 우리는 이미 names가 디렉토리 항목이라는 걸 알고 있습니다. 현재 디렉토리가 각각의 원소의 앞쪽에 붙고, 현재 디렉토리 자체도 리스트에 추가되는 것을 확인할 수 있습니다. 그 후 getInfo를 얻어낼 경로에 적용하기 위해 mapM을 사용합니다.

그 다음 줄은 더 빽빽합니다. 다시 오른쪽에서 왼쪽으로 읽으면, 이줄의 마지막 요소가 문단 끝까지 이어지는 익명 함수의 정의를 시작하고 있는 걸 볼 수 있습니다. 주어진 Info 값에 따라, 이 함수는 디렉토리를 재귀적으로 방문하거나 (여기에 path를 다시 방문하지 않도록 추가로 확인하고), (traverse의 결과 타입에 맞추기 위해) 그 값을 단일 원소 리스트로 반환합니다.

사용자가 제공한 순회 제어 함수 order에서 반환한 Info 리스트의 원소 각각에 forM으로 이 함수를 적용합니다.

줄 시작에서, 리프팅 기법을 새 문맥에서 사용했습니다. liftM 함수는 일반 함수 concat을 받아 IO 모나드로 리프팅합니다. 달리 말하자면, 이 함수는 (IO [[Info]] 타입인) forM의 결과에서 IO 모나드의 내용물을 꺼내 (우리가 원하는 [Info] 타입을 반환하는) concat으로 붙인 다음 , 그 결과를 다시 IO 모나드 안에 집어넣습니다.

마지막으로, getInfo 함수를 정의하는 걸 잊으면 안 됩니다.

-- file: ch09/ControlledVisit.hs
maybeIO :: IO a -> IO (Maybe a)
maybeIO act = handle (\_ -> return Nothing) (Just `liftM` act)

getInfo path = do
  perms <- maybeIO (getPermissions path)
  size <- maybeIO (bracket (openFile path ReadMode) hClose hFileSize)
  modified <- maybeIO (getModificationTime path)
  return (Info path perms size modified)

여기서 주목할 딱 하나는 예외를 던지는 IO 액션의 결과를 Maybe로 감싸는 유용한 컴비네이터인 maybeIO입니다.

연습문제

  1. 알파벳 역순 순서로 디렉토리 트리를 순회하려면 traverse에 어떤 함수를 넘겨야 할까요?

  2. id를 제어함수로 쓰면, traverse id전위 순회를 합니다: 즉 자식 전에 부모 디렉토리를 먼저 반환합니다. traverse가 자신을 먼저 반환하는 후위 순회를 하도록 하는 제어 함수를 작성해 보세요.

  3. “조건자 이어붙이기”에서 조건자와 컴비네이터를 빌려와서 그것들이 이번에 새로 만든 Info 타입에 동작하도록 해보세요.

  4. 순회 제어와 결과를 거르는 데에 조건자를 각각 받는 traverse의 래퍼 함수를 작성해 보세요.

밀도, 가독성, 학습 절차

하스켈에서 traverse만큼 조밀한 코드는 이상하지 않습니다. 표현력을 얻는 것은 중요하고, 이러한 스타일의 코드를 짜거나 읽는 것은 약간의 연습을 필요로 합니다.

비교를 위해, 덜 조밀한 같은 코드를 여기 준비했습니다. 아마 하스켈에 덜 익숙한 프로그래머에겐 더 친숙할 것입니다.

-- file: ch09/ControlledVisit.hs
traverseVerbose order path = do
    names <- getDirectoryContents path
    let usefulNames = filter (`notElem` [".", ".."]) names
    contents <- mapM getEntryName ("" : usefulNames)
    recursiveContents <- mapM recurse (order contents)
    return (concat recursiveContents)
  where getEntryName name = getInfo (path </> name)
        isDirectory info = case infoPerms info of
                             Nothing -> False
                             Just perms -> searchable perms
        recurse info = do
            if isDirectory info && infoPath info /= path
                then traverseVerbose order (infoPath info)
                else return [info]

바꾼 것은 약간의 치환을 한 것이 전부입니다. 마음껏 부분 적용과 함수 합성을 하는 대신 where 블록에 지역 함수를 정의했습니다. maybe 컴비네이터 자리엔 case 표현식을 사용했습니다. 또 liftM을 쓰는 대신 직접 concat을 리프팅했습니다.

조밀한 것이 전반적으로 좋다고 말하는 것이 아닙니다. 원래의 traverse 함수는 줄 각각이 짧았습니다. 우리는 줄 길이를 줄이고 코드를 명확하게 하기 위해 지역 변수 (usefulNames)와 지역 함수 (isDirectory)를 추가했습니다. 이 이름들은 설명적입니다. 함수 합성과 파이프라이닝을 하긴 하지만, 가장 긴 파이프라인도 세 개의 원소만 담고 있을 뿐입니다.

유지보수가 가능한 하스켈 코드를 짜는 열쇠는 밀도와 가독성 간의 균형을 맞추는 것입니다. 이 연속체에서 독자의 코드가 떨어지는 부분은 독자의 경험 수준에 영향을 받을 듯 합니다.

  • 초보 하스켈 프로그래머 앤드류는 표준 라이브러리로 원하는 바를 이루는 방법을 잘 모릅니다. 그 결과, 이미 많이 존재하는 코드를 알지도 못하고 중복시킵니다.

  • 잭은 몇달간 프로그래밍을 해왔고, (.)으로 긴 코드 파이프라인을 짜는 것을 숙달했습니다. 그의 프로그램을 살짝 바꿀 필요가 있을 때마다, 그는 새 파이프라인을 처음부터 짜야 합니다: 그는 기존의 파이프라인을 더 이상 이해하지 못 하고, 어떻게 짰든 간에 코드는 변화에 매우 약합니다.

  • 모니카는 코딩을 상당기간 해왔습니다. 그녀는 하스켈 라이브러리와 탄탄한 코드를 짜는 관용구에 충분히 익숙하지만, 너무 조밀한 스타일은 지양합니다. 그녀의 코드는 유지보수가 편하고, 요구 조건을 바꿀꿀 때마다 리팩토링이 쉬운 것을 발견합니다.

순회를 보는 다른 방법

traverse 함수로 betterFind 함수보다 더 많은 제어가 가능해졌지만, 아직 중요한 결점이 남아있습니다: 우리는 디렉토리 안으로 재귀하는 것을 피할 수 있지만, 트리 안의 모든 항목의 리스트를 만들기 전까지 다른 항목을 걸러낼 수 없습니다. 만약 우리가 3개의 파일을 찾기 위해 100,000개의 파일을 담고있는 뒤져야 한다면, 3개로 걸러낸 결과를 얻기 전에 100,000개의 원소를 가진 리스트를 만들게 될 것입니다.

한 가지 대안으로 리스트를 조립할 때마다 적용할 필터 함수를 traverse에 새 인자로 주는 것이 있습니다. 이렇게 하면 우리가 필요한 만큼만 리스트를 할당할 것입니다.

하지만, 이 방법도 약점이 있는데: 우리가 최대 3개 항목만 원하고, 그 항목이 100,000개의 항목을 순회하는 동안 맨 처음 3개에서 전부 발견한다면, 우리는 나머지 99,997개의 항목을 무의미하게 순회할 것입니다. 이건 딱히 억지로 생각한 예가 아닙니다. 예를 들어, Maildir의 메일 보관함 형식은 이메일 수신함을 각각의 메일 파일을 가진 디렉토리로 저장합니다. 메일 수신함을 나타내는 한 디렉토리가 수만 개의 파일을 가지고 있는 것을 흔한 일입니다.

우리는 앞서 본 두 함수의 약점을 다른 관점에서 바라봄으로써 해결할 수 있습니다. 파일시스템 순회를 디렉토리 계층구조를 접는 것으로 생각하면 어떨까요?

친숙한 접기 함수인 foldrfoldl'은 결과를 누적하는 것으로 리스트를 순회한다는 개념을 알맞게 일반화 합니다. 리스트를 접는다는 개념을 디렉토리 트리로 확장하는 것은 쉽지 않지만, 우리는 접기에 제어 요소를 넣고 싶습니다. 우리는 이 제어를 대수적 데이터 타입으로 나타낼 것입니다.

-- file: ch09/FoldDir.hs
data Iterate seed = Done     { unwrap :: seed }
                  | Skip     { unwrap :: seed }
                  | Continue { unwrap :: seed }
                    deriving (Show)

type Iterator seed = seed -> Info -> Iterate seed

Iterator 타입은 우리가 접기에 쓸 함수를 위한 편리한 별명을 제공합니다. 이 타입은 씨앗과 디렉토리 항목을 나타내는 Info값을 받고 새 씨앗과 Iterate 타입 생성자로 나타내고 있는 접기 함수를 위한 명령을 반환합니다.

  • 명령이 Done이면, 즉시 순회를 중단합니다. 그러고 결과를 Done 으로 감싸 반환할 것입니다.

  • 명령이 Skip이고 현재 Info가 디렉토리를 나타내면, 그 디렉토리 안으로 순회하지 않고 넘어갑니다.

  • 전부 아니면, 감싼 값을 다음 접기 함수의 입력으로 넘기고 순회를 계속 합니다.

이번 폴드는 논리적으로 왼쪽 접기 종류입니다. 왜냐하면 맞닥뜨리는 첫째 항목부터 접기를 시작할 것이고, 각 단계의 씨앗은 이전 단계의 결과이기 때문입니다.

-- file: ch09/FoldDir.hs
foldTree :: Iterator a -> a -> FilePath -> IO a

foldTree iter initSeed path = do
    endSeed <- fold initSeed path
    return (unwrap endSeed)
  where
    fold seed subpath = getUsefulContents subpath >>= walk seed

    walk seed (name:names) = do
      let path' = path </> name
      info <- getInfo path'
      case iter seed info of
        done@(Done _) -> return done
        Skip seed'    -> walk seed' names
        Continue seed'
          | isDirectory info -> do
              next <- fold seed' path'
              case next of
                done@(Done _) -> return done
                seed''        -> walk (unwrap seed'') names
          | otherwise -> walk seed' names
    walk seed _ = return (Continue seed)

이 코드를 작성한 방식에 몇 가지 흥미로운 점이 있습니다. 첫째로 여분의 인자를 넘기는 것을 막기 위해 영역을 사용한 것입니다. 최상위 foldTree 함수는 fold의 최종 결과에서 생성자를 벗겨내기 위한 단순한 래퍼입니다.

fold가 지역함수라서 foldTreeiter 인자를 따로 넘겨줄 필요가 없습니다. 이미 바깥 영역에 접근할 수 있기 때문이죠. 마찬가지로 walk 또한 바깥 영역에 있는 path를 볼 수 있습니다.

또 달리 주목할 점은 이전 함수에선 forM에서 익명 함수를 호출한 것 대신 walk라는 꼬리 호출 함수를 사용했다는 것입니다. 필요하다면 고삐를 잡아 빨리 멈출 수 있습니다. 반복자가 Done을 반환하도록 해서 말이죠.

foldwalk를 호출하고 walkfold를 재귀적으로 호출해 하위 디렉토리를 순회합니다. 각각의 함수는 Iterate로 감싼 씨앗을 반환합니다. walkfold를 호출하고 그 결과를 조사해 계속 진행할 지 Done이어서 중단해야 할 지를 판단합니다. 이런 식으로, 호출자가 제공한 반복자는 Done을 반환하는 것으로 상호 재귀적인 호출을 단번에 끝낼 수 있습니다.

반복자는 실제로 어떻게 보일까요? 여기 SVN 메타데이터 디렉토리를 제외하고 최대 3개의 비트맵 파일을 찾는 약간 복잡할 수 있는 예제가 있습니다.

-- file: ch09/FoldDir.hs
atMostThreePictures :: Iterator [FilePath]

atMostThreePictures paths info
    | length paths == 3
      = Done paths
    | isDirectory info && takeFileName path == ".svn"
      = Skip paths
    | extension `elem` [".jpg", ".png"]
      = Continue (path : paths)
    | otherwise
      = Continue paths
  where extension = map toLower (takeExtension path)
        path = infoPath info

이걸 사용하려면, IO [FilePath]를 반환해주는 foldTree atMostThreePictures []를 호출합니다.

물론 반복자는 저렇게까지 복잡해질 필요는 없습니다. 이건 발견한 디렉토리의 갯수를 세는 반복자입니다.

-- file: ch09/FoldDir.hs
countDirectories count info =
    Continue (if isDirectory info
              then count + 1
              else count)

여기선 처음 foldTree에 넘기는 씨앗은 숫자 0이 되어야 할 겁니다.

연습문제

  1. 호출자가 디렉토리 안 항목의 순회 순서를 바꿀 수 있도록 foldTree를 수정해보세요.

  2. foldTree 함수는 전위 순회를 합니다. 호출자가 순회 순서를 결정할 수 있도록 수정해보세요.

  3. foldTree가 받는 반복자를 표현하는 컴비네이터 라이브러리를 만들어 보세요. 그것이 독자가 작성하는 반복자를 더 간단명료하게 만드나요?

유용한 코딩 지침

좋은 하스켈 코딩 습관은 경험에서 많이 나오지만, 우리는 독자가 더 빨리 가독성있는 코드를 작성할 수 있는 일반적인 팁을 약간 가지고 있습니다.

“탭 VS 공백에 관한 언급” 장에서 언급했다시피, 하스켈 소스에서 절대 탭을 쓰지 마세요. 공백을 쓰세요.

만약 어떤 부분의 코드가 귀신처럼 영리한 것을 자랑스럽게 생각하는 자기 자신을 발견한다면, 일단 멈추고 한 달간 그 코드를 보지 않은 후에 자신이 다시 그 코드를 이해할 수 있을지 생각해 보세요.

타입이나 변수 이름을 합성어로 짓는 관습적인 방법은 “Camel case” 입니다. myVariableName이 예시가 됩니다. 이 스타일이 하스켈 코드에서 가장 보편적입니다. 명명법에 어떤 취향을 가졌던 간에, 표준이 아닌 관습을 따르면 당신의 하스켈 코드는 다른 읽는 사람의 눈에 다소 이상하게 보일 것입니다.

하스켈로 상당히 많은 시간 작업을 해보기 전까지, 작은 함수를 작성하기 전에 몇 분간 라이브러리 함수를 찾아보세요. 이건 특히 리스트나 Maybe, Either같은 널리 쓰는 타입에 해당됩니다. 만약 표준 라이브러리가 정확히 당신이 원하는 기능을 제공하지 않으면, 몇 가지 함수를 합성해서 원하는 결과를 얻을 수 있을 겁니다.

세 개나 네 개 이상의 연쇄된 합성 함수 파이프라인은 읽기 힘듭니다. 만약 그런 파이프라인이 있다면 let이나 where 블록으로 그걸 더 작은 조각으로 쪼개세요. 각각의 파이프라인 원소에 의미있는 이름을 붙인 후 다시 이어 붙이세요. 만약 원소에 적절한 이름이 떠오르지 않으면, 스스로 이게 무엇을 하는지 설명할 수 있는지 확인해보세요. 만약 대답이 “아니오”라면, 코드를 단순화하세요.

80열 이상 텍스트 에디터를 조절하기 쉽다고 해도, 이 너비는 아직 매우 일반적입니다. 더 긴 줄은 80열 짜리 텍스트 에디터 창에서 줄 바꿈되기 마련이고, 가독성을 심하게 해치게 됩니다. 한 줄에 최대 80글자까지만 들어가도록 다루면 당신이 한 줄에 휘갈기는 코드의 양도 제한합니다. 이건 각각의 줄이 덜 복잡해지고, 그 결과 더 이해하기 쉽도록 도와줄 겁니다.

일반적인 레이아웃 스타일

독자가 레이아웃 규칙을 제대로 지켜서 파싱에 애매함을 초래하지 않는 한 하스켈 구현은 당신을 야단치지 않을 겁니다. 그렇긴 하지만, 몇몇 레이아웃 패턴이 널리 쓰이고 있습니다.

in 키워드는 이어지는 표현식 바로 아래에 let 키워드 맞춰 곧바로 정렬됩니다.

-- file: ch09/Style.hs
tidyLet = let foo = undefined
              bar = foo * 2
          in undefined

in의 들여쓰기를 다르게 하거나 표현식 끝에 “매달리게” 해도 적법하지만, 다음 코드는 일반적으로 이상하게 보일 것입니다.

-- file: ch09/Style.hs
weirdLet = let foo = undefined
               bar = foo * 2
    in undefined

strangeLet = let foo = undefined
                 bar = foo * 2 in
    undefined

반면에 do는 줄 끝에 매다는 것이 다음 라인의 시작에 붙이는 것보다 더 일반적입니다.

-- file: ch09/Style.hs
commonDo = do
  something <- undefined
  return ()

-- not seen very often
rareDo =
  do something <- undefined
     return ()

중괄호와 세미콜론은 적법하지만 거의 쓰지 않습니다. 그 자체엔 아무 문제가 없습니다. 이것들은 단지 희소성만으로 코드를 어색하게 만듭니다. 이것들은 프로그램이 레이아웃 규칙을 구현하지 않고 하스켈 코드를 짜는 경우를 의도한 것이지, 사람이 쓰는 것을 의도하지 않았습니다.

-- file: ch09/Style.hs
unusualPunctuation =
    [ (x,y) | x <- [1..a], y <- [1..b] ] where {
                                           b = 7;
 a = 6 }

preferredLayout = [ (x,y) | x <- [1..a], y <- [1..b] ]
    where b = 7
          a = 6

만약 등식의 우변을 새 줄에서 시작한다면, 대개 그것이 정의하는 변수나 함수 이름보다 약간 더 들여쓰기 합니다.

-- file: ch09/Style.hs
normalIndent =
    undefined

strangeIndent =
                           undefined

들여쓰기에 쓰는 실제 공백 개수는 같은 파일 내에서도 자주 변합니다. 2, 3, 4개의 공백이 엇비슷하게 많이 쓰입니다. 공백 하나도 적법하지만, 잘 구분되지 않기에 잘못 읽기 쉽습니다.

where 구문을 들여쓸 땐, 그걸 잘 드러나도록 들여쓰는 게 가장 좋습니다.

-- file: ch09/Style.hs
goodWhere = take 5 lambdas
    where lambdas = []

alsoGood =
    take 5 lambdas
  where
    lambdas = []

badWhere =           -- legal, but ugly and hard to read
    take 5 lambdas
    where
    lambdas = []

연습문제

이 장의 파일을 찾는 코드가 학습 용도로 괜찮긴 하지만, 실제 시스템 프로그래밍 작업에선 별로 이상적이지 않습니다. 하스켈의 이식성 있는 입출력 라이브러리는 정보를 많이 노출하지 않아 궁금하고 복잡한 질의를 허용하지 않기 때문입니다.

  1. 이 장의 코드를 System.PosixSystem.Win32같은 네이티브 API를 사용해 독자의 플랫폼으로 포팅해보세요.

  2. 디렉토리 항목을 누가 소유하고 있는지 확인하는 기능을 독자의 코드에 추가해 보세요. 이 정보를 조건자에서도 쓸 수 있도록 만들어 보세요.

저작자 표시 비영리 동일 조건 변경 허락
신고