한 번쯤 나올 법 했는데, 아직 없었던 펑터 이야기

Posted on March 9, 2023

학술적인 내용이 아니라, 펑터 이해를 위해, 이제 막 출발한 사람이 펑터를 소개하는 정도의 글입니다. 그러니, 당연히 정답 목적지에 도착하지 않은 글입니다. 잘못된 정보를 올려 놓은 게 보이면 꼭 지적 부탁드립니다.

처음 접하길 펑크터란 발음으로 접해, 다른 분들과 대화를 나누기 전에는 펑크터로 n년동안 발음했는데, 국내 업계는 펑터란 발음으로 자리 잡은 것 같습니다. 이 블로그의 초기 글엔 펑크터로 써있고, 2023년 이 후 글들은 펑터란 말로 바꾸기 시작했습니다.
※ 원래 발음 기호는 fəŋ(k)-tər이므로 k를 넣어도, 안 넣어도 맞는 발음입니다.

(번역어로 함자란 말이 있지만, 저와 대화를 나누는 대다수의 분들이 펑터를 더 편하게 생각합니다.)

모노이드, 모나드 관련 블로그 포스트를 몇 개씩 쓰는 동안, 정작 먼저 알고 있어야 하는 펑터 글은 하나도 쓰지 않았습니다. 다른 분들도 쓰윽 무리없이 지나간 키워드, 개념일 수도 있는데, 조금 더 성질을 알면 모노이드, 모나드를 보는데도 도움이되고, 나아가 함수형 설계에도 도움이 됩니다. 보통 펑터를 설명하는 텍스트를 만나면 여기와 같은 얘기를 하지 않습니다. 독특하게 쓸모가 있거나, 전혀 읽을 가치가 없는 글일 수도 있습니다.

  1. 아주 간단하게 보는 카테고리 이론에서 펑터
  2. 똑같은 대상과 닮은 대상
  3. 컨테이너란 선입견에서 벗어나자
  4. fmap
  5. endofunctor
  6. 그 다음은?

보통은 “컨테이너가 있고, 그 안에 든 대상에 매핑할 수 있는 방법을 가진 타입”을 펑터라고 설명하곤 합니다. 여기선, 펑터와 관련된 어려운 수학을 아주 아주 제멋대로 비수학적, 인문학적으로 해석합니다. 위 목차대로 짚어가며 제가 이해하고 있는 펑터 이야기를 해보겠습니다.

간단하게 보는 카테고리 이론에서 펑터

함수function도 매핑 동작을 하고, 펑터functor도 매핑 동작을 합니다. 어차피 매핑 동작을 하는 거면, 같은 이름으로 부르지, 왜 함수를 놔두고 다른 이름 펑터를 만들어냈을까요?

구조

어떤 시스템에서 구성원들이 다른 구성원과 어떤 관계들을 갖느냐를 “구조”라고 합니다.

카테고리

집합은 구성원들 몇 개가 모인 것이고, 이 집합에 규칙을 적당히 두어 마그마, 반군, 모노이드, 군같은 구조로 부릅니다. 반면, 카테고리란 구성원이 대상만 존재하는 것이 아닌 대상object, 대상들간의 모피즘morphism, 모피즘 합성composition, 항등모피즘Identity, 결합 법칙이 있는 “구조”입니다. (대상이 있고, 그 들 사이에 있을 수 있는 관계의 가장 일반적인 표현이 모피즘인데, 하스켈로 좁혀서 보면 모피즘들은 대부분 함수 형태로 나옵니다.) 왜, 이런 구조를 만들었는지는, 이론을 공부해 나가면서 점점 알게될텐데, 입구에서 몇 걸음 가지 않은 저로서는, “구조 보존”을 표현하기 좋게 모아놓은 요소들 아닌가 정도 추측하고 있습니다. 제 지식으로 몇 줄로 요약한다는 건 불가능에 가깝지만, 펑터를 얘기하려면 할 수 없이 짚고 가긴 해야 합니다.

펑터

