Real World Haskell 15장 번역: 모나드로 프로그래밍하기Real World Haskell 15장 번역: 모나드로 프로그래밍하기

Posted at 2015.02.01 19:41 | Posted in 지식저장소/읽은 책 요약
15장. 모나드로 프로그래밍하기

15장. 모나드로 프로그래밍하기

골프 연습: 관계 리스트

웹 서버와 클라이언트는 텍스트로 자주 키-값 쌍을 표현해 서로 전달합니다.

name=Attila+%42The+Hun%42&occupation=Khan

이 인코딩은 application/x-www-form-urlencoded라고 부르고, 매우 이해하기 쉽습니다. 각각의 키-값 쌍은 “&” 문자로 구분합니다. 키-값 쌍 안에선 키 문자열이 나오고 “=”가 따라온 다음 값 문자열이 나옵니다.

키는 간단히 String으로 나타낼 수 있지만, HTTP 명세에선 값이 반드시 키 뒤에 나와야 하는지 알려주지 않습니다. 우린 이 애매함을 값 타입으로 Maybe String을 써 해결할 수 있습니다. 값으로 Nothing을 쓰면, 값이 따라오지 않은 것입니다. 문자열을 Just로 감싸면, 값이 있던 것입니다. Maybe를 써서 “값이 없는 것”과 “빈 값”을 구분할 수 있습니다.

하스켈 프로그래미들은 각각의 원소를 키와 값의 관계로 생각할 수 있는 [(a, b)]타입을 관계 리스트Association list라고 부릅니다. 이 이름은 리스프 커뮤니티에서 유래했고, 거기선 흔히 alist라고 줄여 부릅니다. 위 문자열을 다음 하스켈 값으로 나타낼 수 있습니다.

-- file: ch15/MovieReview.hs
    [("name",       Just "Attila \"The Hun\""),
     ("occupation", Just "Khan")]

“URL 인코딩한 질의 문자열” 절에서, application/x-www-form-urlencoded 문자열을 파싱하고 그 결과를 [(String, Maybe String)]로 나타낼 겁니다. 우리가 자료구조를 채우는 데 이 관계 리스트를 사용하고 싶다고 해 봅시다.

-- file: ch15/MovieReview.hs
data MovieReview = MovieReview {
      revTitle :: String
    , revUser :: String
    , revReview :: String
    }

명백한 것들을 생각 없이 길게 늘여써 시작하겠습니다.

-- file: ch15/MovieReview.hs
simpleReview :: [(String, Maybe String)] -> Maybe MovieReview
simpleReview alist =
  case lookup "title" alist of
    Just (Just title@(_:_)) ->
      case lookup "user" alist of
        Just (Just user@(_:_)) ->
          case lookup "review" alist of
            Just (Just review@(_:_)) ->
                Just (MovieReview title user review)
            _ -> Nothing -- no review
        _ -> Nothing -- no user
    _ -> Nothing -- no title

이 함수는 관계 리스트가 필요한 모든 값을 가지고 있고, 그 값들이 빈 문자열이 아니어야만 MovieReview를 반환합니다. 하지만, 입력 값을 검사한다는 사실만이 유일한 장점이고, 이 함수는 우리가 피해야 한다고 배운 “계단화”가 심각하고, 관계 리스트의 세부 구조까지 알고 있습니다.

우린 이제 Maybe 모나드에 익숙해졌으므로, 이 계단 코드를 가지런히 할 수 있습니다.

-- file: ch15/MovieReview.hs
maybeReview alist = do
    title <- lookup1 "title" alist
    user <- lookup1 "user" alist
    review <- lookup1 "review" alist
    return (MovieReview title user review)

lookup1 key alist = case lookup key alist of
                      Just (Just s@(_:_)) -> Just s
                      _ -> Nothing

더 깔끔해졌지만, 아직 반복 부분이 남아있습니다. 우린 MovieReview 생성자가 “순수 코드와 모나딕 코드 섞기” 절에서 봤듯이 모나드 안으로 일반 순수 함수를 리프팅하는 것과 마찬가지로 동작한다는 걸 이용할 수 있습니다.

-- file: ch15/MovieReview.hs
liftedReview alist =
    liftM3 MovieReview (lookup1 "title" alist)
                       (lookup1 "user" alist)
                       (lookup1 "review" alist)

여기서도 아직 반복이 약간 남아있지만, 상당히 적고, 또한 제거하기도 더 까다롭습니다.

일반화된 리프팅

liftM3가 우리 코드를 정리해주지만, 표준 라이브러리엔 liftM5까지밖에 없으므로 liftM 계열 함수로 이 종류의 문제를 해결할 순 없습니다. 우리가 필요한 숫자의 liftM 변종을 만들 수도 있지만, 노가다에 불과할 겁니다.

만약 우리가 표준 라이브러리를 고수한 상태에서 10개 정도의 인자를 받는 생성자나 순수 함수가 있다면 독자는 이제 끝났다고 생각할지도 모릅니다.

물론, 우리 도구상자는 아직 고갈나지 않았습니다. Control.Monadap라는 흥미로운 타입 시그니처를 가진 함수가 있습니다.

ghci> :m +Control.Monad
ghci> :type ap
ap :: (Monad m) => m (a -> b) -> m a -> m b

독자는 누가 왜 인자 하나를 받는 순수 함수를 모나드 안에 집어넣으려 할 지 의문일 수 있습니다. 하지만 모든 하스켈 함수는 실제론 인자 하나만을 받는다는 걸 떠올리시고, 이게 MovieReview 생성자와 어떤 관계가 있는지 봅시다.

ghci> :type MovieReview
MovieReview :: String -> String -> String -> MovieReview

우린 이 타입을 String -> (String -> (String -> MovieReview))라고 쉽게 쓸 수 있습니다. 만약 기존의 liftM으로 MovieReviewMaybe 모나드 안으로 리프팅하면, Maybe (String -> (String -> (String -> MovieReview))) 타입의 값을 얻을 겁니다. 이 값을 ap에 인자로 주면 Maybe (String -> (String -> MovieReview)) 타입이 결과로 나오는 걸 알 수 있습니다. 이 결과도 차례로 ap에 넘겨 이 정의가 끝날 때까지 연쇄할 수 있습니다.

-- file: ch15/MovieReview.hs
apReview alist =
    MovieReview `liftM` lookup1 "title" alist
                   `ap` lookup1 "user" alist
                   `ap` lookup1 "review" alist

이런 식으로 ap를 필요한 만큼 연쇄해 liftM 계열 함수를 피할 수 있습니다.

ap을 다르게 보면 ap($) 연산자의 모나딕 버전이라고 생각할 수 있습니다. ap의 발음을 어플라이apply라고 생각하세요. 두 함수의 타입 시그니처를 비교해 이 사실을 명확히 볼 수 있습니다.

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

실제로, liftM2 idliftM2 ($)ap을 정의할 수 있습니다.

다른 방법 찾기

아래는 개인 전화번호의 간단한 표현입니다.

-- file: ch15/VCard.hs
data Context = Home | Mobile | Business
               deriving (Eq, Show)

type Phone = String

albulena = [(Home, "+355-652-55512")]

nils = [(Mobile, "+47-922-55-512"), (Business, "+47-922-12-121"),
        (Home, "+47-925-55-121"), (Business, "+47-922-25-551")]

