티스토리 뷰
순수 함수형 언어의 순수 함수로 이뤄진 프로그래밍에서 부수효과 Side Effect를 다루는데 어떻게 Monad가 접목되는지 간단한 개념을 짚어봅니다.
프로그램은 외부 저장소에서 데이터를 읽어 새로 기록하기 위해 존재한다고도 하였습니다.
프로그램 입장에서 기록이라 함은 쓰기(Write) 연산이며, 쓰기 대상은 메모리, 모니터, 프린터, 디스크, 테이프 등 다앙합니다.
이러한 요소들은 모두 외부 요소들이며 정의에 따라 이러한 요소들과 상호작용하는 메서드, 함수는 '부수 효과(Side effect)'를 발생시킨다고 부르고 이를 따로 리턴 타입이 없는 함수, 프로시저라 부르기도 합니다.
그리고 실제로 이런 부수 효과를 갖는 함수/메서드를 저희는 매우 자주 사용하고 있습니다.
지금까지의 컴퓨터 언어에서 이러한 사항은 사실 큰 제약이 아니었으나 순수 함수만을 정의하는 함수형 언어에서는 이러한 부수 효과를 함수 자체로 정의하지 못하는 매우 큰 제약이 발생합니다.
그러나 이를 다루기 위한 방법론이 존재하였으며 이를 다루는 방식이 Monad 개념과 결부하여 어떤 식으로 처리되고 따라서 왜 Monad 가 부수 효과를 다루는 방법이라 불리는지에 대해 같이 고찰해 보고자 합니다.
매우 긴 서론
디버그
정수형 값을 받아 1을 더하여 출력하는 x → x+1 함수 f 와 정수형 값을 받아 2를 빼고 출력하는 함수 g : y → y - 2 가 있다고 가정합니다.
함수가 호출되고 잘 호출되었는지를 확인하고 싶습니다. 보통의 경우 로깅 메서드를 넣어서 화면에 값을 출력하거나 파일에 중간 결과를 쓺으로써 디버깅이 가능합니다.
하지만 함수형은 아닙니다. 함수가 영향을 미칠 수 있는 방법은 리턴값이 유일합니다.
함수가 문자열을 리턴한다면 이를 이용해 디버깅이 가능합니다(함수의 리턴값을 이용해 과정을 확인하는 매커니즘이 존재하는데 이 설명은 생략합니다).
이 경우 할 수 있는 방법은 다른 것이 없습니다. 두 함수 f, g를 변형합니다.
f' : x → (x+1, "f was called")
g' : y → (y-2, "g was called")
이러한 함수는 문자열을 부가적으로 리턴(튜플의 2번째)하기 때문에 '디버깅 가능한 함수(debuggable function)'으로 생각할 수 있습니다.
문제가 생깁니다. 두 함수 f, g는 공역과 치역이 같기 때문에 쉽게 함수 합성이 가능합니다만, f', g'는 불가능합니다. 즉 합성된 결과를 디버깅할 수 없습니다.
이를 해소하기 위하여 다음과 같이 두 함수를 합성하여 결과를 리턴하는 함수를 짜볼 수 있습니다.
H : when (y, s) = g' x, (z, t) = f' y then (z, s ++ t)
public Pair<Integer, String> composeWithX(Function<Integer, Pair<Integer, String>> f, Function<Integer, Pair<Integer, String>> g, Integer x) { Pair<Integer, String> gResult = g.apply(x); Pair<Integer, String> fResult = f.apply(gResult.getFirst()); return Pair.of(fResult.getFirst(), gResult.getSecond() + ", " + fResult.getSecond()); } |
이렇게 될 수 밖에 없던 이유는 새롭게 변형한 함수 f' 의 출력 타입이 g' 의 입력 타입과 맞지 않기 때문입니다.
각설하고 이러한 함수를 매번 새로 선언하는 것은 매우 비효율적이기에,
위 H 를 대신하는 함수 compose 를 도입해 봅니다.
대략 compose f' ( g' x ) 하면 f'와 g'의 합성이 가능하도록 하고 싶습니다.
그렇다면 compose 에 f' 을 적용한 결과의 프로토타입은 아래와 같을 것입니다.
compose f' :: ( int, String ) → ( int, String )
특별한 게 없습니다. compose 에 f' 를 적용한 결과는 (정수, 문자열) 튜플을 받아서 (정수, 문자열) 튜플을 리턴하는 것뿐입니다.
즉, compose 자체는 f' 를 인자로 취하기 때문에 아래와 같이 프로토타입이 추론됩니다.
compose :: ( int → (int, String) ) → ( (int, String) → (int, String) )
이렇게 하면 compose f' ( g' x ) 로 f' 에 g' 의 리턴값을 적용하여 H 와 같이 결과를 리턴하는 것이 가능해집니다.
f', g' 과 시그니처가 같은 (디버깅 가능) 함수가 K', M', N' 등등이 있을 때 아래와 같은 합성이 바로 가능합니다.
compose K' compose M' compose N' compose f' (g' x)
compose f' 의 프로토타입은 ( int, String ) → ( int, String ) 입니다. g' 의 프로토타입은 int → (int, String) 입니다. g' (x) 는 인자 1개 적용하였으므로 그 리턴값이 (int, String) 이 될 것이며, 이는 compose f' 의 인자 타입과 정확히 일치합니다. 따라서 대입이 가능합니다. compose f' g' (x) 는 또한 리턴 값으로 (int, String) 이 됩니다. compose N' 의 프로토타입 또한 (int, String) → (int, String) 이며, compose f' g' (x) 의 리턴값인 (int, String) 과 인자 타입이 정확히 일치하므로 대입이 가능합니다. compose M', compose K' 도 마찬가지 원리로 인하여 compose K' compose M' compose N' compose f' (g' x) 의 합성이 가능합니다. |
다중 값 함수
제곱근, 세제곱근을 구하는 함수가 있습니다.
보통 sqrt, cbrt 로 정의됩니다.
4의 제곱근은 2, -2 로 2개의 값을 가지며 8의 세제곱근은 2, −1 + √3i , −1 − √3i 의 3개가 됩니다.
두 함수의 프로토타입을 써 보자면 다음과 같습니다.
sqrt :: Complex Float(복소수) → [Complex Float]
cbrt :: Complex Float(복소수) → [Complex Float]
여기서 6제곱근을 얻으려면 정의에 따르면 아래와 같이 하면 됩니다.
sqrt ( cbrt x )
하지만 불가능합니다. 리턴값이 입력과 같지 않기 때문입니다.
동일하게 이것도 합성하도록 하는 함수 compose 를 정의해 봅니다.
compose sqrt ( cbrt x ) 형태로 합성하기를 원하기 때문에 다음과 compose sqrt 를 다음과 같이 생각해 볼 수 있고,
compose sqrt :: [Complex Float] → [Complex Float]
따라서 compose 는
compose :: (Compose Float → [Complex Float]) → ( [Complex Float] → [Complex Float] )
가 됩니다(구현은 Java List Monad, Flux flatmap implmentation, Stream flatmap 과 같습니다).
이 경우도 같은 프로토 타입 (Complex Float → [Complex Float]) 을 갖는 함수 Q', W', E' 에 대해
compose Q' compose W' compose E' compose sqrt ( cbrt x ) 와 같이 합성이 가능합니다.
랜덤 함수
랜덤 값을 출력하는 random 함수가 있습니다.
아시다시피 컴퓨터에서의 난수는 Seed 를 기준으로 생성됩니다.
따라서 함수형 언어에서 순수 함수의 random 함수는 다음과 같이 정의할 수 있습니다.
random :: Seed → ( random number, Seed' )
Seed 가 같으면 항상 같은 난수가 생성되기에 리턴된 Seed는 1회 난수가 생성된 상태의 시드로 다른 Seed 인 것을 나타내기 위해 Seed' 로 표기되었습니다. 이로써 같은 입력에 같은 결과가 리턴되므로 random 함수는 순수 함수임이 자명합니다.
특정 함수 f 가 있습니다.
f :: int → int
정수를 받아 정수를 리턴합니다. 이때 f 가 랜덤 함수를 이용한다고 가정합니다. 즉 정수를 받아 랜덤함수를 통해 상응하는 어떤 정수를 리턴하도록 만든다 가정합니다. Seed는 반드시 외부에서 전달되어야 하기 때문에 필연적으로 Seed 가 전달되어야 하며 그 변형은
f' :: int → Seed → (int, Seed)
가 됩니다.
g' :: int → Seed → (int, Seed) 가 있을 때, 두 함수를 합성하기 위해 compose를 같은 방식으로 고민해 봅니다.
인자가 2개이므로 아래와 같이 정의되어야 합성이 가능합니다.
compose f' :: ( seed → (int, seed) ) → ( seed → (int, seed) )
그렇다면 결론적으로
compose :: (int → Seed → (int, Seed)) → (Seed → (int, Seed)) → (Seed → (int, Seed))
가 됩니다.
본론
서론에서 살핀 내용에서 각 함수별 리턴 타입을 아래와 같이 정의해서 단축 표기를 하기로 정해봅니다.
'디버그 기능 함수' D a = (value a, String)
'다중값 함수' M a = [value a]
'랜덤 적용 함수' R a = Seed → (value a, Seed)
이 D, M, R은 Debuggable, Multivalued, Randomized 의 앞글자에서 따왔습니다.
각각이 아래와 같으므로
이걸 이용해 위에서 함수를 합성할 때 겪은 문제를 다시 정리해보겠습니다.
디버그에서
f :: a → b 함수는 디버깅을 위해 f 가 f' :: a → D b 로 변환되었고 이렇게 변환된 함수간의 합성을 위해 combine 을 정의했습니다. 이 때 combine 은 ( a → D b ) → ( D a → D b ) // ( int → (int, String) ) → ( (int, String) → (int, String) )
다중값에서는
sbrt 및 cbrt의 프로토타입이 :: a → M b 의 형태이기 때문에 이러한 함수간의 합성을 위해 combine 을 정의했습니다. 이 때 combine 은 ( a → M b ) → ( M a → M b ) // (Compose Float → [Complex Float]) → ( [Complex Float] → [Complex Float] )
랜덤값에서는
f :: a → b 가 f' :: a → R b 형태로 변환되었고 이렇게 변환된 함수간의 합성을 위해 combine 을 정의했습니다. 이 때 combine 은 ( a → R b ) → ( R a → R b ) // (int → Seed → (int, Seed)) → (Seed → (int, Seed)) → (Seed → (int, Seed))
로 살펴볼 수 있고 결과적으로 D, M, R 을 M 하나로 통일하면
모두 :: a → M b 형태의 함수를 합성하기 위해 ( a → M b ) → ( M a → M b ) 가 나타난 것을 확인할 수 있습니다.
저희가 아는 모나드의 정의 표현식은 아래와 같습니다.
when M is monad, bind is defined as (M a) → (a → M b) → (M b)
( a → M b ) → ( M a → M b ) 를 커링하면 ( a → M b ) → M a → M b 로 바꿔 쓸 수 있고 스칼라나 하스켈 같은 함수형 언어에서는 다음과 같은 문법 설탕을 지원합니다. 그리고 이는 일부 언어의 메서드 호출에서 나타나는 흐름과 같습니다.
문법 설탕 : 함수 Add = ( x , y ) → x + y 가 있을 때 Add 3 5 로 호출할 수 있는데 3 Add 5 로 호출 가능합니다.
Java : 특별히 정의된 정수 타입에 대해 new CustomNumber(3).add(new CustomNumber(5))
(즉 중위 표기법입니다)
이 경우 ( a → M b ) → M a → M b 는 M a → ( a → M b ) → M b 로 순서를 바꿔 쓸 수 있으며 저희가 본 모나드의 정의와 일치합니다.
추가로 f :: a → b 가 f :: a → (a, String) 이 된다거나, a → [a] 가 된다거나 a → Seed → (a, Seed) 가 되는 과정이 공통적으로 존재합니다.
이는 아래와 같이 프로토타입이 정의되는 switching 함수를 통해 진행 가능합니다.
디버그에서
switching :: a → (a, String) = a → D a
다중값에서
switching :: a → [a] = a → M a
랜덤값에서
switching :: a → Seed → (a, Seed) = a → R a
이렇게 주어지면 f :: a → b 에 switching ( f ) 와 같이 switching을 적용하여 f' :: a → M b 를 만들 수 있습니다.
여기서 동일한 포맷을 띄는 것을 알 수 있는데 모두 a → M a 형태입니다.
이는 모나드의 return 함수 :: a → M a 와 정확히 일치합니다.
결론
부수 효과
IO 작업은 거의 필수불가결입니다. 그러나 이런 작업은 전부 외부 시스템과의 상호작용이기 때문에 부수 효과(Side effect) 입니다.
순수 함수에서 이를 다루기 위해 Randomized 에서 한것과 유사하게 외부 계(World, System) 을 별도로 정의하는 작업부터 진행하게 됩니다.
가령 문자열을 받아 외부 Disk 에 쓰고 시스템에 총 몇 바이트가 쓰였는지를 리턴하는 함수가 f 가 있다고 하면 일반적으로
f :: String → int 가 됩니다.
이를
f' :: String → System → (int, System') 과 같이 변형하여(이를 Impurification 이라 부르기도 하며, 아까의 return 함수를 통해 변형합니다), 외부 시스템 자체를 매개변수로 취급하도록 변형하는 식입니다.
이 경우 함수가 호출될 때마다 System 은 변경되어 System'이 되며(문자열이 기록된 상태로 변경), 같은 System 상태에선 함수는 같은 결과만을 내놓게 되는 함수, 즉 순수 함수가 됩니다.
이렇게 IO 작업을 수행하는 순수 함수를 만들었다면 함수형 프로그래밍에서는 이러한 함수들의 합성을 통해 시스템을 구축할 수 있게 됩니다. 이런 IO를 IO 모나드라고 부릅니다.
이러한 함수들을 합성하는 방법론을 제공하는 매커니즘은 위 서론에서 살펴본 바와 같은 맥락에서 아이디어를 얻어왔으며 이 결과로 Monad라는 개념이 실제 데이터에 함수를 일반적으로 적용하는 관점에서 벗어나, 부수 효과를 다루는 하나의 방법론을 제공하는 측면에서의 역할 또한 있고 그 과정이 어떠한 맥락에 있었을지를 고찰해 볼 수 있습니다.
참고 : https://stackoverflow.com/questions/2488646/why-are-side-effects-modeled-as-monads-in-haskell
http://learnyouahaskell.com/a-fistful-of-monads
https://www.cs.princeton.edu/~dpw/courses/cos326-12/notes/reasoning.php
http://blog.sigfpe.com/2006/08/you-could-have-invented-monads-and.html