함수형 프로그래밍과 중위연산자

함수형 프로그래밍의 목표는 무엇일까요? 여러 가지 의견이 있겠지만 함수의 재사용과 합성이야말로 함수형 프로그래밍의 궁극적인 목표라고 생각합니다. 작은 일을 정확하게 처리하는 함수들을 모으고 합성하면 크고 복잡한 문제를 더욱더 쉽게 해결할 수 있게 됩니다.

하스켈을 비롯한 몇몇 언어들은 인자가 두 개인 함수를 +, *와 같은 중위 연산자로 취급 할 수 있습니다. 이 포스팅에서는 중위 연산자를 사용해서 함수의 합성과 적용이 산수 계산처럼 쉽게 표현되는 것을 보이려고 합니다.

함수 평가해보기

함수 fn :: a → b를 평가하는 것은 어렵지 않습니다. 단지 fn에 값을 적용해 주기만 하면 되기 때문이죠.

1fn value

apply

어떤 함수 하나와 그 함수의 첫 번째 인자를 입력받는 함수를 만들고 apply라는 이름을 붙여줍니다. 이 함수는 타입으로 인해 한 가지 방법으로만 구현이 가능합니다.

1apply :: forall a b. (a -> b) -> a -> b
2apply f x = f x

apply 함수의 구현을 ReScript로 작성하면 다음과 같습니다.

