Monad없이 Maybe, State의 Effect 해결하기

Posted on November 18, 2022

Effect와 모나드 시리즈

  1. Effect란?
  2. 모나드 없이 Maybe, State 해결하기
  3. 모나드 없이 List [], Logger 해결하기
  4. Functor로 다른 세계로 옮기기
  5. Functor로 Effect 코드를 추상화하면 모나드가 보인다

결국, 모나드로 가는 발판으로 삼으려고 쓰는 글이지만, 이 글만 보면 Effect 감을 잡는게 목표입니다. 무엇을 Effect라 하고, Effect를 해결하는 코드 모양은 어떻게 나올 수 있는가를 보려 합니다.

첫 번째, Maybe

계산이 실패하면 무슨 일이 생길까요? 계산이 성공하거나 실패한 건, 그 계산 이후 아무 일도 할 필요가 없다면 의미 없는 정보일 뿐입니다. 다음 함수가 받아야 비로소 의미가 생깁니다. 이전 함수의 성공 결과와 실패를 다음 함수가 받아야 의미가 있습니다. ※ 다음 함수로 넘기지 않고, 우리가 답을 확인한다면, 그 행위도 다음 이어지는 작업 중 하나입니다.

f1 :: Int -> Int
f2 :: Int -> Int

이런 함수가 있는데, f1f2도 실패를 결과로 가질 수 있는 상황입니다. 실패를 표현하기 위해, 새로운 Sum 형태의 타입을 만듭니다. (Sum 형태란, 여러 가능한 타입 중 한가지를 가질 수 있는 타입을 말합니다.)

data Maybe a = Just a | Nothing

계산의 결과가 Int일 때, Maybe Int로 표현하면, 성공이면 Just Int로, 실패하면 Nothing으로 표현할 수 있습니다. 이제 실패를 표현할 수 있는 타입은 마련 됐고, 이 타입을 이용해 Int를 받아 실패할 수도 있는 계산을 하는 함수는 다음처럼 표현할 수 있습니다.

f1 :: Int -> Maybe Int
f2 :: Int -> Maybe Int

이제, f1의 실행 결과를 f2가 받아야 되는 상황입니다. f2는 서명에 분명히 Int만 받을 수 있는 함수라 되어 있습니다. 이전 함수의 계산 실패 Nothing의 영향을 받아야만 합니다. 함수의 입출력으로 드러나지 않은 정보를 어떻게 해결할까요? 함수형에서 도구는 함수 뿐이 없습니다. f2가 못 받는 값은, 받을 수 있는 함수를 만들어 앞에서 먼저 받고, f2에는 f2가 처리할 수 있는 값일 때만 넘기면 됩니다.

f1이 받지 못하는 값 Maybe Int를 먼저 받고

combinator :: Maybe Int -> 

이 중 f2가 해결할 수 있는 값은 이어지는 함수 f2가 해결하고,

combinator :: Maybe Int -> (Int -> Maybe Int) -> 

결과는 또 다시 f2의 실패할 수도 있는 결과를 돌려주면 됩니다.

combinator :: Maybe Int -> (Int -> Maybe Int) -> Maybe Int

이제, 이 함수만 있으면 f1f2를 붙일 수 있습니다. f1f2의 서명 Int -> Maybe Int에 드러나지 않는 정보지만, 다음 함수에 영향을 미치는 즉, Effect는 combinator가 알아서 해결합니다.

let res1 = f1 100
 in combinator res1 f2

이어지는 함수 f3 :: Int -> Maybe Int가 있다면,

let res1 = f1 100
 in combinator (combinator res1 f2) f3

그럼, combinator는 어떤 작업을 하면 될까요? 이 번 글에서는 다른 함수 하나를 래핑해서 Effect를 처리하면 된다는 걸 보이는 게 목표이니, 실 구현은 지금 여기서 중요하지 않긴 합니다. 눈을 분산하지 않기 위해 실 구현은 주석에 달아 두겠습니다.1

※ 하스켈에선 중위infix 연산자로 표현하는 방법이 있으니 보기 좋게 바꾸면,

let res1 = f1 100
 in res1 `combinator` f2 `combinator` f3

함수 이름을 연산자 모양으로 바꾸고 다음처럼 표현할 수 있습니다.

(>>>>) :: Maybe Int -> (Int -> Maybe Int) -> Maybe Int
let res1 = f1 100
 in res1 >>>> f2 >>>> f3

두 번째 예시, State

전역 State를 유지하며 계산을 해야 되는 상황을 보겠습니다. 첫 번째 계산이 결과와 별도로 상태를 바꿨는데, 그 상태를 아무도 가져다 쓰지 않으면 의미 없는 정보일 뿐입니다. 상태 값은 다음 상태나 계산의 동작에 영향을 미쳐야 비로소 의미가 생깁니다.

f1 :: Double -> Double
f2 :: Double -> Double

이런 함수가 있는데, 계산 결과가 원래의 값과
10 이상 차이나면, 다음 계산 때, 계산 결과를 반으로 줄이고,
10 보다 작게 차이나면, 다음 계산 때, 계산 결과를 두 배하도록 한다고 해 봅시다.

type Compensation = Double -- 보정한다는 의미
data State a s = State (a, s)

