코모나드 comonad

Posted on May 6, 2021

2022.7.31 추가 effect와 context를 구별해서 써야 합니다. 모나드는 effect가 있는 계산을 위한 것이고, 코모나드는 context를 처리합니다. 구체적 내용을 따로 올리겠습니다.

모나드 트라우마로 움찔한 분들은 코모나드란 용어가 나오기 전까진 코모나드는 잠깐 잊어버리고 보는 게 더 나을 수 있습니다.

지금까지 알게 모르게 이미 아래 같은 패턴으로 코딩한 적이 있을 수도 있습니다. 아래서 참조한 가브리엘 글의 제목도 you could have invented comonads 입니다.

몸풀기

몸풀기를 잠깐 하고 넘어 가도록 하겠습니다.

고차 함수, 람다 함수를 활용한 방법

(가) – 인자 하나를 넣어 주면, 속성을 지정해서 가지고 있는 것과 같다.

haveProperty :: Int -> ( (Int -> Int) -> Int )
haveProperty x = \f -> f x
> let personA = haveProperty 10
> let personB = haveProperty 100
> personA (+20)
30
> personB (+20)
120

(나) – 미리 함수를 넣어 놓기

위 (가)와 같은데, 함수를 넘겨서 갖고 있게 할 수도 있습니다.

haveFunc :: (Int -> Int) -> (Int -> Int)
haveFunc f = \x -> f (x + 1)
> let toolA= haveFunc (+2)
> let toolB = haveFunc (+3)
> toolA 1
4
> toolB 1
5

(다) – 함수명과 인자의 순서를 바꾸기 위한 헬퍼

그리고, 표현을 매끄럽게 하기 위해 (기존 OOP에서 쓰던 익숙한 흐름으로 바꾸기 위해) 함수명과 인자 순서를 거꾸로 써주기 위한 연산자를 정의합니다.

