A
와 전혀 다른 B
가 있는데, 둘을 때에 따라 같은 걸로 볼 필요가 있다고 칩시다. 그럴 땐 data C = A | B
란 타입을 만들어, 둘 모두 C
라고 부를 수 있습니다. A
를 다루던 함수와 B
를 다루던 함수는 그대로 적용할 순 없는, 다른 타입이 되었습니다. A
를 다루는 함수를 C
에 넣어주면, 안에 들어 있는 값이 A
일 때는 적용하고, 아니면 그냥 아무 일도 일어나지 않게 하면 됩니다. B
를 다루던 함수도 마찬가지입니다. 안에 들어있는 게 B
인 경우만 적용하고, 아니면 그대로 두면 됩니다.
1,2,3 ...
이 노는 동네와, Just 1, Just 2, Just 3 ...
동네는 다르다?
1
에서 Just 1
로 가려면 Just
생성자 함수를 쓰면 됩니다. 생성자 함수를 써서 ‘무언가’ 정보 하나를 추가해 격리된 동네로 보냅니다. 단순 꼬리표처럼 보이는 Just
가 붙어 있는 값들은 Maybe
동네에서만 있을 때는 ‘무언가’ 정보가 드러나지 않지만, 격리된 동네를 벗어나서 다른 값들과 어울리려면 그 때 ‘무언가’ 정보가 드러나게 됩니다. 갈 때 생성자 함수를 썼듯이, 올 때도 그냥 쓰면 될 것 같지만, 모든 래핑 타입은 ‘무언가’ 정보를 추가하기 때문에 거꾸로inverse로 오려면 정보를 처리하든지, 버리면서 와야 합니다. 그냥은 올 수 없습니다.
예를 들어, Maybe
에서 ’무언가’를 꺼내는 방법(Maybe a -> a
)은, 반드시
“Just
값, Nothing
갈래 길 중 어디로 갈지 선택(분기)하는 작업”
instance Functor Maybe where
fmap f (Just x) = Just (f x)
fmap f Nothing = Nothing
을 통해서만 꺼낼 수 있습니다. 다른 방법은 없습니다. Maybe
값을 꺼내 오려면(다른 말로 Maybe
특징이 발현되려면) 코드 어딘가에는 반드시 분기문이 존재합니다. 어떤 경우에는 직접 길을 고르고, 어떤 경우에는 미리 만들어 놓은 함수(fmap
)를 쓰기도 합니다. if
를 쓰든, case
를 쓰든 분기 작업을 해야합니다. 하스켈에서 가장 많이 쓰이는 분기문은 if
도 아니고, case
도 아니고, 바로 패턴 매칭입니다. fmap
함수도 패턴 매칭으로 갈 길을 선택합니다. 함수 정의부에서 패턴 매칭 할 수도 있고, case
구문에서 할 수도 있고, let
구문이나 <-
구문으로 패턴 매칭할 수도 있습니다. 어디가 됐든, 반드시 “갈래 길 중 선택”하는 구문은 반드시 나타납니다.
하스켈로 연습 문제를 푸는 수준을 넘어가면 수 많은 래핑 타입들로 로직을 설계합니다. 한 번만 싸인게 아니라, 생성자들로 여러번 싸여 있는 값들을 만나게 됩니다. 그런 값들은 생성자를 풀 때마다 특정 작업을 하도록 되어 있다고 보면 됩니다.
모나드에서 m a
값과 a -> m b
함수를 붙이는 이유입니다. m a
에 m a -> m b
를 붙이면 편할텐데, 그럼 m의 effect가 발현된다는 보장이 없습니다. m a
에 a -> m b
를 적용하려면 반드시 m
을 벗겨내는 작업을 해야 합니다. 비수학적으로 모나드를 이해할 때 필요한 개념입니다.
Computation = 준비
Maybe Int
는 평범한 정수가 아닙니다. 어떤 절차를 거치면 정수를 줄 준비라고 볼 수 있습니다. Maybe
를 벗겨내는 절차 없인 그저 준비만 하고 있는 상태기 때문에 다른 정수들과 어울릴(연산할) 수 없습니다. Int
는 정수고, Maybe Int
는 어떤 절차를 거친 후 정수를 줄 준비입니다.
Reader env Int
는 env
를 받아서 어떤 처리를 한 다음 Int
를 돌려줄 준비를 한 상태입니다. 생성자나 함수를 준비로 해석하는게 언제나 들어 맞는지는 아직 확신이 서진 않지만, 래핑에 래핑에 래핑을 만나 복잡해 보일 때 준비로 해석하면 도움이 될 때가 있습니다.
m a
는 a
와 아예 다른 값이 아닙니다. m a
는 a
의 속성은 그대로 가지고 있는 때에 따라 그대로 가지고 있을 수도 있는 닮은 타입입니다. 닮은 타입간의 관계를 다룰 때 모나드가 등장합니다.
타입을 래핑하는 또 다른 목적은, 같이 쓰일 함수를 고르는 작업을 위해서입니다.(보통 newtype
을 이용합니다.) 타입 클래스와 인스턴스를 쓰면, 타입에 따른 함수를 고를 수 있습니다. 1
이 포스트의 결론은, 래핑 a
는
2020.10.6 추가
타입을 감싸 또 다른 타입을 만드는 작업이 필요한 이유를 좀 더 선명하게 알아야 합니다. 유독 함수형에서만 일어나는 일은 아닙니다. 프로그래밍을 하다 보면 같은 속성을 가지지만 구조는 다른 경우, 역으로 다른 속성을 가지지만 구조는 같은 경우들을 만납니다. 퍼포먼스 때문일 때도 있고, 안전성을 위해서일 때도 있고, 논리를 따라가는 인간의 기억력을 보조해주기 위해서일 때도 있습니다. 감싸면서 정보를 추가하거나, 다른 것과 구별되도록 합니다. 이렇게 감싼 타입에 함수를 적용하는 방식은 대부분
이런 작업은 모든 감싼 타입에서 일어납니다. 모든 감싼 타입 마다 일일이 푸는 방법을 기억해야 합니다. 여기까지 생각이 미치면, 추상화 작업을 해서 뇌의 부하를 줄여줘야겠다는 생각이 들게 됩니다. 푸는 작업을 fmap 한가지로 추상화하자고 약속을 정한게 바로 펑크터입니다.
하스켈에서 함수 하나 하나는 모두 독립적이며, 보통 Lazy하기 때문에 언제 실행될지도 모릅니다. 이런 상태에서 프로그램의 흐름2을 강제로 만들어낼 필요가 있을 때는 함수가 함수를 감싸는composition 방법을 씁니다. (보통 composition을 합성이라 번역하는데, 딱 적합하다는 생각이 들지 않습니다. f . g
의 결과는 g
를 적용한 후 f
를 적용한다는 개념인데, 합성과는 좀 달리 느껴집니다. 조합이란 말이 더 어울리는 것 같기도 합니다.) 웬만하면 composition이 가능해야 여기 저기 쓰임새가 생깁니다. a -> b
, b -> c
함수를 compostion 하려면 그냥 (.)
을 쓰면 되고, m a -> m b
, m b -> m c
도 (.)
을 쓰면 됩니다. 그럼 a -> m b
, b -> m c
함수는 어떻게 composition하면 될까요? 여기도 모나드를 이해하기 위한 시작점 중에 하나입니다.
모나드,같음 글을 참고하세요.
2021.5.9 추가
a -> f b
해석
a
를 받아 작업을 한 후 f b
의 결과를 만드는데, b
가 아니라 f b
인건 반드시 어떤 값 생성자로 감싸져 있다는 뜻입니다.(카인드로 얘기하면 * -> *
) 값 생성자로 감싸져 있으면 무슨일이 생길까요? 값 생성자로 감싸는 이유는 값 생성자를 벗겨낼 때 어떤 작업을 반드시 하게끔 하기 위해서 입니다. 벗겨낼(inverse) 때 반드시 하는 작업을 effect라 부릅니다. 유심히 볼 동작은 값 생성자로 감쌀때가 아닙니다. 값 생성자와 패턴 매칭으로 풀어낼 때 비로소 effect가 발현됩니다. 그리고 또 한가지, 값 생성자를 벗겨 낼 때는 옆에 있는 함수와 뭔가를 하기 위해서 벗겨냅니다. 혼자만 effect를 확인하는 건 의미가 없습니다. effect를 확인 후 다른 함수와의 동작을 결정지을 필요가 있을 때 값 생성자를 벗겨 냅니다. effect가 있는 작업들을 연이어 합성했을 때, 함수가 연결될 때마다 고정적으로 작동하는 effect작업을 context라 부릅니다.
* -> *
카인드를 만났을 때 해석하는 용어를 알았으니 적용해서 읽어 보겠습니다.
traverse :: (Traversable t, Applicative f) => (a -> f b) -> t a -> f (t b)
f
컨텍스트로 만드는 함수를 받아, t
컨텍스트 안에 들어 있는 a
에 적용해서 f
컨텍스트 ( t
컨텍스트 a
)를 만든다.
f
와 t
를 구체 타입으로 바꿔서 읽어 보겠습니다.
Maybe
를 만드는 함수를 받아, [a]
에 적용해서 Maybe [a]
를 만듭니다. 보통 하스켈 문서들이 여기서 설명을 끝내서 난감하게 만듭니다. 왜 이런 작업을 할까요? 실제 코딩하다 보면 만나는 상황을 상상해봐야 합니다.
func :: a -> Maybe a
| x > 0 = Just x
func | _ = Nothing
이 함수를 적용해야 하는 대상들이 하나가 아니라서 리스트에 담아두었다면, 어떻게 적용할까요? fmap
, traverse
둘 모두 가능합니다.
전체가 아닌 각각이 별도의 의미를 가진다면 fmap
으로 각각의 값들을 Maybe
값들로 바꾸어 놓고 다시 리스트로 싸놓으면 되고,
모두 양수일 때만 의미가 있다면, 바로 이 때 traverse
를 등장시키면 됩니다. 함수를 적용하되, Maybe
컨텍스트를 발현시키겠다는 말입니다.
리스트의 fs <*> xs
applicative 정의는 앞에 것도 컨텍스트를 벗겨내고, 뒤에 것도 컨텍스트를 벗겨내서 f x
값을 모으겠다는 말입니다.
컨텍스트 발현
컨텍스트 실행
컨텍스트를 벗겨내고
이게 다 같은 말로 쓰입니다.
Maybe Int
는 Just Int
값 아니면 Nothing
값이 될 수 있습니다. Int
값을 Just Int
로 만들려면, 그냥 Just
로 감싸면 됩니다. 마치 Just
가 인자로 Int
를 받아 Just Int
가 되는 함수 같습니다. 하지만, 일반 함수와 다르게 값 생성자는 패턴 매칭에 쓰일 수 있습니다. 이게 헛갈릴까 싶지만 처음 공부할때 혼동하는 경우가 있습니다.
필요로 하는 값의 타입은 Int
이고, Maybe
는 어떤 절차를 나타내는 구조라 부르기도 합니다. [Int]의 구조는 리스트, Reader env Int
의 구조는 Reader env
라고 보면 됩니다. 항상 구조를 파고 들어 값을 가져오는 건 아닙니다. 구조가 뭔지만 궁금할 때도 있습니다. 하스켈은 Lazy하니 Maybe Int
는 Int
를 필요로 하는 순간이 오기 전까진, 그냥 Maybe
구조의 뭔가입니다. 보통 패턴 매칭으로 구조를 벗겨 냅니다. Maybe Int
를 Just
와 Nothing
으로 패턴 매칭하는 순간 Int
인 걸 알게 됩니다.
타입에 따라 인스턴스를 고르니, 타입과 함수를 연관 짓는 함수로 볼 수 있습니다. 어떤 것과 어떤 것을 연관 지어 놓는 걸 함수라고 하는데, 어떤 것을 연관 짓냐에 따라 이름을 달리 붙여 놨습니다.
펑크터는 함수만 매핑하는 것이 아니라, 타입도 같이 매핑합니다.
a, b
타입이 있고, 이 타입들을 다루는 a -> b
함수가 있다면, 펑크터 F
는 F a
, F b
, F a -> F b
로 매핑합니다.↩︎
main
함수에서 흐름이 시작되는데, main
함수 자체가 >>=
로 엮어 놓은 거대한 함수 composition입니다.↩︎