카테고리 이론은 어떤 구조를 가진 한 카테고리를, 다른 카테고리로 매핑하면서 여러가지 수학적인 개념을 표현합니다. 대상의 수학적 성질을 표현할 때, 대상 자체를 언급하는게 아니라 오로지 다른 대상과의 관계로만 설명합니다. 실제 느낀 효과로는, 한 카테고리에서 표현하기 난해한 것들을 다른 카테고리에서 표현하기 쉬운 경우도 있고, 또는 카테고리들간의 관계로 한 단계 위의 구조를 또 만들 수도 있습니다. 이렇게 만들어진 구조는 또 다른 구조와의 관계로 또 다른 개념을 설명할 수 있습니다. 이럴 때 카테고리간 매핑을 하는 연산을 펑터라 부릅니다. 펑터는 함수처럼 대상 하나와 다른 대상 하나를 매핑하는데서 그치는게 아니라, 모피즘도 매핑하고, 모피즘의 합성도 매핑(구조가 보존된다는 뜻입니다)합니다. 그리고 functoriality를 따라야 합니다. 분명 함수와는 조금 다른 동작을 하니 새로운 이름이 필요하지 않았을까 추측합니다. ※ 모피즘의 합성도 모피즘이니, 대상과 모피즘만 매핑해야 한다고 말해도 됩니다.

한 마디로, 값을 매핑하는 걸 function이라 하고, 구조를 매핑하면 functor라 합니다.

대부분의 텍스트는 이렇게 얘기하고 다음 스텝으로 넘어가는데, 저는 이 게 현실, 프로그래밍에 접목하면 어떤 동작을 하는지가 궁금했습니다.

※ functoriality:
(~ty가 들어가면 성질을 나타냅니다. associativity, commutativity, distributivity…근데, monadity같은 건 못봤습니다.)
항등사상에 펑터를 적용한 것과, 도착할 곳의 항등사상이 같다. Identity Law: F(id_X) = id_FX
합성한 것에 펑터를 적용하든, 펑터를 적용후 합성하든 같다. Composition Law: f:X->Y, g:Y->Z 일 때 F(g∘f) = Fg ∘ Ff

똑같은 구조와 닮은 구조

매핑이란 뭘까에 대한 상상에서 시작합니다. 그림자나, 나무 막대기로 각 관절들을 사람과 연결해 놓은 인형처럼, 한 쪽에서 움직이면 완전히 동일하게 움직이는 두 대상은, 어느 한 쪽의 움직임으로 다른 쪽의 움직임을 완벽하게 알 수 있습니다. 수학적으로 얘기하면 매핑과, 그 매핑을 정확히 뒤집은(역) 매핑이 있으면 둘은 isomorphic하다고 합니다. 언제든지 한 쪽의 정보를 이용해 다른 쪽을 알 수 있습니다. 둘은 다르지만, 연결된 관절의 움직임만 궁금하다면 둘 중 무엇을 지켜 봐도 똑같은 결과가 나와, 둘을 구별할 수 없습니다.
하지만, 세상에는 완전히 같지는 않지만, 비슷한 경우에 의미를 두면 훨씬 많은 것들을 표현할 수 있습니다. 구조가 같다보존된다는 말은, 둘이 isomorphic한 상황만 얘기하는 게 아닙니다. 저도 용어를 실수한 적이 있는데요(@기정님 감사합니다), “구조가 같다”와 “구조가 보존된다”는 차이가 있습니다. isomorphic이면 구조가 같은 것이고, homomorphic은 구조가 보존됩니다. A에 homomorphism을 적용해서 B가 됐다면, B에는 A와 같은 구조는 존재하지만, 전체 구조가 A와 같을 필요는 없습니다. homomorphic은 구조는 보존되었지만, 전체 구조는 같지 않을 수도 있습니다. isomorphic은 구조가 같으니, 당연히 구조도 보존 된, homomorphic의 특별한 경우입니다.

예를들어 A -> B, B -> C, C -> D 의 관계가 있는 걸
A, B, C 모두를 P 한 곳으로 매핑하고, 각 관계들을 P -> P에 모두 매핑해도 구조를 보존했다고 합니다.

