Servant

Posted on June 19, 2020

생각 스트레칭

Proxy

Proxy가 타입 정보를 저장하는 테크닉을 보고나서 servant를 보면 도움이 됩니다.

data Proxy a = Proxy

코드 조립을 위해, 타입 체커에게 힌트를 주기 위한 장치입니다.

값에는 의미 있는 정보를 넣어둘 곳이 없습니다. 값은 오직 한 가지 Proxy만 있습니다. 하지만, =의 왼편 타입 생성자에는 a라는 타입 변수가 있습니다. 코드에서 Proxy란 값이 쓰이면, 이 값의 타입은 Proxy Int, Proxy Double, Proxy (Maybe Int) … 이 될 수 있다는 말입니다. 이런 타입 정보는 오직 타입 체커에서만 쓰이고, 런타임에는 사라지는 정보들입니다. 예로 컴파일 타임에 타입 체커가 클래스의 인스턴스를 고르는데 이 정보를 쓸 수 있습니다.

※ 이렇게 값에 쓰이지 않는 타입 변수를 특별히 phantom variable이라 부릅니다.

data Proxy a = Proxy
data Player
data Villain
data VillainLike

class HasName a where
    who :: Proxy a -> String

instance HasName Player where
    who _ = "Player"

instance HasName Villain where
    who _ = "Villain"

yaho :: HasName a => Proxy a -> IO () 
yaho x = putStrLn $ "Yaho. I am " ++ who x -- 어떤 who가 실행될지는 코드를 조립해봐야 압니다.

GHCi에서 테스트할 때, 아래처럼 타입 지정을 해줍니다.

> yaho (Proxy :: Proxy Player)
Yaho. I am Player
> yaho (Proxy :: Proxy Villain)
Yaho. I am Villain

Q. GHCi 예를 보면 :: Proxy Player로 타입을 지정해서 yaho에 넘기고 있습니다. 런타임에 타입은 모두 사라지는 것 아닌가요?
A. 위 소스는 main 함수가 없습니다. 최종 standalone으로 돌아가는 바이너리가 아닙니다. GHCi에서 테스트할 때는 아직 완벽한 런타임 환경이 아닙니다. main이 있는 코드에서 yaho (Proxy :: Proxy Player)를 만났다면, 컴파일 타임에 타입 미스 매치가 되진 않는지 살펴 본 후, 최종 바이너리에서는 타입 정보는 빼버립니다. 여기서 Proxy의 역할은 yaho의 인스턴스를 고르는 것이니, 런타임에는 HasName의 인스턴스가 어느 하나로 고정되고 타입 정보는 사라집니다.

만일 타입 체커가 코드 조립 중 yaho 함수에 (Proxy :: Proxy VillainLike)가 넘어간다면 컴파일 타임에 잡아 낼 수 있습니다.

Servant

모나드 스택들에서 허우적대다, 쉴 겸 눈에 바로 결과물이 보이는 웹 관련 라이브러리들을 봐 둘까 하고 봤다가 Type Level Programming 을 공부하도록 이끈 라이브러리입니다. Yui, JQuery 쯤에서 웹 관련 작업이 끊겨서, React도 모르는 상황에서 보니, 예시 코드를 봐도 뭘 위한 것인지 알게 되는데 좀 시간이 걸렸습니다.

Servant가 하는 일은, http 리소스 요청과 하스켈 함수를 매칭 시켜주는 역할입니다. 매칭된 함수가 결과를 뱉으면, 이 결과를 요청자에게 보내는 일은 Servant backend로 쓰이고 있는 WAI 라이브러리가 담당합니다. URL과 헤더를 파싱할 때, 타입 레벨 프로그래밍 패턴을 이용해서 안정성을 높인 라이브러입니다.

Servant 동작을 이해하는데 많은 도움을 준 포스트입니다. 이 포스트에서 일부 코드를 조금 가져와 풀어 봤습니다.

Implementing a minimal version of haskell-servant - Andres Löh

data Get (a :: *)

data a :<|> b = a :<|> b
infixr 8 :<|>

data (a :: k) :> (b :: *)
infixr 9 :>

data Capture (a :: *)

값 생성자가 없는 타입이 어떤 의미가 있을까요? 값이 없다면 다른 값들과 어떤 연산도 못합니다. 타입만으로 영향을 줄 수 있는게 뭘까 생각해 봤습니다.

프로그램은 값이 지나가는 길을 만드는 겁니다. 간단한 연산을 위한게 아니라면, 중간 중간 갈래 길을 만들어 조건에 맞게 길을 선택(분기)할 수 있게 만드는 게 프로그램입니다. 값이 없는 타입들은 다른 값들과 어울릴 수 없으나, 갈래 길을 만드는 용도로 쓸 수 있습니다.

타입으로 갈래 길을 만드는 하스켈 요소는 바로 클래스와 인스턴스입니다. 메소드에서 받은 인자 타입에 따라 인스턴스를 고를 수 있습니다. 이 요소를 적극 활용하는게 Type Family1 입니다.

type famliy Server layout :: *
type instance Server (Get a) = IO a
type instance Server (a :<|> b) = (Server a, Server b)
type instance Server (a :<|> b) = Server a :<|> Server b
type instance Server ((s :: Symbol) :> r) = Server r
type instance Server (Capture a :> r) = a -> Server r

이렇게 패밀리를 선언하면, 코드 중에 Server layout 이라 쓰고, 구체 타입은 컴파일 타임에 코드 조합에 따라 결정되도록 할 수 있습니다. 아래 route 메소드는 코딩시 고정된 타입이 아니라, 컴파일 타임에 코드 조합에 따라 결정하겠다는 표현입니다.

class HasServer layout where
  route :: Proxy layout -> Server layout -> [String] -> Maybe (IO String)
instance ...
instance ...
...

serve :: HasServer layout
      => Proxy layout -> Server layout -> [String] -> IO String
serve p h xs = case route p h xs of
  Nothing -> ioError (userError "404")
  Just m  -> m

serve 함수를 콜할 때 들어온 layout 에 따라 Server layout 타입이 결정됩니다. Server (Get a) 가 들어왔다면 route 의 타입은

route :: Proxy (Get a) -> IO a -> [String] -> Maybe (IO String)

로 구체 타입이 결정되고, HasServer (Get a) 인스턴스에 있는 route 를 부릅니다.

… 계속
미완성 포스트


  1. Type Family : 타입 레벨 함수
    타입끼리 연산과 갈래 길을 만드는 건 Type familiy를 통해 표현됩니다. 타입 연산은, 타입 매개 변수를 가지고 있는 타입에 어떤 타입을 넣어주냐에 따라 결과 타입이 결정되는 걸 말합니다.

    class Add a b where  
        type SumTy a b
        plus :: a -> b -> SumTy a b
    
    instance Add Integer Double where  
        type SumTy Integer Double = Double

    SumTy 타입은, 컴파일 타임에 코드 조합을 하다 Add Integer Double 인스턴스를 쓰게 되면 Double 타입이 됩니다. Type family를 쓰면, 인스턴스에 따라 메소드 시그니처를 다르게 할 수 있습니다. 인스턴스를 고를 때나, 위와 같이 메소드들의 타입을 결정을 지을 땐 값이 필요 없고, 타입만 있으면 됩니다. 값이 없는 타입이 보이면 코드 어딘가에서 이렇게 쓰겠구나 생각하면 됩니다.↩︎

Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com