Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

데이터 타입 제네릭 프로그래밍으로 클래스 요리하기

Haskell Generics는 다소 오해받는 주제이지만, 아주 적은 노력으로 방대한 유니버스의 타입들에 걸쳐 재사용 가능하고 비교 가능한 인터페이스를 작성할 수 있게 해 주는 매우 강력한 기법입니다. 아마도 표현력 있는 타입 시스템이 우리에게 주는 이점을 보여 주는 예 중에서 제가 가장 좋아하는 예일 것입니다.

예제 코드의 소스는 여기에서 볼 수 있습니다.

Generics는 *데이터 타입 제네릭 프로그래밍(datatype-generic programming)*의 한 형태이며, 이름은 Java Generics와 다소 비슷하지만 완전히 다른 개념입니다. GHC의 Generics 구현은 Haskell의 모든 데이터 타입을 *곱의 합(sum of products)*의 조합으로 쓸 수 있다는 단순한 관찰에서 자연스럽게 도출됩니다.

**합 타입(sum type)**은 서로 다르지만 고정된 여러 타입 중 하나를 취할 수 있는 값을 담는 데 쓰이는 자료 구조입니다. 예를 들면 다음과 같습니다.

data Pastry
  = Turnover
  | Macaroon
  | Brownie
  | Cookie

**곱 타입(product type)**은 여러 타입으로 이루어진 고정된 순서 집합을 담는 데 쓰이는 자료 구조입니다. 필드 하나를 선택하는 것을 사영(projection)이라고 합니다.

data Person = Person
  { firstName       :: String
  , lastName        :: String
  , age             :: Int
  , height          :: Float
  , phoneNumber     :: String
  , flavor          :: String
  }

Haskell에서는 모든 데이터 타입을 곱의 합으로 표현할 수 있습니다.

data Expr
  = Add { l :: Expr, r :: Expr }
  | Mul { l :: Expr, r :: Expr }
  | Sub { l :: Expr, r :: Expr }
  | Div { l :: Expr, r :: Expr }
  | Number { val :: Int }

컴파일 과정에서 데이터 타입의 구조에 관한 정보는 대부분 버려지며, Haskell은 설계상 모든 타입 정보를 소거합니다. 타입 검사에 앞서 **정교화(elaboration)**라고 하는 단계에서 모든 레코드 선택자가 곱의 이름 붙은 필드를 추출하는 최상위 함수로 전개됩니다.

data Point a b = Point { x :: a, y :: b }
x :: Point a b -> a
x (Point a _) = a

y :: Point a b -> b
y (Point _ b) = b

곱에 관한 나머지 정보도 컴파일 후에는 대부분 버려지고, 곱은 그저 패턴 매칭 코드로 전개됩니다. 합 타입의 경우 남아 있는 유일한 정보는 합 타입의 각 생성자에 붙은 태그입니다. 예를 들어 Add에는 태그 1이, Mul에는 2가 할당되는 식입니다. case 문에서 런타임에 알 수 있는 유일한 정보는 우리가 어느 분기를 검사하고 있는지뿐입니다.

그렇다면 이 정보를 모두 버리지 않고 대신 프로그램에 노출하여, 데이터 타입의 “구조“를 내성(introspect)할 수 있는 제네릭 로직을 작성할 수 있게 한다면 어떨지 생각해 봅시다.

컴파일러 훅

GHC 6.10부터 타입 패밀리가 도입되었는데, 타입 패밀리를 사용하면 무엇보다도 데이터 타입을 타입 클래스에 연관시킬 수 있습니다. 따라서 Generic 클래스의 구조는 타입 클래스와 함께 정보를 전달할 수 있는 연관 타입 Rep를 가질 수 있습니다.

class Generic a where
  type Rep a :: * -> *
  from :: a -> (Rep a) x
  to :: (Rep a) x -> a

데이터 타입의 구조를 표현하려면 합, 곱, 빈 분기, 그리고 필드와 생성자의 이름 및 그 타입에 관한 다양한 메타데이터를 인코딩하는 여러 데이터 타입을 마련해야 합니다. 이들은 모두 자유 매개변수 p를 가지며, 이 매개변수는 연관 데이터 타입 Rep a에서 사용될 때 타입 클래스 인스턴스의 헤드에 바인딩됩니다.