잘 보면 구성원들끼리의 연결은 추가되거나 사라지지 않았습니다. 이렇게 구조를 보존하며 매핑하는 연산을 homomorphism이라 부릅니다. 역으로 돌아 올 수 있을지, 없을지 알 수 없습니다. 하스켈에서 만나는 대부분의 펑터는 isomorphic이 아니라 homomorphic에 관한 얘기입니다. 세상에는 똑같지 않지만, 닮은 구석들을 찾아서 (없으면 강제로 만들어서라도) 모델링하면 되는 경우가, 완전히 똑같은 경우보다 훨씬 많습니다. 예를 들면, 세세한 정보는 필요없고, 큰 정보만 보면 될 때는 세세한 정보가 지워진 것과 매핑해서, 그 구조를 살피면 훨씬 효율적입니다. 또는 매핑된 것에서는 특정 조건이 금방 눈에 띈다거나 할 수 있습니다.

IntMaybe Int는 완전히 일대일 매핑은 아니지만, Int가 가진 구조는 Maybe Int가 고스란히 가지고 있고, 즉 구조가 보존되어 있고, 추가적으로 Nothing과 관련된 구조가 있습니다.

※ 동형isomorphic, 준동형homomorphic, 모피즘(사상)morphism, 범주category를 우리말과 영어를 섞어서 계속 표기하고 있는데, 어느 쪽으로 통일하는 게 잘 읽힐지 아직 잘 모르겠습니다. 일단은 섞어서 쓴 대로 그대로 두겠습니다. 인쇄해서 불변이될 자료가 아닌 언제든 수정 가능한 블로그 글이니, 궅이 어느 한 쪽만 익숙해진 걸 가정할 필요는 없어 보입니다. 둘 다 익숙해지게 섞어 써도 나름 효과가 있겠습니다. 함자functor는 거의 쓰는 분을 못 만났습니다.

관절 연결 보존!

컨테이너란 선입견에서 벗어나자

펑터를 바라 볼 때, 컨테이너 비유로 바라보면 딱 맞아 떨어지는 경우들이 있습니다. 대표적으로 리스트 타입 같은 경우가 그렇습니다. 어떤 타입이 있고, 그 타입을 안에 담고 있는 리스트 타입과의 펑터를 예로 드는 경우가 많습니다. 제 생각은 이런 비유로 바라 보는 게, 펑터 다음 스텝(Applicatives functor, Monad)으로 갈 수록 조금씩 걸림돌이 됩니다. 컨테이너 메타포 없이 두 구조간의 매핑으로, 혹은 세세하게는 대상, 모피즘, 모피즘 합성을 다른 카테고리에 있는 것들과 매핑하는 걸 펑터로 기억하는 게 개인적으론 더 무리가 없었습니다.

하스켈에서 펑터 fmap

아마도 functor mapping의 약자쯤 될 듯한데, 딱 관련 언급을 하는 자료는 못찾았습니다. 하스켈에서 펑터는 두 가지 작업을 합쳐 펑터를 표현합니다. (실제론, 펑터 규칙functor law을 만족하게끔 구현을 잘 해햐 하는데, 하스켈에서는 이 규칙을 따르는지는 컴파일러가 체크하는 건 아니고, 프로그래머가 잘 검증해야 합니다.)

IntMaybe Int와 매핑하는 걸 보면
(좀 더 풀어서 얘기하면, IntMaybe펑터를 이용해 Maybe Int에 매핑하는 걸 보면),
첫 째로 타입 생성자 MaybeInt를 매핑하고,
둘 째로 f :: Int -> Int 타입의 함수는 fmap :: (Int -> Int) -> (Maybe Int -> Maybe Int)로 매핑합니다.
f함수와 fmap f 함수를 매핑한다고 보면 됩니다.(f함수에 fmap을 적용하면 Maybe Int -> Maybe Int타입이 됩니다.)
이 둘을 합쳐 펑터라 부릅니다.

자주 보던 리스트의 mapfmap을 구현한 한 사례입니다.

이렇게 함수 변환으로 바라 보면, 상자 안에 들어 있는 무언가가 바뀌었다는 메타포보단, 구조와 구조가 매핑된다는 느낌이 들지 않나요? 그래서, 전 컨테이너 메타포로 보지 않는 게 오히려 더 편합니다.

endofunctor

