미리 말하지만, 이론만 다룰 거라 그리 쉽지 않은 내용임.
그리고 모나드를 설명하기엔 하스켈만한 언어가 없기 때문에 난 F#을 좋아하지만 이번엔 하스켈로 설명함.
모나드를 제대로 이해하려면 꽤 깊이 내려가서 시작해야 한다.
1. 순수 함수와 함수형 패러다임
순수 함수라는 것은 모든 입력이 인자로 선언되고, 역시 모든 출력이 다 리턴 타입으로 선언된 함수를 뜻함.
반대로 숨겨진 입력이나 출력이 있는 함수는 순수하지 않은 함수이며, 그 숨겨진 입출력값을 side-effect라고 부름.
얘는 함수 내부에서 cin을 통해 입력을 또 받고 있어서 pure한 함수가 아니다.
이 함수를 pure하게 만드려면 숨겨진 입력값을 없애야 하므로 cin 대신 인자로 두 번째 값을 받도록 해야 한다.
이렇게 고치면 이제 숨겨진 입출력 값이 없기 때문에 pure하다.
이렇게 pure하게 고쳐서 얻을 수 있는 이득이 뭘까? 가장 큰 이점은 테스트하기 용이하다는 것이다.
pure한 함수는 마치 수학에서 다루는 함수처럼 모든 입력을 넣으면 정해진 출력값이 튀어나오는 블랙박스고,
따라서 모든 입력값을 통제할 수 있고 리턴값도 예측이 가능하기 때문에 디버깅 난이도가 상당히 내려간다.
그리고 함수형은 모든 함수를 pure하게 작성하는 것을 지향하는 패러다임이고,
side-effect가 없게끔 구성하여 모든 코드가 단지 입력과 출력 간의 상관관계만 기술하도록 하는 게 이상적인 함수형 코딩임.
왜 그래야 하느냐? 함수형 패러다임은 함수를 이리저리 굴려서 재사용을 많이 하게 되는데, 그렇게 많이 굴리는 함수가 pure하지 않다면 언제 어디서 무슨 문제가 발생할지 모르는 잠재적인 위험성을 갖고 있기 때문.
2. 필수불가결한 side-effect
... 그래서 저렇게 pure한 함수로만 코딩이 되면 참 좋겠지만, 안타깝게도 실제로 코딩을 해보면 저게 안되는 경우가 상당히 많음.
가장 쉬운 예시는 로그다. 만약에 어떤 함수 f가 잘 실행되었는지 확인하고 싶으면 어떻게 해야 할까?
디버거로 한줄씩 따라가는게 아니라면 보통 로그를 출력하도록 할 거임.
그런데 함수형은 여기서 문제가 생긴다. 로그를 출력하는 것도 엄연히 함수에서 수행하는 출력이기 때문에,
pure function을 유지하기 위해서는 함수가 바로 콘솔이나 파일에 로그를 쓰는게 아니라 로그로 출력할 그 내용도 리턴해야 함.
그렇기 때문에 단순하게 int를 받아 int를 리턴하는 함수더라도 다음과 같은 형태로 함수의 타입을 짜야 한다.
그런데 이러면 문제가 생긴다. 만일 동일하게 int -> (int, string)인 함수 g가 있다고 해보자.
로그를 출력하건 뭘 하건 결국 본질적으론 정수를 1개 받아 정수를 리턴하는 함수이기 때문에, f로 얻은 값을 g로 바로 집어넣어도 문제가 없어야 함.
그런데 로그때문에 f의 리턴값은 더 이상 int가 아니라 (int, string)이고, 따라서 이걸 바로 g로 집어넣을 수는 없게 되었다.
그렇다고 g의 인자를 수정하는건 더 말도 안되는 짓임. 얘가 필요한건 정수 1개일 뿐이고 남의 로그 내용을 받아줄 이유가 없다.
따라서 f와 g 사이의 중간다리 역할을 해줄 함수 compose를 하나 더 만들어야 한다.
좀 더 구체적으로 말하면, (int -> (int, string)) 타입의 함수 f를 인자로 받아야 하고, 그렇게 (compose f)를 계산해서 나올 것은 (int, string)을 받아 (int, string)을 리턴하는 함수일 것이다.
그럼 이 compose 함수의 타입은 어떻게 되어야 할까?
복잡해 보이지만 위의 내용을 그대로 옮긴 것 뿐이다. (int -> (int, string))을 인자로 받아 ((int, string) -> (int, string))을 리턴하는 함수.
이런 식으로 짜면 동일한 int -> (int, string)함수 f, g, h, i, ... 등등에 대해서
compose f (compose g (compose h (i x)))처럼 계속 합성함수를 이어줄 수 있게 되어 함수형 패러다임을 만족할 수 있게 된다.
위의 예시보다 조금 복잡한 케이스도 하나 더 보자.
잘 알다시피, 완벽한 랜덤은 구현이 불가능하고 시드값을 이용한 의사난수를 구하는 식임. 따라서 랜덤 함수를 짜려면 시드값을 받아야 한다.
함수형은 pure 함수를 지향하기 때문에 당연히 시드값도 외부에서 받아와야겠지? 리턴값 또한 그 시드를 리턴해야 함.
그렇기 때문에 랜덤함수의 타입은 이렇게 될 거임. 얘는 숨기는 정보가 없기 때문에 완벽한 pure function이다.
여기까지만 보면 문제가 없지만 여기서 간단한 변형을 가해보자. 만일 저 random 함수가 정수 한개를 같이 받아서 그에 상응하는 랜덤값을 뱉어야 한다면?
당연히 같이 받아야 하는 정수도 인자에 포함시켜야 하므로 이 함수의 타입은 이렇게 된다.
이제 위에서 했던 것처럼, 저런 함수들끼리의 합성을 생각해보자.
조금만 고민해보면 이 케이스에서 compose 함수의 타입은 이렇게 나온다는 사실을 알 수 있다.
3. Monad
위에서 다룬 저 삽질들을 결과만 갖고와보자.
int -> (int, string) 함수의 compose는 (int -> (int, string)) -> (int, string) -> (int, string)이며,
int -> seed -> (int, seed) 함수의 compose는 (int -> seed -> (int, seed)) -> ((seed -> (int, seed)) -> (seed -> (int, seed)))이다.
잘 보면 공통점을 찾을 수 있음.
원래 함수를 a -> M a라고 하면, 이걸 compose하기 위한 함수는 (a -> M b) -> (M a -> M b)가 됨.
그리고 이건 (a -> M b) -> M a -> M b로 다시 쓸 수 있고, 이는 다시 M a -> (a -> M b) -> M b로 바꿔 쓸 수 있다.
즉, 여기서 얻은 두 가지 함수의 의미는 다음과 같음.
1) a -> M a : 원래 정보 a에 부가정보 M을 붙일 수 있음.
2) M a -> (a -> M b) -> M b : 부가정보가 붙은 정보 M a를 부가정보가 붙은 다른 정보 M b로 변환할 수 있음.
여기서 M이라는건 물론 함수에 따라 의미가 다름. 디버깅 예시에서는 로그 내용이 되겠고, 랜덤함수 예시에서는 시드값으로 랜덤값을 리턴하는 함수를 뜻할 거임.
중요한 점은, 이 함수가 원래 해야 하는 일 외에 추가적으로 생길 수 밖에 없는 side-effect를 이런 식으로 묶어서 관리할 수 있다는 점임.
즉, 순수한 함수형 패러다임을 추구하는 상황에서도,
1) 데이터에 부가 정보를 끼워서 묶을 수 있으며, (a -> M a 함수)
2) 그 부가정보를 유지한 채로 다른 함수에 자유롭게 사용할 수 있다. (compose 함수)
이게 모나드임.
Maybe, Try, IO 같은 것들은 자주 쓰일 법한 부가 정보를 미리 정의해 둔 것이고, 필요하다면 얼마든지 새 모나드를 만들어서 써먹을 수 있다.
모나드 이야기가 보이길래 반가워서 대충 몇글자 끄적임.
+) 모나드 공부하는데 하스켈이 좋은건, 하스켈만 순수한 함수형 언어이기 때문임. 가령 F#만 해도 IO라는 명확한 side-effect를 printfn으로 함수 내에서 그냥 쓸 수 있음.
함수형 안 하는 사람한테 하스켈로 설명하는 건 사실상 설명 포기하는 거임;
하스켈 코드래봤자 타입선언만 가져다쓴건데 그래도?
타입만 나열해서 설명할때는 오히려 하스켈이 젤 명료한거 같은데...
설명잘해놨는데 모나드없는 근로저 유저라 화난듯 ㅋㅋ
함수형 1도 몰라도 뭔 소리 하는 건지는 알 수 있게 써놨는데..
더 연재해줘
야 재밌다 초보자용으로 더 연재해줘
1,2 사진 바뀐거아니노
수정함 ㄳ
선생님 함수 합성에서 타입 변하는 매커니즘이 이해가질 않아요
저건 전위표기법을 중위표기법으로 바꾼거임. (a -> M b) -> M a -> M b만 놓고 보면 (a -> M b)라는 함수에 M a와 M b라는 인자를 받은 형태인데, 그걸 M a라는 인자를 앞으로 빼서 중위표기법으로 고친 것. (int -> int) -> int -> int인 (+ 3 5)를 int -> (int -> int) -> int인 (3 + 5)로 바꾼 거랑 동일함.
꺼마워양
더 연재해주세요 선생님 - dc App
이 댓글은 게시물 작성자가 삭제하였습니다.
해당 댓글은 삭제되었습니다.
닉언자제
모나드 빡세네
-- "(int, string)을 받아 (int, string)을 리턴하는 함수" --이 조건을 만족하는 함수가 엔도펑터 카테고리의 함수고, 자기자신이 항등원이니까 모노이드 조건도 만족하는거죠?
저 예시에서 (int, string) -> (int, string) 함수가 endofunctor에 속하는 것도 맞고, 항등원이 있으니 저 체계는 모노이드에 속하는 것도 맞워요. 사실 저거 때문에 모든 모나드는 이미 모노이드를 만족함.
어려워요 - dc App
모나드가 어렵나.. 모나드를 넘어서 모나드 스택, 렌즈를 만나면 모나드가 양반처럼 보일것
꼰
개추
ㅠㅠ모르겟서요
F#
순수한 함수형 언어에서 중요한건 값을 얻어내는 경로는 무조건 실행 주체와 직접적으로 관련있어야 한다는 건가