고차 함수(Higher Order Functions)
고차 함수(Higher Order Functions)
함수형 프로그래밍에서 함수는 일급 시민(first class citizen) 이에요. 이 말을 풀어 보면, 함수를 다른 값처럼 다룰 수 있다는 뜻이에요. 값을 변수에 담고, 다른 함수에 넘기고, 함수에서 돌려받듯이 — 함수도 똑같이 변수에 담고, 인자로 넘기고, 반환값으로 돌려줄 수 있죠. 이 특징이 바로 고차 함수의 바탕이 돼요.
Clojure에서 함수를 정의할 때 흔히 defn을 써요. 즉 (defn foo ...)처럼 말이죠. 그런데 이것은 사실 (def foo (fn ...))의 문법적 설탕(syntactic sugar) 이에요. fn은 함수 객체를 돌려주고, defn은 그 함수 객체를 가리키는 var를 만들어 줘요.
출처: Clojure 공식문서
본문
고차 함수란 무엇인가요?
고차 함수(higher order function) 는 다음 중 하나를 만족하는 함수를 말해요.
- 함수를 하나 이상 인자로 받는 함수
- 함수를 결과로 돌려주는 함수
이 개념은 어떤 언어를 쓰든 함수형 프로그래밍에서 아주 중요해요. 고차 함수가 있으면 함수를 조합(compose) 할 수 있는데요. 작은 함수 여럿을 만들어서 이어 붙여 더 큰 함수를 만들 수 있다는 뜻이에요. 작은 레고 블록을 여럿 조립해 집을 짓는 것처럼요.
이제 이론에서 조금 벗어나 실제 예시를 볼게요.
함수를 인자로 넘기기
두 함수를 먼저 살펴볼게요.
(defn double-+
[a b]
(* 2 (+ a b)))
(defn double-*
[a b]
(* 2 (* a b)))
이 두 함수는 공통된 패턴을 공유해요. 이름과, a와 b를 계산할 때 쓰는 함수만 다를 뿐이죠. 일반화하면 패턴이 이렇게 돼요.
(defn double-<f>
[a b]
(* 2 (f a b)))
그렇다면 f를 인자로 넘기도록 만들어 버리면 어떨까요? double- 함수를 이렇게 일반화할 수 있어요.
(defn double-op
[f a b]
(* 2 (f a b)))
이제 어떤 연산이든 "그 결과를 두 배로 만들기"라는 개념을 직접 표현할 수 있어요. 특정 연산을 두 배로 하는 함수를 하나하나 따로 만들 필요가 없어진 거예요.
함수 리터럴(Function Literals)
익명 함수(anonymous function) 는 이름 없는 함수를 말해요. Clojure에서는 fn과 리터럴 #(...) 두 가지 방식으로 정의할 수 있어요. defn으로 만들면 함수에 즉시 이름이 붙지만, fn은 함수만 만들 뿐이에요.
밴드 몇 개가 담긴 데이터로 예시를 들어 볼게요.
(def bands [
{:name "Brown Beaters" :genre :rock}
{:name "Sunday Sunshine" :genre :blues}
{:name "Foolish Beaters" :genre :rock}
{:name "Monday Blues" :genre :blues}
{:name "Friday Fewer" :genre :blues}
{:name "Saturday Stars" :genre :jazz}
{:name "Sunday Brunch" :genre :jazz}
])
여기서 rock 밴드만 골라내고 싶다고 해 볼게요. 이건 한 번만 쓰고 말 일회성 작업이라 다른 데서 다시 쓸 일이 없어요. 그럴 땐 익명 함수를 쓰면 타이핑을 줄일 수 있어요.
(def rock-bands
(filter
(fn [band] (= :rock (:genre band)))
bands))
더 간결하게는 함수 리터럴을 써서 이렇게 정의할 수도 있어요.
(def rock-bands (filter #(= :rock (:genre %)) bands))
함수 리터럴은 %, %n, %&를 통해 여러 인자도 지원해요.
#(println %1 %2 %3)
익명 함수를 쓸 때 리터럴 문법은 정말 간결해서 좋아요. 하지만 인자가 몇 개를 넘어가면 그 간결함이 오히려 가독성을 해칠 수 있어요. 그런 경우에는 fn을 쓰는 편이 더 적절하답니다.
함수를 돌려주는 함수와 클로저(Closures)
첫 번째로 adder라는 함수를 만들어 볼게요. 이 함수는 숫자 x 하나를 인자로 받아 함수를 돌려줘요. 그리고 adder가 돌려준 함수는 숫자 a 하나를 인자로 받아 x + a를 반환해요.
(defn adder [x]
(fn [a] (+ x a)))
(def add-five (adder 5))
(add-five 100)
;; => 105
adder가 돌려주는 이 함수 형태를 클로저(closure) 라고 불러요. 클로저는 자신이 만들어질 때 스코프 안에 있던 모든 변수에 접근할 수 있다는 뜻이에요. add-five는 정의상 adder 함수 밖에 있는데도 x에 접근할 수 있죠.
filter
필터링은 프로그래밍에서 정말 흔한 작업이에요. 동물 데이터를 하나 가져와 볼게요.
(def pets [
{:name "Fluffykins" :type :cat}
{:name "Sparky" :type :dog}
{:name "Tibby" :type :dog}
{:name "Al" :type :fish}
{:name "Victor" :type :bear}
])
우리는 엔터프라이즈급 소프트웨어를 만들고 있으니까, 개가 아닌 동물들은 걸러내고 싶어요. 먼저 일반적인 for 루프를 볼게요.
(defn loop-dogs [pets]
(loop [pets pets
dogs []]
(if (first pets)
(recur (rest pets)
(if (= :dog (:type (first pets)))
(conj dogs (first pets))
dogs))
dogs)))
이 코드는 잘 동작하지만 부피가 크고 헷갈려요. 이걸 고차 함수인 filter를 쓰면 훨씬 간단해져요.
(defn filter-dogs [pets]
(filter #(= :dog (:type %)) pets))
filter를 쓴 해법은 훨씬 명확하죠. 명령을 나열하는 대신 의도를 드러낼 수 있어요. 여기에 더해 필터링 함수를 별도의 var로 분리하면 조각을 더 잘게 나눌 수도 있어요.
(defn dog? [pet] (= :dog (:type pet)))
(defn filter-dogs [pets] (filter dog? pets))