-- Data.Function에 있는 (&)와 같은 함수입니다.
-- 굳이 정의하지 않아도 되는데, 원문에 따라 일단 보고 지나 가겠습니다.
(#) :: a -> (a -> b) -> b
x # f = f x

infixl 0 #

※ 아래는 Gabriel Gonzalez의 블로그 https://www.haskellforall.com/2013/02/you-could-have-invented-comonads.html의 코드 일부를 발췌했습니다.

Builder 패턴

type Option = String
type Builder = [Option] -> Config
data Config = MakeConfig [Option] deriving (Show)

defaultConfig :: Builder 
defaultConfig options = MakeConfig (["-default"] ++ options)

opt1 :: Builder -> Config
opt1 builder = builder ["-opt1", "-opt1-1"]

opt2 :: Builder -> Config
opt2 builder = builder ["-opt2"]

위 도구에서 보았던 속성을 먼저 가지고 있는 스타일로 옵션 지정을 하는 코드입니다. 빌더 함수를 넣어서 안에 들어 있는 리스트에 적용합니다.

> defaultConfig # opt1
MakeConfig ["-default","-opt1","-opt1-1"]
> defaultConfig # opt2
MakeConfig ["-default","-opt2"]

아직 일이 끝나지 않았다 -> 실행되지 않은 함수

하지만 opt1opt2는 완성된 Config를 리턴합니다. 그럼 opt1opt2 두 개 모두 옵션 리스트에 넣으려면 어떻게 할까요? 최종 모양부터 상상하면, (다)를 이용해서 defaultConfig # opt1 # opt2 쯤 되는 모양으로 만들려고 합니다. 하스켈에선 뭔가 연이은 동작을 만들 땐 함수로 엮는 방법이 주로 쓰입니다. ※ 무언가 완성되지 않은 상태로 둔다는 말은 “함수”로 만들어 둔다와 같은 말입니다. 이 것도 함수형 스타일로 생각하는 하나의 팁입니다.

opt1' :: Builder -> Builder 
opt1' builder = \options -> builder (["-opt1", "-opt1-1"] ++ options)

opt2' :: Builder -> Builder 
opt2' builder = \options -> builder (["-opt2"] ++ options)

이미 가지고 있던 속성에 인자로 받아 온 함수를 적용했는데, 이 걸 바로 실행하지 않고, 인자 하나를 더 받을때까지 실행을 미루기 위해 (나) 방식으로 만들어 놓습니다.

Builder -> Config였던 타입이 Builder -> Builder가 되어, 옵션 체인을 만들 수 있도록 입출력 타입이 같게 되었습니다.

            builder  ["-opt1", "-opt1-1"] -- opt1 결과가 Config 타입
\options -> builder (["-opt1", "-opt1-1"] ++ options) -- opt' 결과가 Builder 타입 = [Options] -> Config

그리고, 더 이상 연결할 옵션이 없을 때 완성된 Config를 뽑아내기 위해 extract를 만듭니다.

extract :: Builder -> Config
extract builder = builder []

그럼 아래와 같이 체인 형태로 쓸 수 있습니다.

> defaultConfig # opt1' # opt2' # extract
MakeConfig ["-default","-opt1","-opt1-1","-opt2"]

이렇게 optopt'으로 만들면 체인이 가능하게 됩니다. 그럼 만약 기존 코드가 이미 opt 스타일로 많이 만들어졌다면, 일일이 Builder 리턴 타입으로 바꾸는 것보다 변환 함수를 하나 만들어 쓰는게 좋습니다. 헛갈리지 않게 opt1과 opt2의 람다 변수 이름을 바꿔 놓겠습니다.

opt1 = \bf -> bf ["-opt1", "-opt1-1"]
opt2 = \bf -> bf ["-opt2"]

opt1 안에 들어 있는 옵션과 opt2 안에 들어 있는 옵션을 묶어 놓고, 바깥에서 들어온 builder를 적용하는 모양이 되어야 합니다. 처음엔 함수를 변형한다고 하니, 함수를 어떻게 해체해야 하는지 난감했습니다. opt1 함수를 변형하는 방법 중 하나는 어떤 함수를 넣어 정보를 해체하는 겁니다.

완성되어 있는 고차 함수 내부를 조작하려면?

함수가 외부와 소통하는 방법은 매개 변수뿐이 없습니다. 값을 주고 받을 때 뿐만 아니라, 함수 동작을 조작할때도 이용할 수 있는 통로는 매개 변수뿐이 없습니다. 안 쪽에 들어가서 변형시켜 놓을 함수(가)를 매개 변수에 넣어주면 됩니다. 변형된 모양은

\o -> \builder -> ... builder ( ... )

빌더 타입이어야 합니다.

\builder -> \o2 ->     (\bf -> bf ["-opt1","-opt1-1"])      (\o1 -> builder (o1 ++ o2))
                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^      ^^^^^^^^^^^^^^^^^^^^^^^^^^^
                                   original                 o1을 뽑아 o2와 합치는 함수 -- (가)
\builder -> \o2 ->     builder (["-opt1","-opt1-1"] ++ o2) 

\o1으로 opt1이 가진 옵션을 뽑아내 나중에 들어 올 o2와 합쳐 놓습니다. 위에서 본 opt1'과 같은 모양이 나왔습니다. 변형해 주는 함수를 extend라 이름 붙이면 다음과 같이 정리할 수 있습니다.

extend original = \builder -> \o2 -> original (\o1 -> builder (o1 ++ o2)) 
extend original builder = \o2 -> original (\o1 -> builder (o1 ++ o2))

extend를 정의한 후에는 원래 함수에 extend를 적용해주면 위의 최종 결과와 같은 결과가 나옵니다.

> defaultConfig # extend opt1 # extend opt2 # extract
MakeConfig ["-default","-opt1","-opt1-1","-opt2"]

코모나드

위에서 나온 extract, extend의 조합이 바로 코모나드입니다.

-- extract :: Builder -> Config 
-- type Builder a = [Option] -> a 으로 볼 수 있습니다.
extract :: Builder a -> a
extend :: (Builder a -> b) -> Builder a -> Builder b

-- 코모나드 메소드
extract :: w a -> a
extend :: (w a -> b) -> w a -> w b

가브리엘 곤잘레즈 글의 첫 번째 섹션의 소스 코드를 옮겨서 풀어 봤는데, 읽고 나니 원 글이 훨씬 나아 보입니다. 여기 글은 extend 정의가 쉽게 이해가지 않을 때 보충해서 보면 좋을 것 같습니다.

수학적으로 모나드의 듀얼이라 하고 시작하는 순간부터, 또 한번의 모나드 전쟁이 생기진 않을까 두려워집니다. 이론적인 바탕보다 먼저 코드로 풀이한 후 접근하는 게 편한 분들도 있을 겁니다. 모나드만큼 여기 저기 쓰이는 패턴은 아닙니다. 원 글에 따르면 하스켈에서 모나드가 명령형 프로그래밍 비슷한 모양을 보여준다면, 코모나드는 OOP스런 모양을 보여주는 패턴이라 합니다.

나중에 코모나드와 연관된 글을 더 올리도록 하고, 여기서는 w a -> b 함수 연결(엮는 것)을 어떻게 하는지 보는 정도로 만족하고 넘어가겠습니다. 모나드도 특정 형태의 함수를 엮는 방법이고, 코모나드도 특정 형태의 함수를 엮는 방법입니다.

w a로 표기한 이유는 w가 모나드의 m을 거꾸로 놓았다는 뜻에서 w를 쓴다고 합니다.

동작은 알겠는데, 이게 왜 모나드의 듀얼, 코모나드란 이름을 가졌을까요? 사실, 이런 건 궁금해하지 않고 넘어가도 되는데, 이런데서 머뭇거리는게 좋은 건지 나쁜 건지 모르겠습니다. 모나드는 a -> m a 액션을 엮는 패턴이었습니다. 여기서 화살표를 반대로 한 걸 듀얼1이라 한다는데, 이게 가지는 실용적인 의미가 뭘까요?

2022.6.3 추가 (하스켈 학교 디스코드 #코모나드 채널에 제가 올렸던 글을 정리했습니다.)

모나드와 비교

직관적으로 지니는 의미가 뭘지 살펴보겠습니다.

duplicate :: w a -> w (w a)

모나드에서는 m이 만들어내는 effect를 잃어버리지 않기 위해 join으로 effect algebra를 했는데, 코모나드는 contextduplicate하고 있습니다.모나드가 effect 두 개를 합쳐서 하나로 표현할 수 있는 것들만 모나드 구조로 만들 수 있듯이, 코모나드는 context 하나를 복사duplicate해서 또 적용해도 의미가 있는 것들만 코모나드로 만들 수 있습니다.

모든 모나드들과 대응하는 코모나드가 있는 건 아닙니다. - 검증 필요

List 모나드가

[[1],[2,3],[4,5,6]]
join 해서
[1,2,3,4,5,6]

으로 봐도 의미가 있었듯이

Stream 코모나드는

[1,2,3,…]
duplicate하면 
[ [1,2,3,…] , [2,3,4,…] , [3,4,5,…],…]

가 의미가 있는 구조입니다. 완벽하게 똑같이 복사한게 아니라, 첫번째 값이 계속 다르게 복사하고 있음을 주의해서 봐두세요.

또한 extract도 가능해야 합니다.

extract :: w a -> a

Maybe Int 모나드의 경우

Just 1-> 1, 
Just 2-> 2, 
… 등은 되지만, 
Nothing-> ?

이기 때문에 코모나드로 만들 수 없습니다.

Stream 코모나드는 포커싱된 하나를 꺼내는 걸로 extract를 정하고 있습니다.

effect를 만들어 내는 함수들을 컴포지션할 때 생겨나는 모든 effect를 잃어버리지 않기 위해 모나드 패턴이 필요했는데, context를 버려도 의미있는 함수들을 커포지션 할 때 코모나드를 쓰고 있습니다

바인드의 듀얼인 extend를 보면

extend :: (w a -> b) -> w a -> w b

Q. w a에 그냥 w a -> b 함수를 적용하면 안되나요?
A. 마치 w a(w a -> b)를 바로 적용해도 될 것처럼 보입니다. 그런데, 그렇게 적용해 버리면 b가 나오고, 코모나드는 이 걸 w b로 만들 방법을 제공하지 않습니다. 그래서 컨텍스트를 유지한채로 적용하기 위해 fmap을 정의하면 duplicate :: w a -> w (w a)가 필요한 모양이 나옵니다. (꼭, w가 새로 생기거나 하는 게 아니라, shift 하는 느낌입니다.)

Q. fmap으로 적용해도 w가 없어지는 것 아닌가요? A. m a에서 m을 떼어내고 a를 반환하는 순간 m은 다시 살려낼 수 없습니다. 그런데 fmap도 내부 동작을 보면 패턴 매칭으로 m을 벗겨내고 있습니다. 하지만, 그 m은 스코프에 남아 있는 상태로 처리하고, a에 함수 적용을 마치고 난 후 그 걸 다시 가져와 합친 다음 반환합니다. 그래서, 이 걸 컨텍스트를 유지한다고 말합니다.

왜 a -> w a는 안되는데 w a -> w (w a)는 가능한가?

모나드에서 m a -> a 는 effect를 담아둘 곳이 없어 불가능하지만, m (m a) -> m a 는 effect 두 개를 join해서 m에 담아 둘 수 있어 가능했습니다. 이와 비슷하게 코모나드는 a -> w aw를 만들어내질 못하니 불가능하지만, w a -> w (w a)는 기존 가지고 있던 wduplicate하니 가능합니다. 다르게 표현하면, join으로 합친 것이 effect를 잃어버리지 않는 경우만 모나드로 만들 수 있었던 것처럼, duplicate로 복사한게 context로써의 의미가 있는 경우만 코모나드로 만들 수 있습니다.

코모나드는 모든 가능한 경우의 context를 가지고 시작한다.

모나드는 컨텍스트를 유지하며 computation하는 동안 effect를 언제든 만들어내지만, 코모나드는 처음 컨텍스트 computation을 시작할 때부터 필요한 모든 context를 가지고 시작해야 합니다. duplicate의 아이디어는 시작할 때부터 갖고 있는, 여러 context중 현재 가리키는 context를 달리하며 w로 감싸는 것입니다. w aw(w a)가 가리키는 context는 다를 수 있습니다. Product 코모나드는 이 context가 하나의 값인 특수한 경우입니다. 정리하면 duplicate는 완전 똑같이 복사하는 게 아니라 current값, 또는 focus되는 정보는 다르게 복사한다고 말합니다.

Context 복사duplicate

CoKleisli arrow w a -> b가 context를 지우는 것에 눈이가 어떻게 context를 없애면서 의미가 있을 수 있을까 고민했습니다. 바인드의 듀얼인 extend의 반환값은 b가 아니라 context가 살아 있는 w b입니다. context가 지워지는 것은 아니었습니다. w를 조금씩 다르게 복사, 유지하며 computation을 한다가 코모나드의 직관입니다.

context가 하나로 고정일 때 Reader 모나드, Product 코모나드

새로 effect가 계속 추가되는 computation은 모나드가 어울리고, 필요한 context는 모두 있는 상황에서 조금씩 다른 걸 고르는 computation은 코모나드가 어울립니다. 그래서 변하지 않는 환경값을 참조하는 effect는 Reader 모나드로도 Product 코모나드로도 표현될 수 있었던 겁니다.

Store 코모나드

대표적인 코모나드로 Store 코모나드가 있습니다.

class Functor w => Comonad w where
  extract :: w a -> a
  duplicate :: w a -> w (w a)
  extend :: (w a -> b) -> w a -> w b

data Store s a = Store (s -> a) s

instance Comonad (Store s) where
  extract (Store f s) = f s
  duplicate (Store f s) = Store (Store f) s

타입만으로 duplicate가 필요한 이유 찾아 보기

(w a -> b)w a 에 적용하면 w는 사라지고 b만 남습니다. w를 잃어버리지 않기 위해 w aw를 한 번 더 씌워 w (w a)로 만들어 버리고,fmap (w a -> b) $ w (w a) 를 하면 안 쪽에 있는 w는 사라지고 b만 남지만 바깥에 복사해 둔 w가 살아 있어, w b를 리턴할 수 있습니다.

fmap

joinJust NothingNothing으로 만들고, ((a, "log1"), "log2")(a, "log1 log2")로 만들었듯이 w aw (w a)w를 복사해 두면, 이를 벗기는 과정에 코모나드의 성격을 결정하는 작업이 들어갑니다. Store f sStore (Store f) s 가 됐다는 얘기는 s에 접근하려면 Store를 두 번 벗겨야 합니다.
Store 하나를 벗길 때, 어떤 작업을 하는지는 fmap을 보면 됩니다.

instance Functor (Store s) where
  fmap f (Store g s) = Store (f . g) s

두 번 쌓여 있는 값에 fmap을 적용하면 다음 모양이 됩니다.

fmap f (Store (Store f1) s) = Store (f . (Store f1)) s

플레인 s에 도달하려면 어떤 작업이 필요한지 보면 Store의 동작을 확실히 알 수 있을 것 같습니다.

Storeduplicate를 다음처럼 표현하기도 합니다. 매개 변수의 의미가 확실히 보이도록 바꾸겠습니다.

duplicate (Store first warehouse)
  = Store (Store first) warehouse -- extract 하면 Store first warehouse  
  = Store (\nextwh -> Store first nextwh) warehouse 
  -- extract 하면 Store first warehouse 

w (w a)fmap f를 적용하면

fmap f (Store (\nextwh -> Store first nextwh) warehouse)
  = Store (f . (\nextwh -> Store first nextwh)) warehouse

fStore를 받으면 플레인값을 돌려주는 함수입니다. 체이닝이 되어 있는 상태에서 extract를 실행하면

(f . (\nextwh -> Store first nextwh)) warehouse
f ( Store first warehouse )
Store a warehouse --warehouse에서 처음걸 가리키는 상태
movenext :: Store (warehouse -> a) warehouse -> a

extract $ Store f warehouse =>= movenext =>= movenext

이런 식의 체이닝으로 쓸 수 있게 됩니다.

extend vs bind

모나드에서 bindfmap을 적용해서 m (m a)가 된 걸 join을 적용해 m a로 만들고,
코모나드에서 extendduplicate를 적용해서 w (w a)가 된 것에 fmap을 적용해서 w a가 됩니다.

bind는      join (fmapM f (return       a) )
extend는 extract (fmapW f (duplicate (w a)))

첫 의문이 duplicate가 왜 Store (Store f) s 모양일까였는데, extract를 적용해서 Store f s가 나오게 하려면 어떤 모양이어야 하나 생각하면 수긍이 갑니다.

CoState

Store 코모나드는 StateCo입니다. (CoState 코모나드라 부르기도 합니다.)

State monad       s -> (a, s)
CoState comonad   (s -> a, s)

언제 코모나드를 떠올리면 될까?

State는 창고가 변하고,
Store는 창고는 그대로인데, 가리키는 함수만 변합니다.

가능한 모든 context를 모아 놓고 골라야 하는 상황일 때 코모나드를 떠올리면 됩니다.

참고
필 프리먼의 the future is comonadic
웹사이트 UI로 설명
바르토즈 밀레위스키의 코모나드
Comonad.com


  1. 역inverse이란 원래대로 되돌리는 작업이고, 쌍대성duality이란 어떤 현상이나 조건이 반전된 개별 대상.↩︎

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