twalumba = [(Business, "+260-02-55-5121")]

우리가 누군가와 사적인 통화를 하고 싶다고 생각해 봅시다. 회사 전화번호로 걸고 싶진 않을 것이고, 휴대폰보단 (집 전화가 있다면) 집 전화가 나을 것입니다.

-- file: ch15/VCard.hs
onePersonalPhone :: [(Context, Phone)] -> Maybe Phone
onePersonalPhone ps = case lookup Home ps of
                        Nothing -> lookup Mobile ps
                        Just n -> Just n

물론 Maybe를 결과 타입으로 쓰면, 누군가 이 기준을 만족하는 전화번호가 2개 이상 있을 때를 처리하지 못 합니다. 이 경우를 대비해, 리스트 타입으로 바꾸겠습니다.

-- file: ch15/VCard.hs
allBusinessPhones :: [(Context, Phone)] -> [Phone]
allBusinessPhones ps = map snd numbers
    where numbers = case filter (contextIs Business) ps of
                      [] -> filter (contextIs Mobile) ps
                      ns -> ns

contextIs a (b, _) = a == b

두 함수의 case 식이 비슷한 것에 주목하세요. 한 개 짜리 결과 함수는 룩업이 빈 결과를 반환했는지 아닌지에 따라 결과를 처리했습니다.

ghci> onePersonalPhone twalumba
Nothing
ghci> onePersonalPhone albulena
Just "+355-652-55512"
ghci> allBusinessPhones nils
["+47-922-12-121","+47-922-25-551"]

하스켈의 Control.Monad 모듈은 이런 case 식을 추상화 할 수 있는 MonadPlus 타입클래스를 정의해 뒀습니다.

-- file: ch15/VCard.hs
class Monad m => MonadPlus m where
   mzero :: m a 
   mplus :: m a -> m a -> m a

mzero 빈 값을 나타내는 반면, mplus는 결과를 하나로 합칩니다. 아래는 Maybe와 리스트의 mzeromplus 표준 정의입니다.

-- file: ch15/VCard.hs
instance MonadPlus [] where
   mzero = []
   mplus = (++)

instance MonadPlus Maybe where
   mzero = Nothing

   Nothing `mplus` ys  = ys
   xs      `mplus` _ = xs

이제 우린 mpluscase식 전체를 제거할 수 있습니다. 다양한 방법을 보는 셈 치고, 한 개 회사 전화번호와 모든 개인 전화번호를 가져와봅시다.

-- file: ch15/VCard.hs
oneBusinessPhone :: [(Context, Phone)] -> Maybe Phone
oneBusinessPhone ps = lookup Business ps `mplus` lookup Mobile ps

allPersonalPhones :: [(Context, Phone)] -> [Phone]
allPersonalPhones ps = map snd $ filter (contextIs Home) ps `mplus`
                                 filter (contextIs Mobile) ps

각각의 함수에서 lookupMaybe를 반환하고 filter가 리스트를 반환하는 걸 알기에, 각각 어떤 mplus 구현이 쓰일지 명백합니다.

더 재밌는 사실은 mzeromplus모든 MonadPlus 인스턴스에 유용한 함수를 짤 수 있다는 겁니다. 예를 들어, 아래는 Maybe 값을 반환하는 표준 lookup 함수입니다.

-- file: ch15/VCard.hs
lookup :: (Eq a) => a -> [(a, b)] -> Maybe b
lookup _ []                      = Nothing
lookup k ((x,y):xys) | x == k    = Just y
                     | otherwise = lookup k xys

임의의 MonadPlus 인스턴스의 결과 타입을 다음처럼 쉽게 일반화할 수 있습니다.

-- file: ch15/VCard.hs
lookupM :: (MonadPlus m, Eq a) => a -> [(a, b)] -> m b
lookupM _ []    = mzero
lookupM k ((x,y):xys)
    | x == k    = return y `mplus` lookupM k xys
    | otherwise = lookupM k xys

이걸로 결과 타입이 Maybe면 결과가 없거나 하나의 결과를 얻을 수 있고, 리스트면 모든 결과를 얻고, 다른 생소한 MonadPlus 인스턴스에 대해서도 적절히 결과를 얻을 겁니다.

위에서 본 것과 같은 작은 함수들의 경우엔, mplus를 사용해도 거의 효용이 없습니다. mplus의 장점은 더 복잡한 코드나 모나드 문맥에 독립적인 코드에서 드러납니다. 비록 MonadPlus를 독자의 코드에서 쓸 필요를 못 찾았더라도, 다른 사람의 프로젝트에서 맞닥뜨릴 수도 있습니다.

mplus란 이름은 덧셈을 뜻하지 않습니다

mplus 함수 이름에 “plus”란 단어가 있긴 하지만, 이것이 두 값을 더하는 걸 의미한다고 생각해선 안 됩니다.

우리가 작업하는 모나드에 따라, mplus는 덧셈처럼 보이는 작업을 구현할 수도 있습니다. 예를 들어, 리스트의 mplus(++) 연산자로 정의했습니다.

ghci> [1,2,3] `mplus` [4,5,6]
[1,2,3,4,5,6]

하지만 다른 모나드로 바꾼다면, 이 명백한 덧셈과의 유사성은 사라집니다.

ghci> Just 1 `mplus` Just 2
Just 1

MonadPlus를 다루는 규칙

MonadPlus 타입클래스의 인스턴스는 일반적인 모나드 규칙과 더불어 몇가지 규칙을 더 지켜야 합니다.

인스턴스는 mzero가 바인드 식 왼쪽에 오면 반드시 단축 평가를 해야 합니다. 즉, mzero >>= f 식은 mzero와 같은 결과를 반환해야 합니다.

-- file: ch15/MonadPlus.hs
    mzero >>= f == mzero

인스턴스는 mzero가 시퀀스 식 오른쪽에 오면 단축평가를 해야 합니다.

-- file: ch15/MonadPlus.hs
    v >> mzero == mzero

MonadPlus로 안전하게 실패하기

“모나드 타입클래스” 절에서 fail을 소개했을 때, 오남용하지 말라고 경고했습니다. 대부분의 모나드에서, fail은 불행한 결과를 가져올 error로 정의되어 있습니다.

MonadPlus 타입클래스는 재앙을 초래할 fail이나 error 없이도 계산을 실패할 더 신사적인 방법을 제공합니다. 우리가 위에서 소개한 규칙이 mzero를 코드 어느 지점에든 끼워넣어, 그 곳부터 단축 평가를 하도록 만들어줍니다.

Control.Monad 모듈에서, 표준 함수 guard는 이 아이디어를 편리한 형태로 싸맸습니다.

-- file: ch15/MonadPlus.hs
guard        :: (MonadPlus m) => Bool -> m ()
guard True   =  return ()
guard False  =  mzero

간단한 예로, 아래는 숫자 x를 받아 모듈로 n을 구하는 함수입니다. 결과가 0이면 x를 반환하고, 아니면 현재 모나드의 mzero를 반환합니다.

-- file: ch15/MonadPlus.hs
x `zeroMod` n = guard ((x `mod` n) == 0) >> return x

배관 숨기기의 장점

