고차 함수(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) 는 다음 중 하나를 만족하는 함수를 말해요.

  1. 함수를 하나 이상 인자로 받는 함수
  2. 함수를 결과로 돌려주는 함수

이 개념은 어떤 언어를 쓰든 함수형 프로그래밍에서 아주 중요해요. 고차 함수가 있으면 함수를 조합(compose) 할 수 있는데요. 작은 함수 여럿을 만들어서 이어 붙여 더 큰 함수를 만들 수 있다는 뜻이에요. 작은 레고 블록을 여럿 조립해 집을 짓는 것처럼요.

이제 이론에서 조금 벗어나 실제 예시를 볼게요.

함수를 인자로 넘기기

두 함수를 먼저 살펴볼게요.

(defn double-+
    [a b]
    (* 2 (+ a b)))

(defn double-*
    [a b]
    (* 2 (* a b)))

이 두 함수는 공통된 패턴을 공유해요. 이름과, ab를 계산할 때 쓰는 함수만 다를 뿐이죠. 일반화하면 패턴이 이렇게 돼요.

(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))

더 알아보기