endo는 안으로inside 라는 어원을 가지고 있습니다. 한 카테고리와 다른 카테고리를 매핑하는 연산을 펑터라 했는데, 이 때 출발지 카테고리와 도착지 카테고리가 동일한 경우의 매핑을 엔도펑터라 부릅니다. 하스켈은 타입들을 대상으로 하고, 함수를 모피즘으로 하는 Hask 카테고리라 부릅니다. 하스켈에서는 Hask에 있는 어떤 타입에 fmap을 적용해도 또 다시 Hask에 있는 타입 중 하나로 돌아오기 때문에 엔도펑터로 부릅니다. 즉, 하스켈에서 만나는 펑터는 모두 엔도펑터입니다. 당장 펑터를 알기 위해 필요한 용어는 아니지만, 다음 스텝을 위해 알아두고 가면 좋습니다.

비수학적 결론

펑터는 한 시스템을, 필요한 구조는 똑같이 갖고 있고, 전체 모양은 닮은 것들로 변환할 수 있는 도구입니다.

같게 보는 건 아주 중요한 도구다란 상상에 깔려 있는 생각은, 수학은 무엇과 무엇이 같다, 혹은 같게 볼 수 있다는 데에서 여러 표현이 생겨난다는 것입니다. x + 13이 같다=에서 시작해서 x2인 것에 도달하는 것처럼요.

그 다음은?

두 펑터의 관계를 보는 예를 들어보겠습니다.

길이를 재는 자가 있는데, 눈금이 1cm로 촘촘한 자 Dense와, 눈금이 2cm로 듬성 듬성있는 자 Sparse가 있습니다. 우리가 필요한 정밀도가 2cm면 충분할 때는 Sparse로 재면 됩니다. Dense로 재면 3cm인데, Sparse로 재면 3cm는 잴 수가 없으니 큰 쪽 눈금을 읽는다고 정해서 4cm로 읽는 걸로 약속을 정하겠습니다. 어느 자로 쟀는지 표시하기 위해 Int에 타입 Dense, Sparse를 씌우면, 이 약속이 바로

펑터 :: Int -----> Dense Int
펑터 :: Int -----> Sparse Int

입니다(물론 엄밀하게는 더 따져야 할 것들이 있습니다.). 잘 보면, Dense에서 Sparse로 매핑 가능하지만, Sparse에선 Dense로 돌아 올 수 없습니다. 4cm는 3cm일 수도 있고, 4cm일 수도 있습니다. Dense에서 Sparse로 가는 isomorphic이 아니라, homomorphic 동작입니다. 그런데, 이런 상황에서 돌아올 수 있는 경우가 있습니다.

※ 펑터간 매핑을 자연 변환Natural Transformation이라 합니다.

임의로 1cm 정도의 오차는 허용하고, 큰 수로 항상 매핑하겠다 정하면 Sparse에서 Dense로 돌아 올 수 있습니다. 그냥은 안되지만, 어떤 조건이 있으면 가능합니다. 조건은 변환 함수(자연 변환)로 나타납니다.

펑터 적용 후 이전으로 돌아 갈 수 없는 상황에서, 적당한 변환 함수가 있으면 같게 볼 수 있게 되었습니다. 이 걸 읽는 방법은 두 가지인데, 하나는 대상을 변환 시켜 같아지게 만들었다 볼 수 있고, 다른 한가지 방법은 같게 보는 눈을, 판단을 바꾼 걸로 볼 수 있습니다. 아주 인포멀하게 얘기하면

A = B

가 안되는 상황에서 AB를 바꾸는 게 아니라, =을 바꿨다고 볼 수 있습니다.

이 이야기는 모노이드, 모나드로 연결됩니다.

Dense도, SparseInt를 담는 컨테이너로 읽는 분들도 있습니다. 저는 구조간 매핑으로 읽는 게 더 편합니다.

다른 교재나 텍스트들을 그대로 번역하거나 옮겨온 것이 아닌 제 생각, 상상을 쓰는지라, 정답 지식이 아니라, 다른데서 볼 수 없었던 힌트를 얻는 정도 글이 됐으면 좋겠습니다.

실용에서 쓸 때 장점

