모나드 액션들간의 소통 - 상태 "개념"은 함수형에도 있다.

Posted on May 18, 2021

부제 : 하스켈에서 a -> m b가 중요한 이유

“프로그램 전체가 같은 타입 함수들을 엮은 형태가 되어야 한다”

어떻게 그럴 수 있을까요? 간단한 프로그램만 예를 들어도 불가능할 것 같은 얘기처럼 들립니다.

정수를 입력 받아 두 배해서 보여주는 프로그램

입력 —> IO 모나드
계산
출력 —> IO 모나드

계산은 IO모나드가 아닙니다. 어떻게 이 체인안에 들어가 있을 수 있을까요? 계산 함수는 IO 바인드로 묶지 못합니다. 결과 그대로는 묶지 못하지만 방법이 있습니다.

main = do
    v <- getLine
    putStrLn $ show $ (read v) * 2

계산 함수 (*)IO 모나드 체인에서 쓸 수 있는 IO 모나드 타입 함수가 아닙니다. 어떻게 모나드 체인으로 들어오는지 보이나요? putStrLn은 문자열을 받아 IO () 타입으로 바꿔주는, 다른 말로 매핑해주는 함수입니다. 계산 함수 (*)의 결과값을 show 함수를 통해 String으로 바꾼 후 putStrLn에 넘겨져서 모나드 체인으로 들어오는 모양입니다. 항상 모든 작업 절차를 IO로 바꿔야 하는 걸까요?

입력
2배 계산
출력
입력
3배 계산
출력
모두 더해서 출력

모든 계산의 결과를 각 각 출력하려면 개별적으로 putStrLn을 거쳐 IO ()로 바꿔서 체인에 참여합니다. 그런데, 모두 더해서 출력 부분이 마음에 걸립니다. 하스켈은 상태가 없는데 어떻게 두 번의 입력을 어딘가에 모아 두었다가 모두 더해서 출력하는 게 가능할까요?

처음 보면, 함수의 연결을 독립적인 절차끼리 출력을 입력으로 받는 것 쯤으로 오해합니다. 숨어 있는 바인드를 풀어 보겠습니다.

main =      getLine 
        >>= \v1 -> putStrLn (show (((read v1)::Int) * 2))
        >>= \_ -> getLine
        >>= \v2 -> putStrLn (show (((read v2)::Int) * 3))

v1과 v2를 합하려면 다음을 끝에 추가하면 됩니다.

        >>= \_ -> print ( ((read v1)::Int) * 2 + ((read v2)::Int) * 3  )

또 다시 (*) 계산을 해야 합니다. 역시나 상태를 저장할 수 없어 이렇게 계산을 또 해야만 할까요?

main =      getLine 
        >>= \v1str -> return (((read v1str)::Int) * 2)
        >>= \v1int -> putStrLn (show v1int)
        >>= \_ -> getLine 
        >>= \v2str -> return (((read v2str)::Int) * 3)
        >>= \v2int -> putStrLn (show v2int)
        >>= \_ -> putStrLn $ v1str <> " * 2 + " <> v2str <> " * 3 = " <> (show $ v1int + v2int)

눈여겨 볼 게 두 가지입니다.

  1. 계산과 출력putStrLn 과정을 분리했는데, 계산 결과를 IO 체인에서 잡아두기 위해 return으로 IO 값으로 만들었습니다.
  2. 람다 변수(람다식 헤드에 있는 매개 변수)가 상태 역할을 합니다. 각 라인의 람다 변수는 바로 이전 액션의 결과값을 받습니다. 액션의 결과는 람다 변수에 넣어 두면 체인이 끝날때까지 잡아 둘 수 있습니다. 마치 이 체인내에서는 전역 변수같은 역할을 합니다. 이 다음에 연결하는 액션이 있다면 \v1str, \v1int, \v2str, \v2int를 모두 쓸 수 있습니다.

정리하면, 언제든 IO 체인에서 필요한 값은 return으로 감싸서 람다 변수에 넣어 놓으면 됩니다.

위에 >>= 로 구분되어 있는 한 줄 한 줄은 각각 a -> m b 타입의 함수들입니다. 바로 이 이유 때문에 하스켈에서는 a -> m b 타입의 함수가 중요합니다.

아예 이런 테크닉을 신경쓰지 않고 람다와 모나드가 익숙해지면 당연한 것으로 받아들이는 사고 형태로 훈련이 되어야 할 것 같은데, 명령형에서 넘어 오다 보면 쉬운 일이 아닙니다. 저는 처음 상태 없이 프로그래밍 한다고 했을 때, 어떤 아이디어로 그게 가능한지 정말 궁금했습니다. 제가 생각하는 “상태”는 개념이었고, 함수형에서 없다고 하는 “상태”는 명령형에서 값을 변수(메모리)에 지정(배정)해서 가지고 있는 기능을 얘기하는 거였습니다.

곰곰히 생각하니, 알론조 처치도 같은 이유에서 람다 변수의 스코프를 정하지 않았을까요? 함수형이라도 수학처럼 입력값과 출력을 대응만 한다면, 구조를 만들기가 어려워 함수를 엮으면서 어딘가에는 정보를 둘 곳이 필요하지 않았을까 하는 추측을 해봅니다.

생각이 여기에 다다르니, 대부분 익숙해서 뜻을 찾아본 적도 없을 것 같은 단어, ’구조’가 뭘까란 생각이 듭니다. 바로 이전 함수와만 관계를 갖는 것도 구조고(1대1 구조라 이름 붙이겠습니다.), 여러 함수들의 상태를 어딘가에 두었다가 가져다 쓸 수 있도록 해서 여러 함수와 관계를 만들어 내는 것도 구조(다 대 1)입니다. 위에서 구조를 만들기 어려웠다라고 얘기한 부분의 구조는 다 대 1 구조를 말합니다. 람다 변수를 이용해서 다 대 1 구조를 만들어, 여러 함수들을 이용해 복잡한 구조를 만드는게 가능하다란 말입니다. 람다 대수 정식 텍스트에는 이런 언급이 있는지 궁금합니다.

2022.2.12 추가
하스켈을 컴파일할 때 바이너리로 가기전 중간 언어 Core로 뱉어낸 소스를 보면서 람다식을 이렇게 쓰는구나 하는 느낌이 들었습니다. Core는 haskell의 이런 저런 문법을 웬만하면 Case와 람다식으로 풉니다. 필요한 정보들은 타입이 됐든, 값이 됐든 모두 람다 변수에 넣어 놓으면서 람다로 감싸고 감쌉니다.

구조 構造 Structure 꾸밈새 만듦새
네이버 사전 뜻 : 각 부분(部分)이나 요소(要素)들을 모아 어떤 전체(全體)를 짜 이룸.
구글 사전 뜻 : the arrangement of and relations between the parts or elements of something complex.
영어 해설 쪽에 제가 생각한 명확한 뜻이 보입니다. 구성원(요소,원소…)들간에 어떤 관계를 가지고 있느냐를 구조라 말합니다.

람다 변수를 이용해 함수들간의 구조를 만들어 가는게 함수형 프로그래밍입니다. 람다 변수는 람다 함수를 명시적으로 써서 드러나는 경우도 있고, 이름 있는 함수들의 인자로 가려져 있는 경우도 있습니다.

다른 포스트에서도 강조, 또 강조했지만 람다 변수 개념이 함수형 구조를 만드는 근간(또는 도구)입니다.

Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com