학술적인 내용이 아니라, 펑터 이해를 위해, 이제 막 출발한 사람이 펑터를 소개하는 정도의 글입니다. 그러니, 당연히 정답 목적지에 도착하지 않은 글입니다. 잘못된 정보를 올려 놓은 게 보이면 꼭 지적 부탁드립니다.
처음 접하길 펑크터란 발음으로 접해, 다른 분들과 대화를 나누기 전에는 펑크터로 n년동안 발음했는데, 국내 업계는 펑터란 발음으로 자리 잡은 것 같습니다. 이 블로그의 초기 글엔 펑크터로 써있고, 2023년 이 후 글들은 펑터란 말로 바꾸기 시작했습니다.
※ 원래 발음 기호는 fəŋ(k)-tər
이므로 k
를 넣어도, 안 넣어도 맞는 발음입니다.
(번역어로 함자란 말이 있지만, 저와 대화를 나누는 대다수의 분들이 펑터를 더 편하게 생각합니다.)
모노이드, 모나드 관련 블로그 포스트를 몇 개씩 쓰는 동안, 정작 먼저 알고 있어야 하는 펑터 글은 하나도 쓰지 않았습니다. 다른 분들도 쓰윽 무리없이 지나간 키워드, 개념일 수도 있는데, 조금 더 성질을 알면 모노이드, 모나드를 보는데도 도움이되고, 나아가 함수형 설계에도 도움이 됩니다. 보통 펑터를 설명하는 텍스트를 만나면 여기와 같은 얘기를 하지 않습니다. 독특하게 쓸모가 있거나, 전혀 읽을 가치가 없는 글일 수도 있습니다.
보통은 “컨테이너가 있고, 그 안에 든 대상에 매핑할 수 있는 방법을 가진 타입”을 펑터라고 설명하곤 합니다. 여기선, 펑터와 관련된 어려운 수학을 아주 아주 제멋대로 비수학적, 인문학적으로 해석합니다. 위 목차대로 짚어가며 제가 이해하고 있는 펑터 이야기를 해보겠습니다.
함수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에 관한 얘기입니다. 세상에는 똑같지 않지만, 닮은 구석들을 찾아서 (없으면 강제로 만들어서라도) 모델링하면 되는 경우가, 완전히 똑같은 경우보다 훨씬 많습니다. 예를 들면, 세세한 정보는 필요없고, 큰 정보만 보면 될 때는 세세한 정보가 지워진 것과 매핑해서, 그 구조를 살피면 훨씬 효율적입니다. 또는 매핑된 것에서는 특정 조건이 금방 눈에 띈다거나 할 수 있습니다.
Int
와 Maybe Int
는 완전히 일대일 매핑은 아니지만, Int
가 가진 구조는 Maybe Int
가 고스란히 가지고 있고, 즉 구조가 보존되어 있고, 추가적으로 Nothing
과 관련된 구조가 있습니다.
※ 동형isomorphic, 준동형homomorphic, 모피즘(사상)morphism, 범주category를 우리말과 영어를 섞어서 계속 표기하고 있는데, 어느 쪽으로 통일하는 게 잘 읽힐지 아직 잘 모르겠습니다. 일단은 섞어서 쓴 대로 그대로 두겠습니다. 인쇄해서 불변이될 자료가 아닌 언제든 수정 가능한 블로그 글이니, 궅이 어느 한 쪽만 익숙해진 걸 가정할 필요는 없어 보입니다. 둘 다 익숙해지게 섞어 써도 나름 효과가 있겠습니다. 함자functor는 거의 쓰는 분을 못 만났습니다.
펑터를 바라 볼 때, 컨테이너 비유로 바라보면 딱 맞아 떨어지는 경우들이 있습니다. 대표적으로 리스트 타입 같은 경우가 그렇습니다. 어떤 타입이 있고, 그 타입을 안에 담고 있는 리스트 타입과의 펑터를 예로 드는 경우가 많습니다. 제 생각은 이런 비유로 바라 보는 게, 펑터 다음 스텝(Applicatives functor, Monad)으로 갈 수록 조금씩 걸림돌이 됩니다. 컨테이너 메타포 없이 두 구조간의 매핑으로, 혹은 세세하게는 대상, 모피즘, 모피즘 합성을 다른 카테고리에 있는 것들과 매핑하는 걸 펑터로 기억하는 게 개인적으론 더 무리가 없었습니다.
아마도 functor mapping의 약자쯤 될 듯한데, 딱 관련 언급을 하는 자료는 못찾았습니다. 하스켈에서 펑터는 두 가지 작업을 합쳐 펑터를 표현합니다. (실제론, 펑터 규칙functor law을 만족하게끔 구현을 잘 해햐 하는데, 하스켈에서는 이 규칙을 따르는지는 컴파일러가 체크하는 건 아니고, 프로그래머가 잘 검증해야 합니다.)
Int
를 Maybe Int
와 매핑하는 걸 보면
(좀 더 풀어서 얘기하면, Int
를 Maybe
펑터를 이용해 Maybe Int
에 매핑하는 걸 보면),
첫 째로 타입 생성자 Maybe
로 Int
를 매핑하고,
둘 째로 f :: Int -> Int
타입의 함수는 fmap :: (Int -> Int) -> (Maybe Int -> Maybe Int)
로 매핑합니다.
f
함수와 fmap f
함수를 매핑한다고 보면 됩니다.(f
함수에 fmap
을 적용하면 Maybe Int -> Maybe Int
타입이 됩니다.)
이 둘을 합쳐 펑터라 부릅니다.
자주 보던 리스트의 map
은 fmap
을 구현한 한 사례입니다.
이렇게 함수 변환으로 바라 보면, 상자 안에 들어 있는 무언가가 바뀌었다는 메타포보단, 구조와 구조가 매핑된다는 느낌이 들지 않나요? 그래서, 전 컨테이너 메타포로 보지 않는 게 오히려 더 편합니다.
endo는 안으로inside 라는 어원을 가지고 있습니다. 한 카테고리와 다른 카테고리를 매핑하는 연산을 펑터라 했는데, 이 때 출발지 카테고리와 도착지 카테고리가 동일한 경우의 매핑을 엔도펑터라 부릅니다. 하스켈은 타입들을 대상으로 하고, 함수를 모피즘으로 하는 Hask 카테고리라 부릅니다. 하스켈에서는 Hask에 있는 어떤 타입에 fmap
을 적용해도 또 다시 Hask에 있는 타입 중 하나로 돌아오기 때문에 엔도펑터로 부릅니다. 즉, 하스켈에서 만나는 펑터는 모두 엔도펑터입니다. 당장 펑터를 알기 위해 필요한 용어는 아니지만, 다음 스텝을 위해 알아두고 가면 좋습니다.
펑터는 한 시스템을, 필요한 구조는 똑같이 갖고 있고, 전체 모양은 닮은 것들로 변환할 수 있는 도구입니다.
같게 보는 건 아주 중요한 도구다란 상상에 깔려 있는 생각은, 수학은 무엇과 무엇이 같다, 혹은 같게 볼 수 있다는 데에서 여러 표현이 생겨난다는 것입니다. x + 1
과 3
이 같다=
에서 시작해서 x
가 2
인 것에 도달하는 것처럼요.
두 펑터의 관계를 보는 예를 들어보겠습니다.
길이를 재는 자가 있는데, 눈금이 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
가 안되는 상황에서 A
나 B
를 바꾸는 게 아니라, =
을 바꿨다고 볼 수 있습니다.
이 이야기는 모노이드, 모나드로 연결됩니다.
Dense
도, Sparse
도 Int
를 담는 컨테이너로 읽는 분들도 있습니다. 저는 구조간 매핑으로 읽는 게 더 편합니다.
다른 교재나 텍스트들을 그대로 번역하거나 옮겨온 것이 아닌 제 생각, 상상을 쓰는지라, 정답 지식이 아니라, 다른데서 볼 수 없었던 힌트를 얻는 정도 글이 됐으면 좋겠습니다.
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
원 일 때도 있고, 그 때 그 때 환율 c
urrency에 따라 다르게 계산이 될 겁니다. 좀 억지스러운 예시지만, 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
(
= \c -> 1 * c
d1 = \c -> 2 * c
d2 = \c -> 3 * c d3
※ 펑터와 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
라 가정하겠습니다.
저도 “펑터 안에있는 값을 꺼내”라는 표현을 자주 쓰긴 하는데, 사실 펑터 안의 값은 꺼내서 단독으로 둘 수 없습니다. 무슨 말이냐 하면, Int
를 Maybe Int
로 바꿨다면, 이는 다시 Int
로 돌아올 수 없습니다. 이런 의미를 살린다면 꺼낸다라는 의미는 맞지 않습니다. Maybe Int
에 f :: Int -> Int
를 적용한다는 건, 안에 있는 값에 접근해서 f
를 적용한다거나, 펑터로 변환한 함수는 펑터 세계로 가버리고 못 돌아 온다는 상상이 더 어울립니다.
함수를 현재 세계에 두고, 펑터 세계에 있는 값을 가져오는 것이 아니라,
함수를 펑터 세계로 보내버린다가 맞는 은유입니다.
Just 1
에서 1
을 꺼내올 수 있지 않냐라고 물을 수 있습니다. “항상 값에 접근할 때는 Nothing인지 검사해”라는 컨텍스트에 놓여 있는 1
과 그냥 1
은 같지 않습니다. 예를 들면 Just 1
에서 1
을 꺼내오는 장치를 만들었다면, 이 장치는 Nothing
을 넣어 주면 먹통이 됩니다. 마치 이 장치는
getJustVal :: Maybe Int -> Int
Just n) = n
getJustVal (--getJustVal Nothing = ?
Nothing
패턴 매칭을 구현하지 않은 장치와 같습니다. 그럼, Nothing
패턴 매칭도 하면 되지 않냐라고 물을 수 있습니다. 만일 Nothing
처리를 구현한다면, 결과값은 n
이거나 Nothing
을 표시하는 어떤 값이 될 수 있어야만 합니다. 다시 말해, 또 다시 Maybe
컨텍스트에 놓이게 됩니다.
그럼, Just 1
이 가진 1
이라는 정보를 어떻게 써먹지?
Maybe
컨텍스트 안에서 써먹으면 됩니다. 다시 Int
로 돌아오는 것이 아닌 Maybe
동네에서 놀면 됩니다.
함수들을 fmap
으로 Maybe
동네로 보내 실행했다 치고, 그래도 최종 결과로 어떻게든 Int
를 받아내고 싶다는 생각이 들 수 있습니다. 예를 들어 화면에 Int
결과를 출력하고 싶다라고 생각할 수 있습니다. print
함수도 Maybe
동네로 보내버리면 됩니다. 값이 의미를 갖는 순간은 함수와 만날 때로 (눈으로 본다는 것도 어떤 함수가 돈다는 얘기입니다.), 이 함수들을 모두 컨텍스트로 보내 해결합니다.
끝으로 구체적 예를 들어 보겠습니다.
Just 1
과 2
를 더하고 싶다.
이렇게 Maybe Int
와 Int
를 더하고 싶다고 생각할 수 있습니다.
fmap
을 이용해 (+2)
함수를 Maybe
동네로 보낸다고 보거나,
fmap (+2) (Just 1)
(+)
를 Maybe
동네로 보내고, 2
도 Maybe
동네로 보내서 해결한다고 볼 수 있습니다.
+) <$> Just 1 <*> pure 2 (
열심히 고민해서 작성한 글이긴 하지만, 처음 개념을 보는 분들이 이해하가 편하진 않을 것 같습니다. 좀 더 선명하게 설명할 방법이 떠오르면 또 추가하도록 하겠습니다. 여튼, 알수록
펑터는 대단한 개념입니다. 하스켈이 펑터를 쓰기 적한한 문법, 구조인데 우연히 그런 건 아니겠지요?