합 타입의 의미와 장점, 효과적인 활용법을 살펴보고, 유니온 및 서브타입과의 차이와 접점을 정리합니다. 하스켈을 중심으로 합 타입, 파라메트릭 다형성과 타입클래스를 통한 서브타이핑, 존재 타입, 그리고 Expression Problem까지 예제와 함께 설명합니다.
최근에 함수형 프로그래밍 주변에서 트위터 드라마가 또 한바탕 있었는데, 사실 요즘 내가 자주 질문받고 스스로도 자주 생각하게 되는 합 타입의 미묘한 부분들을 건드리기도 했다. 그래서 이 기회에 합 타입의 “왜”와 본질, 효과적으로 사용하는 법, 그리고 프로그래밍/소프트웨어 개발에서 관련 개념들과의 대비, 심지어 합 타입이 최선이 아닌 경우까지 이야기해 보고자 한다.
없어서는 안 될 대표적인 합 타입은 이제 많은 언어에서 Optional로 채택된 Maybe다:
data Maybe a = Nothing | Just a
Maybe Int 타입의 값을 가진다는 건, 그 유효한 값들이 Nothing, Just 0, Just 1 등이라는 뜻이다.
이건 왜 이것을 “합(sum)” 타입이라 부르는지를 잘 보여준다. 만약 a가 가능한 값의 개수가 n개라면, Maybe a는 1 + n개다. Nothing이라는 단 하나의 새로운 값을 더한 것이다.
합 타입의 “장점”도 여기서 명확히 드러난다. Maybe Int 타입의 값을 사용할 때마다, 그 값이 Nothing일 수 있음을 반드시 고려하게 만든다:
showMaybeInt :: Maybe Int -> String
showMaybeInt = \case
Nothing -> "There's nothing here"
Just i -> "Something is here: " <> show i
대부분의 합 타입 구현은 각 경우를 빠짐없이(exhaustive) 처리하도록 강제한다. 그렇지 않다면 합 타입의 유용성은 크게 떨어진다.
가장 근본적으로는, 이는 언어가 사용자 영역(user-space) 수준에서 제공하는 컴파일러 강제 null 체크처럼 동작한다. 컴파일러 마법이나 특수 문법1, 정적 분석이 아니라는 점이 중요하고, 사용자 영역에 머물 수 있다는 사실이 널리 채택된 이유다. 더 높은 수준에서는 Functor, Applicative, Monad, Foldable, Traversable 같은 추상화를 통해 Maybe a를 적절한 의미론으로 거의 일반 a처럼 다룰 수 있다. 하지만 그건 다음에(예: 2014년에) 할 이야기.
이 힘은 개인적으로도 아주 특별하다. 예전에 첫 큰 하스켈 프로젝트에서 어떤 타입을 String에서 Maybe String으로 바꿨는데, GHC가 코드베이스 곳곳에서 무엇을 고쳐야 여전히 동작하는지 전부 알려주었다. 동적 타입 언어 배경에서 왔던 내게, 이 황홀한 경험은 정말로 뇌 화학을 바꾸어 놓았고, 평생 하스켈에 흠뻑 빠지게 만들었다. 그 순간이 정확히 기억난다. 어느 카페였는지, 주문은 무엇이었는지, 그날 날씨까지… 그날은 내 인생의 새로운 첫날이었다.
덧붙이면, 나는 합 타입을 “언어 기능”이나 컴파일러 기능이라기보다는 설계 패턴으로 본다. 합 타입이 내장되지 않은 언어에서도, 타입드 유니온과 추상 방문자(Visitor) 패턴 인터페이스로 구현할 수 있다(이건 뒤에서 더). 물론 실행 전 코드 확인(타입 시스템이나 정적으로 검증된 타입 주석 등)이 가능하면 이런 기능이 훨씬 유용해진다.
아무튼, 이 기본 패턴은 Nothing 분기에서 더 많은 오류 정보를 담도록 확장할 수 있고, 그게 하스켈 표준 라이브러리의 Either e a, 러스트의 Result<T,E>다.
다른 결로는 구문 트리를 정의하는 흔한 용례가 있다:
data Expr =
Lit Int
| Negate Expr
| Add Expr Expr
| Sub Expr Expr
| Mul Expr Expr
eval :: Expr -> Int
eval = \case
Lit i -> i
Negate x -> -(eval x)
Add x y -> eval x + eval y
Sub x y -> eval x - eval y
Mul x y -> eval x * eval y
pretty :: Expr -> String
pretty = go 0
where
wrap :: Int -> Int -> String -> String
wrap prio opPrec s
| prio > opPrec = "(" <> s <> ")"
| otherwise = s
go prio = \case
Lit i -> show i
Negate x -> wrap prio 2 $ "-" <> go 2 x
Add x y -> wrap prio 0 $ go 0 x <> " + " <> go 1 y
Sub x y -> wrap prio 0 $ go 0 x <> " - " <> go 1 y
Mul x y -> wrap prio 1 $ go 1 x <> " * " <> go 2 y
main :: IO ()
main = do
putStrLn $ pretty myExpr
print $ eval myExpr
where
myExpr = Mul (Negate (Add (Lit 4) (Lit 5))) (Lit 8)
-(4 + 5) * 8
-72
이제 합 타입에 새 명령을 추가하면, 컴파일러가 이를 처리하도록 강제한다.
data Expr =
Lit Int
| Negate Expr
| Add Expr Expr
| Sub Expr Expr
| Mul Expr Expr
| Abs Expr
eval :: Expr -> Int
eval = \case
Lit i -> i
Negate x -> -(eval x)
Add x y -> eval x + eval y
Sub x y -> eval x - eval y
Mul x y -> eval x * eval y
Abs x -> abs (eval x)
pretty :: Expr -> String
pretty = go 0
where
wrap :: Int -> Int -> String -> String
wrap prio opPrec s
| prio > opPrec = "(" <> s <> ")"
| otherwise = s
go prio = \case
Lit i -> show i
Negate x -> wrap prio 2 $ "-" <> go 2 x
Add x y -> wrap prio 0 $ go 0 x <> " + " <> go 1 y
Sub x y -> wrap prio 0 $ go 0 x <> " - " <> go 1 y
Mul x y -> wrap prio 1 $ go 1 x <> " * " <> go 2 y
Abs x -> wrap prio 2 $ "|" <> go 0 x <> "|"
또 다른 빛나는 예는 프로세스 간에 명확히 정의된 API다. 예를 들어 서로 다른 페이로드를 가진 여러 종류의 “명령”을 보내는 타입을 상상해 보자. 커맨드라인 인자 파싱 결과나 어떤 통신 프로토콜의 메시지로 해석할 수도 있다.
예컨대 프로세스를 실행하고 제어하는 프로토콜을 만들 수 있다:
data Command a =
Launch String (Int -> a) -- ^ 이름을 받고, 프로세스 ID를 반환
| Stop Int (Bool -> a) -- ^ 프로세스 ID를 받고, 성공/실패 반환
launch :: String -> Command Int
launch nm = Launch nm id
stop :: Int -> Command Bool
stop pid = Stop pid id
이 ADT는 “인터프리터” 패턴(보통 free monad와 함께 쓰임)으로 쓰려고 작성되었다. a와 무관한 인자는 명령의 페이로드이며, X -> a 형태의 인자는 명령이 X를 응답으로 줄 수 있음을 나타낸다.
IntMap을 IORef에 담아 상태를 백엔드로 쓰는 간단한 인터프리터를 작성해 보자:
import qualified Data.IntMap as IM
import Data.IntMap (IntMap)
runCommand :: IORef (IntMap String) -> Command a -> IO a
runCommand ref = \case
Launch newName next -> do
currMap <- readIORef ref
let newId = case IM.lookupMax currMap of
Nothing -> 0
Just (i, _) -> i + 1
modifyIORef ref $ IM.insert newId newName
pure (next newId)
Stop procId next -> do
existed <- IM.member procId <$> readIORef ref
modifyIORef ref $ IM.delete procId
pure (next existed)
main :: IO ()
main = do
ref <- newIORef IM.empty
aliceId <- runCommand ref $ launch "alice"
putStrLn $ "Launched alice with ID " <> show aliceId
bobId <- runCommand ref $ launch "bob"
putStrLn $ "Launched bob with ID " <> show bobId
success <- runCommand ref $ stop aliceId
putStrLn $
if success
then "alice succesfully stopped"
else "alice unsuccesfully stopped"
print =<< readIORef ref
Launched alice with ID 0
Launched bob with ID 1
alice succesfully stopped
fromList [(1, "bob")]
프로세스 ID로 현재 상태를 “조회”하는 명령을 추가해 보자:
data Command a =
Launch String (Int -> a) -- ^ 이름을 받고, 프로세스 ID를 반환
| Stop Int (Bool -> a) -- ^ 프로세스 ID를 받고, 성공/실패 반환
| Query Int (String -> a) -- ^ 프로세스 ID를 받고, 상태 메시지 반환
query :: Int -> Command String
query pid = Query pid id
runCommand :: IORef (IntMap String) -> Command a -> IO a
runCommand ref = \case
-- ...
Query procId next -> do
procName <- IM.lookup procId <$> readIORef ref
pure case procName of
Nothing -> "This process doesn't exist, silly."
Just n -> "Process " <> n <> " chugging along..."
흔한 혼동을 풀자. 합 타입은 “태그된 유니온(tagged union)”으로 설명할 수 있다. 어떤 분기에 있는지를 나타내는 태그(케이스 매칭 가능)가 있고, 나머지 데이터는 조건부로 존재한다.
많은 언어에서 이는 내부적으로 태그가 달린 구조체와 데이터 유니온, 그리고 모든 분기를 빠짐없이 처리하도록 보장하는 추상 방문자 패턴 인터페이스로 구현된다.
기억하자. 이는 정확히 유니온은 아니다. 예를 들어 다음 타입을 보자:
data Entity = User Int | Post Int
여기서 Entity는 사용자 ID의 사용자 혹은 게시물 ID의 게시물을 나타낼 수 있다. 이를 단순히 Int와 Int의 유니온으로 보면:
union Entity {
int user_id;
int post_id;
};
사용자인지 게시물인지에 따라 분기할 수 있는 능력을 잃는다. 태그드 유니온으로 만들면 본래 합 타입의 의미를 회복한다:
struct Entity {
bool is_user;
union {
int user_id;
int post_id;
} payload;
};
물론, 이것을 합 타입처럼 모든 분기 처리 보장을 갖고 사용하려면 방문자 패턴 같은 추상 인터페이스가 여전히 필요하다. 또는 언어가 동적 디스패치를 잘 지원한다면, 더 높은 수준의 방문자 패턴 인터페이스를 뒷받침하는 다른 구현도 가능하다.
합 타입은 보편적인 프로그래밍 교육 커리큘럼에는 그리 깊지 않지만, _서브타입_과 _슈퍼타입_은 모든 컴공 학생의 1학년 때부터 뇌리에 박히고 악몽에 나올 정도로 다들 배운다.
비공식적으로(리스코프의 정의 풍으로), B가 A의 서브타입(그리고 A는 B의 슈퍼타입)이라는 것은, 어디서 A가 필요하든 B를 제공할 수 있다는 뜻이다.
일반적인 객체지향 프로그래밍에서는 Cat과 Dog가 Animal 클래스의 하위 클래스이거나, Square와 Circle이 Shape의 하위 클래스인 예로 일찍부터 등장한다.
사람들이 합 타입을 처음 배울 때, 이를 서브타이핑과 비슷하게 이해하려는 경향이 있다. 안타깝지만 이해할 만한데, 합 타입 소개가 종종 이런 식으로 시작하기 때문이다:
-- | Bad Sum Type Example!
data Shape = Circle Double | Rectangle Double Double
이게 합 타입 대 서브타이핑의 구분에서 표면적으로는 나쁜 예다(물론 API 명세나 상태 머신처럼 이런 합 타입이 좋은 상황도 있다).
합 타입의 본질적인 “긴장감”을 눈치챘을 것이다. 가능한 옵션을 미리 전부 선언하고, 그 값을 소비하는 함수들은 열려 있으며 임의로 선언된다. 그리고 새 옵션을 추가하면, 모든 소비 함수들이 조정되어야 한다.
반면 서브타입 (및 슈퍼타입)은 반대 끝으로 치우칠 때 더 효과적이다. 가능한 옵션의 우주는 열려 있고 임의로 선언되지만, 그 값을 _소비하는 함수들_은 닫혀 있다. 그리고 새 함수를 추가하면, 모든 멤버가 조정되어야 한다.
“객체”와 “클래스” 개념이 있는 정적 타입 언어에서, 서브타이핑은 보통 상속과 인터페이스로 구현된다.
interface Widget {
void draw();
void handleEvent(String event);
String getName();
}
class Button implements Widget {
// ..
}
class InputField implements Widget {
// ..
}
class Box implements Widget {
// ..
}
그래서 Widget을 기대하는 processWidget(Widget widget) 같은 함수는 Button, InputField, Box를 받을 수 있다. 그리고 List<Widget> 같은 컨테이너가 있으면, Button, InputField, Box로 구조를 조립할 수 있다. 완벽한 리스코프 풍경.
일반적인 라이브러리 설계에서, Widget의 구현을 새로 추가하는 것은 열린 우주라서 쉽다. Widget을 임포트만 하면 누구나 만들 수 있고, 이제 Widget을 받는 함수들과 함께 쓸 수 있다. 하지만 Widget 인터페이스에 새 기능을 추가하고 싶다면, 모든 하위 구현에 깨지는 변경이 된다.
그러나 이런 서브타이핑 구현은 널리 퍼져 있지만 개념의 가장 지루한 실현이다. 이 얘기를 하느라 내 영혼이 고통받았다. 그러니 이제 모든 것이 흥미로운 유일한 언어, 하스켈에서 서브/슈퍼타입 관계가 나타나는 더 재밌는 방식을 보자.
하스켈에서는 서브타이핑이 파라메트릭 다형성과 때로 타입클래스로 구현된다. 이를 통해 함수와 API를 서로의 서브/슈퍼타입으로 잘 다룰 수 있다.
예를 들어, 인덱서 함수를 받아 적용하는 함수를 보자:
sumAtLocs :: ([Double] -> Int -> Double) -> [Double] -> Double
sumAtLocs ixer xs = ixer xs 1 + ixer xs 2 * ixer xs 3
ghci> sumAtLocs (!!) [1,2,3,4,5]
14
그렇다면 sumAtLocs에 어떤 함수를 넘길 수 있을까? 정말로 [Double] -> Int -> Double만 넘길 수 있을까?
꼭 그렇진 않다. 위에서 우리가 넘긴 (!!)의 타입은 forall a. [a] -> Int -> a다!
사실 또 어떤 타입을 넘길 수 있을까? 예를 들면:
fun1 :: [a] -> Int -> a
fun1 = (!!)
fun2 :: [a] -> Int -> a
fun2 xs i = reverse xs !! i
fun3 :: (Foldable t, Floating a) => t a -> Int -> a
fun3 xs i = if length xs > i then xs !! i else pi
fun4 :: Num a => [a] -> Int -> a
fun4 xs i = sum (take i xs)
fun5 :: (Integral b, Num c) => a -> b -> c
fun5 xs i = fromIntegral i
fun5 :: (Foldable t, Fractional a, Integral b) => t a -> b -> a
fun5 xs i = sum xs / fromIntegral i
fun5 :: (Foldable t, Integral b, Floating a) => t a -> b -> a
fun5 xs i = logBase (fromIntegral i) (sum xs)
무슨 일이 벌어지고 있는 걸까? 함수는 [Double] -> Int -> Double을 _기대_하지만, 실제로는 넘길 수 있는 다른 타입들이 아주 많다.
처음엔 그저 말장난처럼 보일 수 있지만 더 깊은 의미가 있다. 위의 각 타입은 실제로 매우 다른 의미와 가능한 동작을 갖는다!
forall a. [a] -> Int -> a는 결과 a가 반드시 주어진 리스트에서 와야 함을 의미한다. 사실 이 타입을 가진 함수는 모두 부분함수일 것이 보장된다. 빈 리스트를 주면 쓸 a가 없기 때문이다.forall a. Num a => [a] -> Int -> a는 결과가 리스트 밖에서 올 수도 있음을 의미한다. 구현이 리스트가 비어 있어도 항상 0이나 1을 반환할 수 있다. 또한 더하기/빼기/곱하기/부호반전만 할 것이고, 나누기는 하지 않음을 보장한다.forall a. Fractional a => [a] -> Int -> a는 결과에 나누기를 할 수도 있지만, 제곱근이나 로그처럼 “부동(floating)” 연산은 못 한다.forall a. Floating a => [a] -> Int -> a는 입력 수에 제곱근을 취하거나 로그를 취할 수도 있음을 뜻한다.[Double] -> Int -> Double은 동작에 대한 보장이 가장 적다. 결과가 허공에서 올 수도 있고(리스트의 일부가 아닐 수 있음), 입력의 머신 표현을 살펴보는 것도 가능하다.이처럼 완전히 다른 의미와 의미론을 가진 타입들이 있다. 그럼에도 [Double] -> Int -> Double을 기대하는 곳에 모두 넘길 수 있다. 이는 그 모든 타입이 [Double] -> Int -> Double의 _서브타입_이라는 뜻이다! [Double] -> Int -> Double은 수많은 가능한 값들을 담는 슈퍼타입으로, 가능한 값들과 의미론을 하나의 큰 슈퍼타입으로 묶는다.
파라메트릭 다형성과 타입클래스의 힘으로, 우리는 서브타입뿐 아니라 _슈퍼타입_의 확장 가능한 계층도 만들 수 있다.
JSON 직렬화의 흔한 API를 생각해 보자. 여러 타입을 JSON으로 직렬화하는 함수들이 있을 수 있다:
fooToJson :: Foo -> Value
barToJson :: Bar -> Value
bazToJson :: Baz -> Value
타입클래스를 통해 다음을 만들 수 있다:
toJSON :: ToJSON a => a -> Value
toJSON :: forall a. JSON a => a -> Value의 타입은 Foo -> Value, Bar -> Value, Baz -> Value의 서브타입이다. 왜냐하면 Foo -> Value가 필요한 어디에서나 대신 toJSON을 줄 수 있기 때문이다. Foo를 직렬화하고 싶을 때마다 toJSON을 쓸 수 있다.
이런 사용은 확장 가능한 추상화를 통해 코드를 설계하게 해 준다. Monoid a에 대해 다형적인 코드를 작성하면, 값의 모노이드성과 관련된 측면에 대해서만 사고하도록 만든다. Num a에 대해 다형적인 코드를 작성하면, 덧셈/뺄셈/부호반전/곱셈으로 다룰 수 있는 방식에만 집중하게 하고, 머신 표현 같은 건 신경 쓸 필요가 없다.
확장성은 forall a. ToJSON a => a -> Value의 _더 슈퍼_한 타입을 간단히 만들 수 있다는 점에서 온다. 새 타입클래스 인스턴스를 정의하기만 하면 된다. 예컨대 MyType -> Value가 필요하면, ToJSON 인스턴스를 정의해 toJSON :: ToJSON a => a -> Value의 슈퍼타입이 되게 만들 수 있고, 이제 그 자리에 쓸 수 있다.
_실용적으로_도 많은 라이브러리에서 이용된다. 예를 들어 ad는 자동미분에 이를 쓴다. diff 함수는 무섭게 보이지만:
diff :: (forall s. AD s ForwardDouble -> AD s ForwardDouble) -> Double -> Double
사실 (forall s. AD s ForwardDouble -> AD s ForwardDuble)이 (forall a. Floating a => a -> a), (forall a. Num a => a -> a) 등의 _슈퍼클래스_라는 사실에 의존한다. 그래서 \x -> x * x 같은 함수(이는 forall a. Num a => a -> a)를 넘기면 AD s 타입으로 잘 동작한다:
ghci> diff (\x -> x * x) 10
20 -- 2*x
이 “수치 과적재” 방식은 GPU 프로그래밍 라이브러리에서도 사용되어, 수치 함수를 받아 최적화하고 GPU 코드로 컴파일한다.
또 다른 큰 응용은 lens 라이브러리로, 서브타이핑으로 옵틱 계층을 통일한다.
예컨대 Iso는 Traversal의 서브타입이고, Traversal은 Lens의 서브타입이다. 그리고 Lens는 Fold와 Traversal의 슈퍼타입 등이다. 결국 이 시스템은 _Prelude_의 id조차 렌즈나 트래버설로 쓰게 해 준다. id :: a -> a의 타입이 실제로 그 모든 타입의 서브타입이기 때문이다!
OOP 등에서의 서브타입의 _정신_에 더 가까운 것은 존재 타입(existential type): 어떤 인터페이스를 만족하는 임의의 타입의 값이 될 수 있는 값이다.
예를 들어, Num의 어떤 인스턴스가 될 수 있는 값을 상상해 보자:
data SomeNum = forall a. Num a => SomeNum a
someNums :: [SomeNum]
someNums = [SomeNum (1 :: Int), SomeNum (pi :: Double), SomeNum (0xfe :: Word)]
이는 자바의 List<MyInterface> 또는 List<MyClass>, 파이썬의 List[MyClass]와 어느 정도 유사하다.
하스켈에서 슈퍼/서브클래스와 함께 효과적으로 쓰려면, 래핑/언래핑을 수동으로 해야 한다:
data SomeFrational = forall a. Fractional a => SumFractional a
castUp :: SomeFractional -> SumNum
castUp (SomeFractional x) = SomeNum x
즉 SomeNum은 “기술적으로” SomeFractional의 슈퍼타입이다. SomeNum이 필요한 어디에든 SomeFractional을 줄 수 있다. …하지만 하스켈에서는 명시적 캐스트가 필요해서 훨씬 덜 편하다.
OOP 언어에서는 런타임 리플렉션으로 종종 “다운캐스트”(‘SomeNum -> Maybe SomeFractional’)가 가능하다. 그러나 우리가 쓴 방식대로라면 하스켈에서는 불가능하다!
castDown :: SomeNum -> Maybe SomeFractional
castDown = error "impossible!"
그 이유는 타입 소거(type erasure) 때문이다. 하스켈은 (기본적으로) 런타임의 값에 그 모든 인터페이스 구현을 결합하지 않는다. SomeNum 값을 만들면, 그 값에 대한 untyped 포인터와 그것으로 사용할 수 있는 모든 함수들의 “사전(dictionary)”를 함께 포장한다:
data NumDict a = NumDict
{ (+) :: a -> a -> a
, (*) :: a -> a -> a
, negate :: a -> a
, abs :: a -> a
, fromInteger :: Integer -> a
}
mkNumDict :: Num a => NumDict a
mkNumDict = NumDict (+) (*) negate abs fromInteger
data FractionalDict a = FractionalDict
{ numDict :: NumDict a
, (/) :: a -> a -> a
, fromRational :: Rational -> a
}
-- | 기존 'SomeNum'과 사실상 동일
data SomeNum = forall a. SomeNum
{ numDict :: NumDict a
, value :: a
}
-- | 기존 'SomeFractional'과 사실상 동일
data SomeFractional = forall a. SomeFractional
{ fractionalDict :: FractionalDict a
, value :: a
}
castUp :: SomeFractional -> SomeNum
castUp (SomeFractional (FractionalDict {numDict}) x) = SomeNum d x
castDown :: SomeNum -> Maybe SomeFractional
castDown (SomeNum nd x) = error "not possible!"
이 모든 함수 포인터는 런타임에 SomeNum _안_에 존재한다. 그래서 SomeFractional을 SomeNum으로 “업캐스트”하는 건 FractionalDict을 버리기만 하면 된다. 그러나 SomeNum에서 “다운캐스트”할 수는 없다. FractionalDict을 재구성할 방법이 없기 때문이다. 타입과 인스턴스의 연결이 런타임에 소실된다. OOP 언어들은 보통 값 _자체_가 런타임에 모든 인터페이스 구현으로의 포인터를 들고 있도록 해서 이를 회피한다. 그러나 하스켈은 기본적으로 타입 소거다. 런타임에 테이블을 들고 다니지 않는다.2
결국 존재 기반 서브타이핑은 OOP 언어처럼 암묵적이거나 가벼운 캐스팅 대신, 명시적 래핑/언래핑이 필요하다.3 하스켈에서는 파라메트릭 다형성이 유사 문제를 대부분 해결해 주기 때문에 존재 기반 서브타이핑은 덜 흔하다. 이 주제에 대해 Simon Peyton Jones의 좋은 강연이 있다.
컨테이너([SomeNum]) 안에서 존재적으로 정성된 데이터를 사용하는 패턴은 “위젯 패턴”이라고도 하는데, xmonad 같은 라이브러리에서 조작 메서드들과 함께 확장 가능한 “위젯”을 저장하기 위해 쓰인다. 존재 타입클래스보다 “사전(dictionary)”을 타입 안에 명시적으로 저장하는 방식이 더 흔하지만, 때로는 컴파일러가 메서드 테이블을 암묵적으로 생성/전달해 주도록 하는 것도 편하다. 존재 타입클래스를 사용하면 타입에 대해 어떤 메서드/함수를 “정식(canonical)”으로 축복할 수 있고, 컴파일러가 항상 일관성을 보장해 준다.
다만 나는 존재 리스트의 다양한 타입 안전 수준에 대한 글에서, 이런 “인스턴스의 컨테이너” 타입이 하스켈에서는 여러 이유로 다른 언어들보다 훨씬 덜 유용하다고 언급했다. 위아래 캐스팅 이슈도 그중 하나다. 게다가 하스켈은 동종 파라미터([a]처럼 모든 원소 타입이 같은 경우)를 다루기 위한 풍부한 기능을 제공하므로, 이질 리스트로 뛰어드는 순간 포기해야 할 것이 너무 많다.
이제 잠깐 타입클래스 계층이 어떻게 미묘한 서브/슈퍼타입 관계를 주는지 보자.
고전적인 Num과 Fractional을 보자:
class Num a
class Num a => Fractional a
Num은 Fractional의 _슈퍼클래스_이고, Fractional은 Num의 _서브클래스_다. Num 제약이 필요한 곳이라면 어디든 Fractional 제약으로도 같은 일을 할 수 있다.
그러나 다음 두 타입에서는:
Num a => a
Fractional a => a
forall a. Num a => a는 사실 forall a. Fractional a => a의 _서브클래스_다! 왜냐하면 forall a. Fractional a => a가 필요하면 그 대신 forall a. Num a => a를 제공할 수 있기 때문이다. 사실 세 단계, Double, forall a. Fractional a => a, forall a. Num a => a를 보자.
-- `Double`로 쓸 수 있다
1.0 :: Double
1.0 :: Fractional a => a
1 :: Num a => a
-- `forall a. Fractional a => a`로 쓸 수 있다
1.0 :: Fractional a => a
1 :: Num a => a
-- `forall a. Num a => a`로 쓸 수 있다
1 :: Num a => a
그래서 Double은 Fractional a => a의 슈퍼타입이고, 이는 다시 Num a => a의 슈퍼타입이다.
일반적으로 더 슈퍼(super)할수록, 실제로 만들고 있는 항(term)에 대해 더 많이 “안다”. 따라서 Num a => a는 가장 적게 알고(그리고 가능한 실제 항이 가장 많다. Num 인스턴스가 Fractional보다 많으므로), Double은 가장 많이 안다. 심지어 머신 표현까지!
즉 Num은 Fractional의 슈퍼클래스지만, forall a. Num a => a는 forall a. Fractional a => a의 서브클래스다. 이는 전형적인 서브타이핑 규칙을 따른다. 어떤 것이 화살표의 “왼쪽”(여기서는 =>)에 나타나면, 서브/슈퍼가 뒤집힌다. 우리는 왼쪽을 종종 “음수”(반변, contravariant) 위치, 오른쪽을 “양수”(공변) 위치라 부른다. 음수의 음수(왼쪽의 왼쪽, 예: (a -> b) -> c에서의 a)는 양수다.
또한 우리의 “존재 래퍼”:
data SomeNum = forall a. Num a => SomeFractional a
data SomeFractional = forall a. Fractional a => SomeFractional a
은 다음과 같이 CPS 변환으로 동치 타입으로 바꿀 수 있다:
type SomeNum' = forall r. (forall a. Num a => a -> r) -> r
type SomeFractional' = forall r. (forall a. Fractional a => a -> r) -> r
toSomeNum' :: SomeNum -> SomeNum'
toSomeNum' (SomeNum x) f = f x
toSomeNum :: SomeNum' -> SomeNum
toSomeNum sn = sn SomeNum
이 경우에도 Num과 Fractional은 공변(양수) 위치에 나타난다. 음수의 음수이기 때문이다. 그래서 SomeFractional이 SomeNum의 서브타입이라는 우리의 직관과도 부합한다.
앞서 설명한 이 긴장감은 Expression Problem과 밀접하다. 이는 언어와 추상화 설계의 다양한 측면에 내재된 긴장이다. 하지만 이 글의 맥락에서는 어떤 패턴을 선택할지 가늠하는 일반 지침이 된다.
나는 Expression Problem을 “처리해야 할 장애물” 같은 의미의 “문제”로 보지 않는다. 오히려 “수학 문제”처럼 본다. 접근을 조정함으로써, 설계에 필요한 요구사항에서 최선을 끌어내도록 식을 가지고 놀 수 있다.
하스켈(그리고 일반적으로 프로그래밍)에서의 많은 좌절은 도구와 추상을 본래의 의도가 아닌 방식으로 억지로 쓰려 할 때 생긴다. 이 짧은 개요가 이러한 설계 패턴의 요점에 _역행_하지 않고, 그들이 줄 수 있는 것을 최대한 끌어내는 데 도움이 되길 바란다. 즐거운 하스켈링!
나는 놀라운 커뮤니티의 지원에 늘 겸허함을 느낀다. 그분들 덕분에 이런 글을 연구하고 쓰는 데 시간을 바칠 수 있다. 특히 patreon의 “Amazing” 등급 후원자 Josh Vera님께 깊이 감사드린다! :)