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
= "Player"
who _
instance HasName Villain where
= "Villain"
who _
yaho :: HasName a => Proxy a -> IO ()
= putStrLn $ "Yaho. I am " ++ who x -- 어떤 who가 실행될지는 코드를 조립해봐야 압니다. yaho x
GHCi에서 테스트할 때, 아래처럼 타입 지정을 해줍니다.
> yaho (Proxy :: Proxy Player)
Yaho. I am Player
> yaho (Proxy :: Proxy Villain) Yaho. I am Villain
Q. GHCi 예를 보면
:: Proxy Player
로 타입을 지정해서yaho
에 넘기고 있습니다. 런타임에 타입은 모두 사라지는 것 아닌가요?
A. main이 있는 코드에서 yaho (Proxy :: Proxy Player)를 만났다면, 컴파일 타임에 타입 미스 매치가 되진 않는지 살펴 본 후, 최종 바이너리에서는 타입 정보는 빼버립니다. 여기서 Proxy의 역할은 yaho의 인스턴스를 고르는 것이니, 런타임에는 HasName의 인스턴스가 어느 하나로 고정되고 타입 정보는 사라집니다. (만일, 런타임에도 타입 정보가 필요하다면,Typeable
을 이용해서 남겨 놓는 방법이 있습니다. 아마도Typeable
도 내부 구현은 타입 클래스와 인스턴스를 쓰고 있을 것으로 생각합니다.)
만일 타입 체커가 코드 조립 중 yaho 함수에 (Proxy :: Proxy VillainLike)가 넘어간다면 컴파일 타임에 잡아 낼 수 있습니다.
※ 여기서는 따로 번역 안하고 음차해서 타입 레벨로 쓰고 있는데, 번역어를 붙인 다면, 타입 수준 프로그래밍 보다는, 타입층 프로그램이 더 어울리지 않을까 개인 생각입니다만, 통용되는 번역어는 아닌 것 같습니다.
모나드 스택들에서 허우적대다, 쉴 겸 눈에 바로 결과물이 보이는 웹 관련 라이브러리들을 봐 둘까 하고 봤다가 Type Level Programming 을 공부하도록 이끈 라이브러리입니다. Yui, JQuery 쯤에서 웹 관련 작업이 끊겨서, React도 모르는 상황에서 보니, 예시 코드를 봐도 뭘 위한 것인지 알게 되는데 좀 시간이 걸렸습니다.
Servant가 하는 일은, http 리소스 요청과 하스켈 함수를 매칭 시켜주는 역할입니다. 매칭된 함수가 결과를 뱉으면, 이 결과를 요청자에게 보내는 일은 Servant backend로 쓰이고 있는 WAI 라이브러리가 담당합니다. URL과 헤더를 파싱할 때, 타입 레벨 프로그래밍 패턴을 이용해서 안정성을 높인 라이브러입니다.
Servant 동작을 이해하는데 많은 도움을 준 포스트입니다. 이 포스트에서 일부 코드를 조금 가져와 풀어 봤습니다. (실제 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
= case route p h xs of
serve p h xs 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 를 부릅니다.
2025.7.6 추가
가끔 이론들 공부하다 보면, 얼핏 아는 것 같은데도, 계속 나사가 하나 빠진듯이 느껴질 때가 있습니다. 내가 뭘 모르는지도 모르는 그런 느낌 말입니다. 타입 레벨 프로그래밍은 타입 레벨의 함수, 연산자도 정의하고 컴파일러에게 일을 떠 맡긴다는 알고 있는데도 계속 그랬습니다. 결국 함수를 “현실화realize”해서 “값” 레벨에 영향을 미쳐야 하는데, 타입 레벨에서만 놀고 있으면 현실화 되는 게 없습니다. 언젠가는 값 레벨에 영향을 미쳐야 합니다. 이 영향을 미치는 방법이 정리되지 않아, 나사가 빠진 느낌이었던 걸로 보입니다. 값 레벨에 영향을 미치는 함수를 골라내는데 타입 레벨 장치들을 씁니다. 이를 위해 하스켈이 제공하는 문법은 타입 클래스, 인스턴스입니다. 즉 타입 레벨에서 가지고 놀아서 나온 결과 타입(팬텀 타입)은, 언젠가는 어떤 타입 인스턴스를 선택하냐는 질문에 답으로 쓰여야 합니다.
*
카인드는 Type
카인드라 부르기도 합니다. Nat
, Symbol
, Type
… 카인드가 있는데, Nat
, Symbol
등이 값 레벨에 영향을 미치려면 언젠가는 타입 함수(family)를 통해 Type
과 매핑되어야 합니다. Type
카인드는 런타임에 값이 존재할 수 있는 타입들을 의미합니다. 이에 반해, 런타임에 값이 없는 Nat
, Symbol
등은 phantom 타입 카인드라 부르고, GHC가 추론할 때만 씁니다.
Int :: Type -- 나중에 실제 존재하는 값이 들어갈 자리
5 :: Nat -- 타입 레벨에만 존재하고, 값이 없습니다.
모호하게 했던 것 또 하나가 있는데, 바로 GHC의 애드혹한 동작입니다. 보통 찾는 인스턴스가 없으면 오류를 낼텐데, Nat, Symbol 카인드의 경우, GHC가 특별한 동작을 합니다. 이들 카인드에 대해선 컴파일 타임에 필요한 인스턴스를 자동으로 생성합니다.
풀 코드
{-# LANGUAGE DataKinds, PolyKinds, TypeOperators #-}
{-# LANGUAGE TypeFamilies, FlexibleInstances, ScopedTypeVariables #-}
{-# LANGUAGE InstanceSigs #-}
module TinyServant where
import Control.Applicative
import GHC.TypeLits
import Text.Read
import Data.Time
-- API specification DSL
data Get (a :: *)
data a :<|> b = a :<|> b
infixr 8 :<|>
-- 타입 매개 변수로 타입 두 개를 받는 타입입니다.
-- A :<|> B 도 하나의 타입으로, A 와 (B :<|> C) 를 받아
-- A :<|> (B :<|> C) 이렇게 써 줄 수 있습니다.
-- 오른 쪽 결합이니 괄호를 생략할 수 있습니다.
-- A :<|> B :<|> C
data (a :: k) :> (b :: *)
-- k는 카인드 변수입니다. b는 * 카인드만 받고, a는 카인드 제한이 없다는 말입니다.
-- b는 * 카인드니 Maybe Int 같은 * -> * 는 못받는 걸로 오해할 수 있습니다.
-- Maybe는 * -> * 카인드고, 여기에 인자로 Int를 넘긴 Maybe Int 는 * 카인드입니다.
infixr 9 :>
data Capture (a :: *)
-- Example API
type MyAPI = "date" :> Get Day
:<|> "time" :> Capture TimeZone :> Get ZonedTime
data Proxy a = Proxy
-- The Server type family
type family Server layout :: *
type instance Server (Get a) = IO a
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
-- Handler for the example API
handleDate :: IO Day
= utctDay <$> getCurrentTime
handleDate
handleTime :: TimeZone -> IO ZonedTime
= utcToZonedTime tz <$> getCurrentTime
handleTime tz
handleMyAPI :: Server MyAPI
= handleDate :<|> handleTime
handleMyAPI
-- The HasServer class
class HasServer layout where
route :: Proxy layout -> Server layout -> [String] -> Maybe (IO String)
------------여기서 부턴, (팬텀) 타입들에 따라 선택될 코드들입니다.--------------
-- HasServer류의 타입을 받아, 해당 타입에 맞는 (해당 타입의 인스턴스의) route를 씁니다.
serve :: HasServer layout
=> Proxy layout -> Server layout -> [String] -> IO String
= case route p h xs of
serve p h xs Nothing -> ioError (userError "404")
Just m -> m
-- The HasServer instance
type instance Server (Get a) = IO a
-- 타입들을 분해해서, 조각난 타입 각각에 맞는 route를 가져다 씁니다.
instance Show a => HasServer (Get a) where
route :: Proxy (Get a) -> IO a -> [String] -> Maybe (IO String)
= Just (show <$> handler)
route _ handler [] = Nothing
route _ _ _
instance (HasServer a, HasServer b) => HasServer (a :<|> b) where
route :: Proxy (a :<|> b) -> (Server a :<|> Server b) -> [String] -> Maybe (IO String)
:<|> handlerb) xs =
route _ (handlera Proxy :: Proxy a) handlera xs
route (<|> route (Proxy :: Proxy b) handlerb xs
-- (A :<|> (B :<|> C)) 를 만나면,
-- A 타입을 위한 route를 부르고,
-- (B :<|> C) 타입을 위한 route를 부릅니다. B :<|> C 도 하나의 타입입니다.
instance (KnownSymbol s, HasServer r) => HasServer ((s :: Symbol) :> r) where
route :: Proxy (s :> r) -> Server r -> [String] -> Maybe (IO String)
: xs)
route _ handler (x | symbolVal (Proxy :: Proxy s) == x = route (Proxy :: Proxy r) handler xs
= Nothing
route _ _ _
instance (Read a, HasServer r) => HasServer (Capture a :> r) where
route :: Proxy (Capture a :> r) -> (a -> Server r) -> [String] -> Maybe (IO String)
: xs) = do
route _ handler (x <- readMaybe x
a Proxy :: Proxy r) (handler a) xs
route (= Nothing route _ _ _
다음으로 Singleton 패턴, Reflection 을 보면 좋을 것 같습니다.
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를 쓰면, 인스턴스에 따라 메소드 시그니처를 다르게 할 수 있습니다. 인스턴스를 고를 때나, 위와 같이 메소드들의 타입을 결정을 지을 땐 값이 필요 없고, 타입만 있으면 됩니다. 값이 없는 타입이 보이면 코드 어딘가에서 이렇게 쓰겠구나 생각하면 됩니다.↩︎