오늘은 잘못 손대면 어둠의 길로 빠질 수 있는 프로그래밍 기술인 메타프로그래밍에 대해 살펴보려고 한다. 대부분의 언어는 코드를 생성할 수 있는 메타프로그래밍을 지원하고 잘 활용하면 코드를 많이 줄일 수 있지만 잘못 쓰면 디버깅이 어려운 애물단지가 돼버린다. 하스켈도 역시 메타프로그래밍을 지원하고 TemplateHaskell
언어 확장을 통해 가능하다. 하스켈 TemplateHaskell에 대한 한글 자료도 많이 없고 해서 간단히 살펴보고 적어보려고 한다.
원래 Clojure 개발을 주로 했기 때문에 한때 매크로에 빠져 어둠의 세계에 갔다가 간신히 살아 돌아온 경험이 있었다. 그래서 하스켈에서도 사용하지 않으려고 했지만 그래도 TemplateHaskell을 사용하는 라이브러리가 많이 있어 어떻게 동작하는지 이해는 해야 할 것 같아 살펴보기로 했다. 다음은 하스켈 JSON 패키지인 aeson에서 제공하는 기능이다.
{-# LANGUAGE TemplateHaskell #-}
module Lib where
import Data.Aeson
import Data.Aeson.TH
data User = User
{ userId :: Int
, userName :: String
} deriving (Show)
$(deriveJSON defaultOptions ''User)
-- >>> encode $ User 1 "Todd"
-- "{\"userId\":1,\"userName\":\"Todd\"}"
여기서 $()
문법이 템플릿 하스켈을 사용하는 부분이다. 이 자리에 deriveJSON
이 생성하는 코드가 들어간다. VSCode를 사용하고 있다면 이 부분에 마우스를 가져가 보면 다음과 같이 생성되는 코드를 볼 수 있다.
Data.Aeson.TH
모듈에 있는 deriveJSON
는 어떤 타입을 JSON으로 바꾸거나 JSON을 타입으로 바꿀 수 있는 ToJSON
과 FromJSON
타입 클래스의 인스턴스를 자동으로 만들어 준다. 물론 aeson에는 deriveJSON
말고 Generic을 사용해서 ToJSON
과 FromJSON
인스턴스를 만들 수도 있기 때문에 항상 deriveJSON
로 인스턴스를 만들지 않아도 된다.
그럼 TemplateHaskell을 어떻게 쓰는지 살펴보자. 템플릿 하스켈은 $()
에 하스켈 AST(abstract syntax tree) 데이터를 넘겨 실제 하스켈 코드를 생성하는 식이다. 따라서 먼저 하스켈 AST가 어떻게 생겼고 어떻게 만드는지 알아야 한다. AST를 확인하는 가장 쉬운 방법은 옥스퍼드 괄호라고 하는 [| ... |]
형태의 구문 안에 코드를 작성하고 runQ
에 넘기는 것이다. runQ
는 template-haskell
패키지에 Language.Haskell.TH
모듈에 있다.
import Language.Haskell.TH
-- >>> runQ [| 1 + 2 |]
-- InfixE (Just (LitE (IntegerL 1))) (VarE GHC.Num.+) (Just (LitE (IntegerL 2)))
runQ
의 결과는 대략 예측할 수 있을 것 같은 형태이다. LitE
뭔가 리터럴 표현식을 표시하는 것 같고 IntegerL
은 뭔가 숫자를 표시하는 것 같고 InfixE
는 중위 함수를 VarE
는 이름이 붙은 값을 나타내는 표현식이고 대략 알 수 있다. 그래서 이 데이터 생성자들을 이용해 AST 표현식을 만들고 $()
에 넣으면 실제 코드가 생성될 것이라고 추측할 수 있다. 해보자.
-- >>> $(LitE (IntegerL 1))
-- Couldn't match type ‘Exp’ with ‘Q Exp’
-- Expected type: ExpQ
-- Actual type: Exp
LitE
로 만든 표현식은 Exp
타입인데 $()
는 Q Exp
타입을 받는 것 같다. return
이나 pure
를 써주면 되겠다.
-- >>> $(pure $ LitE (IntegerL 1))
-- 1
-- >>> $( [| 1 |] )
-- 1
그러면 이제 Exp
값만 자동으로 생성해주는 함수를 만들면 코드 생성을 할 수 있을 것 같다. 연습으로 뭘 해볼까? 아마도 aeson처럼 인스턴스 생성해주는 코드를 만들 경우가 있을 수도 있기 때문에 전에 Generic으로 만들었던 Bson 변환 코드를 HaskellTemplate 버전으로 만들어 두면 편리할 수 있을 것 같다. 하지만 일기에 적기에는 너무 길어질 것 같으니 Bson
에 있는 Val
인스턴스를 만들어주는 코드를 만들어 보자. 직접 작성한다면 다음과 같이 인스턴스를 만들 것이다.
instance Val User where
val user = Doc [ "userId" =: userId user
, "userName" =: userName user
]
cast' = undefined
이 코드에 대한 Exp는 어떨까? 옥스퍼드 괄호로 코드를 만들어보자. 일반 표현식은 [| ... |]
를 쓰면 되지만 최상위 선언은 [d| ... |]
를 써야 한다. 그래서 다음과 같이 Exp 값을 만들 수 있다.
deriveVal :: Name -> Q [Dec]
deriveVal _ = [d|
instance Val User where
val user = Doc [ "userId" =: userId user
, "userName" =: userName user
]
cast' = undefined
|]
User
로 하드 코딩이 되어 있는 부분을 aeson처럼 타입 이름을 Name
으로 받아서 처리하도록 바꿔보자.
deriveVal :: Name -> Q [Dec]
deriveVal name = [d|
instance Val $a where
val user = Doc [ "userId" =: userId user
, "userName" =: userName user
]
cast' = undefined
|]
where
a = conT name
요렇게 바꾸면 이제 User
로 하드코딩하지 않아도 된다. conT
함수로 Name
값에서 생성자 이름을 가져올 수 있나 보다. 찾아보니 reify
로 타입에 대한 정보를 가져올 수 있기 때문에 하드 코딩된 필드 명도 바꿀 수 있을 것이다. 일단 잘 되는지 확인해보자. 참고로 템플릿 하스켈은 Exp를 생성하는 모듈과 그것을 $()
로 코드화 하는 모듈을 달라야 한다는 제한이 있어 다른 모듈에서 deriveVal
를 확인해봤다.
data User = User
{ userId :: Int
, userName :: String
} deriving (Eq, Show)
$(deriveVal ''User)
-- >>> val $ User 1 "Todd"
-- [ userId: 1, userName: "Todd"]
비록 User
타입에 대해 하드 코딩되어 있지만 val
함수가 잘 동작한다. 생성된 코드를 펼쳐보니 다음과 같았다.
instance Val User where
val user_aQRy
= Doc
[("userId" =: userId user_aQRy),
("userName" =: userName user_aQRy)]
cast' = undefined
이제 필드명으로 Bson Document를 생성해주면 될 것 같은데 오늘은 늦었으니 다음에 살펴보자. 제목 뒤에 1이라고 붙이고 2부에 나머지를 구현해보자.
좋은 글 올려주셔서 감사합니다. 잠깐 검색으론, 한글로 된 TH글은 거의 유일한 것 같습니다.
splice ( $(...) )에 넣어 주는 게 하스켈 언어의 AST이긴 한데, GHC가 일반 하스켈을 컴파일하며 뽑아내는 AST와는 다른 AST입니다. Template Haskell이 필요에 맞게 모델링한 별도의 AST를 쓰는 걸로 보입니다. 그냥 하스켈AST로 되어 있어, 혹 저처럼 오해하는 분이 있을까 댓글 남깁니다.