수학 기반에서 보지 않고, 순전히 코드만 보고 어떤 경우에 모나드를 떠올렸을까 상상해 봤습니다.
Display
: IO
모나드의 Realworld
1 모형
현실 세계의 복잡함이 정수 하나로 표시된다고 가정하겠습니다. 특별히 Input 없고, Output만 있고 Output은 오로지 정수로만 나타납니다.
Display
가 표시하는 정보는 작업의 횟수 0
, 1
, 2
, 3
..100
… 등입니다.
Display
값을 변형하게 되는 동작들을 inc
, dec
라고 표현해 보겠습니다.
Total
은 0
,1
,2
,3
네가지 값만 가지는 modular 4
라고 하겠습니다.
매개 변수 이외의 방법으로도 외부와 소통할 수 있는 함수는 아래처럼 작업할 수 있습니다.
-- pseudo code
global total, display
=
setDisplay = display + 1
display
= 0
display = 0
total
=
inc = (total + 1) `mod` 4
total
setDisplay total =
dec = (total - 1) `mod` 4
total
setDisplay total
-- program
inc
dec
inc
dec -- 결과 0, display는 4
하지만, 외부와 매개 변수와 반환값으로만 소통할 수 있는 환경에선 어떻게 할까요? 전역 변수를 둘 수 없으니, display, total 두 개를 모든 함수에 넘기고, 결과로 수정된 display, total을 받아야 합니다.
inc :: Total -> Display -> Total? Display?
반환값이 두 개여야 합니다. 어떻게 할까요? 튜플을 쓰면 됩니다.
inc :: Total -> Display -> (Total, Display)
합성을 해야 하니 입력도 같은 타입으로 받도록 하겠습니다.
inc :: (Total, Display) -> (Total, Display)
type Display = Int
type Total = Int
inc :: (Total, Display) -> (Total, Display)
= let newTotal = (s + 1) `mod` 4
inc (s,d) = d + 1
newDisplay in (newTotal, newDisplay)
dec :: (Total, Display) -> (Total, Display)
= let newTotal = (s - 1) `mod` 4
dec (s,d) = d + 1
newDisplay in (newTotal, newDisplay)
1
를 더하고,
1
을 빼고,
1
을 더하고,
1
를 빼는데
반드시 이순서로 실행되고 결과를 돌려줘야 한다고 가정합니다. 하스켈은 함수의 실행 순서가 정해져 있지 않습니다. 하지만 하나의 함수가 다른 함수의 값이 필요하도록 만들면 실행 순서를 강제할 수 있습니다.
= \(s,d) -> dec (inc (dec (inc (s,d)))) program
이제 program
에 초기 (0, 0)
을 넣어 주면 inc
, dec
, inc
, dec
를 순차적으로 실행합니다.
inc
와 dec
는
1. 현재 상태(Total, Display)
를 입력으로 받고
1. 상태를 가지고 작업한 후
1. 새로운 (Total, Display)
를 반환합니다.
매개 변수로도 비순수 함수로 만든 코드와 똑같은 결과를 가져오는 작업을 만들었습니다.
만일 Display
없이
= (t + 1) `mod` 4 inc t
이럴 수만 있다면, 하스켈은 입력 값이 같으면 다시 계산하지 않고 캐싱해 두었던 결과값을 돌려줍니다.
0 ... inc 0 ...
inc ^^^^^
. 또 계산하지 않는다
하지만 지금은 Display
값이 계속 변하고 있어 memoize가 되지 않습니다.
0,0)... inc (0,4) ...
inc (^^^^^^^^^
. 또 계산해야 한다
함수형에서 함수는, 되도록 하나의 작업에 집중하는 것이 좋습니다. inc
함수는 total
에 1
을 더하는 역할만 하면 됩니다. Display
상태를 바꾸는 건 별도의 작업입니다. memoize가 되게 하려면 어떻게든 입력값이 덜 변하게 해야 하니, total
를 받아 1
을 더하고, Display
는 더할 값만 반환해서 다른 함수가 담당하도록 바꿔 보겠습니다.
inc :: Total -> (Total, Display)
= let newTotal = (s + 1) `mod` 4
inc s in (newTotal, 1)
dec :: Total -> (Total, Display)
= let newTotal = (s - 1) `mod` 4
dec s in (newTotal, 1)
inc
와 dec
안에서 Display
를 계산하는 부분이 빠졌습니다.
이렇게 타입을 바꾸니 다음 문제가 생겼습니다.
Display
를 누적 계산해 줄 작업이 필요합니다.첫 번째, 입출력이 다른 문제를 해결해야 합니다.
입력은 Display
가 포함되어 있는 (Total, Display)
이고,
액션은 Total -> (Total, Display)
형태의 함수입니다.
컴비네이터는 이 둘을 받아 (Total, Display)
를 출력해야 합니다.
combinator :: (Total, Display) -> (Total -> (Total, Display)) -> (Total, Display)
(Total, Display)
을 받고,inc
나 dec
같은 상태를 변화시키는 함수를 받아
(특별히 이런 함수를 액션이라 부르겠습니다.)(Total, Display)
을 돌려주는 함수입니다.두 번째, 누적 작업을 해야합니다.
= let (news, newd) = action s
combinator (s,oldd) action ^^^^^^^^ ^^^^^^^^^^^^
(가) (나)in (news, oldd + newd)
^^^^^^^^^^^
(다)
Total
와 Display
를 꺼냅니다.s
에 액션을 적용해서,0,0) inc -- (Total, Display) combinator (
이 걸 다음 inc
에 넘기면 다음 모양이 됩니다.
0,0) inc) inc combinator (combinator (
combinator를 중위 연산으로 표현하면
0,0) `combinator` inc
(
0,0) `combinator` inc) `combinator` inc ((
컴비네이터를 보기 좋게 기호로 정의하면,
(>>>>):: (Total, Display) -> (Total -> (Total, Display)) -> (Total, Display)
0,0) >>>> inc) >>>> inc ((
비순수 함수로 작업했던 것과 동일한 결과를 내는 작업이 다음 모양이 되었습니다.
0,0) >>>> inc) >>>> dec >>>> inc >>>> dec ((
(다)에서
Display
변화 값을 합치는 부분이 모나드를 이해하는 굉장히 중요한 부분입니다. 액션을 컴비네이터를 이용해 합성해도 원래 있던Display
값을 잃어버리지 않고, 액션으로 인해 생긴 Display 값도 잃어버리지 않고 있습니다. 정리에서 이야기를 이어 가겠습니다.
컴비네이터를 연속으로 적용해서 액션 적용을 아래처럼 할 수 있습니다.
>>>> action1) >>>> action2) >>>> action3 ((m
>>>>
가 하는 일은, m
에서 Total
값을 꺼내 action1
에 주면 (Total, 새 Display)
값이 나옵니다. 여기서 다시 누적값을 꺼내 action2
에 넘겨주고 있습니다. 그런데, 이 함수 체인을 다음과 같이 바꾸면 어떤 일이 일어나는지 보겠습니다.
>>>> (\r0 -> action1 r0 >>>> (\r1 -> action2 r1 >>>> (\r2 -> action3 r2))) m
굳이 람다 변수로 인자를 받아 적용하는 모양으로 바꿨습니다. action 하나 하나를 보면 결과는 같습니다. 왜 이렇게 바꿀까요? 이유를 보기 위해 단순화해서 살펴보겠습니다.
컴비네이터와 인자 순서가 비슷한 &
을 이용하겠습니다.
&
는 함수 인자 쓰는 순서를 “인자 & 함수”로 바꿔주는 연산자입니다.
import Data.Function (&)
> 1 & (+1) & (+1)
3
이렇게 초기값1을 받아 작업 두 번하는 모양을 람다 변수로 인자를 받는 모양으로 바꾸면 아래 같이 됩니다.
> 1 & (+1)
2
> 1 & (\r0 -> (+1) r0)
2
> 1 & (\r0 -> (+1) r0 & (\r1 -> (+1) r1))
3
이렇게 바꾸면 굉장히 중요한 특징이 생깁니다.
> 1 & (\r0 -> (+1) r0 & (\r1 -> (+ r0) r1))
^^^^^^
3
인자를 그냥 바로 적용하지 않고, 람다 변수를 통해서 쓰고 있습니다. 어차피 받아서 쓸 값을 이렇게 람다 변수를 통하게 하면, 람다 컨텍스트 특징 때문에 두 번째 \r1 -> ...
액션 안에서, 첫 번째 액션의 람다 변수로 있던 r0
를 쓸 수 있습니다.
“하스켈의 순수 함수는 매개 변수와 람다 컨텍스트 변수를 통해서만 외부와 소통할 수 있습니다.”
이 제약을 뚫고 상태 개념을 만들기 위해 액션 결과값들이 람다 컨텍스트 변수에 기억되고 있습니다. mutable 변수를 만들지 못하는 하스켈에서는 아주 중요한 특징입니다.
다시 컴비네이터로 돌아가서, 여기 쓰인 컴비네이터로 action
들을 묶는데, 람다 변수를 이용하는 패턴2으로 바꾸면 모든 action들의 결과값들을 따로 기억시키는 효과가 납니다.
>>>> (\r0 -> action1 r0 >>>> (\r1 -> action2 r1 >>>> (\r2 -> action3 r2))) m
람다 정의는 특별히 괄호나 쌍을 이루는 문법 요소가 없으면 라인 끝까지입니다. 그리고 컴비네이터의 연산 우선 순위를
infixl 1 >>>>
이렇게 지정하고 나면 괄호를 생략하고 다음처럼 쓸 수 있습니다.
>>>> \r0 -> action1 r0
m >>>> \r1 -> action2 r1
>>>> \r2 -> action3 r2
위 예시에 적용하면 다음 모양이 됩니다.
0,0) >>>> \r0 -> inc
(>>>> \r1 -> dec
>>>> \r2 -> inc
>>>> \r3 -> dec
※ 사실은 매개 변수만을 통해서만 가능하다고 말해도 됩니다.
= doSomething
func arg = \arg -> doSomething func
위 두 개는 같은 함수입니다. 람다 컨텍스트 변수가 곧 매개 변수입니다.
Display
가 없이 Total
만 있는 값을 초기 Display
값을 포함한 튜플로 바꿔주는 함수를 아래와 같이 정의합니다.
return :: Total -> (Total, Display)
return n = (n, 0)
순수함수와 컴비네이터로 만든 실제 작동하는 코드로 정리하면 다음과 같습니다.
module Test where
type Display = Int
type Total = Int
return :: Total -> (Total, Display)
return n = (n, 0)
(>>>>):: (Total, Display) -> (Total -> (Total, Display)) -> (Total, Display)
>>>>) (s,oldd) action = let (news, newd) = action s
(in (news, oldd + newd)
inc :: Total -> (Total, Display)
= let newTotal = (s + 1) `mod` 4
inc s in (newTotal, 1)
dec :: Total -> (Total, Display)
= let newTotal = (s - 1) `mod` 4
dec s in (newTotal, 1)
program :: (Total, Display)
= (0,0) >>>> \r0 -> inc r0
program >>>> \r1 -> dec r1
>>>> \r2 -> inc r2
>>>> \r3 -> dec r3
= do
main print program
컴비네이터>>>>
가 바인드>>=
의 구현입니다. 비순수 함수로 만들었던 코드와 완전히 동일한 작업을 하는데, 모두 순수한 함수로만 작업하고 있습니다.
코드로 얘기하면 위combinator
와 같은 bind
(혹은 join
)와 return
이 있는 구조를 모나드라 합니다.
inc
와 dec
로 바꾸길 원하는 값은 Total
하나입니다. 그런데, 이 Total
을 변경하기 위해서 몇 번의 작업을 했는지 카운팅을 하기 위해 Display
를 만들었습니다.
Total ------> (Total, Display)
(Total, Display)
에서 Total
로 갈 때 잃어버리는 정보를 Effect라 합니다.
combinator
함수 안을 들여다보면 입력으로 들어온 Display
값을 꺼내, inc
또는 dec
로 인해 새로 생긴 Display
값을 더하고 있습니다. 말을 바꾸면 입력으로 들어온 Effect1과 inc
또는 dec
같은 액션으로 생긴 Effect2 두 개를 합쳐(여기서는 단순히 더하기지만, Effect 타입에 따라 적절하게 정의합니다.) 새로운 (Total, Display)
값을 돌려주고 있습니다.
이렇게 “두 개 Effect를 합쳐서 Effect 하나로 만들 수 있는 구조를 모나드”라 합니다.
여기서 모나드의 효과는 mutable 변수를 정의할 수 없는데도, program을 실행하는 동안 Total
, Display
두 가지 정보가 각 각 독자적으로 흘러가고 있습니다. 마치 Display
를 mutable하게 쓰는 것과 같은 결과가 나와, 여러가지 effect 종류 중 side effect를 해결했다고 합니다.
※ 최대한 단순화 해서 Effect가 합성되는 것을 보이는 걸 목표로 했습니다. 조금 더 자세히 모나드를 다룬 글도 같이 올려 놨습니다. 여기서 Effect를 어떻게 별도로 끌고 다니는지 확인 후 보시면 좋을 것 같습니다. 모나드, 같음 - m (m a)와 m a는 얼마나 다를까?
다른 문서들과 마찬가지로, 오류가 없다는 확신은 없습니다. 오류나 의심나는 부분은 댓글이나, 메일을 주고 받으며 고쳐 나가면 좋겠습니다.
module Test where
type Display = Int
type Total = Int
newtype SimpleMonad a = SimpleMonad (a, Display)
deriving Show
instance Functor SimpleMonad where
fmap f (SimpleMonad (t, d)) = SimpleMonad (f t, d)
instance Applicative SimpleMonad where
pure n = SimpleMonad (n, 0)
SimpleMonad (f, d1)) <*> (SimpleMonad (t, d2)) = SimpleMonad (f t, d1 + d2)
(
instance Monad SimpleMonad where
return = pure
>>=) (SimpleMonad(oldTotal ,oldD)) action =
(let SimpleMonad (newTotal, newD) = action oldTotal
in SimpleMonad (newTotal, oldD + newD)
inc :: Total -> SimpleMonad Total
= let newTotal = (s + 1) `mod` 4
inc s in SimpleMonad (newTotal, 1)
dec :: Total -> SimpleMonad Total
= let newTotal = (s - 1) `mod` 4
dec s in SimpleMonad (newTotal, 1)
program :: SimpleMonad Total
= do
program <- return 0
r0 <- inc r0
r1 <- dec r1
r2 <- inc r2
r3
dec r3
= do
main print program
실제 IO모나드 타입은 다음과 같습니다.
newtype IO a = IO (State# RealWorld -> (# State# RealWorld, a #))
#
은 타입이 Unboxed
라는 뜻인데, 나중에 다른 글에서 다루도록 하고, IO
는 RealWorld
란 State
를 받아, RealWorld
와 a
튜플을 돌려주는 함수 타입입니다. 하스켈의 함수는 모두 순수 함수이기 때문에 입력이 같으면 출력이 같아야 합니다. 예를 들어 Random 값이 계속 다르게 받으려면 입력값을 계속 다르게 해서 불러야 합니다. RealWorld
는 특별히 의미 있는 정보를 담고 있는게 아니라, 순수 함수 규칙을 깨지 않기위해 존재하며 하스켈 런타임에서 프리미티브하게 처리 합니다.
액션 하나를 실행하고 다음 액션으로 넘어갈 때는 변형된 Realworld
가 들어간다는 의미로 쓰이는데, 실제 Realworld
값이 변하진 않고, 하스켈 런타임에서 프리미티브하게 처리하는 걸로 보입니다.↩︎
이런 패턴으로 체이닝 한 것과 그냥 체이닝한 패턴이 같다가 모나드 제3법칙입니다.
>>= f) >>= g = m >>= (\x -> f x >>= g) (m