2025.1 추가
(글을 짜임새 있게 쓰려면, 펑터로 설계하는 이유가 좀 더 명확하게 드러나는 설명이 먼저 들어가면 좋겠지만, 따로 글 쓰기 위한 고민을 한다기 보다, 그 때 그 때 생각이 나는 걸 옮기다보니, 지금 당장은 여러모로 부족한 글입니다.)

하스켈에서 펑터의 힘은 대단합니다. 여기서는 이렇게 대단한 펑터의 전체 개념을 보자는 게 아니라, 어떤 대상을, 구조를 보존하는 비슷한 대상으로, 펑터로 매핑하게 되면 표현이 복잡해질텐데, 어떻게 이를 해결하는가를 보는 게 목표입니다.

“펑터 구조를 가진 값도, 마치 프리미티브 값처럼 보이게 하고 싶다.”

예를 집어 넣어 풀어서 얘기하면, Some 1 같은 펑터값도, 1처럼 다루고 싶다는 뜻입니다.

겉모양

여기서는 펑터 덕분에 코드 모양이 이뻐지는 것만 보겠습니다.

data Some a = Some a 
fmap f (Some a) = Some (f a)

위와 같이, 특별히 하는 일 없는 가장 간단한 펑터를 정의했습니다. 아래와 같은 표현을 원한다고 가정하겠습니다.

1 + Some 2

Some 2를 그냥 1과 다름 없는 대상처럼 쓸 수 있으면 좋겠습니다. 하스켈에는 값에 함수를 적용할 때 f a,f (a + a)같이 쓰는 걸, $ 연산자가 정의되어 있어, 괄호 없이 f $ a, f $ a + a 라고 쓸 수 있습니다.

(1 +) $ 2

이와 비슷하게, 하스켈은 fmap f v를 중위 표현 f <$> v로 쓸 수 있게 fmap의 중위 연산자 <$>를 정의해 두었습니다.

(1 +) <$> Some 2

둘이 그럭 저럭 비슷해 보이지 않나요?

이 번엔, 아래와 같이 인자가 둘 이상일 때는 어떻게 할까요?

-- 1 + 2 를 전위 형태로 쓰면
(+) 1 2 
-- (Some 1) + (Some 2) 를 전위 형태로 쓰면
(+) (Some 1) (Some 2)

이렇게 쓰면 좋은데, 이 건 펑터의 fmap(<$>) 만으론 해결하지 못하는 상황입니다. +Some 1에 적용하면, Some (1+)가 되어 적용할 함수도 Some안에 있게 됩니다. 이를 위해 아래 연산자를 정의합니다.

(<*>) (Some f) (Some a) = Some $ f a

이제 아래와 같이 쓸 수 있습니다. ($는 Apply라 부르고, <$>는 Applicative Apply라 부릅니다.)

((+) <$> Some 1) <*> Some 2

괄호가 거슬립니다. 연산자 우선 순위를 잘 지정하면 괄호를 없앨 수 있습니다.

ghci> :info <$>
(<$>) :: Functor f => (a -> b) -> f a -> f b
infixl 4 <$>

ghci> :info <*>
class Functor f => Applicative f where
  (<*>) :: f (a -> b) -> f a -> f b
infixl 4 <*>

<*> 메소드는 이미 Applicative 클래스의 인스턴스로 정의되어 있는데, 둘의 우선 순위를 보면, 둘 다 infixl 왼쪽 우선이며, 순위는 같습니다. 연속으로 두 연산자를 섞어서 쓰면, 특별히 괄호가 없어도 왼쪽 우선 결합이 된다는 얘기입니다. 그럼 최종 다음처럼 쓸 수 있습니다.

(+) <$> Some 1 <*> Some 2

이 정도면 (+) 1 2와 비슷해 보이지요? <*>가 정의되어 있는 구조는 따로 이름이 있습니다. Applicative Functor라 부릅니다.(pure 정의도 같이 가지고 있어야 합니다.)

※ 모든 프리미티브 값들이 Identity a처럼 정의되어 있다 가정하면, (+) 1 2
(+) <$> Identity 1 <*> Identity 2로 볼 수 있고, 이 경우만 특별히 <$><*>, 그리고 Identity를 생략하고 (+) 1 2라고 쓴다고 상상할 수도 있습니다.