f1 :: Double -> State Double Compensation
f2 :: Double -> State Double Compensation

f1, f2 각각은 다음 계산의 결과를 두배로 할지, 반으로 할지 알려주는 상태값을 만들어 냅니다. 지금 모양의 f1, f2는 상태값에 따라 계산 결과가 바뀔 수 있다는 정보는 받을 수가 없습니다. 상태값을 받는다는 동작, 상태값에 따라 최종 결과를 바꾼다는 동작은 함수의 서명에 드러나 있지 않습니다. 이들 함수가 상태값을 받아서 필요한 동작을 하게 하려면 어떻게 할까요? 함수형에서 쓸 수 있는 도구는 함수 뿐입니다. 당연히, 다른 함수가 먼저 받으면 됩니다. f1, f2가 받지 못하는 값을 먼저 받아 f1, f2의 계산이 나오면 2배 또는 반으로 보정해주는 작업을 함수를 두면 됩니다.

f1이 받지 못하는 상태값이 포함되어 있는 State Int Compensation을 먼저 받고,

combinator :: State Double Compensation -> 

계산 후 보정Compensation을 적용하게 될 이어지는 함수를 받고

combinator :: State Double Compensation -> (Double -> State Double Compensation) -> 

보정Compensation을 적용하고, 새로운 보정값과 함께 결과를 돌려주면 됩니다.

combinator :: State Double Compensation -> (Double -> State Double Compensation) -> State Double Compensation 

이 함수만 있으면 Compensation을 받지도 ,적용하지도 못하던 f1과, f2를 합성할 수 있습니다. f1,f2에는 이 부분
Compensation을 받고, 계산이 끝나면 Compensation을 적용하여 보정, 그리고 새로운 보정값을 돌려주는 것”
이 서명에 드러나 있지 않습니다. 이렇게 서명에 보이지 않게, 다음 함수에 영향을 미치는, 즉 Effect는 Combinator가 알아서 해결합니다.

let res1 = f1 100.0
 in combinator res1 f2

이어지는 함수 f3 :: Double -> State Double Compensation가 있다면,

let res1 = f1 100.0
 in combinator (combinator res1 f2) f3

함수 이름을 연산자 모양으로 바꾸고 다음처럼 표현할 수 있습니다.

(>>>>) :: State Double Compensation -> (Double -> State Double Compensation) -> State Double Compensation
let res1 = f1 100.0
 in res1 >>>> f2 >>>> f3

역시, 의도적으로 combinator의 실제 구현은 주석에 넣어 놨습니다. 2

첫 번째 예시 MaybeState의 다른 점을 확인해 보시기 바랍니다. Maybe 예시와 State 예시의 공통점을 인지하고, Effect가 뭔지 감을 잡는 게 이 글의 목표입니다. 둘 다 함수 서명에 드러나지 않는 Effect를 combinator를 통해 합성하며 해결하고 있습니다.

결론

아직, 위의 예시 둘 다 모나드는 아닙니다. (두 번째 예시는 모나드 법칙을 만족 못해 모나드 클래스의 인스턴스로 만들지는 못합니다.) 여기서는 모나드 없이 Effect를 개별적으로 어떻게 해결하는지를 보는 게 목적입니다. 모나드를 도입 전에도 Effect 개념은 있었으며, Effect 훈련이 충분히 된 상태에서 모나드를 만났을 것이다란 상상입니다. 그냥, 천재여서 훈련이 필요 없었을 가능성도 물론 존재하지만요.

※ 나중 마지막 글에 넣을 최종 결론을 일부 먼저 살펴보면, 위 얘기에 하스켈의 모나드 “클래스”를 쓰지는 않았지만, 모나드 “개념”은 이미 존재합니다. Effect가 모나드고, 모나드가 Effect입니다. 같은 개념, 대상, 상황을 서로 다른 학문에서 기술했다고 볼 수 있습니다. 프로그래밍에서는 Effect란 말을 쓰고, 수학에서는 Monad란 말을 썼을 뿐, 두 개가 지칭하는 개념은 서로 같습니다.


  1. 이전 함수의 계산 결과가
    성공하면 Just 200.0 같은 값일테니, 다음 함수에 Just 없이 200.0을 넘겨 주면 되고
    실패하면 Nothing이어서 다음 함수 f2는 받을 수 없으니, f2를 거치지 않고, 그냥 Nothing을 돌려주면 됩니다.

    Maybe combinator 구현

    combinator mv f = case mv of
                        Just v -> f v
                        Nothing -> Nothing
    ↩︎
  2. Compensation을 분리해서 잠시 보관하고, f2에 넘겨 계산한 후, 분리해놨던 Compensation을 적용합니다. 그 뒤, f2가 계산하며 새로 만들어 낸 Compensation값과 함께 State 계산값 Compensation 형태로 돌려주면 됩니다.(아직 아래 예시는 문제가 있습니다. 모나드 Associative law를 만족하지 못합니다.)

    State combinator 구현

    combinator (State (a,c)) f = 
      let State (res1, newc) = f a 
       in if (abs (a - res1)) > 10 then State (res1 * c, newc * 0.5)
                                   else State (res1 * c, newc * 2)
    ↩︎
Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com