Covariant, Contravariant, Positive, Negative

Posted on November 10, 2022

Co, Contra

어원
co- “together, with” - Etymonline
contra- “against, in oppsition” - Etymonline

순방향, 역방향
같은 방향으로, 반대 방향으로
등을 의미할 때 주로 쓰입니다. 무언가 기준이 있고, 그 것의 움직임, 변화에 따라 다른 것이 어떻게 되냐에 따라 붙는 용어입니다.

Variant

어원은 “tending to change”인데, 변하려는 성향정도 번역할 수 있을까요? 자료들을 보면 “변성”이라고 번역하기도 합니다. 위 접두어와 합치면, 같은 방향으로 변하면 공변covariant, 반대 방향으로 변하면 반변contravariant입니다. 반변은 반공변으로 번역하기도 합니다.

기저 벡터

웹에서 Covariant, Contravariant를 검색하면, 수학쪽 용어도 같이 검색됩니다. 예를 들어 기저 벡터를 phi만큼 회전하면, 좌표 변환은 -phi만큼 돌린 것과 같습니다. 이럴 때 반대로Contra 변성variant, Contravariant 합니다.

OOP에서

P : Parent 의미, C : Child 의미

아래와 같은 상황을 가정하면

Pa, b, c 메소드를 가지고 있고,
CP가 할 수 있는 a, b, c는 모두 그대로 가지고 있고, d, f 메소드도 있다.

이럴 때 P가 필요한 자리에, C를 넣을 수 있습니다. P타입을 받는다는 건, a, b, c 메소드를 쓰겠다는 건데, C타입도 이들 메소드를 모두 가지고 있으니 문제가 생기지 않습니다.

P(super) <- C(sub) 관계가 있을 때, PC에 의존하는 어떤 Generic 클래스, 타입 등이 SomeVariant P <- SomeVariant C 와 같이 변성(변환) 전의 것과 같은 방향의 관계가 있을 때는 Covariant, 반대일 때는 Contravariant라 합니다.

하스켈에서는 무엇이 변할까?

위에 예를 든 것처럼, 다른 곳에서도 이 용어는 많이 쓰입니다. 여기서는 하스켈에서 타입 얘기를 할 때 나오는 뜻만 보겠습니다. 적어도 두 가지가 변화하는 게 있어야, 이 용어를 쓸텐데, 무엇이 변화할 때 무엇이 co, contra란 말 없이 그냥 covariant와 contravariant로 설명하는 경우가 있어, 금방 이해가 가지 않을 때가 있습니다.

하스켈에선 Functor로 타입이 변환 되었을 때, 기존 관계(함수)가 어떻게 되느냐에 관한 용어입니다.

P, C 타입이 있고, f :: P -> C 란 함수가 있을 때 SomeFunctor로 변환한다면, SomeFunctor P, SomeFunctor C 로 변환하고, 함수가
f 변환 :: SomeFunctor P -> SomeFunctor C 이면 Covariant 이고, f 변환 :: SomeFunctor P <- SomeFunctor C 이면 Contravariant 라 합니다.

fmap :: (a -> b) -> Maybe a -> Maybe b

fmapa -> bMaybe로 변환하면 Maybe a -> Maybe b로 만들기 때문에 Covariant functor라고 부릅니다. 예를 들면, Int -> String 함수를 covariant functor로 변환해서 Maybe Int 에 적용할 수 있습니다. 여기까진 자주 보던 것으로 그리 낯설지 않습니다. 그런데, Int가 출력에 있는 Double -> Int 같은 함수는 Maybe Int에 적용할 수 없습니다.

만일, a -> bSome b -> Some a로 변환되면 Somea에 Contravariant라 말하는데, 다음에서 예시를 보겠습니다.

Contravariant

FPcomplete Covariance and Contravariance 에서 발췌

newtype MakeString a = MakeString { makeString :: a -> String }

showInt :: MakeString Int
showInt = MakeString show

-- (a -> b)가 아니라 (b -> a)입니다.
mapMakeString :: (b -> a) -> MakeString a -> MakeString b
mapMakeString f (MakeString g) = MakeString (g . f)

plus3ShowInt :: MakeString Int
plus3ShowInt = mapMakeString (+3) showInt

main :: IO ()
main = putStrLn $ makeString plus3ShowInt 5

makeString 함수는 어떤 타입이든 받아서 String 타입을 돌려주는 함수입니다. 예를 들면 타입에 따라 타입 이름을 돌려 준다든지 하는 함수일 수 있습니다. 일단 시작부터 의도가 보입니다. a가 출력에 있지 않고, 입력에 있습니다. 이게 fmap을 구현할 때 어떤 영향을 미치는지 보겠습니다.
showInt는 정수를 받아 문자열로 바꾸는 함수입니다. 그런데, 이 함수로 출력하기 전에, 정수값을 변화 시키는 함수(+3)를 먼저 적용 후 문자열로 출력하게 하고 싶습니다. 이럴 경우, 자주 썼던 covariant functor로 시도해 보겠습니다. 위 FPcomplete의 예시는 (+1) :: Int -> Int으로 입력과 출력이 Int로 같아 미스 매치되는게 잘 안보이니, 원래 타입 그대로 두고 매칭 여부를 보겠습니다.