전체 코드

data Some a = Some a deriving Show

instance Functor Some where
  fmap f (Some a) = Some $ f a

instance Applicative Some where
  (<*>) (Some f) (Some a) = Some $ f a

나중에 작업이 필요하지만, 당장은 평범한 값으로 취급하자

조금 더 복잡한 예시를 살펴 보겠습니다. 나중에 환율값을 받으면, 값으로 계산 될 Dollar란 타입을 정의해 보겠습니다. Dollar c n이라고만 가격을 붙여 놓으면, 외국인이 한국 들어와서 지불하는 돈은 1200원 일 때도 있고, 1400원 일 때도 있고, 그 때 그 때 환율 currency에 따라 다르게 계산이 될 겁니다. 좀 억지스러운 예시지만, 1달러는 d1 = \c -> 1 * c, 2달러는 d2 = \c -> 2 * c, … 와 대응된다고 보겠습니다.

전체 코드

data Dollar c a = Dollar { runDollar :: c -> a }

instance Functor (Dollar c) where
  fmap func (Dollar f) = Dollar $ func . f

instance Applicative (Dollar c) where
  (<*>) (Dollar func) (Dollar f) = Dollar $ \c -> ((func c) . f) c

d1 = \c -> 1 * c
d2 = \c -> 2 * c
d3 = \c -> 3 * c

※ 펑터와 Applicative 펑터의 인스턴스 정의를 보면, c -> a에서 a에 함수를 적용하기 위해 함수 합성을 쓰고 있습니다. 함수 안으로 뚫고 들어가 안에 있는 a에 접근하는 방법은 따로 없습니다. c -> a의 결과로 나올 a에 함수를 적용하는 방법뿐이 없습니다.

값을 두 배로 올리기 위해 (2 *) 1로 쓰듯 Dollar를 쓰고 싶으면 펑터를 써서

(2 *) <$> Dollar d1 

로 표현하면 되고, 1 + 2와 비슷한 작업을 원하면 Applicative Functor를 써서

(+) <$> Dollar d1 <*> Dollar d2

라 표현하면 됩니다. 1 (* 2) + 3 (* 4)같은 복잡한 식은

(+)   <$>   ((* 2) <$> Dollar d1)   <*>   ((* 4) <$> Dollar d3)

로 표현할 수 있습니다.

눈여겨 봐야 할 것은, 이런 저런 복잡한 계산을 거친 후에도 최종 결과는 Dollar 타입입니다. c를 인자로 넘겨주기 전엔, 아직 값이 아닙니다. 함수임에도 겉 코드 모양은 마치 보통의 값을 대하는 모양과 비슷합니다.

최종 환율 적용 값을 얻기 위해 Dollar 안에 들어 있는 함수에 접근하기 위해 Dollar 정의에 있는 runDollar를 이용합니다.

data Dollar c a = Dollar { runDollar :: c -> a }

이제 복잡한 계산을 거쳐 나온 최종 Dollar 타입에 다음처럼 오늘의 환율(2025.1.11 약1,474원 - 너무 높네요)을 넣어주면, 몇 원인지 계산할 수 있습니다.

runDollar (
  (+)   <$>   ((* 2) <$> Dollar d1)   
        <*>   ((* 4) <$> Dollar d3)
) 1474

현재 계산에 들어가 있는 모든 Dollar 타입은, 전역 변수 (혹은 상수)를 참고해서 값을 계산하는 작업을 하고 있습니다. 환율에 의존하는 값, 즉 함수를 보통의 값을 다룰 때와 비슷한 모양으로 다룰 수 있고, 환율을 모른 채로 모듈을 먼저 만들어 두었다가, 나중에 필요할 때 run할 수 있습니다.

펑터 덕분에 복잡한 절차를 가지고 있을 수 있는 타입을 마치 프리미티브한 값처럼 다룰 수 있게 됐습니다. 하스켈을 공부한다면 위와 같은 모양을 매우 자주 보게 됩니다. 펑터 개념 전체에 대한 이야가기 아닙니다. 펑터가 왜 필요했고, 왜 그렇게 쓸 수 있는지 이해하는데 필요한, 힌트 일부를 줄 수 있는 글이 됐으면 좋겠습니다.

