일주일 사이로 두 개의 Apply 글을 올렸습니다.
출처 Wikimedia Commons - Jeff Beck in Amsterdam 1979
오래 전에 타브 악보를 보고, 더듬 더듬 곡을 연습하다가, 이렇게 하는 게 아닌데란 생각이 들었었습니다. 원곡자들은 오랫동안 베어 있는 자신의 멜로디 습관이나, 익숙한 손놀림등을 기반으로 나온 연주일텐데, 그 걸 기본기 없이 따라해서 내 것이 될까하는 의문이었습니다. 아마도, 시간 투자를 많이한다면, 언젠가는 내 것이 될지도 모르지만, 더 효율적인 방법은 스케일 연습같은 것들을 먼저하고, 조금씩 곡 해석을 하며 내 것으로 만드는 게 더 빠르지 않을까 생각했었습니다.
함수형 프로그래밍도 비슷하게 접근하고 있습니다. 절차형에 찌든 사람이 느끼는 함수형의 이 독특한 패턴들을 떠올리고 구현하기까지, 만든 사람들은 분명 하루 아침에 순간적으로 만든 것이 아닌, 그들이 지나온 생각의 길이 있었을텐데요. 물론, 천재들이 걸어 온 길을 나도 아무 정보 없이 찾아내겠다는 아니고, 적어도 도착지는 알고 있는 상태니 역으로 추적해보는 생각 놀이를 자주합니다.
이 글을 읽는다고, 함수형이 갑자기 편해지거나, 무슨 태초의 원리를 알게된다거나 하는 글은 당연히 아닙니다. 아직 검증되지 않은, 이런 생각 길도 있지 않았을까 정도의 글일 뿐입니다. 주의해서 보세요. 아직 Apply만 따로 떼어 이렇게 설명하는 문서는 본적이 없으니, 제가 틀린 눈을 가졌을 수 있습니다.
이미 함수형이 네이티브인 분들한테는 필요한 해석이 아닐 수 있습니다. 절차형에 쩔은 사람이 함수형으로 넘어가는 중에 정리한 해석입니다. 요즘은 Apply
에 꽂혀 있습니다. 그동안 Apply
의 강력함을 왜 눈치 못챘나 싶습니다. 언젠가 더 진도가 나가면 또 바뀔지 모르지만, 지금은 함수형에서 원톱 주인공이 Apply
로 보입니다.
※ 아래 나오는 코드들은 실제 구현과 다르고, 실제 동작하지 않는 의사 코드입니다.
함수가 필요한 정보를 값으로 넘기지 않고, 함수로 넘기면 어떤 효과가 날까요?
예를 들어 다음과 같은 경우
func1 :: Int -> Int
= arg + 1 func1 arg
인데, arg
를 값으로 넘기지 않고, 일부 정보가 없어, 아직 값이 안된 함수인 채로 넘긴다면,
-- func2 :: (Int -> Int) -> Int
-- func2 f = f x + 1
-- ^^^
-- 뭔지 모를 x에 '적용'하고 있다.
나중에 함수를 적용(실행)해야 값을 얻으니, 인자가 필요하고, 이를 x
라 하면 이는 나중에 무언가와 바인딩 될 값이니, 최종 아래와 같은 모양이 됩니다.
func3 :: (Int -> Int) -> Int -> Int
= \x -> f x + 1 func3 f
arg
란 값을 받아 +1
하는 작업이었는데, arg
를 함수로 만들어 넘겨서, 나중에 외부에서 개입할 수 있는 여지 x
를 남길 수 있게 됐습니다. 예를 들어 100
원을 넘겨 101
원이 되는 작업이었는데, 100
원이 아니라 \x -> 100 * x
같은 것들을 넘기고, x
를 통해 미래1에 (환율 같은 것들을 적용해) 값을 바꿔줄 수 있습니다. 값을 넘길 때 미리 했어야 하는 작업을, 당장은 모르니 뒤로 미뤘다고 볼 수도 있습니다. 당장은 모르는 것들은 그대로 두고, 구조를 만들어 가는 것이 함수형의 특징중 하나입니다.
실제로 작업이 일어나진 않고, 작업을 모아둔 상태입니다. 값들에 고정적으로 할 일
과 (+1)
을 하겠다는 걸 모아 두기만 했습니다.
func3
구현에 보면, f x
가 보입니다. 이렇게, 함수를 적용Apply하는 걸, 또 다른 함수가 담당하게 만들어 두는 건 굉장한 유연성을 줍니다. 이 글은, Apply를 이리 저리 정의해서 어떤 효과가 있는가를 보는 글입니다.
여기서 주연과 조연을 바꿔 보겠습니다. (+1)
하는데 추가로 값들에 고정적으로 할 일
을 하는 게 아니라, 값들에 어떤 일
을 하는데, 추가로 (+1)
을 하는 걸로 바꿔서 바라 보겠습니다.
위 func3
이 하는 일을 보면, 별로 특별할 것 없는 적용 Apply
\x -> f x
입니다. 잘 보면 이 때, Apply
전이나 후에 추가 작업을 할 수 있습니다. 위의 예에서 + 1
처럼 말입니다. 한 마디로, 특별한 Apply
를 만들어 두고, 항상 해야 되는 작업을 심어둘 수 있습니다. 어떤 함수든 이 Apply
를 거치면, 항상해야 되는 절차 (+1)
이 진행됩니다.
추가 작업을 둘 수 있다라는 속성이, 지금 보기에는(저의 지금 지식 한계에선) 람다 산법의 근간이 되는, 즉 함수형의 근간이 되는 굉장히 중요한 속성이지 않을까 생각합니다. (함수형으로 여러 제약을 돌파하는 구현들을 보다 보면, 함수 몸체와 인자를 연결해주는 지점인, 적용이 바로 매직이 일어나는 순간입니다.) 함수들이 공통적으로 해야되는 작업인 컨텍스트가 있다면, 이를 통해 구현할 수 있습니다.
※ 잠깐, 컨텍스트 구현 전에, 눈에 확연히 보이는 장점을 먼저 보겠습니다. 하스켈에선 Apply
정의를 통해 표현(외관)이 간결해지는 효과도 있습니다. 다른 함수형 언어를 몰라, 다른 언어가 얼마나 이쁘게 나오는지는 잘 모릅니다.
적용을 다음과 같이 정의하면,
-- 하스켈에선 함수를 두 인자 사이에 두는 "연산자"로 정의할 수 있다. 중위 표현식이라한다.
<: v :: (Int -> Int) -> Int -> Int
f <: v = f v -- 특별히 하는 일 없이, 그냥 적용
f infixr 오른쪽 우선 결합으로 지정
func1 <: v1
의 결과값은 Int
입니다. 그럼, 이 결과값을 받아, 연이어 실행할 함수들을 아래처럼 써 줄 수 있습니다.
<: func2 <: func1 <: v1 func3
원래 func3 (func2 (func1 v1))
이렇게 써 줘야 되는데, 괄호 없이 예쁘게 바뀌었습니다.
제일 먼저 실행할 함수가 가장 뒤에 나오는 게 불편하다면, 인자 순서를 바꿔서 좀 더 직관적인 모양으로 바꿀 수도 있습니다.
:> f :: Int -> (Int -> Int) -> Int
v infixl 왼쪽 우선 결합으로 지정
이제 다음 처럼 써 줄 수 있습니다.
:> func1 :> func2 :> func3 v1
예쁘지요?. (지금은 에디터의 도움을 받아, 괄호를 다루는 게 특별히 어려운 일은 아니지만, 예전에 단순 메모장으로 Lisp를 하려면 괄호가 하나의 장벽이기도 했습니다.)
이제 함수의 텍스트2와 컨텍스트를 분리해서 볼 준비가 끝났습니다.
예를 들어, func1
, func2
, func3
이 실행할 때, 모두 공통으로 환경값 Env
가 필요하다면, 공통되는 작업인, 환경값을 받아 참조하는 작업을 컨텍스트로 보고 Env
를 유통하는 역할을 하는 추가 작업을 Apply
, 즉 :>
에 심어둘 수 있습니다.Env
의 흐름은 :>
가 담당하는 거지요. 나중에 더 얘기하겠지만, 하스켈에선 타입으로 살짝 가리면, :>
가 컨텍스트를 담당하고 있다는 사실까지도 눈에 안띄게 할 수 있습니다. Apply
를 잘 정의하면, 드라마틱하게 프로그래머 뇌의 수고를 덜어 줄 수 있게 됩니다.
하스켈에선, 특별히 추가 작업이 없는 적용을 func $ arg = func arg (오른쪽 우선 결합)
로 정의해 두었습니다.
람다 산법의 프로그래밍 흐름은 오직 Apply에 의해서 만들어집니다. h (g (f x))
식으로 써서 Apply를 눈치 못챘다면, Apply가 눈에 보이게 $
를 쓰면, h $ g $ f $ x
으로 쓸 수 있습니다. 다른 곳에선 함수의 합성으로 흐름이 만들어진다고 말하곤 하지만, 함수 합성 구현을 보면 안에서 적용이 이루어지고 있습니다. 여기선 콕 찝어서 적용으로 흐름이 만들어진다고 보겠습니다. 흐름을 만들어내는 적용은 다양하게 만들 수 있습니다.
Int -> Int
란 함수들의 흐름을 아래와 같이 표현할 수 있습니다.
$ g $ f $ x h
이제 연이어 적용할 때 추가적인 작업이 필요한 함수들의 흐름을 만들어 보겠습니다.
최대한, 필요한 사전 지식이 없도록 쓰려고 노력 중인데, Maybe
정도는 있어야 말이 풀리는 것 같아, 어쩔 수 없이 간단히 설명하고 넘어 가겠습니다.
Maybe Int
: 정수를 계산하는 흐름을 만들다가 값이 없을 수 있는 경우를 가정해 보겠습니다. 아래 reciprocal
(역수)은 0
이 인자로 넘어 오면 계산할 수 없습니다. 값이 있을 수도 있고, 없을 수도 있는 상황을 하나의 타입으로 모델링 하려면 어떻게 할까요? 다음과 같이 정의합니다.
data Maybe a = Just a | Nothing
값이 있는 경우는 Just 값
으로 표현하고, 값이 없을 때는 Nothing
으로 표현합니다. 이제 값이 있든, 값이 없든 Maybe
타입을 반환한다고 말할 수 있습니다.
reciprocal :: Int -> Maybe Int
= \x -> if x == 0 then Nothing else Just (1 / x) reciprocal
0
을 인자로 넘기면, 1/0
이면 불능이 되니, Maybe
로 결과를 표현했습니다.
이제 (+1)
, (+2)
등과 어울려서 reciprocal
을 쓰는 상황을 가정하겠습니다.
※ 다른 언어에선 Optional
이라 표현하는 곳도 있습니다. 컨텍스트가 있는 타입 중에 만만한 타입이라 설명에 자주 등장합니다.
+1) $ (+2) $ reciprocal $ (+1) $ 1 (
이렇게 쓸 수 있으면 좋겠지만, 그냥은 위와 같이 쓸 수 없습니다. 최대한 위와 비슷하게 쓰는 게 목표입니다.
다른 연산들은 괜찮지만, 역수 때문에 언제든 Maybe 타입을 받는 걸 가정해야 되는 상황입니다. (+1)
도, (+2)
도 Int
가 아닌 Maybe Int
용으로 만들어야 합니다. 연산자가 한 두개면 다시 정의하는 것도 어렵지 않겠지만, 연산자가 많다면? 바로 특별한 적용을 정의하면 됩니다. Int -> Int
를 Maybe Int
에 적용하는, 새로운 적용을 만들어 보겠습니다.
Maybe
타입 값이 Just 값
이면 값
에 보통의 함수 적용을 하면 되고, Nothing
이면, 함수 적용 없이 그냥 Nothing
으로 두면 됩니다.
<$> v = 만일 v가 Just val 이면 Just (f $ val)
f Nothing 아니면
※ 실제 하스켈 구현3의 결합 우선 순위는 왼쪽으로 되어 있는데, 괄호를 없애고 보기 위해 여기선 오른쪽 우선 결합으로 가정하겠습니다. 실제 구현은 주석을 참고하세요.
이제 다음처럼 써 줄 수 있을 것만 같은데, 안됩니다.
+1) <$> (+2) <$> reciprocal <$> (+1) <$> 1 (
(+1) <$> reciprocal $ 1
은 되지만,
reciprocal <$> (+1) $ 1
은 안됩니다. (+1) $ 1 = 2
로 Just 2
가 아니니 <$>
를 쓰지 못합니다. Int
를 받으면 $
를, Maybe Int
를 받으면 <$>
를 써야 되는데, 어떻게 해결하는 게 좋을까요? Int
를 받는 경우가 없고, 항상 Maybe Int
만 들어오게 만들면, 해결할 수 있을 것만 같습니다.
pure :: Int -> Maybe Int
pure n = Just n
Int
를 단순히 Just 값
으로 만들어 주는 함수입니다. 이를 이용하면,
<$> (+1) <$> pure 1 reciprocal
이제 될 것 같지만, 여전히 다음이 문제 입니다.
<$> (reciprocal <$> ((+1) <$> pure 1)) reciprocal
<$>
의 구현을 잘 보시면 Just (f val)
입니다. 만일 f
가 결과로 Maybe
타입 값을 만들어 내는 함수라면 Maybe (Maybe Int)
타입이 되어 버립니다. 예를 들어 reciprocal
의 결과가 Just 1
이 나왔는데, 여기에 다시 reciprocal
을 <$>
하면 Just ( Just 1 )
되어 버립니다. 계산은 오류가 난 건 아니니 더 가볼까요? reciprocal
을 한 번 더 적용 하려 하면, <$>
구현 안에서 Just 1
에 f
를 <$>
이 아니라 f $ (Just 1)
을 하는 상황이 되어 계산을 할 수 없는 상태가 됩니다.
reciprocal :: Int -> Maybe Int
를 Just (Just 1)
에 적용할 방법이 필요해졌습니다.
Just (Just 1)
을 Just 1
로 만들면, <$>
을 이용해 다시 적용할 수 있습니다.
Just (Just 1)
의 의미를 살펴 보겠습니다. Just
는 값이 있다는 의미를 표현하는 값 생성자로, 의미상 Just (Just 1)
은 Just 1
과 다른 의미를 가지고 있지 않습니다. 만일, Nothing
이었으면, Just (Nothing)
까지 가지도 않고 Nothing
입니다.
Maybe (Maybe a) -> Maybe a
로 봐도 의미가 달라지지 않는, 즉 Maybe (Mayb a)
와 Maybe a
는 다른 구조지만, 같게 봐도 되는 상황입니다. 필요에 따라서는 말이지요. 4이를 코드로 표현하면,
join :: Maybe (Maybe a) -> Maybe a
Just (Just v)) = Just v
join (= Nothing join _
이제, 다음처럼 쓸 수 있습니다.
. <$>) reciprocal <$> (+1) <$> pure 1 reciprocal (join
결과 타입이 Maybe a
인 것들로만 흐름을 만들기 위해 (+1)
은 빼고 보겠습니다. ((+1)
이 필요하면 (+1) <$> pure 1 = pure (1+1)
상태로 흐름에 들어오면 됩니다.)
. <$>) reciprocal (join . <$>) pure 1 reciprocal (join
실제 하스켈에 정의되어 있는 것과 맞추기 위해, 인자 순서를 바꾸고, 결합 우선 순위를 왼쪽으로 조정한 >>=
를 정의하면
pure 1 >>= reciprocal >>= reciprocal
이라 쓸 수 있게 됐습니다. a -> m b
를 m a
에 적용하는 새로운, 특별한 적용을 정의했습니다.
Apply를 활용한 이런 구조는 특별한 테크닉이 아니라, 함수형 전반에서 쓰는 테크닉입니다. 테크닉이라 하기에도 뭐한 본질 같은 것입니다. 이렇게 따로 떼어내서 보는 것이, “왜, 이렇게 구현을 했지?”, 혹은 “어떻게 이런 구현을 떠올렸지?”가 궁금한, 저같이 함수형이 제 2 외국 코딩 언어인 분들에겐 도움이 될 수 있습니다.
하스켈에선 위와 같은 연산자 기호들을 무수히 만나게 되는데, 주요 연산자들은 Apply
인 경우가 많습니다. 아직 함수형이 네이티브가 아닌, 제 2, 제 3 외국어쯤 되는 분들이, 기호가 잔뜩 들어간 문장을 독해할 때, 전 후에 특별한 작업이 들어간 Apply
겠거니 하는 눈이 생긴다면, 이 글을 읽는데 들인 시간의 본전은 뽑으신 게 아닐까 합니다.
※ 일부러 제목을 적지 않았습니다. 1번은 펑터고, 2번은 모나드입니다. 수학쪽에서 기가 막히게 똑같은 개념이 존재합니다. 위에서 정의한 걸로만, 그대로 되는 건 아니고, 둘 다 몇가지 법칙을 만족하게 설계하면 수학쪽 개념과 완전히 일치하는 펑터, 모나드가 됩니다. 이 법칙들은 코드로 구현되는 건 아니고, 프로그래머가 해당 법칙을 따르는지 잘 보면서 설계해야 하는데, 여기선 코드로 나오는 <$>
, pure
, >>=
들의 필요성과, 그렇게 설계한 이유만 먼저 보는 글입니다.
너무 비수학적으로 풀어서 도움이 안되는 것 아닐까 걱정하는 분이 계실수도 있는데, 혼자 생각으론 수학쪽 개념의 시작도 여기서 본 것과 다르지 않다고 생각합니다. 서로 조금씩 다르지만, 닮은 것들을 얼마나 닮았는지 표현하며, 때로는 같게 보기 위한 개념들입니다. a
와 Maybe a
와 Maybe (Maybe a)
들을 다루면서 최대한 통일성 있는 방법을 찾습니다.
람다 함수는 아래처럼 읽을 수도 있습니다. 재미난 인문 표현이지만, 가끔 필요한 해석입니다.
-> 미래와 작업 \미래
원래 텍스트란 용어는, 글자로 된 것만 텍스트가 아니라, 무언가 해석이 필요한 것(그림이든, 건축이든, 뭐든…)을 텍스트라 하고, Text를 해석하는데 필요한 부가 정보를 Context라 합니다. Con은 함께together란 뜻입니다.↩︎
아직 (->)
를 하나의 타입으로 바라보는 것에 익숙하지 않은 분은 나중에 봐도 됩니다. 아래는 여기에 꼭 필요한 내용은 아닙니다.
실제 하스켈에서 구현은 $
는 오른쪽 우선 결합, <$>
는 왼쪽 우선 결합입니다. <$> :: (a -> b) -> f a -> f b
로 되어 있는데, 왼쪽 결합이면, (+2) <$> (+1) <$> Just 1
이 안돼야 될 것처럼 보입니다. (+2) <$> (+1)
자체가 타입이 맞지 않을 것 같으니까요. 하지만, 하스켈의 다른 기능이 쓰였습니다. 여기서 <$>
는 Maybe
에 적용하기 위한 적용이 아니라, 함수 타입에 쓰기 위한 적용입니다. 그리고 (+1) <$> Just 1
에서 쓰인 <$>
는 Maybe에 쓰기 위한 적용입니다. 하나의 구문에 들어 있지만, 컴파일러가 영리하게 적당한 구현을 찾아옵니다.
하스켈은 타입 클래스를 이용해, 컴파일러가 인자에 따라서 적절한 구현(인스턴스)을 불러다 쓸 수 있어, 왼쪽 우선인데도 (+1) <$> (+1) <$> Just 1
이렇게 쓸 수 있습니다. 아래는 좀 생각이 복잡해지긴 하는데, (->)
를 하나의 타입처럼 바라보는 경우를 자주 만나니, 읽는 훈련을 해두면 좋긴 합니다.
함수 타입(r ->)
의 <$>
는 다음처럼 정의되어 있습니다.
<$> :: (a -> b) -> f a -> f b
-- f 에 (r ->)를 넣는다.
<$> :: (a -> b) -> (r -> a) -> (r -> b)
(a -> b)
함수를 (r -> a)
에 있는 a
에 적용하려면, (r -> a)
의 결과 a
에 (a -> b)
를 적용하면 됩니다.
<$> f' = f . f' -- fmap = (.) f
함수 타입의 적용은 합성으로 정의되어 있습니다.↩︎
모나드 구조가 되기 위한 조건입니다. 두 번의 구조 변환 혹은 추가가 한 번의 구조 변환과 다를 게 없을 때 (다를 게 없다고 봐도 내가 필요한 만큼에선 문제가 없을 때) 모나드 구조를 만들 수 있습니다.↩︎