“상태 모나드 사용하기: 랜덤 값 생성” 절에서, State 모나드로 어떻게 쉽게 난수에 접근할 수 있는지 그 방법을 보았습니다.

우리가 개발한 코드의 단점은 그 코드가 새기 쉽다는 것입니다. 누군가 그 코드를 사용하는 사람이 그 코드가 State 모나드 안에서 돌아갈 걸 안다고 해 봅시다. 그건 사용자도 제작자 만큼이나 쉽게 난수 생성기의 상태를 조사하고 바꿀 수 있다는 걸 의미합니다.

우리가 내부 구현을 노출한 채 남겨두면, 누군간 당연히 인간의 본능에 따라 그 부분을 파고들어 원숭이같은 짓을 할 겁니다. 작은 프로그램엔 괜찮을 수 있지만, 더 큰 소프트웨어 프로젝트에선, 한 사용자가 다른 사용자가 준비하지 못한 상태에서 내부 구현을 수정해 놓으면, 발생하는 버그는 추적하기 매우 어려운 축에 들어갈 겁니다. 이런 버그는 우리가 다른 모든 가능성을 조사해보고 탈진한 다음 절대 깨질 것 같지 않은 라이브러리에 대한 기본 전제가 깨져야 해결할 수준입니다.

게다가 한번 우리 구현을 노출한 채로 두면, 누군가 바른 의도를 가진 사람이 불가피하게 API를 우회해 구현을 직접 사용한다면, 우리가 버그를 고치거나 개량을 할 때 옴싹달싹 못하게 됩니다. 내부 구현을 수정하고 거기에 의존하는 코드를 포기하거나, 내부 구현에 얽매이고 가능한 다른 우회책을 찾아야 합니다.

어떻게 State 모나드를 사용한다는 걸 숨기도록 난수 모나드를 개선할 수 있을까요? 우리 사용자가 get이나 put을 호출하는 걸 막을 방법을 찾아야 합니다. 이건 하기 어렵지 않고, 매일의 하스켈 프로그래밍에 재활용할 약간의 기교를 도입합니다.

범위를 넓혀, 난수를 넘어 임의 종류의 고유한 값을 제공하는 모나드를 구현하겠습니다. 이번에 만들 모나드 이름은 Supply입니다. 우린 실행 함수 runSupply과 값의 리스트를 제공할 겁니다. 각각의 원소가 고유할지는 우리에게 달렸습니다.

-- file: ch15/Supply.hs
runSupply :: Supply s a -> [s] -> (a, [s])

이 모나드는 값이 난수일지, 임시 파일 이름일지, HTTP 쿠키 ID일지 신경쓰지 않을 겁니다.

모나드 안에서 사용자가 값을 요구할 때마다 next 액션은 리스트에서 다음 하나를 가져와 사용자에게 전달할 겁니다. 각각의 값은 리스트가 부족할 경우를 대비해 Maybe 생성자로 감쌉니다.

-- file: ch15/Supply.hs
next :: Supply s (Maybe s)

우리 배관을 감추기 위해서, 모듈 헤더에 타입 생성자와 실행 함수, next 액션만 드러내도록 하겠습니다.

-- file: ch15/Supply.hs
module Supply
    (
      Supply
    , next
    , runSupply
    ) where

라이브러리를 불러오는 모듈이 모나드의 내부를 못 보기 때문에, 내부를 조작할 수 없습니다.

이 배관 공사는 극히 간단합니다. 우린 기존의 State 모나드를 감싸기 위해 newtype 선언을 썼습니다.

-- file: ch15/Supply.hs
import Control.Monad.State

newtype Supply s a = S (State [s] a)

s 인자는 우리가 제공할 고유한 값의 타입이고, a는 이 타입을 모나드로 만들기 위해 반드시 제공해야 하는 보통의 타입 인자입니다.

Supplynewtype으로 선언한 것과 모듈 헤더로 사용자가 State 모나드의 getset 액션을 쓰는 것을 막고 있습니다. 우리 모듈이 S 데이터 생성자를 드러내지 않았기 때문에, 사용자는 프로그래밍적 방법으로 우리가 State 모나드를 쓰고 있다는 걸 알거나, 접근할 수 없습니다.

이제 Monad 타입클래스의 인스턴스로 만들어야 하는 Supply 타입을 만들었습니다. 평범하게 (>>=)return을 정의할 수도 있지만, 그러면 순수한 보일러플레이트 코드가 될 겁니다. 우리가 해야할 건 State 모나드의 (>>=)returnS 값 생성자로 감싸거나 푸는 것입니다. 아래는 그런 코드의 예시입니다.

-- file: ch15/AltSupply.hs
unwrapS :: Supply s a -> State [s] a
unwrapS (S s) = s

instance Monad (Supply s) where
    s >>= m = S (unwrapS s >>= unwrapS . m)
    return = S . return

하스켈 프로그래머는 보일러플레이트를 별로 좋아하지 않고, GHC는 물론 이런 작업을 없애줄 달콤한 언어 확장을 가지고 있습니다. 이 언어 확장을 사용하기 위해 모듈 헤더 전 우리 소스 파일의 맨 위에 다음 지시자를 넣을 겁니다.

