펑터가 보존하는 ‘모양’이라는 직관을 바탕으로 Functor, Applicative, Alternative, Monad, 그리고 자유 구조를 이해하는 방법을 이야기합니다.
여러 해 동안 나는 펑터(functor), 어플리케이티브(applicative), 모나드(monad) 같은 하스켈 추상화와 관련 개념들을 이해하고 가르치고 쓰는 데 큰 도움이 된 직관적/정신적 모델을 사용해 왔습니다. 새로운 추상화를 배울 때 접근하는 방식에도 도움이 되었죠. 가끔 하스켈을 가르칠 때 이 개념을 이야기하면서 모두가 이미 들어봤으리라 가정하는데, 실제로는 학습 경로에 따라 놓치기 쉬운 보편적 아이디어임을 깨닫곤 합니다. 그래서 여기, 내가 하스켈에서 Functor 및 관련 추상화들과 자유 구조들을 이해하는 방식이 있습니다.
핵심은 이것입니다: fmap이 무엇을 ‘바꾸는지’를 생각하기보다, fmap이 무엇을 ‘상수로 유지하는지(변하지 않게 두는지)’를 물어보세요.
이것은 엄밀한 정의는 아니고 모든 Functor의 모든 면을 설명해 주지도 않으며, 아마도 하스켈의 Functor에 대해 어느 정도 아는 경우에만 유용할 겁니다. 하지만 지금까지 나를 크게 오도하지 않은, 좋은 직관적 요령입니다.
먼저, Functor란 무엇일까요? 대문자 F의 Functor, 즉 하스켈의 타입클래스이자 추상화 말입니다. 길거리의 아무 하스켈러에게 물어보면 리스트나 옵션(Maybe)처럼 “map을 적용할 수 있는 것”이라고 답할 겁니다. 그 중 몇몇은 이 매핑이 어떤 법칙들을 따라야 한다고 굳이 언급할지도 모릅니다… 심지어 그 법칙들을 나열할 수도 있죠. 왜 그 법칙들이 중요한지 묻는다면, 자신 있게 대답할 누군가를 찾기 위해 그 하스켈러 거리에서 한동안 머물러야 할지도 모릅니다.
그래서 약간의 동어반복적 도약을 해 보겠습니다: Functor는 ‘모양(shape)을 보존’하는 방식으로 값을 “매핑”하는 방법을 제공합니다. 그리고 “모양”이란 무엇일까요? 모양이란 바로 fmap이 보존하는 그 ‘것’입니다.
Functor 타입클래스는 충분히 단순합니다: Functor f에 대해 fmap :: (a -> b) -> f a -> f b가 있고, fmap id = id, fmap f . fmap g = fmap (f . g)가 있습니다. 인스턴스에 대해 QuickCheck에 던져 넣어 증명해 볼 수 있는 귀여운 것들이지만, 그 법칙들은 어떤 더 깊고 근본적인 진실을 숨기고 있는 듯 보입니다.
더 많은 Functor를 배울수록 fmap이 항상 어떤 ‘무언가’를 보존한다는 것을 보게 됩니다:
어떤 타입에 Functor 인스턴스를 정의하거나, 그 타입에 Functor 인스턴스가 있음을 아는 순간, 마치 자동으로 보존되어야 하는 어떤 ‘것’이 유도되는 듯합니다.1 보존량이 반드시 존재합니다. 이는 물리학에서의 뇌터 정리를 떠올리게 합니다. 어떤 연속적 대칭성은 보존량을 “유도”하죠(병진 대칭성이 운동량 보존을 “야기”하는 것처럼). 하스켈에서는, 모든 합법적인 Functor 인스턴스가 보존량을 유도합니다. 이 보존량에 정식 명칭이 있는지는 모르겠지만, 저는 이를 “모양(shape)”이라고 부르기를 좋아합니다.
“모양(shape)”이라는 단어는 외부의 짐이나 의미를 최대한 덜면서도 어느 정도 의미를 갖도록 선택되었습니다. 중요한 건 단어 자체가 아니라, fmap에 의해 보존되는 ‘어떤’ 것이 있다는 사실입니다. 그 ‘것’의 본성은 Functor마다 많이 달라지며, 때로는 더 구체적으로 “효과(effect)”나 “구조(structure)”라고 부를 수도 있겠지만, ‘무언가’가 존재한다는 점은 거의 보편적입니다.
사실, 이 ‘것’에 표준적인 이름을 붙이는 것이 가치가 있는지조차 논쟁적입니다. 내가 완전히 새로운 용어를 만든다면 물리학에 빗대어 “보존 전하(conserved charge)”나 “게이지(gauge)”라고 부를 수도 있겠습니다. 하지만 아마도 가장 유용한 이름은 ‘모양(shape)’일 겁니다.
몇몇 Functor 인스턴스에서는 ‘모양’이라는 단어가 다른 경우보다 더 문자 그대로입니다. 예컨대 트리의 경우 트리의 문자 그대로의 모양이 보존됩니다. 리스트에서는 “길이”가 문자 그대로의 모양으로 여겨질 수 있습니다. Map k의 모양 역시 꽤 문자 그대로입니다: 맵에 존재하는 키들의 구조를 설명하죠. 그러나 Writer w나 Const w에서는, 모양을 매핑되는 값들 바깥에 있는, 매핑으로는 변하지 않는 일부 정보로 해석할 수 있습니다. Maybe와 Either e에서는, 모양이 단락(short-circuiting)이 일어났는지도 포함합니다. State s, IO, Parser에서는 ‘모양’이 fmap으로 바뀌지 않는 어떤 부가 연산 또는 소비와 관련되며, 흔히 효과(effect)라고 부릅니다. optparse-applicative에서는, ‘모양’이 프로그램의 정적이고 관찰 가능한 어떤 면들을 포함합니다. ‘모양’은 여러 형태로 나타납니다.
하지만 “그 보존량을 찾아라”는 이 직관은 새로운 Functor를 배울 때 아주 유용합니다. 어떤 새로운 타입에 Functor 인스턴스가 있다는 것을 알게 되면, 곧바로 “이 fmap이 보존하는 모양은 무엇인가?”를 물을 수 있고, 거의 언제나 그 타입에 대한 통찰을 얻게 될 겁니다.
이 관점은 또한 왜 Data.Set에서의 Set.map이 Functor의 fmap 후보로 적합하지 않은지를 보여줍니다: Set.map f가 보존하는 ‘것’은 무엇일까요? 적어도 크기는 아닙니다. 만약 가상의 세계에서 ordfmap :: Ord b => (a -> b) -> f a -> f b를 가질 수 있다 해도, 그것이 “Ord-제한 Functor”로 유용하려면 Set.map이 여전히 ‘무언가’를 보존해야 합니다.2
다음으로 넘어가기 전에, 펑터를 논할 때 흔히 쓰이는 또 다른 연관된(하지만 모호한) 개념을 살펴봅시다: fmap은 ‘모양을 보존’하면서 ‘결과(result)를 바꾸는’ 함수를 매핑하는 방법입니다.
‘모양’이 fmap에 의해 ‘보존’되는 것이라면, ‘결과’는 fmap에 의해 ‘바뀌는’ 것입니다. fmap은 이 둘을 깔끔하게 분리합니다.
재미있게도, 대부분의 Functor 입문은 펑터 값을 ‘결과’를 가진 것으로 설명하고, fmap을 그것을 바꾸는 무엇으로 설명하며 시작합니다. 아이러니하게도 더 흔한 용어임에도 불구하고, 이는 훨씬 더 모호하고 직관화하기 어려운 개념입니다.
Maybe 같은 것에서는 ‘결과’가 충분히 쉽습니다: 값이 존재한다면 그 값입니다. 파서 콤비네이터 Parser에서도 비교적 단순합니다: ‘모양’은 소비된 입력이고, ‘결과’는 그 소비의 결과로 얻는 하스켈 값입니다. optparse-applicative 파서에서는 런타임에 사용자가 실제로 제공한 커맨드라인 인자가 결과입니다. 그러나 때로는 더 복잡합니다: 리스트 펑터(비결정성 펑터)의 엄밀한 의미에서, ‘모양’은 선택지의 개수와 그를 얻는 순서이고, ‘결과’(정확한 의미로)는 결국 선택하거나 순회하는 비결정적 선택입니다.
그래서 ‘결과’는 일반화하기 혼란스러울 수 있습니다. 그래서 내 마음속에서는 보통 정의를 이렇게 환원합니다:
이로부터 Functor 법칙들을 ‘유도’할 수 있습니다:
깔끔하죠? 아마도 Functor를 배울 때 너무 ‘결과’에 집중하는 것이 큰 미스디렉션일 수 있습니다. 사실은 ‘모양’에 더 집중하거나, 적어도 두 가지를 함께 보는 편이 좋겠죠.
‘Functor는 모양 보존을 준다’를 내면화하면, 하스켈의 다른 흔한 타입클래스 추상화들이 왜 가치가 있는지도 이해하는 데 도움이 됩니다. 그리고 그것들이 ‘모양’과 ‘결과’를 어떻게 조작하는지를 통해 그 작동 방식을 파악할 수 있습니다.
예를 들어, Traversable 타입클래스는 우리에게 무엇을 줄까요? Functor가 ‘순수한’ 함수들을 맵하고 모양을 보존하는 방법을 준다면, Traversable은 ‘효과가 있는’ 함수들을 맵하고 모양을 보존하는 방법을 줍니다.
누군가가 내가 가장 좋아하는 Traversable 인스턴스를 묻는다면, 나는 항상 Map k의 traversable이라고 답합니다:
traverse :: Applicative f => (a -> f b) -> Map k a -> f (Map k b)
k에 아무 제약도 없는 것을 보셨나요? 놀랍지 않나요? Map k b는 (a -> f b)를 맵의 각 키에 있는 값들에 적용하고, 그 결과들을 원래 a가 있던 키 아래에 모아줍니다.
본질적으로, 결과 맵은 원래 맵과 ‘같은 키들’을 갖게 됨을 보장할 수 있습니다. 맵의 “모양”을 완벽하게 보존하는 것이죠. Map k 인스턴스는 아름다운 Traversable 인스턴스의 정수(精髓)입니다. traverse가 보존하도록 강제되는 ‘모양’을 식별함으로써 우리는 이를 알아볼 수 있습니다.
Applicative 타입클래스는 우리에게 무엇을 줄까요? ap와 pure가 있고, 그 법칙들은 악명 높게 이해하기 어렵습니다.
하지만 liftA2 (,)를 보세요:
liftA2 (,) :: Applicative f => f a -> f b -> f (a, b)
이것은 “두 가지”를 가져와 그 모양을 ‘결합’하게 해 줍니다. 더 중요하게도, 그 결합은 ‘결과를 고려하지 않고’ 모양만 결합합니다.
핵심은 “최종 모양”이 입력 모양들에만 ‘의존’하고 결과들에는 의존하지 않는다는 것입니다. 두 리스트를 <>로 결합한 결과의 길이는 입력 리스트들의 길이만 알면 알고, 입력과 출력의 상대적 순서도 알 수 있습니다. IO의 의미론이라는 특정 문맥에서, 입력 IO 액션들의 효과만 알면 두 IO 액션을 <>로 결합했을 때 어떤 ‘효과’가 나는지도 알 수 있습니다3. 두 optparse-applicative 파서를 <>로 결합하면 어떤 커맨드라인 인자들이 되는지도 입력 파서의 커맨드라인 인자들만 알면 알 수 있습니다. 두 파서 콤비네이터 파서를 <>로 결합하면 어떤 문자열이 소비되거나 거절되는지도 입력 파서들의 소비/거절만으로 알 수 있습니다. 두 Writer w a를 <*>로 결합한 최종 로그도 입력 writer 액션들의 로그만으로 알 수 있습니다.
그리고… 이런 결합들 중 몇몇은 “모노이드적”으로 느껴지지 않나요?
또한 “아무 일도 하지 않는(no-op)” 액션을 상상할 수 있습니다:
익숙하게 들리죠 — 이 모든 것이 Applicative 타입클래스의 pure입니다!
그러니 Applicative 법칙들은 전혀 신비롭지 않습니다. Functor가 유도하는 ‘모양’을 이해한다면, Applicative는 그 모양 위에 ‘모노이드’를 제공합니다! 이것이 Applicative가 종종 “고차(kind) 모노이드”라고 불리는 이유입니다.
이 직관은 상당히 멀리까지 데려다 줍니다. 위의 예시들에서, 특정 Applicative 인스턴스들을 특정 Monoid 인스턴스들과 명확히 연결합니다(Monoid w, Monoid (Product Int), Monoid (Endo s)).
코드로 쓰면:
-- 리스트의 모양의 일부는 길이이고, 그 모노이드는 (*, 1)
length (xs <*> ys) == length xs * length ys
length (pure r) == 1
-- Maybe의 모양은 isJust이고, 그 모노이드는 (&&, True)
isJust (mx <*> my) == isJust mx && isJust my
isJust (pure r) = True
-- State의 모양은 execState이고, 그 모노이드는 (flip (.), id)
execState (sx <*> sy) == execState sy . execState sx
execState (pure r) == id
-- Writer의 모양은 execWriter이고, 그 모노이드는 (<>, mempty)
execWriter (wx <*> wy) == execWriter wx <> execWriter wy
execWriter (pure r) == mempty
이것은 비표준 Applicative 인스턴스로도 확장할 수 있습니다: ZipList 뉴타입 래퍼는 리스트에 대해 <*>가 zipWith인 Applicative 인스턴스를 제공합니다. 이 둘은 같은 Functor 인스턴스를 가지므로, 그들의 ‘모양’(길이)도 같습니다. 그리고 일반 Applicative와 ZipList Applicative 모두에서, 결과의 길이는 입력의 길이만으로 알 수 있지만, ZipList는 Product 모노이드 대신 Min 모노이드로 모양을 결합합니다. 그리고 Min의 항등원은 양의 무한대이므로, ZipList의 pure는 무한 리스트입니다.
-- ZipList의 모양 일부는 길이이고, 그 모노이드는 (min, infinity)
length (xs <*> ys) == length xs `min` length ys
length (pure r) == infinity
“결과를 모른 채 모양을 알 수 있는” 성질은 많은 라이브러리에서 실제로 활용됩니다. optparse-applicative가 --help 출력을 제공할 수 있는 방식이 그렇습니다: optparse-applicative 파서의 ‘모양’(커맨드라인 인자 목록)은 ‘결과’(런타임의 실제 인자들)를 몰라도 계산할 수 있습니다. 사용자 입력을 전혀 받지 않고도 어떤 인자들을 기대하는지 나열할 수 있죠.
이는 async 라이브러리가 Concurrently Applicative 인스턴스를 제공하는 방식에서도 활용됩니다. 보통 IO에 대한 <>는 IO 효과의 순차 결합을 제공합니다. 하지만 Concurrently에 대한 <>는 IO 효과의 ‘병렬’ 결합을 제공합니다. 우리는 ‘결과’를 얻기 위해 실행하기 전에, ‘어떤 IO 효과들이 있는지’를 이미 알고 있으므로 모든 IO 효과를 동시에 병렬로 시작할 수 있습니다. 만약 결과를 알아야 했다면 이는 불가능했을 것입니다.
이는 Backwards Applicative 래퍼에 대한 통찰도 줍니다 — 최종 모양이 어느 쪽의 결과에도 의존하지 않으므로, 우리는 모양을 어떤 순서로든 결합할 수 있습니다. 모든 모노이드가 “역방향” 모노이드를 낳는 것처럼:
ghci> "hello" <> "world"
"helloworld"
ghci> getDual $ Dual "hello" <> Dual "world"
"worldhello"
모든 Applicative는 모양의 “mappend”를 역순으로 하는 “역방향” Applicative를 낳습니다:
ghci> putStrLn "hello" *> putStrLn "world"
hello
world
ghci> forwards $ Backwards (putStrLn "hello") *> Backwards (putStrLn "world")
world
hello
Applicative의 모노이드적 성격(모양과 효과에 관하여)은 원래 의도의 핵심이며, 이는 이전 블로그 글들에서도 논의했습니다.
Alternative 타입클래스의 주요 연산은 <|>입니다:
(<|>) :: Alternative f => f a -> f a -> f a
처음 보면 이것은 <*>나 liftA2 (,)와 아주 비슷해 보일 수 있습니다:
liftA2 (,) :: Applicative f => f a -> f b -> f (a, b)
둘 다 두 개의 f a 값을 받아 하나로 ‘압축’합니다. 둘 다 결과와 무관하게 모양에 대해 모노이드적입니다. 다만 <|>와 <*>는 서로 ‘다른’ 모노이드 작용을 가집니다:
-- 리스트의 모양 일부는 길이:
-- Ap 모노이드는 (*, 1), Alt 모노이드는 (+, 0)
length (xs <*> ys) == length xs * length ys
length (pure r) == 1
length (xs <|> ys) == length xs + length ys
length empty == 0
-- Maybe의 모양은 isJust:
-- Ap 모노이드는 (&&, True), Alt 모노이드는 (||, False)
isJust (mx <*> my) == isJust mx && isJust my
isJust (pure r) = True
isJust (mx <|> my) == isJust mx || isJust my
isJust empty = False
Functor들이 ‘모양’을 가진다는 것을 이해한다면, Applicative는 그 모양들이 모노이드적임을, Alternative는 그 모양들이 “이중 모노이드(double-monoid)”임을 시사합니다. 다만 두 모노이드가 정확히 어떻게 서로 관련되는지는 보편적으로 합의된 바가 없습니다. 많은 인스턴스에서는 세미링을 이루기도 하는데, 여기서 empty는 empty <> x == empty로 ‘소멸(annihilate)’하고, <>는 <|>에 대해 분배되어 x <> (y <|> z) == (x <> y) <|> (x <*> z)가 됩니다. 하지만 이는 보편적이지는 않습니다.
그러나 Alternative가 Applicative에는 없던 무엇을 모양/결과의 이분법에 더해 줄까요? 두 가지의 미묘한 차이를 보세요:
liftA2 (,) :: Applicative f => f a -> f b -> f (a, b)
(<|>) :: Alternative f => f a -> f a -> f a
Applicative에서는 ‘결과’가 두 입력의 결과에서 옵니다. Alternative에서는 ‘결과’가 두 입력 중 하나에서 올 수 있습니다. 그래서 결과에 대한 근본적인 데이터 의존성이 도입됩니다:
이는 또한 Applicative와 Alternative에서 모양을 결합하는 방식의 선택이 임의적이지 않음을 암시합니다: 전자는 어떤 의미에서 ‘공동(conjoint)’이어야 하고, 후자는 ‘분리(disjoint)’되어야 합니다.
다시 보듯, 모양과 결과를 명확히 분리하면 서로 다른 데이터 의존성을 정확히 말할 수 있는 어휘를 얻게 됩니다.
모양과 결과를 이해하는 것은 모나드가 주는 순수한 ‘힘’을 더 깊이 감상하는 데도 도움이 됩니다. >>=를 보세요:
(>>=) :: Monad m => m a -> (a -> m b) -> m b
=을 사용한다는 것은 최종 액션의 모양이 첫 번째 액션의 ‘결과’에 ‘의존’할 수 있음을 의미합니다! 우리는 더 이상 Applicative/Alternative의 세계(모양이 모양에만 의존하던 세계)에 있지 않습니다.
이제 다음과 같은 코드를 쓸 수 있습니다:
greet = do
putStrLn "What is your name?"
n <- getLine
putStrLn ("Hello, " ++ n ++ "!")
기억하세요: IO에서 ‘모양’은 IO 효과(이 경우 터미널에 정확히 무엇이 출력되는가)이고, ‘결과’는 그 IO 효과를 실행해 얻는 하스켈 값입니다. 여기서 최종 액션(무엇을 출력하는가)은 중간 액션들의 ‘결과’(getLine)에 의존합니다. 실제로 실행해서 결과를 얻기 전에는 프로그램이 어떤 액션을 할지 미리 알 수 없습니다.
파서 콤비네이터 파서들을 순차화할 때도 같은 일이 벌어집니다: 실제로 파싱을 시작해 중간 파싱 결과를 얻기 전에는 유효한 파스가 무엇인지, 파서가 얼마나 소비할지 알 수 없습니다.
Monad는 또한 guard 등의 유용함을 가능케 합니다. 순수한 Applicative만 사용한 경우를 생각해 봅시다:
evenProducts :: [Int] -> [Int] -> [Bool]
evenProducts xs ys = (\x y -> even (x * y)) <$> xs <*> ys
길이가 100인 리스트와 200인 리스트를 전달했다면, 리스트의 아이템들을 전혀 몰라도 결과가 100 * 200 = 20000개의 아이템을 가진다는 것을 알 수 있습니다.
하지만 Monad 연산을 사용할 수 있는 다른 정식을 생각해 봅시다:
evenProducts :: [Int] -> [Int] -> [(Int, Int)]
evenProducts xs ys = do
x <- xs
y <- ys
guard (even (x * y))
pure (x, y)
이제, 입력 리스트들의 길이를 ‘안다 해도’, 그 리스트들 안에 무엇이 들어있는지를 ‘알지 않고는’ 출력 리스트의 길이를 알 수 없습니다. 실제로 “샘플링”을 시작해야 합니다.
이것이 Backwards나 optparse-applicative 파서에 Monad 인스턴스가 없는 이유입니다. Backwards는 이제 비대칭성이 도입되었기 때문에(두 번째 m b가 첫 번째 m a의 a에 의존), 이를 역전할 수 없기 때문입니다. optparse-applicative에서는, 런타임의 결과를 모른 채 모양을 검사할 수 있기를 원하기 때문입니다(그래야 실제 인자 없이도 유용한 --help를 보여줄 수 있음): 그런데 Monad에서는 결과를 모르면 모양을 알 수 없습니다!
어떤 의미에서 Monad는, 최종 모양이 결과에 의존하도록 허용되는 방식으로 Functor의 모양들을 결합하는 방법입니다. 하하, 모나드 튜토리얼을 읽게 속이려던 건데 성공했네요!
나는 이 블로그에서 자유 구조에 대해 너무 많이 쓰곤 합니다. 하지만 이 ‘모양 중심’의 사고방식은, 자유 구조가 하스켈에서 왜 그렇게 매력적이고 흥미로운지에도 통찰을 줍니다.
지금까지 우리는 이미 존재하는 Functor, Applicative, Monad의 모양을 기술했습니다. ‘이’ Functor가 있으면, ‘그’ 모양은 무엇인가?
그런데 만약 우리가 염두에 둔 모양이 있고, 그 모양을 조작하는 Applicative나 Monad를 ‘만들고’ 싶다면 어떻게 할까요?
예를 들어, --myflag somestring 옵션만 지원하는 자체 버전의 optparse-applicative를 만들어 봅시다. 우리는 ‘모양’을 지원되는 옵션과 파서들의 목록으로 말할 수 있습니다. 이 모양의 단일 요소는 단일 옵션의 명세가 될 수 있습니다:
data Option a = Option { optionName :: String, optionParse :: String -> Maybe a }
deriving Functor
여기서 ‘모양’은 이름과, 본질적으로 어떤 값들이 파싱될지입니다. fmap은 옵션의 이름을 바꾸지 않고, 무엇이 성공/실패할지도 바꾸지 않습니다.
이제 완전한 다중 인자 파서를 만들기 위해 free 라이브러리의 Ap를 사용할 수 있습니다:
type Parser = Ap Option
원하는 모양을 지정했으니, 이제 그 모양의 Applicative를 공짜로 얻습니다! 이제 <*> 인스턴스를 사용해 모양들을 모노이드적으로 결합하고, runAp_로 이를 검사할 수 있습니다:
data Args = Args { myStringOpt :: String, myIntOpt :: Int }
parseTwo :: Parser args
parseTwo = Args <$> liftAp stringOpt <*> liftAp intOpt
where
stringOpt = Option "string-opt" Just
intOpt = Option "int-opt" readMaybe
getAllOptions :: Parser a -> [String]
getAllOptions = runAp_ (\o -> [optionName o])
ghci> getAllOptions parseTwo
["string-opt", "int-opt"]
Applicative는 모양에 대한 ‘모노이드’와 같으므로, Ap는 여러분의 사용자 정의 모양 위에 자유로운 ‘모노이드’를 제공합니다: 이제 <*>를 통해 연결(concatenation)로 병합되는, 리스트 같은 “시퀀스”를 만들 수 있습니다. 또한 Ap Option에 대한 fmap은 옵션을 추가하거나 제거하지 않는다는 것도 알 수 있습니다: 실제 옵션을 그대로 둡니다. 무엇이 실패/성공할지도 바꾸지 않습니다.
이 방식으로 파서 콤비네이터 라이브러리도 작성할 수 있습니다! 파서 콤비네이터 Parser의 ‘모양’은 소비되거나 거절되는 문자열이라는 점을 기억하세요. 단일 요소는 단일 Char를 소비/거절하는 파서일 수 있습니다:
newtype Single a = Single { satisfies :: Char -> Maybe a }
deriving Functor
‘모양’은 해당 문자를 소비/거절하는지 여부입니다. 여기서 fmap은 문자를 거절/수용하는지 ‘바꾸지’ 못한다는 점에 주목하세요: 바꿀 수 있는 것은 하스켈 결과 a 값뿐입니다. fmap은 Maybe를 Just나 Nothing으로 뒤집을 수 없습니다.
이제 free 라이브러리의 Free를 사용해 완전한 모나딕 파서 콤비네이터 라이브러리를 만들 수 있습니다:
type Parser = Free Single
다시 말해, 원하는 모양을 지정했고, 이제 그 모양을 위한 Monad를 얻었습니다! 이를 사용하는 법에 대해서는 과거에 블로그 글을 쓴 바 있습니다. Ap는 모양에 대한 자유로운 “모노이드”를 제공하지만, 어떤 의미에서 Free는 모양을 위한 “트리”를 제공합니다. 모양들의 시퀀스가 그 결과에 따라 어느 길로 내려가느냐에 따라 달라지죠. 그리고 역시 fmap은 무엇이 파싱될지/되지 않을지를 절대 바꾸지 않습니다.
어떤 자유 구조를 고를지 어떻게 알 수 있을까요? 우리가 모양으로 무엇을 하고 싶은지에 대해 질문합니다. 결과를 알지 못한 채 모양을 검사하고 싶다면, free Applicative나 free Alternative를 사용합니다. 앞서 논의했듯이, free Applicative를 사용하면 최종 결과가 입력 결과 전부를 만들어 내야 하지만, free Alternative를 사용하면 그렇지 않습니다. 모양이 결과에 의존하도록 허용하고 싶다면(문맥 민감(parser)처럼), free Monad를 사용합니다. ‘모양’이라는 개념을 이해하면 이 선택이 매우 직관적이 됩니다.
다음에 새로운 Functor를 만나면, 이 통찰이 도움이 되길 바랍니다. 스스로에게 물어보세요. fmap은 무엇을 보존하는가? fmap은 무엇을 바꾸는가? 그러면 그 비밀이 여러분 앞에서 펼쳐질 것입니다. 에미 뇌터도 자랑스러워할 겁니다.
놀라운 커뮤니티의 후원에 늘 겸허한 마음입니다. 여러분이 있기에 저는 이 글들을 연구하고 쓰는 데 시간을 쏟을 수 있습니다. Patreon에서 “Amazing” 등급으로 저를 후원해 주시는 Josh Vera님께 특별히 감사드립니다! :)
약간의 예외는 있습니다. 특히 Writer ()(= Identity)처럼 의미 있는 구조를 추가하지 않는 퇴화 사례들이 그렇습니다. 이런 경우에는 이 정신 모델이 그리 유용하지 않을 수 있습니다.↩︎
여담으로, Set.map은 한 가지를 ‘보존’하긴 합니다: 비공집합성(non-emptiness). 빈 집합을 비빈 집합으로, 혹은 그 반대로 Set.map으로 바꿀 수 없습니다. 그러니 만약 Set을 “적어도 하나의 결과를 찾는” Functor나 Monad(단 하나의 값만 관찰할 수 있는)로 재맥락화한다면, 합법적인 Ord 인스턴스를 가정할 때, Set.map이 Ord-제한 버전의 그 추상화들에 작동할지도 모르겠습니다.↩︎
즉, 하스켈 결과 내부에서 일어나는 일을 독립적으로 두고, 외부 세계와의 모든 입출력의 총합적 관점에서 본다면, 효과의 결합은 결정적이라고 말할 수 있습니다.↩︎