클로저 매크로에 대해 알아보자

매크로에 대한 기억

어린 시절 즐기던 게임에서는 레벨 업을 위해 지루한 반복 사냥을 해야 할 때가 많았습니다. 제 친구 중 몇 명은 매크로 프로그램을 이용해 반복 사냥을 컴퓨터가 대신하게 했습니다. 매크로를 이용하면 캐릭터가 어떤 방향으로 공격을 하게 하거나, 몇 분에 한 번씩 아이템을 사용하도록 하는 등 일련의 작업을 자동으로 실행할 수 있었습니다. 이처럼, '매크로'란 미리 정해놓은 명령을 자동으로 실행하여 단순 반복 작업을 줄이는 기능입니다. 그런데 프로그래밍에서도 따분한 반복 작업을 해야 하는 경우가 있습니다. 프로그래밍 언어에는 일정한 문법과 규칙이 있기 때문입니다. 예를 들어, 자바에서 새 객체를 만들려면 다음과 같은 절차를 반드시 따라야 합니다.

  1. class 키워드로 클래스를 정의한다.
  2. new 키워드로 객체를 생성한다.

이 두 문장은 뒤바뀔 수 없습니다. 둘 중 하나의 행위만으로는 객체를 만들 수 없죠. 프로그래밍 언어의 문법의 틀을 당연시한다면 생각은 그와 유사한 방식으로만 상상할 수밖에 없다고 생각합니다. 클로저(Clojure)의 매크로는 주어진 규칙을 허무는 데 도움을 줄 것입니다.

프로그래밍 언어의 매크로

C 언어의 매크로

클로저의 매크로에 관해 알아보기 전에, 더 많은 분들이 알고 계실 C 언어의 매크로를 잠시 살펴봅시다.

1#include <stdio.h>
2
3#define CUBE_MACRO(n) n*n*n
4
5int main()
6{
7 printf("%d", CUBE_MACRO(1+2)); // 1+2*1+2*1+2 -> 7
8 return 0;
9}

위 코드에서는 #define 전처리 명령으로 n을 세제곱하는 CUBE_MACRO라는 매크로 함수를 정의했습니다. CUBE_MACRO 매크로는 일반 함수를 호출하는 것처럼 사용할 수 있습니다. 한편, 아래의 코드에서는 CUBE_MACRO 매크로의 n*n*n과 동일한 식을 일반 함수 cube_func()로 정의했습니다.

1#include <stdio.h>
2
3int cube_func(int n)
4{
5 return n*n*n;
6}
7
8int main()
9{
10 printf("%d", cube_func(1+2)); // 3*3*3 -> 27
11 return 0;
12}

이 두 코드는 동일한 일을 하는 것 같지만, 결과 값이 다릅니다.[2][3] CUBE_MACRO7이 출력되며, cube_func27이 출력되었습니다. C언어 매크로는 기호(여기서는 1+2)를 다른 기호로 치환한 뒤, 코드를 컴파일하고 실행합니다. 함수는 이미 코드가 컴파일 된 상태에서, 기호를 값으로 평가한 후에(2+1 -> 3) 실행합니다. 컴파일 전에는 1+2라는 코드가 3이라는 값으로 평가되지 않으므로, n*n*n3*3*3이 아니라 1+2*1+2*1+2로 치환됩니다. 따라서 매크로 함수와 일반 함수의 실행 결과에 차이가 생기는 것입니다. C 언어의 매크로는 이처럼 코드 자체를 변경하며, 이를 이용하면 제한적으로나마 기존 언어에서 정한 규칙의 선을 넘을 수 있습니다.

클로저 매크로

클로저 매크로도 C언어 매크로와 비슷하지만, 단순히 문자열 치환이 아닙니다. 코드를 프로그래밍하는 것에 가깝습니다. 코드를 치환한다는 점에서 클로저의 매크로는 C언어 매크로와 비슷하지만, 리스프 계열 언어의 특징인 동형성으로 특별해집니다. [4][5][6]

동형성

클로저는 리스프 계열 언어입니다. 리스프(LISP)라는 이름은 "리스트 처리(LISt Processing)"에서 딴 것입니다. 그 이름에서도 짐작할 수 있듯이, 연결 리스트(linked list)가 리스프의 핵심 자료구조입니다. 심지어 코드 자체도 리스트로 구성할 정도이죠. 그래서 클로저에서는 코드가 리스트 자료구조와 같은 방식으로 표현·저장됩니다. 코드를 변경하는 것이 리스트를 변경하는 것과 똑같은 일입니다. 클로저에서는 소괄호 안에 요소들을 나열하는 방식으로 리스트를 표기합니다. 다음 두 코드는 각각 자바와 클로저로 리스트를 표현한 것입니다.