mapMakeString :: (a -> b) -> MakeString a -> MakeString b
mapMakeString f (MakeString g) = MakeString (g . f)

mapMakeString (+3 :: a -> b) (showInt :: a -> String) 
  = MakeString ( showInt . (+3) :: b -> String)

이렇게 하면 될 것 같지만, 타입을 적어보면 출력이 맞지 않습니다. (+3) :: a -> b 이니, 그 다음 함수 showIntb -> String을 받아야 합니다. 하지만 showInta -> String입니다. 말로 풀어 보면, (+3)을 나중에 적용하는 게 아니라, 먼저 적용한 후 showInt를 적용하고 있습니다. MakeString 타입의 폴리모픽 부분은 출력이 아니라, 입력입니다. mapMakeString을 적용해서 합성이 이루어질 때, 이 폴리모픽한 부분은 먼저 들어 온 함수의 입력과 만나지 않고, 출력과 만나야만 원하는 동작을 합니다. 소스를 잘 읽어보면 (b -> a)로 뒤집힌 걸 요구하기 때문에 그냥 covariant functor로 구현할 수 없습니다.

mapMakeString :: (b -> a) -> MakeString a -> MakeString b
mapMakeString f (MakeString g) = MakeString (g . f)

이렇게 Contravariant하게 변하는 걸 fmap하기 위해 아래와 같이 뒤집힌 타입을 메소드로 가진 클래스가 있습니다.

class Contravariant f where
  contramap :: (a -> b) -> f b -> f a

결과 타입은 고정되어 있는데, 입력 타입이 달라질 필요가 있을 때 쓸 수 있습니다. (fmap은 covariant한 경우에 쓴다고 말하는데, 잘 생각해 보면, fmap은 입력 타입이 고정이고, 결과 타입이 달라질 필요가 있을 때 써왔습니다.)

입력 위치에 있으면 Contravariant

출력에 있으면 Covariant
입력에 있으면 Contravariant

위와 같은 상황에서도 같은 용어를 쓰는데, 무엇이 기준이고 무엇이 따라 변하는 건지 모호합니다. 이전 섹션의 Contravariant를 보면, a에서 Some a로 변환할 때, Some 정의안에 a의 위치가 SomeType -> a이면, 특별히 “a와”란 말을 쓰지 않고도, 그냥 Covariant란 말을 쓰고, a -> SomeType이면 a와 Contravariant라고 말합니다. functor의 성질이 a의 위치에 따라 Co, Contra로 바뀝니다.

Consume하면 Contravariant

Produce하면 Covariant
Consume하면 Contravariant

SomeType -> a이면 결과로 a를 만들어내니 Produce한다고 말하고, a -> SomeTypea를 인자로 받아 써먹으니 Consume이라 말합니다.

Consume하면 Negative, Produce하면 Positive

생각 스트레칭
(a -> b) -> b 타입만으로 알 수 있는 게 있습니다.
이 함수는 다른 걸 받는 건 없고, (a -> b)함수만 받으면 b라는 값을 만들어 냅니다. (a -> b)함수를 실행하면 b가 나올텐데, 실행하려면 a가 필요합니다. 함수만 받았는데, b가 나온다는 말은, 안에 이미 a가 있을거라 예상할 수 있습니다. (물론, 받은 함수를 무시할 수도 있겠지만, b를 generate할 방법은 어떤식으로든 존재해야 합니다.)

※ 엄밀히 얘기하면, forall a => (a -> b) -> b 일 때, 이 함수는 a가 뭔지 모르니, a를 가지고 있을 방법이 없습니다.(딱 한가지 undefined(bottom)을 가지고 있다고 말할 순 있겠습니다만) 예시는 조금 더 다듬어야 하지만, 이해하는데 도움은 되는 예시니 일단 그대로 두겠습니다. @jason님이 놓친 걸 지적해 주셨습니다. 감사합니다.

아래는 사장, 팀장, 요리사, 레시피가 모여 요리를 만드는 코드입니다.

data Meat = Beef | Pork deriving Show
data Cooking a = Steak a | Stew a deriving Show
newtype Cookbook = Cookbook { runCook :: Meat -> Cooking Meat }

steakRecipe :: Meat -> Cooking Meat
steakRecipe = Steak

chef :: Cookbook -> Cooking Meat
chef cb = runCook cb Beef 

-- leader는 chef가 들어오면 준비된 steak recipe를 넣어 줍니다.
leader :: (Cookbook -> Cooking Meat) -> Cooking Meat
leader ch = ch (Cookbook steakRecipe) 

-- boss는 외부에서 leader가 들어오면 Beef를 다루는 chef를 붙여 줍니다.
boss :: ((Cookbook -> Cooking Meat) -> Cooking Meat) -> Cooking Meat
boss ld = ld chef 