복잡한 실 세계에서 바로 들어 올 정보들은 펑터로 가리고, 마치 순수한 값인 것처럼 로직을 설계할 수 있을 것만 같은 느낌이 들었다면, 성공입니다.

2025.1 추가

펑터로 변환하면, 다시는 원래로 못 돌아와

※ 아래 나오는 숫자들은 모두 Int라 가정하겠습니다.
저도 “펑터 안에있는 값을 꺼내”라는 표현을 자주 쓰긴 하는데, 사실 펑터 안의 값은 꺼내서 단독으로 둘 수 없습니다. 무슨 말이냐 하면, IntMaybe Int로 바꿨다면, 이는 다시 Int로 돌아올 수 없습니다. 이런 의미를 살린다면 꺼낸다라는 의미는 맞지 않습니다. Maybe Intf :: Int -> Int를 적용한다는 건, 안에 있는 값에 접근해서 f를 적용한다거나, 펑터로 변환한 함수는 펑터 세계로 가버리고 못 돌아 온다는 상상이 더 어울립니다.

함수를 현재 세계에 두고, 펑터 세계에 있는 값을 가져오는 것이 아니라,
함수를 펑터 세계로 보내버린다가 맞는 은유입니다.

Just 1에서 1을 꺼내올 수 있지 않냐라고 물을 수 있습니다. “항상 값에 접근할 때는 Nothing인지 검사해”라는 컨텍스트에 놓여 있는 1과 그냥 1은 같지 않습니다. 예를 들면 Just 1에서 1을 꺼내오는 장치를 만들었다면, 이 장치는 Nothing을 넣어 주면 먹통이 됩니다. 마치 이 장치는

getJustVal :: Maybe Int -> Int
getJustVal (Just n) = n
--getJustVal Nothing = ?

Nothing 패턴 매칭을 구현하지 않은 장치와 같습니다. 그럼, Nothing 패턴 매칭도 하면 되지 않냐라고 물을 수 있습니다. 만일 Nothing 처리를 구현한다면, 결과값은 n이거나 Nothing을 표시하는 어떤 값이 될 수 있어야만 합니다. 다시 말해, 또 다시 Maybe 컨텍스트에 놓이게 됩니다.

그럼, Just 1이 가진 1이라는 정보를 어떻게 써먹지?

Maybe 컨텍스트 안에서 써먹으면 됩니다. 다시 Int로 돌아오는 것이 아닌 Maybe 동네에서 놀면 됩니다.

함수들을 fmap으로 Maybe동네로 보내 실행했다 치고, 그래도 최종 결과로 어떻게든 Int를 받아내고 싶다는 생각이 들 수 있습니다. 예를 들어 화면에 Int 결과를 출력하고 싶다라고 생각할 수 있습니다. print 함수도 Maybe동네로 보내버리면 됩니다. 값이 의미를 갖는 순간은 함수와 만날 때로 (눈으로 본다는 것도 어떤 함수가 돈다는 얘기입니다.), 이 함수들을 모두 컨텍스트로 보내 해결합니다.

끝으로 구체적 예를 들어 보겠습니다.

Just 12를 더하고 싶다.

이렇게 Maybe IntInt를 더하고 싶다고 생각할 수 있습니다.

fmap을 이용해 (+2)함수를 Maybe동네로 보낸다고 보거나,

fmap (+2) (Just 1)

(+)Maybe동네로 보내고, 2Maybe 동네로 보내서 해결한다고 볼 수 있습니다.

(+) <$> Just 1 <*> pure 2

열심히 고민해서 작성한 글이긴 하지만, 처음 개념을 보는 분들이 이해하가 편하진 않을 것 같습니다. 좀 더 선명하게 설명할 방법이 떠오르면 또 추가하도록 하겠습니다. 여튼, 알수록

펑터는 대단한 개념입니다. 하스켈이 펑터를 쓰기 적한한 문법, 구조인데 우연히 그런 건 아니겠지요?

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