2022.7.31 추가 effect와 context를 구별해서 써야 합니다. 모나드는 effect가 있는 계산을 위한 것이고, 코모나드는 context를 처리합니다. 구체적 내용을 따로 올리겠습니다.
모나드 트라우마로 움찔한 분들은 코모나드란 용어가 나오기 전까진 코모나드는 잠깐 잊어버리고 보는 게 더 나을 수 있습니다.
지금까지 알게 모르게 이미 아래 같은 패턴으로 코딩한 적이 있을 수도 있습니다. 아래서 참조한 가브리엘 글의 제목도 you could have invented comonads 입니다.
몸풀기를 잠깐 하고 넘어 가도록 하겠습니다.
고차 함수, 람다 함수를 활용한 방법
haveProperty :: Int -> ( (Int -> Int) -> Int )
= \f -> f x haveProperty x
> let personA = haveProperty 10
> let personB = haveProperty 100
> personA (+20)
30
> personB (+20)
120
위 (가)와 같은데, 함수를 넘겨서 갖고 있게 할 수도 있습니다.
haveFunc :: (Int -> Int) -> (Int -> Int)
= \x -> f (x + 1) haveFunc f
> let toolA= haveFunc (+2)
> let toolB = haveFunc (+3)
> toolA 1
4
> toolB 1
5
그리고, 표현을 매끄럽게 하기 위해 (기존 OOP에서 쓰던 익숙한 흐름으로 바꾸기 위해) 함수명과 인자 순서를 거꾸로 써주기 위한 연산자를 정의합니다.
-- Data.Function에 있는 (&)와 같은 함수입니다.
-- 굳이 정의하지 않아도 되는데, 원문에 따라 일단 보고 지나 가겠습니다.
(#) :: a -> (a -> b) -> b
# f = f x
x
infixl 0 #
※ 아래는 Gabriel Gonzalez의 블로그 https://www.haskellforall.com/2013/02/you-could-have-invented-comonads.html의 코드 일부를 발췌했습니다.
type Option = String
type Builder = [Option] -> Config
data Config = MakeConfig [Option] deriving (Show)
defaultConfig :: Builder
= MakeConfig (["-default"] ++ options)
defaultConfig options
opt1 :: Builder -> Config
= builder ["-opt1", "-opt1-1"]
opt1 builder
opt2 :: Builder -> Config
= builder ["-opt2"] opt2 builder
위 도구에서 보았던 속성을 먼저 가지고 있는 스타일로 옵션 지정을 하는 코드입니다. 빌더 함수를 넣어서 안에 들어 있는 리스트에 적용합니다.
> defaultConfig # opt1
MakeConfig ["-default","-opt1","-opt1-1"]
> defaultConfig # opt2
MakeConfig ["-default","-opt2"]
하지만 opt1
과 opt2
는 완성된 Config
를 리턴합니다. 그럼 opt1
과 opt2
두 개 모두 옵션 리스트에 넣으려면 어떻게 할까요? 최종 모양부터 상상하면, (다)를 이용해서 defaultConfig # opt1 # opt2
쯤 되는 모양으로 만들려고 합니다. 하스켈에선 뭔가 연이은 동작을 만들 땐 함수로 엮는 방법이 주로 쓰입니다. ※ 무언가 완성되지 않은 상태로 둔다는 말은 “함수”로 만들어 둔다와 같은 말입니다. 이 것도 함수형 스타일로 생각하는 하나의 팁입니다.
opt1' :: Builder -> Builder
= \options -> builder (["-opt1", "-opt1-1"] ++ options)
opt1' builder
opt2' :: Builder -> Builder
= \options -> builder (["-opt2"] ++ options) opt2' builder
이미 가지고 있던 속성에 인자로 받아 온 함수를 적용했는데, 이 걸 바로 실행하지 않고, 인자 하나를 더 받을때까지 실행을 미루기 위해 (나) 방식으로 만들어 놓습니다.
Builder -> Config
였던 타입이 Builder -> Builder
가 되어, 옵션 체인을 만들 수 있도록 입출력 타입이 같게 되었습니다.
"-opt1", "-opt1-1"] -- opt1 결과가 Config 타입
builder [-> builder (["-opt1", "-opt1-1"] ++ options) -- opt' 결과가 Builder 타입 = [Options] -> Config \options
그리고, 더 이상 연결할 옵션이 없을 때 완성된 Config
를 뽑아내기 위해 extract
를 만듭니다.
extract :: Builder -> Config
= builder [] extract builder
그럼 아래와 같이 체인 형태로 쓸 수 있습니다.
> defaultConfig # opt1' # opt2' # extract
MakeConfig ["-default","-opt1","-opt1-1","-opt2"]
이렇게 opt
를 opt'
으로 만들면 체인이 가능하게 됩니다. 그럼 만약 기존 코드가 이미 opt
스타일로 많이 만들어졌다면, 일일이 Builder
리턴 타입으로 바꾸는 것보다 변환 함수를 하나 만들어 쓰는게 좋습니다. 헛갈리지 않게 opt1과 opt2의 람다 변수 이름을 바꿔 놓겠습니다.
= \bf -> bf ["-opt1", "-opt1-1"]
opt1 = \bf -> bf ["-opt2"] opt2
opt1
안에 들어 있는 옵션과 opt2
안에 들어 있는 옵션을 묶어 놓고, 바깥에서 들어온 builder
를 적용하는 모양이 되어야 합니다. 처음엔 함수를 변형한다고 하니, 함수를 어떻게 해체해야 하는지 난감했습니다. opt1 함수를 변형하는 방법 중 하나는 어떤 함수를 넣어 정보를 해체하는 겁니다.
함수가 외부와 소통하는 방법은 매개 변수뿐이 없습니다. 값을 주고 받을 때 뿐만 아니라, 함수 동작을 조작할때도 이용할 수 있는 통로는 매개 변수뿐이 없습니다. 안 쪽에 들어가서 변형시켜 놓을 함수(가)를 매개 변수에 넣어주면 됩니다. 변형된 모양은
\o -> \builder -> ... builder ( ... )
빌더 타입이어야 합니다.
-> \o2 -> (\bf -> bf ["-opt1","-opt1-1"]) (\o1 -> builder (o1 ++ o2))
\builder ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^
-- (가)
original o1을 뽑아 o2와 합치는 함수 -> \o2 -> builder (["-opt1","-opt1-1"] ++ o2) \builder
\o1
으로 opt1
이 가진 옵션을 뽑아내 나중에 들어 올 o2
와 합쳐 놓습니다. 위에서 본 opt1'
과 같은 모양이 나왔습니다. 변형해 주는 함수를 extend
라 이름 붙이면 다음과 같이 정리할 수 있습니다.
= \builder -> \o2 -> original (\o1 -> builder (o1 ++ o2))
extend original = \o2 -> original (\o1 -> builder (o1 ++ o2)) extend original builder
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를 했는데, 코모나드는 context
를 duplicate
하고 있습니다.모나드가 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
에 함수 적용을 마치고 난 후 그 걸 다시 가져와 합친 다음 반환합니다. 그래서, 이 걸 컨텍스트를 유지한다고 말합니다.
모나드에서 m a -> a
는 effect를 담아둘 곳이 없어 불가능하지만, m (m a) -> m a
는 effect 두 개를 join
해서 m
에 담아 둘 수 있어 가능했습니다. 이와 비슷하게 코모나드는 a -> w a
는 w
를 만들어내질 못하니 불가능하지만, w a -> w (w a)
는 기존 가지고 있던 w
를 duplicate
하니 가능합니다.
다르게 표현하면,
join
으로 합친 것이 effect를 잃어버리지 않는 경우만 모나드로 만들 수 있었던 것처럼,
duplicate
로 복사한게 context로써의 의미가 있는 경우만 코모나드로 만들 수 있습니다.
모나드는 컨텍스트를 유지하며 computation하는 동안 effect를 언제든 만들어내지만, 코모나드는 처음 컨텍스트 computation을 시작할 때부터 필요한 모든 context를 가지고 시작해야 합니다. duplicate
의 아이디어는 시작할 때부터 갖고 있는, 여러 context중 현재 가리키는 context를 달리하며 w
로 감싸는 것입니다. w a
와 w(w a)
가 가리키는 context는 다를 수 있습니다. Product
코모나드는 이 context가 하나의 값인 특수한 경우입니다. 정리하면 duplicate는 완전 똑같이 복사하는 게 아니라 current값, 또는 focus되는 정보는 다르게 복사한다고 말합니다.
CoKleisli arrow w a -> b
가 context를 지우는 것에 눈이가 어떻게 context를 없애면서 의미가 있을 수 있을까 고민했습니다. 바인드의 듀얼인 extend
의 반환값은 b
가 아니라 context가 살아 있는 w b
입니다. context가 지워지는 것은 아니었습니다. w
를 조금씩 다르게 복사, 유지하며 computation을 한다가 코모나드의 직관입니다.
새로 effect가 계속 추가되는 computation은 모나드가 어울리고, 필요한 context는 모두 있는 상황에서 조금씩 다른 걸 고르는 computation은 코모나드가 어울립니다. 그래서 변하지 않는 환경값을 참조하는 effect는 Reader
모나드로도 Product
코모나드로도 표현될 수 있었던 겁니다.
대표적인 코모나드로 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
Store f s) = f s
extract (Store f s) = Store (Store f) s duplicate (
(w a -> b)
를 w a
에 적용하면 w
는 사라지고 b
만 남습니다. w
를 잃어버리지 않기 위해 w a
를 w
를 한 번 더 씌워 w (w a)
로 만들어 버리고,fmap (w a -> b) $ w (w a)
를 하면 안 쪽에 있는 w
는 사라지고 b
만 남지만 바깥에 복사해 둔 w
가 살아 있어, w b
를 리턴할 수 있습니다.
join
이 Just Nothing
을 Nothing
으로 만들고, ((a, "log1"), "log2")
를 (a, "log1 log2")
로 만들었듯이
w a
를 w (w a)
로 w
를 복사해 두면, 이를 벗기는 과정에 코모나드의 성격을 결정하는 작업이 들어갑니다.
Store f s
가 Store (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
의 동작을 확실히 알 수 있을 것 같습니다.
Store
의 duplicate
를 다음처럼 표현하기도 합니다. 매개 변수의 의미가 확실히 보이도록 바꾸겠습니다.
Store first warehouse)
duplicate (= 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
f
는 Store
를 받으면 플레인값을 돌려주는 함수입니다. 체이닝이 되어 있는 상태에서 extract
를 실행하면
. (\nextwh -> Store first nextwh)) warehouse
(f Store first warehouse ) f (
Store a warehouse --warehouse에서 처음걸 가리키는 상태
movenext :: Store (warehouse -> a) warehouse -> a
$ Store f warehouse =>= movenext =>= movenext extract
이런 식의 체이닝으로 쓸 수 있게 됩니다.
모나드에서 bind
는 fmap
을 적용해서 m (m a)
가 된 걸 join
을 적용해 m a
로 만들고,
코모나드에서 extend
는 duplicate
를 적용해서 w (w a)
가 된 것에 fmap
을 적용해서 w a
가 됩니다.
return a) )
bind는 join (fmapM f ( extend는 extract (fmapW f (duplicate (w a)))
첫 의문이 duplicate
가 왜 Store (Store f) s
모양일까였는데, extract
를 적용해서 Store f s
가 나오게 하려면 어떤 모양이어야 하나 생각하면 수긍이 갑니다.
Store
코모나드는 State
의 Co
입니다. (CoState
코모나드라 부르기도 합니다.)
State monad s -> (a, s)
CoState comonad (s -> a, s)
State
는 창고가 변하고,
Store
는 창고는 그대로인데, 가리키는 함수만 변합니다.
가능한 모든 context를 모아 놓고 골라야 하는 상황일 때 코모나드를 떠올리면 됩니다.
참고
필 프리먼의 the future is comonadic
웹사이트 UI로 설명
바르토즈 밀레위스키의 코모나드
Comonad.com
역inverse이란 원래대로 되돌리는 작업이고, 쌍대성duality이란 어떤 현상이나 조건이 반전된 개별 대상.↩︎