apply

Lisp을 다룬다면 역사적인 함수인 apply를 빠뜨릴 수 없을 것이다.

apply는 시퀀스를 받아 주어진 함수의 인자로 제공해 준다.

나는 이번에 Clojure를 통해 Lisp을 처음 접하므로 내가 apply를 가장 많이 사용해 본 언어인 Javascript를 통해 연습해 보자.

Javascript에서 apply는 다음과 같이 사용한다.

function add(a, b) {
  return a + b;
}

add.apply(null, [7, 12]); // 19

add(7, 12)로 호출할 수 있는 함수에 [7, 12]를 제공해서 호출하고 있다. this 바인딩은 필요 없어서 그냥 null을 줬다.

그렇다면 이번엔 Clojure에서 apply를 사용해 보자.

(+ 1 2 3) ; 6

(apply + [1 2 3]) ; 6

참 쉽다. apply 함수에 적용할 함수와 인자 시퀀스를 함께 넘기면 끝.

Clojure sequence의 특징

  • lazy하다. 따라서 무한대의 시퀀스를 다룰 수도 있다.
  • immutable하다.

sequence 연산

first, rest

first는 첫 번째 원소를 리턴한다.

(first '(2 4 6)) ; 2
(first [3 6 9]) ; 3
(first [])      ; nil

rest는 첫 번째 원소를 제외한 나머지 원소들을 리턴한다.

(rest '(2 4 6)) ; (4 6)
(rest [3 6 9]) ; (6 9)
(rest [3])     ; ()
(rest [])      ; ()

cons, conj, into

cons는 주어진 시퀀스의 앞에 원소를 추가한 시퀀스를 리턴한다.

(cons 3 [1 2]) ; (3 1 2)
(cons 6 '())   ; (6)

conjcons와 비슷하지만 자료구조의 특성에 따라 원소가 추가되는 위치가 다르다.

(conj '(1 2 3) 4) ; (4 1 2 3) ; 앞에 추가
(conj [1 2 3] 4)  ; [1 2 3 4] ; 뒤에 추가

into는 두 컬렉션을 합친 결과를 리턴한다. conj처럼 어디에 추가되는지는 자료구조에 따라 다르다.

(into '(1 2 3) '(4 5)) ; (5 4 1 2 3) ; 앞에 추가
(into [1 2 3] '(4 5))  ; [1 2 3 4 5] ; 뒤에 추가
(into [1 2 3] [4 5])   ; [1 2 3 4 5]

seq, next

seq는 다른 자료구조를 시퀀스로 만들어 리턴한다.

; vector를 시퀀스로 만들기
(seq [1 2 3])  ; (1 2 3)

; set을 시퀀스로 만들기
(seq #{"a" "b" "c"}) ; ("a" "b" "c")

; map을 시퀀스로 만들기
(seq {1 "a", 2 "b", 3 "c"}) ; ([1 "a"] [2 "b"] [3 "c"])

next(seq (rest x))와 같다.

(next [1 2 3])        ; (2 3)
(next {1 "a", 2 "b"}) ; ([2 "b"])

range, repeat, iterate, take

take는 주어진 시퀀스의 첫 n개의 원소를 리턴한다.

(take 2 [:a :b :c :d :e]) ; (:a :b)
(take 3 [11 22 33 44 55]) ; (11 22 33)

range는 특정 범위의 시퀀스를 만들어 리턴한다.

(range 5)      ; (0 1 2 3 4)
(range 5 10)   ; (5 6 7 8 9)
(range 5 10 2) ; (5 7 9)

(take 3 (range 5 10)) ; (5 6 7)

repeat은 주어진 원소를 반복한다.

(repeat 3 1)      ; (1 1 1)
(repeat 5 :a)     ; (:a :a :a :a :a)
(repeat 4 "test") ; ("test" "test" "test" "test")

단, repeat은 인자를 1개만 주면 무한히 반복하므로 take를 사용해 무한반복을 방지하도록 하자.

(take 10 (repeat 1)) ; (1 1 1 1 1 1 1 1 1 1)

iterate는 무한히 반복하는 시퀀스를 만들어준다.

(take 3 (iterate inc 10)) ; (10 11 12)

위의 코드는 10부터 1씩 증가하는(inc 함수 사용) 무한한 시퀀스에서 take를 사용해서 앞의 3개만 추출한 결과를 리턴한다.

cycle, interleave, interpose

cycle은 컬렉션을 무한히 반복하는 시퀀스를 리턴한다.

(take 10 (cycle [1 7 99]))
; (1 7 99 1 7 99 1 7 99 1)

interleave는 두 컬렉션을 교차시켜서 반복시킨다. 단 두 컬렉션 중 하나가 다 소진되면 반복을 멈춘다.

(interleave (range 1 10) ["홀" "짝"]) ; (1 "홀" 2 "짝")

(interleave
  (range 1 10)
  (cycle ["홀" "짝"]))
; (1 "홀" 2 "짝" 3 "홀" 4 "짝" 5 "홀" 6 "짝" 7 "홀" 8 "짝" 9 "홀")

interposeinterleave와 비슷하지만 구분자를 넣을 수 있다는 특징이 있다.

(interpose "," [1 2 3]) ; (1 "," 2 "," 3)

apply를 써서 str에 시퀀스를 인자로 적용해주면 다음과 같이 문자열을 콤마 조인시킬 수도 있다.

(apply str (interpose "," [1 2 3])) ; "1,2,3"

하지만 그냥 문자열 join이라면 이미 string/join이 있으므로 굳이 apply를 쓰지 않아도 된다.

(clojure.string/join "," [1 2 3]) ; "1,2,3"

clojure.string/join을 쓰는 게 좀 아쉽다면 use를 써도 된다.

(use 'clojure.string)
(join "," [1 2 3]) ; "1,2,3"

vec, set, hash-set

vec은 vector를 만들어 준다.

(vec '(1 2 3)) ; [1 2 3]
(type (vec '(1 2 3))) ; clojure.lang.PersistentVector

set은 주어진 컬렉션을 set으로 만들어 리턴한다.

(set [1 2 3])  ; #{1 3 2}
(set '(1 2 3)) ; #{1 3 2}

hash-set은 컬렉션이 아니라 인자를 받는데, 결과 타입은 set과 다르지 않다.

(hash-set 1 2 3) ; #{1 3 2}

(type (set '(1 2 3)))   ; clojure.lang.PersistentHashSet
(type (hash-set 1 2 3)) ; clojure.lang.PersistentHashSet

hash가 아니라 굳이 hash-set을 쓰겠다면 apply를 써도 될 것 같다. 하지만 이렇게 쓸 일은 없을 것 같다.

(apply hash-set [1 2 3]) ; #{1 3 2}

filter

filter는 이름 그대로의 역할을 한다.

(filter neg? [1 -2 3 -4 5 -6]) ; (-2 -4 -6)

neg?는 음수값을 판별하는 함수이다. 즉, 다음과 같이 해도 똑같다.

(filter (fn [x] (< x 0)) [1 -2 3 -4 5 -6])
; (-2 -4 -6)

익명함수 축약 문법을 사용하면 이렇게도 할 수 있겠다.

(filter #(< % 0) [1 -2 3 -4 5 -6])
; (-2 -4 -6)

take-while, drop-while

take-while은 주어진 함수가 실패하는 지점까지의 컬렉션을 리턴한다.

(filter even? [2 4 5 8 10 11 12])
; (2 4 8 10 12)

(take-while even? [2 4 5 8 10 11 12])
; (2 4)

위의 예제는 take-while은 짝수가 아닌 5 에서 멈추고 (2 4)를 리턴했다. filter와는 다르다는 점을 분명히 기억해 두자. filter였다면 뒤에 있는 짝수인 8, 10, 12도 추가됐을 것이다.

filtertake-while의 차이를 Java 코드로 살펴보자.

// filter
List<Integer> list = List.of(2, 4, 5, 8, 10, 11, 12);
List<Integer> result = new ArrayList<>();
for (int num : list) {
  if (num % 2 == 0) {
    result.add(num);  // 루프를 끝까지 돌면서 수집한다.
  }
}
// take-while
List<Integer> list = List.of(2, 4, 5, 8, 10, 11, 12);
List<Integer> result = new ArrayList<>();
for (int num : list) {
  if (num % 2 == 0) {
    result.add(num);
  } else {
    break;  // 조건이 만족되지 않으면 루프를 중단한다.
  }
}

drop-whiletake-while과 반대로 동작한다.

take-while이 조건에 맞는 아이템을 수집한다면, drop-while은 조건에 맞는 아이템을 제외한다.

(drop-while even? [2 4 5 8 10 11 12])
; (5 8 10 11 12)

drop-while도 앞부분의 2, 4는 제외했지만 drop을 멈춘 이후에 나온 짝수인 8, 10, 12는 그대로 남겨두고 있다.

split-at, split-with

split-at은 컬렉션을 쪼갠다.

(split-at 3 ["a" "b" "c" "d" "e" "f" "g"])
; [("a" "b" "c") ("d" "e" "f" "g")]

split-with는 체크 함수를 기준으로 컬렉션을 쪼갠다. filter처럼 작동하지는 않고, 함수가 false를 리턴하는 곳을 기준으로 쪼갠다.

(split-with even? [0 2 4 5 6 8])
; [(0 2 4) (5 6 8)]

every?, not-every?

(every? zero? [0 0 0 1 0]) ; false
(every? zero? [0 0 0 0 0]) ; true

(not-every? zero? [0 0 0 1 0]) ; true
(not-every? zero? [0 0 0 0 0]) ; false

map, reduce

(map #(* 10 %) [1 2 3]) ; (10 20 30)

(reduce * [2 3 10]) ; 60

(reduce #(- %1 %2) [2 3 10]) ; -11
; 2 - 3 - 10 의 결과는 -11

sort, sort-by, reverse

(sort [2 1 3])                    ; (1 2 3)
(sort (fn [a b] (< a b)) [2 1 3]) ; (1 2 3)
(sort (fn [a b] (> a b)) [2 1 3]) ; (3 2 1)

위의 예제 중 익명 함수를 사용한 것은 이렇게 표현하는 것이 좀 더 심플할 것이다.

(sort < [2 1 3]) ; (1 2 3)
(sort > [2 1 3]) ; (3 2 1)

sort-by는 구조체의 특정 멤버를 기준으로 정렬할 수 있다.

(sort-by :grade > [{:grade 83} {:grade 90} {:grade 77}])
; ({:grade 90} {:grade 83} {:grade 77})
(sort-by :grade < [{:grade 83} {:grade 90} {:grade 77}])
; ({:grade 77} {:grade 83} {:grade 90})

reverse는 시퀀스를 뒤집는다.

(reverse [1 2 3]) ; (3 2 1)

for

다른 언어의 for와는 다르다. Clojure의 for는 루프 키워드가 아니라 다양한 조건을 받아 새로운 리스트를 만들어내는 함수다.

(for
  [x '(1 2 3)]
  (* 2 x))
; (2 4 6)

이 코드를 Javascript로 표현하면 다음과 같다.

[1, 2, 3].map(x => 2*x); // [2, 4, 6]

:when 키워드를 사용하고 뒤에 함수를 제공하면 filter로 작동한다.

(for
  [x '(1 -2 3) :when (> x 0)]
  (* 10 x))
; (10 30)

Javascript로는 다음과 같다.

[1, -2, 3]
  .filter(x => x > 0)   // :when (> x 0)
  .map(x => 10 * x);    // (* 10 x)
// [10, 30]

:let을 사용하면 임시 변수를 사용할 수 있다.

(for
  [x [1 -2 3]
  :let [temp (* x 100)]
  :when (pos? temp)]
  (str temp "%"))
; ("100%" "300%")

Javascript로는 다음과 같다.

[1, -2, 3]
  .map(x => x * 100)    // :let [temp (* x 100)]
  .filter(x => x > 0)   // :when (pos? temp)]
  .map(x => `${x}%`)    // (str temp "%")
// ['100%', '300%']

다음 예제는 책 '프로그래밍 클로저'의 예제를 일부 수정한 것인데, [] 내에서 두 개의 임시변수를 생성해 사용한다.1 이 에제는 체스판의 모든 위치를 출력한다.

(for
  [col "ABCDEFGH" row (range 1 9)]
  (format "%c%d" col row))

; ("A1" "A2" "A3" ... "H8")

Python에서 2중 루프를 사용해 비슷한 일을 한다면 이럴 것 같다.

col = list('ABCDEFGH')
row = range(1, 9)
for c in col:
  for r in row:
    print('{}{}'.format(c, r))

lazy sequence

Clojure의 시퀀스 대부분은 lazy하다. 다음은 '프로그래밍 클로저'에 등장하는 예제이다.2

(def x
  (for
    [i (range 1 3)]
    (do (println i) i)))

이 코드에는 (println i)가 있는데도, 이 코드를 실행해도 출력 결과는 나오지 않는다. lazy하기 때문에 아직 실행이 미뤄져 있는 것이다.

doall을 사용하면 지연된 연산을 실행할 수 있다.

(doall x)
1
2
=> (1 2)

dorundoall처럼 작동하지만 실행 과정에서 접근했던 원소를 메모리에 보존하지 않는다고 한다. 그래서 nil을 리턴한다. dorun을 사용하면 굉장히 커서 메모리에 부담이 되는 컬렉션도 취급할 수 있을 것으로 보인다.

(dorun x)
1
2
=> nil

다만, dorundoall은 Clojure에서는 거의 사용되지 않는 함수라고 한다.

map 연산

keys로 map의 key, vals로 map의 value를 얻을 수 있다.

(def m
  {:apple "사과",
   :orange "오렌지"})

(keys m) ; (:apple :orange)
(vals m) ; ("사과" "오렌지")

map을 함수처럼 사용하고 key를 넘기면 값이 나온다. 대안이 필요하다면 get을 쓰면 된다.

(m :apple) ; "사과"
(get m :pear "없는 과일입니다") ; "없는 과일입니다"

내가 지금까지 경험한 언어들과 굉장히 다른 느낌인데, map의 key로 키워드를 사용한다면 이런 것도 된다.

(:apple m) ; "사과"

contains?를 통해 map에 등록된 key인지 확인할 수 있다.

(contains? m :orange) ; true
(contains? m :pear) ; false

assoc를 사용하면 새로운 엔트리가 추가된 map을 얻을 수 있다. 원래 있던 map은 immutable이므로 이렇게 새로 생성된 map은 새로운 이름으로 할당해줘야 쓸 수 있다는 점에 주의해야 한다.

(assoc m :banana "바나나")
; {:apple "사과", :orange "오렌지", :banana "바나나"}

dissoc를 쓰면 해당 key를 제거한 map을 얻을 수 있다. 이것도 새로 할당해줘야 쓸 수 있다는 점에 주의해야 한다.

(dissoc m :orange)
; {:apple "사과"}

select-keys를 쓰면 주어진 key만 갖는 map이 리턴된다.

(select-keys m [:apple :pear :banana])
; {:apple "사과"}

merge는 두 map을 합친 결과를 리턴한다. 중복 key가 있다면 나중 것이 선택된다.

(merge
  {:apple "사과" :orange "오렌지"}
  {:orange "오렌지!" :banana "바나나"})

; {:apple "사과", :orange "오렌지!", :banana "바나나"}

merge-with는 함수를 사용해 중복 key의 value를 처리한다.

(merge-with
  str
  {:apple "사과" :orange "오렌지"}
  {:orange "오렌지!" :banana "바나나"})

; {:apple "사과", :orange "오렌지오렌지!", :banana "바나나"}

str 함수를 줬더니 오렌지오렌지!가 합쳐져서 오렌지오렌지!가 됐다.

set 연산

union으로 두 set를 합친 결과(합집합)를 얻을 수 있다.

(def s1 #{:apple :orange})
(def s2 #{:orange :banana})

(clojure.set/union s1 s2)
; #{:orange :apple :banana}

intersection은 교집합이다.

(clojure.set/intersection s1 s2)
; #{:orange}

difference는 차집합이다.

(clojure.set/difference s1 s2)
; #{:apple}

select는 익명 함수를 넘겨 필터링을 할 수 있다. 하지만 filter를 사용해도 문제는 없다.

(clojure.set/select #(= :orange %) s1)
; #{:orange}

(filter #(= :orange %) s1)
; (:orange)

rename은 좀 굉장한데, key를 일괄적으로 수정해 준다.

(def fruit
  #{
    {:name "apple", :korean "사과"}
    {:name "orange", :korean "오렌지"}})

(clojure.set/rename fruit {:name :english})
; #{
    {:korean "오렌지", :english "orange"}
    {:korean "사과", :english "apple"}}

잘 보면 :name:english로 한꺼번에 바뀌었다.

이 외에도 다양한 함수들이 있으니 나중에 필요할 때 찾아보자.

참고문헌

  • 프로그래밍 클로저 / 스튜어트 할로웨이 저 / 유찬우 역 / 인사이트(insight) / 초판 1쇄 발행 2010년 06월 20일 / 원제 : Programming Clojure (2009)

주석

  1. 프로그래밍 클로저. 4장. 121쪽. 

  2. 프로그래밍 클로저. 4장. 123쪽.