1List.of(1,2,3,4)
1(list 1 2 3 4) ;; (1 2 3 4)

클로저에서는 식을 계산하거나 함수를 호출할 때 코드를 리스트로 작성합니다.

1(+ 1 2)
2(* 2 3)
3(/ 3 4)
4
5(println "Hello, Clojure")

함수 정의도 마찬가지입니다.

1(defn hello-world [name]
2 (println (str "Hello, " name)))
3
4(defn add1 [number]
5 (+ 1 number))

코드가 리스트 자료구조로 구성되어 있으므로, 코드를 다른 리스트를 조작할 때와 똑같이 조작할 수 있습니다.

1(count (list * 3 3 3)) ;; 4
2(first (list * 3 3 3)) ;; *
3(rest (list * 3 3 3)) ;; (3 3 3)
4(concat (list * 3 3 3) '(5)) ;; (3 3 3 5)

클로저 코드는 소괄호를 적극적으로 활용하여 문법을 최소화하였습니다. 일반적으로 리스트의 첫 번째 요소를 연산자(함수)로, 나머지 요소들을 피연산자(인자)로 취급합니다. 동형성은 매크로를 사용할 때 아주 강력한 힘이 됩니다. 우리는 '코드를 생산하는 코드'를 만들 능력을 얻은 것입니다.

클로저 매크로로 문법을 뛰어넘기

비아웹(Viaweb) 편집기의 전체 소스 코드 중 약 20%에서 25% 정도가 매크로였다. (중략) 그 프로그램에 포함된 모든 매크로는 꼭 필요해서 넣은 것이다. 이는 이 프로그램 중 최소 20%에서 25%가량의 코드는 다른 언어로는 하기 어려운 일을 하고 있음을 의미한다.

  • 평균을 넘어서기, 폴 그레이엄(Paul Graham)

다른 언어에서는 구현하기가 몹시 까다로운 문제들을, 클로저의 매크로를 이용하면 평상시의 코딩과 다름없이 간단히 처리할 수 있는 경우가 많습니다. 몇 가지 사례를 소개해 드리겠습니다. 자세한 문법 설명은 생략하겠습니다. 그저 이런 것이 가능하다는 것만 즐겨주시면 좋겠습니다.

매크로 맛보기

C언어 매크로 소개 예시와 비슷한 일을 하는 코드를 소개합니다.

1;; 매크로 정의
2(defmacro cube-macro [n]
3 (list '* n n n))
4
5(cube-macro 3) ;; 27
6
7;; 함수 정의
8(defn cube-fn [n]
9 (* n n n))
10
11(cube-fn 3) ;; 27

C언어 매크로와는 다르게 함수 정의와 이질감이 없습니다. 그리고 결괏값이 함수 호출과 동일하게 나오는 것도 다릅니다.

예제 1 - anaphoric macro

anaphoric은 '앞서 나온 어구'를 지칭할 때 사용합니다. 대명사라고 이해하면 쉬울 것 같습니다. [9] if문에서 검사한 값을 그대로 사용하고 싶지는 않으신가요? 자바를 예로 들면 null을 관리하기 위해 종종 아래와 같은 코드를 만들 것입니다.

1SomeObject someObject = someMethod(arg1, arg2);
2if (someObject != null) {
3 doSomeThing(someObject);
4}
5// 또는
6Optional<SomeObject> someObjectOption = Optional.ofNullable(someMethod(arg1, arg2));
7if (!someObjectOption.isEmpty()) {
8 doSomething(someObjectOption.get());
9}
10// 또는
11Optional<SomeObject> someObjectOption = Optional.ofNullable(someMethod(arg1, arg2));
12someObjectOption.ifPresent(someObject -> {
13 doSomething(someObject);
14}

그렇다면 혹시 이런 것은 가능할까요? someMethod(arg1, arg2) 의 결괏값이 null이 아니라면 someMethod(arg1, arg2)의 리턴문을 it이라는 키워드로 직접 사용할 수는 없을까요? 가능한지는 차치하고 새로운 문법을 상상해봅시다. ifexists는 값이 null이거나 false가 아니라면 중괄호 안의 statement를 수행한다고 상상해보죠.

1ifexists (someMethod(arg1, arg2)) {
2 doSomething(it); // it을 someMethod의 리턴값에 바인딩이 가능할까?
3}

이런 코드를 자바에서 만들 수 있을까요? 아뇨, 우리는 이런 코드를 만들 수 없습니다. 예약어들은 정해져있습니다. 우리가 마음대로 추가할 수 없지요. it처럼 정의되지도 않은 이름을 바로 쓸 수도 없습니다. 누군가는 '당연히 정의부터 해야지. 순서라는 것이 있잖아.' 라고 생각할지도 모르죠. 하지만 클로저에서는 가능합니다. aif, aand 라는 매크로를 만들어 보겠습니다.(코드를 이해하실 필요는 없습니다. 가능하다는 것만 느껴주시면 좋겠습니다.)

1(defmacro aif [test then else]
2 `(let [~'it ~test]
3 (if `'it ~then ~else)))
4
5(defmacro aand [& body]
6 (cond (nil? body) true
7 (nil? (next body)) (first body)
8 :else `(aif ~(first body)
9 (aand ~@(rest body))
10 false)))
11
12(aif 1
13 (inc it)
14 "Nope")
15;; 2
16
17(aand 1
18 (inc it)
19 (* 100 it)
20 (= 200 it))
21;; true
22
23(aand 1
24 (inc it)
25 (* 2 it)
26 (= 5 it))
27;; false

aif는 첫번째 인자가 false, nil(자바의 null과 비슷)이 아닌경우 it에 바인딩되어 사용할 수 있습니다. aand는 더 나아가서 함수들을 체이닝해서 수행할 수 있지요. it은 이전 함수의 리턴값으로 체이닝됩니다.

예제 2 - for-loop문 만들기

Clojure에는 for-loop가 없습니다. 하지만 사용하고 싶다면 만들면 됩니다.[10]

1(defmacro for-loop [[sym init check change :as params] & steps]
2 `(loop [~sym ~init value# nil]
3 (if ~check
4 (let [new-value# (do ~@steps)]
5 (recur ~change new-value#))
6 value#)))
7
8(for-loop [i 0 (< i 10) (inc i)]
9 (println i))

마치 새로운 문법이 추가된 것처럼 보이네요!

  • 리스트의 첫번째 요소 이름을 두번째 요소의 값으로 정의
  • 리스트의 3번째 요소의 함수가 true 일때까지 재귀를 돈다.
  • 리스트의 4번째 요소는 첫번째 요소에 적용한 값으로 재귀호출한다.

이런 규칙을 단 6줄로 만들 수 있습니다.

예제 3 - 간단한 패턴매칭 만들기

위 예제는 매크로가 할 수 있는 일을 보여드리긴 했지만, 너무 작은 변화이기에 쓸모를 못 느낄 수도 있으신 분들을 위한 소개입니다. 클로저는 기본적으로 패턴매칭기능이 없습니다. 하지만 패턴매칭도 필요하면 만들 수 있습니다. 아래의 코드는 다음에 우리가 만들어볼 my-match를 사용하는 예시입니다.[11][12][14]

1(doseq [n (range 1 101)]
2 (println
3 (my-match [(mod n 3) (mod n 5)]
4 [0 0] (str "FizzBuzz")
5 [0 _] (str "Fizz")
6 [_ 0] (str "Buzz")
7 :else n)))
8;; 1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzz ,,,

위 코드는 간단한 FizzBuzz 문제를 우리가 만들 my-match를 사용하여 푼 모습입니다. 3의 배수이면 Fizz를 5의 배수이면 Buzz를 3의 배수이며 5의 배수는 FizzBuzz를 출력합니다. 그리고 아무런 배수가 아니면 해당 숫자를 출력합니다. 여기서 특이한 점은 _ 입니다. 이 값은 하스켈 패턴매칭의 와일드카드처럼 어떤 값이 와도 상관없도록 만듭니다.[13] 그리고 심볼 바인딩도 가능하도록 할 것입니다. 아래 예제를 소개합니다.

1(doseq [n (range 1 101)]
2 (println
3 (my-match [(mod n 3) (mod n 5)]
4 [0 0] (str "FizzBuzz with n=" n)
5 [0 a] (str "Fizz with a=" a ", n=" n)
6 [b 0] (str "Buzz with b=" b ", n=" n)
7 :else n)))
8;; 1
9;; 2
10;; Fizz with a=3, n=3
11;; 4
12;; Buzz with b=2, n=5
13;; Fizz with a=1, n=6 ...

같은 FizzBuzz 문제이지만 이번에는 와일드카드 대신 심볼을 바인딩하였습니다. 각 심볼에 해당하는 값이 바인딩 됩니다. n이 3의 배수일 때, 5의 mod 값을 a에 바인딩하여 사용할 수 있게 됩니다.

이를 구현한 코드는 아래와 같습니다. (굳이 이해하실 필요는 없습니다. 몇 개의 함수와 매크로로 매턴매칭이 가능하다는 것을 느끼시면 좋겠습니다.)

1;; https://gist.github.com/ssisksl77/4ea8f4945d52a054802e29a5b58337f8
2(ns pattern-match.diy)
3
4(defn process-vars
5 [vars]
6 (letfn [(process-var [var]
7 (if-not (symbol? var)
8 (gensym "ocr-")
9 var))]
10 (vec (map process-var vars))))
11
12(defn make-default-match [vars cs]
13 (let [cs (partition 2 cs)
14 [p a] (last cs) ;; 심볼의 경우 p를 a에 바인딩하는 기능 추가 필요.
15 last-match (vec (repeat (count vars) '_))]
16 (if (= p :else)
17 (conj (vec (butlast cs)) [last-match a])
18 (throw (RuntimeException. "last match must be :else")))))
19
20(defn make-pattern-let-binding
21 "let 바인딩을 위한 자료구조 생성"
22 [vs vars]
23 (interleave vs vars))
24
25(defn make-cond
26 "cond predicate을 만들기 위한 비교문"
27 [vs cls]
28 (map (fn [v c]
29 `(= ~v ~c)) vs cls))
30
31(def backtrack-exception (Exception. "BackTrack!"))
32
33(defn catch-error
34 "예외를 잡는 자료구조 추가"
35 [& body]
36 `(catch Exception e#
37 (if (identical? e# ~'backtrack-exception)
38 (do
39 ~@body)
40 (throw e#))))
41
42(defn compile-rec
43 "재귀적으로 try문 안에 있는 비교문을 생성."
44 [cnds return]
45 (let [cnd (first cnds)
46 [v c] (vec (rest cnd))] ;; c가 심볼인 경우 v를 바인딩하도록 해야함.
47 (if (seq cnd)
48 (cond
49 (symbol? c)
50 `(let [~c ~v] (do ~(compile-rec (rest cnds) return)))
51
52 (= '_ c)
53 `(do ~(compile-rec (rest cnds) return))
54
55 :else
56 `(do (cond ~cnd ~(compile-rec (rest cnds) return)
57 :else ~'(throw backtrack-exception))))
58 return)))
59
60(defn match-compile
61 [conds+return]
62 (let [[cnds return] (first conds+return)
63 cnd (first cnds)
64 [v c] (vec (rest cnd))] ;; c가 심볼인 경우 v를 바인딩하도록 해야함.
65 (if (seq cnd)
66 (cond
67 (symbol? c)
68 `(let [~c ~v]
69 (try ~(compile-rec (rest cnds) return)
70 ~(catch-error (match-compile (rest conds+return)))))
71
72 (= '_ c)
73 `(try ~(compile-rec (rest cnds) return)
74 ~(catch-error (match-compile (rest conds+return))))
75
76 :else
77 `(try (cond ~cnd ~(compile-rec (rest cnds) return)
78 :else ~'(throw backtrack-exception))
79 ~(catch-error (match-compile (rest conds+return)))))
80 return)))
81
82(defmacro my-match
83 [vars & clauses]
84 (let [vs (process-vars vars)
85 cs (make-default-match vars clauses)
86 pattern-let-binding (vec (make-pattern-let-binding vs vars))
87 conds (map (fn [c] [(make-cond vs (first c)) (second c)]) cs)]
88 `(let ~pattern-let-binding
89 ~(match-compile conds))))

기존 함수 정의 및 호출과 동일한 형태의 코드임을 알 수 있습니다. 생성하는 결괏값이 소스 코드인 것만 다릅니다.

만약에 여러분이 사용하는 언어에서 패턴매칭이 없을 때, 여러분은 임의로 이 기능을 추가할 수 있으신가요 아니면 새로운 버전이 나오기를 기다리실 수밖에 없으신가요? 리스프의 매크로라면 여러분이 원하는 것을 무엇이든 손쉽게 만들 수 있습니다. 여러분은 문법에 구애받지 않고 작성할 힘을 갖게 됩니다. [7][8]

언어가 여러분을 제약하지 않도록 하세요. 여러분이 언어를 제약하세요.

참고문헌

  1. 언어는 어떻게 생각하는 방식을 형성하는가
  2. C 언어 매크로
  3. 리스프의 이상한 문법
  4. 리스프 매크로에 대한 논의
  5. anaphoric macro
  6. for-loop
  7. 패턴매칭




Gravatar for ssisksl77@gmail.com
남영환백엔드 개발자
2021. 11. 22.

추천 콘텐츠