data    V1        p                       -- Empty
data    U1        p = U1                  -- ()
data    (:+:) f g p = L1 (f p) | R1 (g p) -- Sum
data    (:*:) f g p = (f p) :*: (g p)     -- Product
newtype K1    i c p = K1 { unK1 :: c }    -- a container for a c
newtype M1  i t f p = M1 { unM1 :: f p }  -- metadata wrapper

이제 모든 데이터 타입에 대해 이 인스턴스를 손으로 작성할 수도 있으며, 단순한 열거형의 경우 다음과 같습니다.

data Ingredient
  = Flour
  | Sugar

instance Generic Ingredient where
  type Rep Ingredient = M1 D (T_Ingredient ((M1 C (C_Flour U1)) :+: (M1 C (C_Sugar U1))))

  from Flour = M1 (L1 (M1 U1))
  from Sugar = M1 (R1 (M1 U1))

  to (M1 (L1 (M1 U1))) = Flour
  to (M1 (R1 (M1 U1))) = Sugar

data T_Ingredient
data C_Flour
data C_Sugar

여기서의 인스턴스는 순전히 기계적이며, GHC가 내부적으로 표현하는 구문 트리, 즉 GHC.DataCon 타입과 GHC.TypeCon 타입으로부터 유도할 수 있습니다. -XDeriveGeneric 확장을 사용하면 GHC가 이 타입 클래스를 자동으로 찍어 내게 할 수 있습니다.

