단순한 모나드 코드

Posted on May 22, 2022

수학 기반에서 보지 않고, 순전히 코드만 보고 어떤 경우에 모나드를 떠올렸을까 상상해 봤습니다.

Display : IO모나드의 Realworld1 모형
현실 세계의 복잡함이 정수 하나로 표시된다고 가정하겠습니다. 특별히 Input 없고, Output만 있고 Output은 오로지 정수로만 나타납니다.
Display가 표시하는 정보는 작업의 횟수 0, 1, 2, 3..100… 등입니다.
Display값을 변형하게 되는 동작들을 inc, dec라고 표현해 보겠습니다.
Total0,1,2,3 네가지 값만 가지는 modular 4라고 하겠습니다.

비순수 함수

매개 변수 이외의 방법으로도 외부와 소통할 수 있는 함수는 아래처럼 작업할 수 있습니다.

-- pseudo code
global total, display

setDisplay = 
    display = display + 1

display = 0
total = 0

inc = 
    total = (total + 1) `mod` 4
    setDisplay total 
dec = 
    total = (total - 1) `mod` 4
    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)
inc (s,d) = let newTotal = (s + 1) `mod` 4
                newDisplay = d + 1
            in (newTotal, newDisplay)

dec :: (Total, Display) -> (Total, Display)
dec (s,d) = let newTotal = (s - 1) `mod` 4
                newDisplay = d + 1
            in (newTotal, newDisplay)

1를 더하고,
1을 빼고,
1을 더하고,
1를 빼는데
반드시 이순서로 실행되고 결과를 돌려줘야 한다고 가정합니다. 하스켈은 함수의 실행 순서가 정해져 있지 않습니다. 하지만 하나의 함수가 다른 함수의 값이 필요하도록 만들면 실행 순서를 강제할 수 있습니다.

program = \(s,d) ->  dec (inc (dec (inc (s,d))))

이제 program에 초기 (0, 0)을 넣어 주면 inc, dec, inc, dec를 순차적으로 실행합니다.

incdec
1. 현재 상태(Total, Display)를 입력으로 받고 1. 상태를 가지고 작업한 후 1. 새로운 (Total, Display)를 반환합니다.

매개 변수로도 비순수 함수로 만든 코드와 똑같은 결과를 가져오는 작업을 만들었습니다.

memoize 문제

만일 Display 없이

inc t = (t + 1) `mod` 4

이럴 수만 있다면, 하스켈은 입력 값이 같으면 다시 계산하지 않고 캐싱해 두었던 결과값을 돌려줍니다.

inc 0 ... inc 0 ...
          ^^^^^
          또 계산하지 않는다.

하지만 지금은 Display값이 계속 변하고 있어 memoize가 되지 않습니다.

inc (0,0)... inc (0,4) ...
             ^^^^^^^^^
             또 계산해야 한다.

함수형에서 함수는, 되도록 하나의 작업에 집중하는 것이 좋습니다. inc 함수는 total1을 더하는 역할만 하면 됩니다. Display 상태를 바꾸는 건 별도의 작업입니다. memoize가 되게 하려면 어떻게든 입력값이 덜 변하게 해야 하니, total를 받아 1을 더하고, Display는 더할 값만 반환해서 다른 함수가 담당하도록 바꿔 보겠습니다.

inc :: Total -> (Total, Display)
inc s = let newTotal = (s + 1) `mod` 4
        in (newTotal, 1)

dec :: Total -> (Total, Display)
dec s = let newTotal = (s - 1) `mod` 4
        in (newTotal, 1)

incdec안에서 Display계산하는 부분이 빠졌습니다.

컴비네이터

이렇게 타입을 바꾸니 다음 문제가 생겼습니다.

  1. 입, 출력이 달라서 합성이 안되니, 컴비네이터(조합기 - 접착 도구 쯤)가 필요해졌습니다.
  2. Display를 누적 계산해 줄 작업이 필요합니다.

첫 번째, 입출력이 다른 문제를 해결해야 합니다. 입력은 Display가 포함되어 있는 (Total, Display)이고,
액션은 Total -> (Total, Display) 형태의 함수입니다.
컴비네이터는 이 둘을 받아 (Total, Display)를 출력해야 합니다.

combinator :: (Total, Display) -> (Total -> (Total, Display)) -> (Total, Display)

두 번째, 누적 작업을 해야합니다.

combinator (s,oldd) action = let (news, newd) = action s
           ^^^^^^^^              ^^^^^^^^^^^^
             (가)                     (나)
                             in (news, oldd + newd)
                                       ^^^^^^^^^^^            
                                           (다)

컴비네이터 합성

combinator (0,0) inc -- (Total, Display)

이 걸 다음 inc에 넘기면 다음 모양이 됩니다.

