Effect와 모나드 시리즈
결국, 모나드로 가는 발판으로 삼으려고 쓰는 글이지만, 이 글만 보면 Effect 감을 잡는게 목표입니다. 무엇을 Effect라 하고, Effect를 해결하는 코드 모양은 어떻게 나올 수 있는가를 보려 합니다.
계산이 실패하면 무슨 일이 생길까요? 계산이 성공하거나 실패한 건, 그 계산 이후 아무 일도 할 필요가 없다면 의미 없는 정보일 뿐입니다. 다음 함수가 받아야 비로소 의미가 생깁니다. 이전 함수의 성공 결과와 실패를 다음 함수가 받아야 의미가 있습니다. ※ 다음 함수로 넘기지 않고, 우리가 답을 확인한다면, 그 행위도 다음 이어지는 작업 중 하나입니다.
f1 :: Int -> Int
f2 :: Int -> Int
이런 함수가 있는데, f1
도 f2
도 실패를 결과로 가질 수 있는 상황입니다. 실패를 표현하기 위해, 새로운 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
이제, 이 함수만 있으면 f1
과 f2
를 붙일 수 있습니다. f1
과 f2
의 서명 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를 유지하며 계산을 해야 되는 상황을 보겠습니다. 첫 번째 계산이 결과와 별도로 상태를 바꿨는데, 그 상태를 아무도 가져다 쓰지 않으면 의미 없는 정보일 뿐입니다. 상태 값은 다음 상태나 계산의 동작에 영향을 미쳐야 비로소 의미가 생깁니다.
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
첫 번째 예시 Maybe
와 State
의 다른 점을 확인해 보시기 바랍니다. Maybe
예시와 State
예시의 공통점을 인지하고, Effect가 뭔지 감을 잡는 게 이 글의 목표입니다. 둘 다 함수 서명에 드러나지 않는 Effect를 combinator를 통해 합성하며 해결하고 있습니다.
아직, 위의 예시 둘 다 모나드는 아닙니다. (두 번째 예시는 모나드 법칙을 만족 못해 모나드 클래스의 인스턴스로 만들지는 못합니다.) 여기서는 모나드 없이 Effect를 개별적으로 어떻게 해결하는지를 보는 게 목적입니다. 모나드를 도입 전에도 Effect 개념은 있었으며, Effect 훈련이 충분히 된 상태에서 모나드를 만났을 것이다란 상상입니다. 그냥, 천재여서 훈련이 필요 없었을 가능성도 물론 존재하지만요.
※ 나중 마지막 글에 넣을 최종 결론을 일부 먼저 살펴보면, 위 얘기에 하스켈의 모나드 “클래스”를 쓰지는 않았지만, 모나드 “개념”은 이미 존재합니다. Effect가 모나드고, 모나드가 Effect입니다. 같은 개념, 대상, 상황을 서로 다른 학문에서 기술했다고 볼 수 있습니다. 프로그래밍에서는 Effect란 말을 쓰고, 수학에서는 Monad란 말을 썼을 뿐, 두 개가 지칭하는 개념은 서로 같습니다.
이전 함수의 계산 결과가
성공하면 Just 200.0
같은 값일테니, 다음 함수에 Just
없이 200.0
을 넘겨 주면 되고
실패하면 Nothing
이어서 다음 함수 f2
는 받을 수 없으니, f2
를 거치지 않고, 그냥 Nothing
을 돌려주면 됩니다.
Maybe combinator
구현
= case mv of
combinator mv f Just v -> f v
Nothing -> Nothing
Compensation
을 분리해서 잠시 보관하고, f2
에 넘겨 계산한 후, 분리해놨던 Compensation
을 적용합니다. 그 뒤, f2
가 계산하며 새로 만들어 낸 Compensation
값과 함께 State 계산값 Compensation
형태로 돌려주면 됩니다.(아직 아래 예시는 문제가 있습니다. 모나드 Associative law를 만족하지 못합니다.)
State combinator
구현
State (a,c)) f =
combinator (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)