main = print $ boss leader

위 예시는 boss, leader, chef, recipe 가 모두 있어야 요리가 만들어 집니다.
bossleader없이 chef만 데리고 있습니다.
leaderrecipe만 가지고 있습니다.
bossleader를 넣어 주면, boss가 가진 chefleader에게 넣어 주고,
leader는 가지고 있는 recipechef에 넣어 줘서 요리를 만들게 됩니다.

위에서 말한 Consume 모양 중 V -> Int 함수를 받는 고차 함수를 살펴 보겠습니다. reduce하려면 필요한 정보가 어디에 있는지 살펴 보겠습니다.

func1 ::                 f :: V -> Int
func2 ::               ( f :: V -> Int ) -> Int
func3 ::        ( g :: ( f :: V -> Int ) -> Int ) -> Int
func4 :: ( h :: ( g :: ( f :: V -> Int ) -> Int ) -> Int ) -> Int

실행되는 코드는 아니고, 고차 함수들이 함수를 인자로 받는 부분에 f,g,h 이름을 붙여 놨습니다.

잘 보면, V가 contravariant 위치에 있는 함수를 받는 고차 함수를, 차수를 올릴 때마다 Consume, Produce가 반복되고 있습니다.

마치 마이너스(-), 즉 Negative 기호 연산(음수 x 음수 = 양수)을 반복하는 것 같은 모양이 나옵니다. Contravariant인 것을 Contravariant에 두면 Covariant로 바뀌고 있습니다. 이런 성질 때문에 Negative, Positive로 표현하기도 합니다.

@재원님이 (a -> b) -> b 형태의 함수는 안에 a를 가지고 있다는 설명 방식에 헛점이 있다고 지적해 주셨습니다. 왜냐하면 꼭 a를 가지고 있지 않고, 함수 자체를 써먹지 않고 버리고, 어떤 방법으로 a를 만들어 낼 수도 있으니 “반드시” a를 가지고 있을 거라 설명하면 안된다는 말씀입니다. 물론, 맞는 말씀인데, 여기선 직관을 위해 “대부분” 그럴거란 가정하에 그대로 두었습니다. a를 미리 가지고 있다 = a를 생성generate 한다와 같은 의미로 봐주시면 되겠습니다.

@다믜님의 Positive/Negative 설명입니다. @다믜님 허락하에 원문을 올립니다.

a -> b 를 구현할 때에는 a가 주어지니 이걸 사용하기만 하면 되죠. negative입니다.
(a -> b) -> c 의 경우 a -> b가 주어지는데, 이걸 사용하는 유일한 방법은 a -> b에 인자로 a를 주는것입니다. 결국 a를 만들어줘야 하죠. positive입니다.
((a -> b) -> c) -> d 에서는 (a -> b) -> c 가 주어지고 이걸 사용하려면 a -> b 를 건네줘야 하죠. 그리고 a -> b 를 만들 때에는 주어진 a를 사용하면 되니 결국 a를 받는 꼴이 됩니다. negative가 되지요

negative/positive를 이해하는 다른 좋은 방법으로 monotonic function이 있는데요
increasing function은 postivive, decreasing function은 negative에 대응이 됩니다. increasing function은 a ≤ b → f(a) ≤ f(b) 를 만족하는 함수 f, decreasing function은 b ≤ a → f(a) ≤ f(b) 를 만족하는 함수 f를 말하는데, 이것만 보더라도 이미 co/contra-variant functor와의 유사성이 보이죠

그리고 increasing/decreasing function을 합성할 때 decreasing function이 짝수 번 나오면 increasing, 홀수 번 나오면 decreasing이 된다는걸 쉽게 할 수 있습니다 가령 f, g 가 decreasing function이라고 할 때 f∘g 가 어떻게 되는지 보면
1. a ≤ b 이면 g(b) ≤ g(a) (g가 decreasing 이므로)
2. g(b) ≤ g(a) 이면 f(g(a)) ≤ f(g(b)) (f가 decreasing 이므로)
3. 1,2 를 합하면 a ≤ b 일 때 f(g(a)) ≤ f(g(b)). 즉 f∘g 는 increasing. 이렇게 됩니다.
이걸 굳이 monotonic function으로 설명한 이유는 이제 그래프로 볼 수가 있어서 그렇습니다.

increasing
decreasing

각각 increasing/decreasing function의 그래프구요 decreasing function 두개를 합성하면 increasing이 된다는건 아무 점이나 두개찍고 대소관계를 비교해보면 쉽게 느낄 수 있습니다 negative/positive란 용어는 논리학에서 왔구요, 함수를 대상으로 하는 경우에는 monotonic, increasing/decreasing이라는 표현을 주로 사용하죠. 카테고리에서 이제 covariant/contravariant라는 표현을 쓰구요

※ 참고
정적 타입 지정과 변성 - 오현석
FPcomplete Covariance and Contravariance

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