-- file: ch15/Supply.hs
{-# LANGUAGE GeneralizedNewtypeDeriving #-}

대개 ShowEq같은 일부의 표준 타입클래스만 자동으로 인스턴스를 선언할 수 있습니다. 이름이 알려주듯이 GeneralizedNewtypeDeriving 확장은 타입클래스 인스턴스 자동 선언 범위를 넓혀주고, newtype에만 적용됩니다. 어떤 타입클래스의 인스턴스를 newtype으로 감싼다면, 새 타입도 자동으로 다음과 같이 그 타입클래스의 인스턴스가 될 겁니다.

-- file: ch15/Supply.hs
    deriving (Monad)

이건 밑바탕 타입의 (>>=)return의 구현을 사용하고, S 데이터 생성자로 필요한 감싸기와 풀기를 진행하는 함수를 만들어 Monad 인스턴스를 끌어내는 데 사용합니다.

우리가 여기서 얻은 이득은 이 예제 너머에 있습니다. 우리는 newtype을 밑바탕 타입을 감싸는 데 쓸 수 있고, 우리가 원하는 타입클래스만 노출할 수 있으며, 거의 노력을 들이지 않고도 더 특수화된 타입을 만들 수 있습니다.

이제 GeneralizedNewtypeDeriving 기법을 봤으니, 남은 것은 nextrunSupply를 정의하는 것 뿐입니다.

-- file: ch15/Supply.hs
next = S $ do st <- get
              case st of
                [] -> return Nothing
                (x:xs) -> do put xs
                             return (Just x)

runSupply (S m) xs = runState m xs

모듈을 ghci로 불러와, 몇가지 방법으로 시험해 볼 수 있습니다.

ghci> :load Supply
[1 of 1] Compiling Supply           ( Supply.hs, interpreted )
Ok, modules loaded: Supply.
ghci> runSupply next [1,2,3]
Loading package mtl-1.1.0.0 ... linking ... done.
(Just 1,[2,3])
ghci> runSupply (liftM2 (,) next next) [1,2,3]
((Just 1,Just 2),[3])
ghci> runSupply (liftM2 (,) next next) [1]
((Just 1,Nothing),[])

또한 State 모나드가 새지 않는 것도 확인할 수 있습니다.

ghci> :browse Supply
data Supply s a
next :: Supply s (Maybe s)
runSupply :: Supply s a -> [s] -> (a, [s])
ghci> :info Supply
data Supply s a   -- Defined at Supply.hs:17:8-13
instance Monad (Supply s) -- Defined at Supply.hs:17:8-13

난수 제공하기

Supply 모나드를 난수 생성기로 쓰려고 한다면, 몇가지 어려움을 당면합니다. 가능하면, 이 모나드로 무한 난수 스트림을 제공하고 싶을 것입니다. StdGenIO 모나드 안에서 받아올 순 있지만, 작업이 끝나면 다른 StdGen을 반드시 “도로 넣어야” 합니다. 그러지 않으면 StdGen을 쓰는 다음 코드 조각도 같은 상태에 있게 될 겁니다. 이건 충분히 재앙이 될 수 있는, 매번 같은 난수가 나오는 상황을 의미합니다.

지금까지 본 System.Random 모듈의 일부분으로 생각하면, 이 요구조건을 만족시키긴 어렵습니다. 타입에서 StdGen 하나를 받고 다른 StdGen을 되돌려주는 걸 보장하는 getStdRandom을 쓸 수 있습니다.

ghci> :type getStdRandom
getStdRandom :: (StdGen -> (a, StdGen)) -> IO a

난수를 받을 때 새 StdGen도 받기 위해 random을 사용할 수 있습니다. 무한 난수 리스트를 받기 위해 randoms를 사용할 수 있습니다. 하지만 어떻게 무한 난수 리스트StdGen을 동시에 받을 수 있을까요?

난수 생성기 하나를 받고 두 난수 생성기로 바꿔주는 RandomGen 타입 클래스의 split 함수에 답이 있습니다. 난수 생성기를 이처럼 분할하는 건 가능하다는 건 매우 이상해 보입니다. 이건 순수 함수형 환경에서 매우 유용하지만, 비순수 언어에선 필요하지도 않고 제공하지도 않을 것입니다.

split 함수로 얻는 StdGen 하나는 runSupply에 넘겨줄 무한 난수 리스트를 생성하는 데 사용할 수 있고, 다른 하나는 IO 모나드에 넘겨줍니다.

-- file: ch15/RandomSupply.hs
import Supply
import System.Random hiding (next)

randomsIO :: Random a => IO [a]
randomsIO =
    getStdRandom $ \g ->
        let (a, b) = split g
        in (randoms a, b)

만약 우리가 이 함수를 제대로 구현했다면, 우리 예제는 호출할 때마다 매번 다른 값을 반환해야 합니다.

ghci> :load RandomSupply
[1 of 2] Compiling Supply           ( Supply.hs, interpreted )
[2 of 2] Compiling RandomSupply     ( RandomSupply.hs, interpreted )
Ok, modules loaded: RandomSupply, Supply.
ghci> (fst . runSupply next) `fmap` randomsIO

<interactive>:1:17:
    Ambiguous occurrence `next'
    It could refer to either `Supply.next', imported from Supply at RandomSupply.hs:4:0-12
                                              (defined at Supply.hs:32:0)
                          or `System.Random.next', imported from System.Random
ghci> (fst . runSupply next) `fmap` randomsIO

<interactive>:1:17:
    Ambiguous occurrence `next'
    It could refer to either `Supply.next', imported from Supply at RandomSupply.hs:4:0-12
                                              (defined at Supply.hs:32:0)
                          or `System.Random.next', imported from System.Random

runSupply 함수는 실행한 모나딕 액션의 결과와 쓰고 남은 리스트를 반환한다는 걸 떠올리세요. 우리가 무한 난수 리스트를 넘겼기 때문에, fst를 합성해서 ghci가 결과를 출력할 때 난수 해일에 빠지는 걸 방지합니다.

또다른 골프 라운드

쌍의 한 원소에 함수를 적용하고 다른 원소는 냅둔 채 그 원소만 바꾼 새 쌍을 만드는 건 표준 코드가 될 만큼 하스켈에서 빈번했습니다.

Control.Arrow 모듈엔 firstsecond라는 그런 동작을 하는 함수가 있습니다.

ghci> :m +Control.Arrow
ghci> first (+3) (1,2)
(4,2)
ghci> second odd ('a',1)
('a',True)

(실제로, “겹친 인스턴스 없는 JSON 타입클래스” 절에서 이미 second 함수를 봤습니다.) 우리 randomsIO 정의에 first를 활용해 한 줄 짜리로 만들 수 있습니다.

-- file: ch15/RandomGolf.hs
import Control.Arrow (first)

randomsIO_golfed :: Random a => IO [a]
randomsIO_golfed = getStdRandom (first randoms . split)

인터페이스와 구현 분리하기

이전 장에서, 어떻게 Supply의 상태를 유지하기 위해 State 모나드를 사용하는 걸 숨기는지 봤습니다.

코드를 조립이 편리하게 만들기 위한 또다른 중요한 방법은 인터페이스—코드가 무엇을 할 수 있는지—와 그 구현—어떻게 하는지—를 분리하는 것입니다.

System.Random에 있는 표준 난수 생성기는 꽤 느리다고 합니다. 우리가 Supply에 난수를 공급하는 데 randomsIO 함수를 쓰면, next 액션은 그리 빠르지 못할 것입니다.

이 문제를 해결하는 한 가지 효과적인 방법은 더 나은 난수 생성기를 Supply에 제공하는 겁니다. 하지만 이 생각인 일단 제쳐두고, 더 범용적인 다른 대안을 생각해봅시다. 우리는 타입클래스를 사용해서 모나드로 할 수 있는 동작과 어떻게 동작하는지를 분리할 것입니다.

-- file: ch15/SupplyClass.hs
class (Monad m) => MonadSupply s m | m -> s where
    next :: m (Maybe s)

이 타입클래스는 모든 서플라이 모나드가 반드시 구현해야 하는 인터페이스를 정의하고 있습니다. 또한 몇가지 낯선 언어 확장을 쓰기 때문에 유심히 봐야 합니다. 각각의 확장은 이어지는 절에서 다룰 것입니다.

다중 인자 타입클래스

타입클래스 안에 있는 MonadSupply s m을 어떻게 읽어야 할까요? 괄호를 추가한다면 (MonadSupply s) m이 되고 다소 명확해집니다. 즉, Monad인 타입 변수 m에 한해서 , 이걸 타입클래스 MonadSupply s의 인스턴스로 만들 수 있습니다. 보통의 타입클래스와 다르게, 이건 인자를 가지고 있습니다.

이 언어 확장은 타입클래스가 인자를 2개 이상 가지도록 허용하기 때문에 MultiParamTypeClasses라는 이름이 붙었습니다. 인자 sSupply 타입의 타입 인자 s와 역할이 같습니다. snext 함수가 넘겨주는 값의 타입을 나타냅니다.

MonadSupply s의 정의에 (>>=)return을 넣을 필요가 없다는 걸 알아두세요. 타입 클래스의 문맥(슈퍼클래스)이 이미 MonadSupply sMonad인 것을 요구하기 때문입니다.

함수 종속

앞에서 무시한 조각을 다시 보면, | m -> s함수 종속functional dependency이고, 대개 fundep이라고 부릅니다. |를 “오른쪽을 만족하는”, 화살표 ->를 “고유하게 결정하는”이라고 읽을 수 있습니다. 함수 종속은 ms 사이의 관계를 구축합니다.

함수 종속의 허용 여부는 FunctionalDependencies 언어 프라그마컴파일러 지시자에 따라 결정됩니다.

관계를 선언하는 이면의 목적은 타입 검사기를 도와주는 것입니다. 하스켈 타입 검사기는 기본적으로 정리 증명기고, 그 동작 방식은 매우 보수적인 걸 상기하세요. 타입 검사기는 증명이 반드시 종료되는 걸 요구합니다. 끝나지 않는 증명은 컴파일러가 포기하거나 무한 루프에 빠지는 걸 초래합니다.

함수 종속으로, 타입 검사기가 MonadSupply s 문맥에서 사용한 모나드 m을 만날 때마다, s 타입만이 그것과 같이 사용할 수 있는 유일한 타입이라고 알려줍니다. 함수 종속을 생략한다면, 타입 검사기는 에러 메시지를 출력하고 포기할 겁니다.

ms 사이의 관계가 뭘 의미하는지 묘사하긴 어려우므로, 이 타입클래스의 인스턴스를 봅시다.

-- file: ch15/SupplyClass.hs
import qualified Supply as S

instance MonadSupply s (S.Supply s) where
    next = S.next

여기서 타입 S.Supply s로 타입 변수 m을 교체합니다. 함수 종속 덕분에 타입 검사기는 S.Supply s 타입을 발견하면 이 타입을 타입클래스 MonadSupply s의 인스턴스로 쓸 수 있다는 걸 알게 됩니다.

함수 종속이 없었다면 타입 검사기는 MonadSupply s의 타입 인자와 Supply s의 타입 인자 사이의 관계를 밝혀내지 못했을 테고, 에러를 내며 컴파일을 중지할 겁니다. 정의 그 자체는 컴파일 될 테지만, 우리가 처음 사용하는 시점에서 타입 에러가 발생할 겁니다.

S.Supply Int를 예를 들어 마지막 추상화 한 단계를 벗겨봅시다. 함수 종속이 없어도 이 타입을 MonadSupply s의 인스턴스로 선언할 수 있습니다. 하지만 이 인스턴스를 사용하는 코드를 짜면 컴파일러는 S.SupplyInt 인자와 타입클래스의 s 인자와 같아야 한다는 걸 모르고 에러를 출력할 겁니다.

함수 종속을 이해하는 건 어려울 수 있고, 단순한 사용을 넘어서면 대개 실제로 동작하게 만들기 어렵습니다. 다행히도 함수 종속의 주된 사용처는 이처럼 문제를 일으키키 힘들 단순한 상황입니다.

모듈 제작 마무리하기

SupplyClass.hs 파일에 우리가 만든 타입클래스와 인스턴스를 저장하고, 아래와 같은 모듈 헤더를 추가해야 합니다.

-- file: ch15/SupplyClass.hs
{-# LANGUAGE FlexibleInstances, FunctionalDependencies,
             MultiParamTypeClasses #-}

module SupplyClass
    (
      MonadSupply(..)
    , S.Supply
    , S.runSupply
    ) where

컴파일러가 우리 인스턴스 선언을 납득하기 위해선 FlexibleInstances 확장이 필요합니다. 이 확장은 컴파일러의 타입 검사기가 일부 상황에서 증명이 종료되는 걸 보장하게 만들어 인스턴스 작성 규칙을 느슨하게 합니다. 여기서 FlexibleInstances가 필요한 건 함수 종속 때문이지만, 자세한 이유는 안타깝게도 이 책의 범위를 벗어납니다.

언제 언어 확장이 필요한지 아는 법

GHC가 일부 코드를 어떤 언어 확장이 없어서 컴파일하지 못 하면, 무슨 언어 확장을 써야 하는지 알려줍니다. 예를 들어 GHC가 코드를 컴파일 하는 데 FlexibleInstances 지원이 필요하다고 판단하면, -XFlexibleInstances 옵션과 함께 컴파일해보라고 제안할 겁니다. -X 옵션은 LANGUAGE 지시자와 마찬가지로 특정 확장을 활성화하는 효과가 있습니다.

마지막으로, 이 모듈에서 runSupplySupply 명칭을 다시 내보내는 점을 주목하세요. 다른 모듈에서 그 명칭을 정의했더라도 그걸 내보내는 것 역시 적법합니다. 우리 경우엔, 이건 사용자 코드에서 Supply 모듈도 가져올 필요 없이 SupplyClass 모듈만 가져와도 되는 걸 의미합니다. 이걸로 우리 코드의 사용자는 염두에 둘 “움직이는 부분”의 개수를 줄일 수 있습니다.

모나드 인터페이스 프로그래밍

아래는 Supply 모나드에서 값 두 개를 가져와 문자열로 조립해 반환하는 간단한 함수입니다.

-- file: ch15/Supply.hs
showTwo :: (Show s) => Supply s String
showTwo = do
  a <- next
  b <- next
  return (show "a: " ++ show a ++ ", b: " ++ show b)

이 코드는 결과 타입 때문에 Supply 모나드에 묶여있습니다. 이 함수의 타입을 바꿔 MonadSupply 인터페이스를 구현한 임의의 모나드를 대상으로 일반화할 수 있습니다. 함수의 본체는 바뀌지 않았다는 걸 주목하세요.

-- file: ch15/SupplyClass.hs
showTwo_class :: (Show s, Monad m, MonadSupply s m) => m String
showTwo_class = do
  a <- next
  b <- next
  return (show "a: " ++ show a ++ ", b: " ++ show b)

Reader 모나드

State 모나드는 가변적인 상태를 코드 사이사이에서 전달하도록 도와줬습니다. 때때로 우린 프로그램 설정같은 어떤 불변 상태를 전달하고 싶을 때도 있습니다. State 모나드를 이 목적으로 사용할 순 있지만, 예기치 않게 바꾸지 말아야 할 상태를 바꾸는 사고가 생길 수 있습니다.

모나드는 잠시 잊어버리고 우리가 필요한 특성을 가진 함수가 뭘 해야 하는지 생각해봅시다. 이 함수는 넘겨준 데이터를 나타내는 (환경environment에서 따온) 타입 e 값을 받아 다른 타입 a를 결과로 반환합니다. 우리가 원하는 전체 타입은 e -> a가 됩니다.

이 타입을 편리한 Monad 인스턴스로 바꾸기 위해, newtype으로 감싸겠습니다.

-- file: ch15/SupplyInstance.hs
newtype Reader e a = R { runReader :: e -> a }

이걸 Monad 인스턴스로 만드는 건 큰일은 아닙니다.

-- file: ch15/SupplyInstance.hs
instance Monad (Reader e) where
    return a = R $ \_ -> a
    m >>= k = R $ \r -> runReader (k (runReader m r)) r

e 타입 값을 평가 중인 식의 환경이라고 생각할 수 있습니다. 환경이 어떻든 간에 return은 동일한 동작을 해야하기 때문에 우리가 만든 return은 환경을 무시합니다.

(>>=)의 정의는 약간 더 복잡하지만, 현재 계산과 연쇄할 계산에 환경—여기선 변수 r—을 제공하기만 하면 됩니다.

이 모나드 안에서 동작하는 코드는 환경을 어떻게 가져올까요? 간단히 ask를 호출하면 됩니다.

-- file: ch15/SupplyInstance.hs
ask :: Reader e e
ask = R id

아래 액션 연쇄에서 환경에 저장한 값은 변하지 않기 때문에, ask를 호출할 때마다 같은 값을 반환합니다. 우리 코드는 ghci에서 테스트하기 쉽습니다.

ghci> runReader (ask >>= \x -> return (x * 3)) 2
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.
6

Reader 모나드는 대개 GHC에 딸려오는 표준 mtl에 포함돼 있고, Control.Monad.Reader 모듈에서 찾을 수 있습니다. 이 모나드는 대개 복잡한 코드에서 유용하기 때문에, 처음 이 모나드를 보면 좀 쓸모없어 보일 수 있습니다. 우린 자주 프로그램 깊숙한 곳에서 설정 정보의 한 조각을 접근해야 할 때가 있습니다. 그 정보를 일반 인자로 넘기려면 우리 코드를 힘들게 갈아 엎어야 할 것입니다. 이 정보를 모나드 배관에 숨겨서 중간에 낀 설정에 상관 없는 함수들은 그 인자들을 볼 필요가 없게 됩니다.

Reader 모나드를 사용하는 명확한 동기는 몇몇 모나드를 조합해 새 모나드를 만드는 주제를 다루는 18장. 모나드 변환기에서 볼 것입니다. 그 장에서 State 모나드로 일부 값을 변경하고, Reader 모나드로 나머지 값들은 불변으로 남기는 방법으로 상태에 대한 섬세한 조작을 하는 법을 볼 것입니다.

자동 타입클래스 선언으로 돌아가서

Reader 모나드도 알았으니 MonadSupply 타입클래스 인스턴스를 만드는 데 써먹어 봅시다. 예제를 간단히 하기 위해서, MonadSupply의 목적을 위반하겠습니다. next 액션은 매번 다른 값을 반환하는 게 아니라 같은 값을 반환할 겁니다.

Reader 타입을 곧바로 MonadSupply의 인스턴스로 만드는 건 안 좋은 생각입니다. 그러면 모든 ReaderMonadSupply로 동작할 수 있고, 보통 이건 말이 안 됩니다.

대신, Reader에 기반한 newtype을 만들 겁니다. 이 newtype은 우리가 내부적으로 Reader를 쓴다는 사실을 숨깁니다. 이 타입은 반드시 우리가 신경쓰는 두 타입클래스의 인스턴스로 만들어야 합니다. GeneralizedNewtypeDeriving를 활성화하면 GHC가 힘든 작업을 대신 해 줄 겁니다.

-- file: ch15/SupplyInstance.hs
newtype MySupply e a = MySupply { runMySupply :: Reader e a }
    deriving (Monad)

instance MonadSupply e (MySupply e) where
    next = MySupply $ do
             v <- ask
             return (Just v)

    -- more concise:
    -- next = MySupply (Just `liftM` ask)

새 타입을 MonadSupply가 아니라 MonadSupply e의 인스턴스로 만들어야 한다는 사실에 주목하세요. 타입 인자를 빠뜨리면 컴파일러는 불만을 터뜨릴 겁니다.

MySupply 타입을 시험해보기 위해 먼저 아무 MonadSupply 인스턴스와 동작하는 간단한 함수를 짜보겠습니다.

-- file: ch15/SupplyInstance.hs
xy :: (Num s, MonadSupply s m) => m s
xy = do
  Just x <- next
  Just y <- next
  return (x * y)

이 함수를 Supply 모나드와 randomsIO 함수와 같이 사용하면 기대한 대로 매번 다른 값을 얻을 것입니다.

ghci> (fst . runSupply xy) `fmap` randomsIO
-15697064270863081825448476392841917578
ghci> (fst . runSupply xy) `fmap` randomsIO
17182983444616834494257398042360119726

MySupply 모나드는 newtype으로 두 번 감쌌기 때문에, 따로 실행 함수를 만들면 더 쉽게 사용할 수 있습니다.

-- file: ch15/SupplyInstance.hs
runMS :: MySupply i a -> i -> a
runMS = runReader . runMySupply

이 실행함수로 xy 액션을 적용하면 매번 같은 값을 얻게 됩니다. 우리 코드는 그대로지만, 다른 MonadSupply 구현 안에서 실행시켰기 때문에 동작이 변한 것입니다.

ghci> runMS xy 2
4
ghci> runMS xy 2
4

MonadSupply 타입클래스와 Supply 모나드처럼 거의 모든 하스켈 모나드는 인터페이스와 구현을 분리해 놨습니다. 예를 들어 State 모나드에 “속한” 것처럼 소개한 getput은 사실 MonadState 타입클래스의 메서드고, State 타입은 이 타입클래스의 인스턴스입니다.

마찬가지로 표준 Reader 모나드는 ask 메서드를 가진 MonadReader의 인스턴스입니다.

위에서 말한 인터페이스와 구현 분리가 설계적 깔끔함으로 가지는 매력 외에도, 나중에 알게 될 실용적인 활용처도 있습니다. 18장. 모나드 변환기에서 모나드를 조합할 때, GeneralizedNewtypeDeriving과 타입클래스를 사용해 많은 수고를 줄일 것입니다.

IO 모나드 숨기기

IO 모나드가 양날의 칼인 이유는 너무 강력하기 때문입니다. 주의해서 타입을 사용하는 게 프로그래밍 실수를 피하는 걸 도와준다고 하면, IO 모나드는 거기서 눈엣가시가 됩니다. IO 모나드는 우리가 할 수 있는 것을 제한하지 않기 때문에 모든 종류의 사고에 우리를 취약하게 합니다.

어떻게 이 힘을 제어할 수 있을까요? 우리가 어떤 코드 조각이 로컬 파일시스템은 읽고 쓸 수 있지만 네트워크는 접근할 수 없도록 보장하고 싶다고 해 봅시다. 일반적인 IO 모나드는 그런 식으로 제한하지 않기 때문에 사용할 수 없습니다.

newtype 사용하기

파일을 읽고 쓰는 작은 기능 집합을 제공하는 모듈을 만들어봅시다.

-- file: ch15/HandleIO.hs
{-# LANGUAGE GeneralizedNewtypeDeriving #-}

module HandleIO
    (
      HandleIO
    , Handle
    , IOMode(..)
    , runHandleIO
    , openFile
    , hClose
    , hPutStrLn
    ) where
    
import System.IO (Handle, IOMode(..))
import qualified System.IO

IOnewtype으로 감싸 제한된 IO를 만드는 첫번째 걸음을 딛겠습니다.

-- file: ch15/HandleIO.hs
newtype HandleIO a = HandleIO { runHandleIO :: IO a }
    deriving (Monad)

이젠 익숙할 데이터 생성자 말고 타입 생성자와 runHandleIO 실행 함수만 내보내는 방법을 썼습니다. 이게 HandleIO 안에서 HandleIO가 감싸고 있는 IO 모나드를 얻는 걸 막아줄 겁니다.

우리에게 남은 건 우리 모나드가 허용할 동작 각각을 감싸는 겁니다. 이건 IO 각각을 HandleIO 데이터 생성자로 감싸면 됩니다.

-- file: ch15/HandleIO.hs
openFile :: FilePath -> IOMode -> HandleIO Handle
openFile path mode = HandleIO (System.IO.openFile path mode)

hClose :: Handle -> HandleIO ()
hClose = HandleIO . System.IO.hClose

hPutStrLn :: Handle -> String -> HandleIO ()
hPutStrLn h s = HandleIO (System.IO.hPutStrLn h s)

이제 제한된 HandleIO 모나드를 입출력에 사용할 수 있습니다.

-- file: ch15/HandleIO.hs
safeHello :: FilePath -> HandleIO ()
safeHello path = do
  h <- openFile path WriteMode
  hPutStrLn h "hello world"
  hClose h

runHandleIO로 이 액션을 실행합니다.

ghci> :load HandleIO
[1 of 1] Compiling HandleIO         ( HandleIO.hs, interpreted )
Ok, modules loaded: HandleIO.
ghci> runHandleIO (safeHello "hello_world_101.txt")
Loading package old-locale-1.0.0.0 ... linking ... done.
Loading package old-time-1.0.0.0 ... linking ... done.
Loading package filepath-1.1.0.0 ... linking ... done.
Loading package directory-1.0.0.0 ... linking ... done.
Loading package mtl-1.1.0.0 ... linking ... done.
ghci> :m +System.Directory
ghci> removeFile "hello_world_101.txt"

만약 허용하지 않은 액션을 HandleIO 모나드 안에서 연속sequence하려고 하면, 타입 시스템에서 막을 겁니다.

ghci> runHandleIO (safeHello "goodbye" >> removeFile "goodbye")

<interactive>:1:36:
    Couldn't match expected type `HandleIO a'
           against inferred type `IO ()'
    In the second argument of `(>>)', namely `removeFile "goodbye"'
    In the first argument of `runHandleIO', namely
        `(safeHello "goodbye" >> removeFile "goodbye")'
    In the expression:
        runHandleIO (safeHello "goodbye" >> removeFile "goodbye")

예상치 못한 경우를 대비한 설계

HandleIO 모나드엔 작지만 중요한 문제가 있습니다. 때때로 탈출구가 필요할 가능성을 고려하지 않았단 점입니다. 우리가 이런 모나드를 정의한다면, 부득이하게 우리 모나드가 허용하지 않은 입출력 액션을 수행해야 할 때도 생길 겁니다.

일반적인 상황에서 견고한 코드를 짜기 쉬우라고 이런 모나드를 정의한 것이지, 예기치 못한 상황이 없으라고 한 게 아닙니다. 그러니 우리 스스로 쓸 탈출구를 만들어 봅시다.

Control.Monad.Trans 모듈은 “표준 탈출구”를 MonadIO 타입클래스에 정의했습니다. IO 액션을 다른 모나드에 심을 수 있는 liftIO 함수가 그것입니다.

ghci> :m +Control.Monad.Trans
ghci> :info MonadIO
class (Monad m) => MonadIO m where liftIO :: IO a -> m a
    -- Defined in Control.Monad.Trans
instance MonadIO IO -- Defined in Control.Monad.Trans

이 타입클래스를 위한 구현은 간단합니다. 그냥 IO를 우리 데이터 생성자로 감쌀 겁니다.

-- file: ch15/HandleIO.hs
import Control.Monad.Trans (MonadIO(..))

instance MonadIO HandleIO where
    liftIO = HandleIO

liftIO를 현명하게 사용함으로써, 우린 필요한 곳에서 족쇄를 풀고 IO 액션을 호출할 수 있습니다.

-- file: ch15/HandleIO.hs
tidyHello :: FilePath -> HandleIO ()
tidyHello path = do
  safeHello path
  liftIO (removeFile path)

MonadIO와 자동 유도deriving

HandleIOderiving 구문에 타입클래스를 추가해서 컴파일러가 자동으로 MonadIO 인스턴스를 유도하게 할 수도 있었을 겁니다. 실제로도 이건 일반적인 전략입니다. 단지 이전 MonadIO 코드와 분리해서 보여주기 위해 그러지 않았습니다.

타입 클래스 사용하기

실제 구현에 얽매였다는 점이 다른 모나드에서 IO를 숨기는 것의 단점입니다. 만약 HandleIO를 다른 모나드와 바꾸려면, 우린 HandleIO를 쓰는 모든 액션의 타입을 바꿔야 합니다.

대안으로 파일을 다루는 모나드에서 우리가 원하는 인터페이스를 노출하는 타입클래스를 만드는 방법이 있습니다.

-- file: ch15/MonadHandle.hs
{-# LANGUAGE FunctionalDependencies, MultiParamTypeClasses #-}

module MonadHandle (MonadHandle(..)) where

import System.IO (IOMode(..))

class Monad m => MonadHandle h m | m -> h where
    openFile :: FilePath -> IOMode -> m h
    hPutStr :: h -> String -> m ()
    hClose :: h -> m ()
    hGetContents :: h -> m String

    hPutStrLn :: h -> String -> m ()
    hPutStrLn h s = hPutStr h s >> hPutStr h "\n"

여기선 모나드 타입과 파일 핸들 타입 둘 다 추상화하는 걸 택했습니다. 타입 검사기를 위해 함수 종속을 사용했습니다. 모든 MonadHandle 인스턴스는 쓸 수 있는 핸들 타입이 제각기 하나만 있을 겁니다. IO 모나드를 이 타입클래스의 인스턴스로 만들면, 일반 Handle을 사용하게 될 겁니다.

-- file: ch15/MonadHandleIO.hs
{-# LANGUAGE FunctionalDependencies, MultiParamTypeClasses #-}

import MonadHandle
import qualified System.IO

import System.IO (IOMode(..))
import Control.Monad.Trans (MonadIO(..), MonadTrans(..))
import System.Directory (removeFile)

import SafeHello

instance MonadHandle System.IO.Handle IO where
    openFile = System.IO.openFile
    hPutStr = System.IO.hPutStr
    hClose = System.IO.hClose
    hGetContents = System.IO.hGetContents
    hPutStrLn = System.IO.hPutStrLn

MonadHandle 또한 Monad여야 하기 때문에, 파일을 다루는 코드를 어떤 모나드에서 실행하는지 신경쓸 일 없이 do 표기법으로 짤 수 있습니다.

-- file: ch15/SafeHello.hs
safeHello :: MonadHandle h m => FilePath -> m ()
safeHello path = do
  h <- openFile path WriteMode
  hPutStrLn h "hello world"
  hClose h

IO를 이 타입클래스의 인스턴스로 만들었기에 이 액션을ghci에서 실행할 수 있습니다.

ghci> safeHello "hello to my fans in domestic surveillance"
Loading package old-locale-1.0.0.0 ... linking ... done.
Loading package old-time-1.0.0.0 ... linking ... done.
Loading package filepath-1.1.0.0 ... linking ... done.
Loading package directory-1.0.0.0 ... linking ... done.
Loading package mtl-1.1.0.0 ... linking ... done.
ghci> removeFile "hello to my fans in domestic surveillance"

타입클래스를 쓰는 방법의 좋은 점은, 우리 코드가 쓰지 않거나 구현에 신경쓰지 않는, 바탕으로 쓰는 모나드를 코드를 많이 건드리지 않고도 다른 모나드로 바꿀 수 있다는 점입니다. 예를 들어, IO를 출력하는 즉시 압축해 파일에 기록하는 모나드로도 바꿀 수 있습니다.

모나드의 인터페이스를 타입클래스로 정의하는 덴 다른 장점도 있습니다. 이건 다른 사람이 우리 구현을 newtype 래퍼 안에 숨기게 하고, 노출하고 싶은 타입클래스만 인스턴스로 자동 선언하게 만듭니다.

격리와 테스트

safeHello 함수가 실제로 IO 타입을 안 쓰기 때문에, 입출력을 못 하는 모나드도 쓸 수 있습니다. 이로인해 일반적으로 부수 효과를 가지는 코드를 완전히 순수한, 통제되는 환경에서 테스트할 수 있게 됩니다.

이걸 해보기 위해, 입출력을 수행하진 않지만 나중에 쓸 파일에 관련한 이벤트를 기록하는 모나드를 만들겠습니다.

-- file: ch15/WriterIO.hs
data Event = Open FilePath IOMode
           | Put String String
           | Close String
           | GetContents String
             deriving (Show)

“새 모나드 사용하기: 직접 만들어 봅시다!” 절에서 Logger 타입을 고안하긴 했지만, 여기선 표준이고 더 일반적인 Writer 모나드를 쓰겠습니다. 다른 mtl 모나드처럼 Writer 모나드의 API도 타입클래스로 제공하고, 그 타입클래스 이름은 MonadWriter입니다. 가장 유용한 메서드는 값을 기록하는 tell입니다.

ghci> :m +Control.Monad.Writer
ghci> :type tell
tell :: (MonadWriter w m) => w -> m ()

아무 Monoid 타입이나 기록할 수 있습니다. 리스트 타입도 Monoid기에 Event 리스트에 기록할 겁니다.

Writer [Event]MonadHandle의 인스턴스로 만들 수도 있지만, 이게 더 특수 목적 모나드를 만들기 저렴하고, 쉽고, 안전합니다.

-- file: ch15/WriterIO.hs
newtype WriterIO a = W { runW :: Writer [Event] a }
    deriving (Monad, MonadWriter [Event])

실행 함수는 단순히 우리가 추가한 newtype 래퍼를 제거하고, 일반 Writer 실행 함수를 호출합니다.

-- file: ch15/WriterIO.hs
runWriterIO :: WriterIO a -> (a, [Event])
runWriterIO = runWriter . runW

이 코드를 ghci에서 실행하면, 함수의 파일 액세스 기록을 줄 것입니다.

ghci> :load WriterIO
[1 of 3] Compiling MonadHandle      ( MonadHandle.hs, interpreted )
[2 of 3] Compiling SafeHello        ( SafeHello.hs, interpreted )
[3 of 3] Compiling WriterIO         ( WriterIO.hs, interpreted )
Ok, modules loaded: SafeHello, MonadHandle, WriterIO.
ghci> runWriterIO (safeHello "foo")
((),[Open "foo" WriteMode,Put "foo" "hello world",Put "foo" "\n",Close "foo"])

Writer 모나드와 리스트

우리가 tell을 호출할 때마다 Writer 모나드는 mappend를 호출합니다. 리스트의 mappend(++)이고, 이런 반복적인 추가는 비싸기 때문에, Writer에 쓰기엔 실용적이지 않습니다. 위에선 단지 단순함을 위해 리스트를 사용했습니다.

실사용 코드에서 리스트같은 동작을 하는 Writer 모나드가 필요하다면, 추가하는데 더 나은 특성을 가진 타입을 쓰세요. 그런 타입 중 하나로 “데이터로서의 함수 활용하기” 절에서 소개한 차이 리스트difference list가 있습니다. 자신만의 차이 리스트 구현을 고민할 필요도 없는 게, 잘 짠 라이브러리를 Hackage, 하스켈 패키지 데이터베이스에서 다운받을 수 있습니다. 아니면 “일반 목적 시퀀스” 절에서 소개한 Data.Sequence모듈의 Seq 타입을 쓸 수도 있습니다.

다시 보는 임의 입출력

IO를 제한하는데 타입클래스를 활용하면 기존의 입출력 액션을 보존할 수 있습니다. 그러기 위해 MonadIO를 타입클래스에 제약 조건으로 넣을 수 있습니다.

-- file: ch15/MonadHandleIO.hs
class (MonadHandle h m, MonadIO m) => MonadHandleIO h m | m -> h

instance MonadHandleIO System.IO.Handle IO

tidierHello :: (MonadHandleIO h m) => FilePath -> m ()
tidierHello path = do
  safeHello path
  liftIO (removeFile path)

하지만 이 방법엔 문제가 하나 있습니다. 추가한 MonadIO 제약 조건이 우리 코드를 순수한 환경에서 테스트할 수 없게 만듭니다. 어떤 테스트가 부수 효과를 일으킬지 더 이상 구분할 수 없게 되기 때문입니다. 대안으로 이 타입 제약을 모든 함수를 “감염시키는” 타입클래스에서 실제 입출력이 필요한 함수쪽으로만 옮기는 것이 있습니다.

-- file: ch15/MonadHandleIO.hs
tidyHello :: (MonadIO m, MonadHandle h m) => FilePath -> m ()
tidyHello path = do
  safeHello path
  liftIO (removeFile path)

MonadIO가 없는 함수에는 순수한 프로퍼티 테스트를 사용할 수 있고, 나머지에는 전통적인 유닛 테스트를 쓸 수 있습니다.

불행히도, 우린 문제 하나를 다른 문제로 바꾼 것에 불과합니다. MonadIOMonadHandle 제약 조건을 둘 다 가진 코드를 MonadHandle만 제약 조건으로 가진 코드에서 사용할 수 없습니다. 만약 우리가 이 사실을 MonadHandle만 사용한 코드 깊숙한 곳에서 발견한다면, MonadIO 제약 조건도 필요하게 되고, 그 곳에 이르는 모든 경로에 모두 제약 조건을 추가해야 할 것입니다.

임의 입출력 작업을 허용하는 건 위험하고, 우리가 코드를 개발하고 테스트하는데 상당한 영향을 끼칩니다. 자유도를 늘리는 것과, 쉬운 추론과 테스트 중 하나를 택해야만 한다면 보통 후자를 선호할 겁니다.

연습문제

  1. QuickCheck로 열리지 않은 파일 핸들에 쓰기를 시도하는지 MonadHandle 모나드 안에서 액션을 테스트하세요. safeHello에 시도해보세요.
  2. 닫힌 핸들에 쓰기를 시도하는 액션을 작성해보세요. 독자의 테스트가 이 버그를 잡아내나요?
  3. URL 인코딩 된 문자열에선 key&key=1&key=2 같이 같은 키가 여러 번 나오거나, 값이 있거나 없을 수 있습니다. 이런 문자열을 키값으로 나타내는 데 어떤 타입을 사용하겠습니까? 모든 정보를 정확하게 읽어내는 파서를 작성해보세요.
저작자 표시 비영리 동일 조건 변경 허락
신고