combinator (combinator (0,0) inc) inc

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 값도 잃어버리지 않고 있습니다. 정리에서 이야기를 이어 가겠습니다.

람다 컨텍스트 활용

컴비네이터를 연속으로 적용해서 액션 적용을 아래처럼 할 수 있습니다.

((m >>>> action1) >>>> action2) >>>> action3

>>>>가 하는 일은, m에서 Total 값을 꺼내 action1에 주면 (Total, 새 Display) 값이 나옵니다. 여기서 다시 누적값을 꺼내 action2에 넘겨주고 있습니다. 그런데, 이 함수 체인을 다음과 같이 바꾸면 어떤 일이 일어나는지 보겠습니다.

m >>>> (\r0 -> action1 r0 >>>> (\r1 -> action2 r1 >>>> (\r2 -> action3 r2)))

굳이 람다 변수로 인자를 받아 적용하는 모양으로 바꿨습니다. 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들의 결과값들을 따로 기억시키는 효과가 납니다.

m >>>> (\r0 -> action1 r0 >>>> (\r1 -> action2 r1 >>>> (\r2 -> action3 r2)))

람다 정의는 특별히 괄호나 쌍을 이루는 문법 요소가 없으면 라인 끝까지입니다. 그리고 컴비네이터의 연산 우선 순위를

infixl 1 >>>>

이렇게 지정하고 나면 괄호를 생략하고 다음처럼 쓸 수 있습니다.

m >>>> \r0 -> action1 r0 
  >>>> \r1 -> action2 r1 
  >>>> \r2 -> action3 r2

위 예시에 적용하면 다음 모양이 됩니다.

(0,0) >>>> \r0 -> inc
      >>>> \r1 -> dec
      >>>> \r2 -> inc
      >>>> \r3 -> dec

※ 사실은 매개 변수만을 통해서만 가능하다고 말해도 됩니다.

func arg = doSomething
func = \arg -> doSomething

위 두 개는 같은 함수입니다. 람다 컨텍스트 변수가 곧 매개 변수입니다.

정리

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)    
inc s = let newTotal = (s + 1) `mod` 4    
        in (newTotal, 1)    
    
dec :: Total -> (Total, Display)    
dec s = let newTotal = (s - 1) `mod` 4    
        in (newTotal, 1)    
    
program :: (Total, Display)    
program = (0,0) >>>> \r0 -> inc r0 
                >>>> \r1 -> dec r1 
                >>>> \r2 -> inc r2 
                >>>> \r3 -> dec r3
      
main = do    
  print program  

컴비네이터>>>>가 바인드>>=의 구현입니다. 비순수 함수로 만들었던 코드와 완전히 동일한 작업을 하는데, 모두 순수한 함수로만 작업하고 있습니다.

코드로 얘기하면 위combinator와 같은 bind(혹은 join)와 return이 있는 구조를 모나드라 합니다.

incdec로 바꾸길 원하는 값은 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         
inc s = let newTotal = (s + 1) `mod` 4    
        in SimpleMonad (newTotal, 1)      
    
dec :: Total -> SimpleMonad Total    
dec s = let newTotal = (s - 1) `mod` 4    
        in SimpleMonad (newTotal, 1)    
    
program :: SimpleMonad Total    
program = do    
             r0 <- return 0    
             r1 <- inc r0    
             r2 <- dec r1    
             r3 <- inc r2    
             dec r3    
    
main = do    
  print program 

  1. 실제 IO모나드 타입은 다음과 같습니다.

    newtype IO a = IO (State# RealWorld -> (# State# RealWorld, a #))

    #은 타입이 Unboxed라는 뜻인데, 나중에 다른 글에서 다루도록 하고, IORealWorldState를 받아, RealWorlda 튜플을 돌려주는 함수 타입입니다. 하스켈의 함수는 모두 순수 함수이기 때문에 입력이 같으면 출력이 같아야 합니다. 예를 들어 Random 값이 계속 다르게 받으려면 입력값을 계속 다르게 해서 불러야 합니다. RealWorld는 특별히 의미 있는 정보를 담고 있는게 아니라, 순수 함수 규칙을 깨지 않기위해 존재하며 하스켈 런타임에서 프리미티브하게 처리 합니다.
    액션 하나를 실행하고 다음 액션으로 넘어갈 때는 변형된 Realworld가 들어간다는 의미로 쓰이는데, 실제 Realworld 값이 변하진 않고, 하스켈 런타임에서 프리미티브하게 처리하는 걸로 보입니다.↩︎

  2. 이런 패턴으로 체이닝 한 것과 그냥 체이닝한 패턴이 같다가 모나드 제3법칙입니다.

    (m >>= f) >>= g     =     m >>= (\x -> f x >>= g)
    ↩︎
Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com