{-# LANGUAGE DeriveGeneric #-}

data Ingredient
  = Flour
  | Sugar
  deriving (Generic)

GHC가 하는 작업을 대충 얼버무리고 넘어가지 않도록, 모듈의 데이터 정의로부터 Generic 클래스를 인스턴스화할 때 GHC가 사용하는 내성 로직을 실제로 재현해 봅시다. 모듈을 동적으로 로드하고 컴파일을 가로채 그 데이터 타입들의 내부 구조를 덤프하여, 이것이 어떻게 기계적으로 타입 클래스 인스턴스로 변환되는지 살펴봅시다.

import GHC
import GHC.Paths as Paths

import Name
import TyCon
import TypeRep
import DataCon
import HscTypes

import Text.Show.Pretty

main :: IO ()
main = do

  -- Inside the GHC Monad
  rep <- runGhc (Just Paths.libdir) $ do

    -- Spin up a GHC compiler environment
    dflags <- getSessionDynFlags
    setSessionDynFlags dflags

    -- Make a dummy module to inject
    let mn = mkModuleName "Test"

    -- Make a dummy target
    addTarget Target {
      targetId = TargetModule mn
    , targetAllowObjCode = True
    , targetContents = Nothing
    }

    -- Run the GHC pipeline
    load LoadAllTargets
    modSum <- getModSummary mn
    p <- parseModule modSum
    t <- typecheckModule p

    -- Pluck out the module tycons after we're done type-checking
    DesugaredModule tcmod modguts <- desugarModule t
    let tycons = mg_tcs modguts

    -- Deconstruct all datatypes into their sums-of-products.
    return (deconstruct tycons)

  putStrLn (ppShow rep)

이제 GHC가 내부적으로 표현하는 “모듈 내장(module guts)“에 접근할 수 있으므로 분해 로직을 작성할 수 있습니다. 이 로직은 대부분 ADT를 주무르는 코드로 이루어진 고작 수백 줄 정도의 분량입니다.

deconstruct :: [TyCon] -> [Data]
deconstruct = fmap go
  where
    go x
      | isProduct x = M1 $ D Datatype
        { dataTypeName = getOccString (tyConName x)
        , modName      = modString x
        , isNewtype    = isNewTyCon x
        , datatype     = Product (mkProduct x)
        , recursive    = isRecursiveTyCon x
        }

      | isVoid x = M1 $ D Datatype
        { dataTypeName = getOccString (tyConName x)
        , modName      = modString x
        , isNewtype    = isNewTyCon x
        , datatype     = V1
        , recursive    = isRecursiveTyCon x
        }

      | otherwise = M1 $ D Datatype
        { dataTypeName = getOccString (tyConName x)
        , modName      = modString x
        , isNewtype    = isNewTyCon x
        , datatype     = Sum (mkProduct x)
        , recursive    = isRecursiveTyCon x
        }

mkRecord :: TyCon -> [Data]
mkRecord x = concatMap mkRProduct (tyConDataCons x)

mkProduct :: TyCon -> [Data]
mkProduct x = fmap go (tyConDataCons x)
  where
    go :: DataCon -> Data
    go x | isRecord x   = Product (mkRProduct x)
    go x | isDProduct x = Product (mkDProduct x)
    go x                = M1 (C (Constructor (conNames x)))

mkDProduct :: DataCon -> [Data]
mkDProduct xs = [K1 (showType x) | x <- dataConOrigArgTys xs]

mkRProduct :: DataCon -> [Data]
mkRProduct x = [M1 (S (Selector (getOccString fld)) ty) | (fld, ty) <- zip (fieldNames x) (mkDProduct x)]

디컴파일 스크립트를 실행하기 위해 더미 모듈 Test.hs를 다음과 같이 준비합니다.

data PlatonicSolid
  = Tetrahedron
  | Cube
  | Octahedron
  | Dodecahedron
  | Icosahedron

data Person = Person
  { firstName       :: String
  , lastName        :: String
  , age             :: Int
  , height          :: Float
  , phoneNumber     :: String
  , flavor          :: String
  } deriving (Show)

data T
  = T1 { a :: Int, b :: Float }
  | T2 { c :: Int, d :: Double }

PlatonicSolid에 대해서는 다음과 같은 표현을 얻습니다.

M1
   (D Datatype
        { dataTypeName = "PlatonicSolid"
        , modName = "Test"
        , isNewtype = False
        , datatype =
            Sum
              [ M1 (C Constructor { conName = "Tetrahedron" })
              , M1 (C Constructor { conName = "Cube" })
              , M1 (C Constructor { conName = "Octahedron" })
              , M1 (C Constructor { conName = "Dodecahedron" })
              , M1 (C Constructor { conName = "Icosahedron" })
              ]
        , recursive = False
        })

Person에 대해서는 다음과 같은 표현을 얻습니다.

M1
    (D Datatype
         { dataTypeName = "Person"
         , modName = "Test"
         , isNewtype = False
         , datatype =
             Product
               [ M1 (S Selector { selName = "firstName" } (K1 "String"))
               , M1 (S Selector { selName = "lastName" } (K1 "String"))
               , M1 (S Selector { selName = "age" } (K1 "Int"))
               , M1 (S Selector { selName = "height" } (K1 "Float"))
               , M1 (S Selector { selName = "phoneNumber" } (K1 "String"))
               , M1 (S Selector { selName = "flavor" } (K1 "String"))
               ]
         , recursive = False
       })

곱의 합인 T에 대해서는 다음과 같은 표현을 얻습니다:

M1
  (D Datatype
       { dataTypeName = "T"
       , modName = "Test"
       , isNewtype = False
       , datatype =
           Sum
             [ Product
                 [ M1 (S Selector { selName = "a" } (K1 "Int"))
                 , M1 (S Selector { selName = "b" } (K1 "Float"))
                 ]
             , Product
                 [ M1 (S Selector { selName = "c" } (K1 "Int"))
                 , M1 (S Selector { selName = "d" } (K1 "Double"))
                 ]
             ]
       , recursive = False
       })

이 데이터는 이후 파생된 Generic 인스턴스에서 Rep 인스턴스를 생성하는 데 사용됩니다. 이것이 -XDeriveGeneric이 내부에서 동작하는 방식을 대략적으로 설명한 것입니다. 아주 복잡한 것은 없고 단지 정보를 기록하고 관리하는 일일 뿐입니다.

GHC.Generics

내부 표현으로부터 여러 생성자에 대한 메타데이터를 저장하는 타입 클래스 인스턴스 몇 개를 찍어 냅니다.

class Datatype d where
  datatypeName :: t d f a -> String
  moduleName   :: t d f a -> String
  isNewtype    :: t d f a -> Bool
  isNewtype _ = False

class Selector s where
  selName :: t s f a -> String

class Constructor c where
  conName :: t c f a -> String

  conFixity :: t c f a -> Fixity
  conFixity _ = Prefix

  conIsRecord :: t c f a -> Bool
  conIsRecord _ = False

예를 들어 앞서 본 Ingredient 예제에서는 생성자 인스턴스 몇 개가 자동으로 생성되며, 이를 통해 AST에서 이름을 조회할 수 있습니다.

type Rep Ingredient = M1 D (T_Ingredient ((M1 C (C_Flour U1)) :+: (M1 C (C_Sugar U1))))

data T_Ingredient
data C_Flour
data C_Sugar

instance Datatype T_Ingredient where
  datatypeName _ = "Ingredient"
  moduleName _ = "Main"

instance Constructor C_Flour where
  conName _ = "Flour"

instance Constructor C_Sugar where
  conName _ = "Sugar"

Java 같은 언어의 리플렉션과 달리 제네릭은 타입 정보를 런타임으로 밀어 넣지 않습니다. 딕셔너리 조회를 제외하면 제네릭은 오버헤드가 없는, 사실상 공짜인 추상화입니다. 우리는 단지 타입 검사 단계에서 컴파일러가 가진 더 많은 정보를 타입에 드러나게 할 뿐이며, 이 정보는 모두 컴파일 과정에서 소거됩니다.

예제

제네릭의 유용성을 보여 줄 억지스럽지 않은 예제를 생각해 보려 했는데, 웹의 다른 곳에 이미 잘 문서화된 예제(JSON 직렬화기, Protocol Buffers, SQL 생성, 순회, 명령줄 파서 등)가 많이 있습니다. 그러니 이 글의 제목에 담긴 실없는 말장난, 즉 타입 클래스 요리하기에 기반한 예제를 살펴봅시다.

그러니 당연히 Pie 타입이 있습니다.

data Pie = Pie
  { filling :: Filling
  , topping :: Maybe Topping
  } deriving (Show, Generic)

data Filling = Apple | Cherry | Pumpkin
  deriving (Show, Generic)

data Topping = IceCream | WhipCream
  deriving (Show, Generic)

제네릭을 사용하여 Haskell 타입의 구조로부터 메뉴에 올릴 수 있는 파이 종류의 목록을 생성하려고 합니다. 레코드는 메뉴 항목의 이름 붙은 변형(“filling” 대 “topping”)을 나타내고, 합 타입은 각 변형 안의 여러 선택지(“cherry filling” 대 “apple filling”)를 나타냅니다.

data Item
  = Item Text [Item]
  | Variant Text [Item]
  | Choice Text
  deriving (Show, Generic)

**기본 시그니처(default signature)**를 가진 타입 클래스를 구현합니다. 이를 통해 타입이 메뉴 항목으로 변환되는 방식을 직접 지정하거나, 제네릭 표현을 사용해 자동으로 생성하는 방식으로 돌아갈 수 있습니다.

class Menu a where
  menu :: a -> [Item]
  default menu :: (Generic a, GMenu (Rep a)) => a -> [Item]
  menu _ = gmenu (Proxy :: Proxy a)

gmenu :: forall a. (Generic a, GMenu (Rep a)) => Proxy a -> [Item]
gmenu _ = gopts (Proxy :: Proxy (Rep a))

제네릭 메뉴는 여러 GHC.Generics 타입 위에서 동작하며 합과 곱을 메뉴에 대응하는 Item 범주로 펼칩니다. GMenu (K1 R f)에 대한 인스턴스에는 Menu 제약이 있어 특정 데이터 타입에 대해 직접 재정의할 수 있습니다. 프록시를 전달하고 있으므로, 때로는 해결해야 하는 인스턴스의 타입으로 캐스트한 undefined를 넘겨 딕셔너리를 직접 전달해야 합니다.

-- Generic Menu
class GMenu a where
  gopts :: Proxy a -> [Item]

-- Datatype
instance GMenu f => GMenu (M1 D x f) where
  gopts _ = gopts (Proxy :: Proxy f)

-- Constructor Metadata
instance (GMenu f, Constructor c) => GMenu (M1 C c f) where
  gopts x
    | conIsRecord (undefined :: t c f a) =
      [Item (pack (conName m)) (gopts (Proxy :: Proxy f))]

    | otherwise = [Choice (pack (conName m))]
    where m = (undefined :: t c f a)

-- Selector Metadata
instance (GMenu f, Selector c) => GMenu (M1 S c f) where
  gopts _ = [Variant (pack (selName m)) (gopts (Proxy :: Proxy f))]
    where m = (undefined :: t c f a)

-- Constructor Paramater
instance (GMenu (Rep f), Menu f) => GMenu (K1 R f) where
  gopts _ = menu (undefined :: f)

-- Sum branch
instance (GMenu a, GMenu b) => GMenu (a :+: b) where
  gopts _ = gopts (Proxy :: Proxy a) ++ gopts (Proxy :: Proxy b)

-- Product branch
instance (GMenu a, GMenu b) => GMenu (a :*: b) where
  gopts _ = gopts (Proxy :: Proxy a) ++ gopts (Proxy :: Proxy b)

-- Void branch
instance GMenu U1 where
  gopts _ = []

구체적으로는 Maybe 타입을 재정의하여, 변형의 “AsIs“를 선택하거나 내부 매개변수가 부여하는 선택지 목록 중 하나를 고르도록 단순히 펼쳐지게 합니다.

instance Menu a => Menu (Maybe a) where
  menu _ = [Choice (pack "AsIs")] ++ (menu (undefined :: a))

예를 들어 Maybe Topping은 세 가지 선택지로 펼쳐집니다.

menu (a :: Maybe Topping) ~ [Choice "AsIs", Choice "IceCream", Choice "whipCream"]

GHC 7.10의 새로운 -XDeriveAnyClass 확장을 사용하면 실제로 다시 돌아가 deriving 절 안에서 Menu를 자동으로 파생할 수 있습니다.

data Pie = Pie
  { filling :: Filling
  , topping :: Maybe Topping
  } deriving (Show, Generic, Menu)

이제 Pie에 대한 딕셔너리를 합성하면 메뉴를 얻을 수 있습니다

menu (undefined :: Pie)

짜잔:

[ Item
    "Pie"
    [ Variant
        "filling" [ Choice "Apple" , Choice "Cherry" , Choice "Pumpkin" ]
    , Variant
        "topping"
        [ Choice "AsIs" , Choice "IceCream" , Choice "WhipCream" ]
    ]
]

우리의 로직은 데이터 타입 제네릭이므로, 작성할 수 있는 어떤 Haskell 타입이든 deriving Menu만으로 Menu로 자동 변환될 수 있습니다. 그러니 이제 새로운 Crisp 디저트(제가 가장 좋아하는 것입니다!)를 추가할 수 있으며, 모든 것을 공짜로 얻습니다!

data Crisp = Crisp
  { contents :: Filling
  , temperature :: Temperature
  } deriving (Show, Generic, Menu)

data Temperature = Warm | Cold
  deriving (Show, Generic, Menu)

-- Add an instance for a pair of menu items. That expands into multiple items.
instance (Menu a, Menu b) => Menu (a,b) where
  menu _ = menu (undefined :: a) ++ menu (undefined :: b)

그리고 두 디저트를 합친 메뉴를 생성할 수 있습니다:

menu (undefined :: (Pie, Crisp))
[ Item
    "Pie"
    [ Variant
        "filling" [ Choice "Apple" , Choice "Cherry" , Choice "Pumpkin" ]
    , Variant
        "topping"
        [ Choice "AsIs" , Choice "IceCream" , Choice "WhipCream" ]
    ]
, Item
    "Crisp"
    [ Variant
        "contents" [ Choice "Apple" , Choice "Cherry" , Choice "Pumpkin" ]
    , Variant "temperature" [ Choice "Warm" , Choice "Cold" ]
    ]
]

이것이 제네릭입니다. 표현력 있는 타입 시스템과 몇 가지 영리한 컴파일러 훅이 보일러플레이트를 대신 요리해 주고 더 맛있고 더 올바른 코드를 내어 줌으로써 프로그래머의 삶을 얼마나 편하게 만들 수 있는지 보여 주는 가장 좋은 대표 사례 중 하나입니다.


원문 © Stephen Diehl, stephendiehl.com · 글 CC BY-SA 4.0, 코드 MIT · Stephen Diehl은 런던의 소프트웨어 엔지니어이자 작가입니다(저자가 정한 소개 문구). 이 글은 저자가 개인 자격으로 쓴 것이며 어떤 조직과의 연관도 밝히거나 암시해서는 안 됩니다 (라이선스와 고지).
이 페이지는 원문을 한국어로 옮기며 변경한 비공식 번역이며 같은 CC BY-SA 4.0으로 제공합니다 · yeokja 한국어 번역 · Anthropic 사의 claude-opus-5-5 모델을 활용하여 번역되었으며 학습을 모두 비허용한 상태로 작업하였습니다.