1let apply = (fn: ('a => 'b), value: 'a): 'b => fn(value)

ReScript는 타입 표기를 생략해도 정확한 타입을 추론해줍니다.

1let apply = (fn, value) => fn(value)

apply 함수에 첫 번째 인자로 임의의 함수를 입력하면 그 함수 자신이 리턴됩니다. apply fn 을 평가하면 그대로 fn이 되는 것이죠. apply fn이 리턴 하는 타입이 a → b이기 때문에 이는 당연한 일입니다. 따라서 apply fn valuefn value와 완전히 동일한 동작을 하게 됩니다. 이해를 돕고자 apply fn일 때의 리턴 타입을 명시적으로 괄호로 묶어보았습니다. (apply fn을 평가해서 fn이 되는 것은 하스켈과 ReScript 모두 함수가 커링되어있기 때문입니다.)

1apply :: forall a b. (a -> b) -> (a -> b)

apply 함수는 map, ap, flatMap 과 나란히 놓고 보면 그 특징과 연관성이 더욱 두드러집니다. 이런 내용은 다른 포스팅에서 다룰 기회를 가져보도록 하겠습니다.

compose

두 개의 함수가 있고, 두 함수 중 하나의 리턴 타입이 다른 함수의 인자 자입과 동일하다고 가정해봅시다. 이를 코드로 표현하면 다음과 같습니다.

1f :: String -> Int
2g :: Int -> Boolean

함수 f에 대한 평가 결과를 g의 인자로 사용 할 수 있는 것이죠.

1g (f x) -- 다른 언어에서는 g(f(x)) 와같이 표현했을 것입니다.

apply가 하나의 함수와 하나의 인자를 받았듯이, 두 개의 함수와 하나의 인자를 받아 합성해주는 함수를 만들고 compose 라는 이름을 붙여봅시다.

1compose :: forall a b c. (b -> c) -> (a -> b) -> (a -> c)

compose의 인자 순서가 (b → c) → (a → b) → a → c 인 것에 주의합시다. 인자의 입력부터 연속되는 함수의 평가는 g (f x)에서 보듯 오른쪽에서 왼쪽으로 적기 때문에 compose도 이 흐름에 맞춰서 구현하는 게 일반적입니다.

compose 함수를 사용해서 g (f x)를 표현하면 이렇게 됩니다.

1composite2Fn :: forall a c. a -> c
2composite2Fn x = (compose g f) x -- x는 a타입의 값입니다.

=를 기준으로 좌변에 있는 마지막 인자가 우변에서 가장 마지막에 입력이 된다면, 좌우 변에서 모두 생략시킬 수 있습니다. composite2Fn의 인자 x가 우변에 있는 함수에서 가장 끝에서 입력이 되었기 때문에 양쪽에서 모두 생략하고 이렇게 작성을 해도 같은 표현이 됩니다.

1composite2Fn :: forall a c. a -> c
2composite2Fn = compose g f -- compose g f는 여전히 a타입의 인자가 필요합니다.

이렇게 인자를 생략해서 함수 선언을 작성하는 방식을 point-free style이라고 부릅니다. 하스켈에서는 반복을 피하고 간결한 코드 작성을 위해서 자주 사용하는 기법입니다.

compose g f를 먼저 평가하면 a → c타입의 함수가 됩니다. 이는 다시 말하면 compose 함수가 2개의 함수를 입력받아 순차적으로 실행하는 또 다른 함수를 만들어 낼 수 있다는 것을 의미합니다. (compose g f) xg (f x)가 완전히 같은 동작을 하게 된다는 것을 기억합시다.

만약 h :: c → d 라는 함수가 있어 f, g, h를 합성하고 싶다면 어떻게 하면 될까요?

1compose h (compose (g f)) x

같은 방식으로 더 많은 함수를 합성해 나간다면 무수히 많은 괄호가 필요하고 우리의 뇌는 이 괄호를 해석하기 위해 더 많은 일을 해야 할 것입니다. 이것을 단순하게 표현 할 수 있는 방법을 찾아봅시다.

함수 풀어서 써보기

첫 번째 인자에서 두 번째 인자를 더하는 함수 plus가 있다고 가정합니다.

1plus x y = x + y

plus함수를 사용해서 x,y,z 세 개의 숫자를 순서대로 더해나간다면 이렇게 표시할 수 있습니다.

1plus x (plus y z)

이 표현식을 바깥에서부터 안쪽으로 풀어나가면 다음과 같습니다.

1x + (plus y z)
2x + (y + z)
3x + y + z -- 더하기는 결합 법칙에 의해서 연산의 순서가 결과에 영향을 주지 않기 때문에 괄호가 생략 가능함을 떠올립시다.

이번에는 i,j,k,l 네 개의 변수를 더해가는 과정을 안쪽에서부터 풀어봅시다.

1plus i (plus j (plus k l))
2plus i (plus j (k + l))
3plus i (j + (k + l))
4i + (j + (k + l))

함수의 위치

인자가 2개인 함수라면, 사칙 연산의 연산자를 숫자 중간에 적을 수 있듯이 인자가 2개인 함수 호출에 대해서는 함수를 중간에 표시를 할 수 있습니다. 이러한 방식을 infix operation이라고 부릅니다. 하스켈을 포함한 몇몇 언어에서는 함수의 이름을 백틱(`)으로 감싸주면 infix operation으로 사용할 수 있습니다.

1i `plus` j `plus` k `plus` l -- i + j + k + l 과 완전히 동일합니다.

또한 + 연산자처럼 함수 이름이 특수문자로만 이루어졌다면 자동으로 infix operator로서 동작합니다. 일반적인 함수를 백틱으로 감싸 infix operator로 사용했듯이, 특수문자 함수명인 infix operator를 괄호로 감싸주면 일반 함수처럼 중간이 아닌 앞에 적을 수 있게 됩니다.

1(+) i ((+) j ((+) k l)) -- plus i (plus j (plus k l)) 와 같은 의미입니다.

point-free style과 이를 합치면 plus 함수를 이렇게 정의 할 수 있습니다.

1plus = (+) -- plus x y = (+) x y 의 point-free 표기입니다.

compose 함수도 infix operation으로 사용할 수 있습니다.

1(h `compose` (g `compose` f)) x

compose 함수의 infix opearator로 .을 지정하고 apply에는 $ 를 지정해봅니다. 특수문자로 이루어진 함수명은 괄호를 써야 일반 함수처럼 다룰 수 있습니다.

1($) = apply
2(.) = compose

compose 함수는 +와 마찬가지로 결합법칙을 만족하기 때문에 연산 순서와 관계없고 따라서 아래와 같은 항등식이 성립합니다. 어떤 자료구조가 이처럼 대수 법칙을 만족한다면 결과를 예측하기 쉬워지므로 논리의 검증이 편해집니다.

1h . (g . f) = (h . g) . f = h . g . f

이제 f,g,h함수의 합성은 간단하게 할 수 있습니다.

1(h . g . f) x

다른 언어와 마찬가지로 연산자 양옆의 공백은 생략해도 됩니다. apply까지 사용하면 괄호도 필요 없어집니다.

1h.g.f $ x

중첩된 괄호의 늪에서 빠져나와 간단명료한 방법으로 함수를 합성할 수 있게 되었습니다.

마치며

이번 포스팅에서는 함수를 합성하는 방법과 하스켈에서 이를 간단하게 표현하는 방법에 대해서 살펴보았습니다. 하스켈에서는 infix operator, currying, point-free 표기 등을 사용해서 마치 수식을 다루는 것과 같이 단순하고 명료한 코드를 작성하고 합성해서 복잡한 문제를 해결하는 방식을 선호합니다. 기존 언어에서도 이런 관점으로 코드를 작성하고 리팩토링을 해나간다면 단순하면서도 견고한 코드를 작성하기가 쉬워질 것입니다.



Gravatar for ck.kim@greenlabs.co.kr
김춘구함수형 프로그래머
2021. 04. 27.



추천 콘텐츠