2007년 12월 12일 수요일

10. 매크로 - ANSI Common Lisp 번역

10. 매크로

리습 코드는 리습 객체 중 하나인 리스트로 표현된다. 섹션 2.3에서 리습 코드가 리스트로 표현될 수 있기 때문에 프로그램을 만드는 프로그램을 작성하는 일이 가능하다고 언급했었다. 이 장에서는 어떻게 그런 일이 가능한지를 살펴보려고 한다.


10.1 Eval

표현식을 어떻게 만드는지는 명확하다: 그저 list 를 호출하면 된다. 하지만 아직까지 우리는 어떻게 해야 리습이 리스트를 코드로 받아들이게 할 수 있는지를 살펴보지 않았다. 함수 eval 이 바로 그와 같은 일을 하는데, eval은 표현식을 받아서, 그것을 평가하고, 평가한 값을 리턴한다:

> (eval '(+ 1 2 3))
6
> (eval '(format t "Hello"))
Hello
NIL

별로 새로워 보이지 않는다. 우리가 여태까지 탑레벨에서 이야기해 온 상대가 바로 eval 이었다. 다음 함수는 탑레벨을 거의 유사하게 구현한다:

(defun our-toplevel ()
(do ()
(nil)
(format t "~%> ")
(print (eval (read)))))

이 함수를 보면 탑레벨이 왜 read-eval-print loop로 불리는지 알 수 있을 것이다.

eval을 호출하는 것은 리스트를 코드로 바꾸는 방법 중 하나이다. 하지만 그리 썩 좋은 방법은 아니다:

1. 일단 느리다: eval은 리스트를 받아서 그 시점에 컴파일하거나 인터프리터에서 평가한다. 어떤 식으로 하든 이미 컴파일된 코드가 실행되는 것보다는 매우 느릴 수 밖에 없다.

2. eval로 넘겨지는 표현식이 문맥 속에서 평가될 수 없다는 것도 문제다. eval을 let 안에서 호출한다고 해도, eval로 넘겨지는 표현식에서 let이 설정한 변수를 참조할 수 없다.

코드를 만들어내는 보다 나은 방법을 다음 섹션에서 설명할 것이다. eval을 사용하기에 적합한 곳은 탑레벨 루프 정도 뿐이다.

프로그래머에게 eval은 리습을 모사하는 모델로서 쓸모가 있다. 우리는 eval이 다음과 같은 cond 표현식으로 되어 있을 거라고 상상할 수 있다.

(defun eval (expr env)
(cond ...
((eql (car expr) 'quote) (cadr expr))
...
(t (apply (symbol-function (car expr))
(mapcar #'(lambda (x)
(eval x env))
(cdr expr))))))

대부분의 표현식은 default 절(t ... )에 의해 처리될 것이다. 표현식에서 car로 함수를 뽑아내고 cdr로 뽑아낸 각 인자들을 평가해서, 함수를 각 인자들에 적용한 결과를 리턴하는 식이다.

하지만 표현식이 (quote x)와 같은 경우에는 quote가 인자를 평가되지 않도록 보호하기 때문에 이런 식으로 처리되지 않는다. 따라서 우리는 quote를 위한 별개의 절이 필요하다. 특수 오퍼레이터들은 quote 처럼 eval 안에서 별개의 절을 가지게 된다.

coerce 와 compile 역시 리스트를 코드로 바꿀 수 있다. coerce를 이용해 람다 표현식을 함수로 바꿀 수 있다.

> (coerce '(lambda (x) x) 'function)
#

그리고 compile의 첫 번째 인자로 nil을 주게 되면 compile은 두 번째 인자로 주어진 람다 표현식을 컴파일한다.

> (compile nil '(lambda (x) (+ x 2)))
#
NIL
NIL

coerce와 compile이 리스트를 인자로 받기 때문에 프로그램이 실행 중에 새로운 함수를 만드는 것이 가능하다. 하지만 이런 것은 eval을 사용하는 것과 같이 매우 드문 경우다.

eval, coerce, compile의 문제점은 리스트를 코드로 바꿀 수 있다는 데 있는 게 아니라, 런타임에 그와 같은 일을 한다는 데 있다. 런타임에 그와 같은 일을 하는 것은 프로그램의 수행 속도를 크게 떨어뜨린다. 그와 같은 일을 컴파일 타임에 한다면, 프로그램이 실제로 실행될 때는 속도 저하가 전혀 없을 것이다. 다음 섹션에서 우리가 하고자 하는 일이 바로 이런 것이다.


10.2 매크로

프로그램을 짜는 프로그램을 만드는 것은 대부분 매크로를 사용해서 이루어진다. 매크로는 변환을 규정하는 오퍼레이터이다. 따라서 매크로를 정의한다는 것은 매크로를 호출할 때 어떤 식으로 변환이 일어날지를 규정하는 것이다. 이 같은 변환을 매크로 확장(macro-expansion)이라고 하는데, 이는 컴파일러에 의해 행해진다. 따라서 매크로에 의해 생성된 코드는 직접 타이핑한 코드와 마찬가지로 프로그램의 일부가 된다.

일반적으로 defmacro 를 사용해서 매크로를 정의할 수 있다. defmacro는 defun과 비슷해 보이지만, defun은 호출의 결과가 어떤 값을 낳아야 하는지를 정의하는 반면에, defmacro는 호출의 결과가 어떤 변환을 유발하는지를 정의한다. 예를 들어, 받은 인자를 nil로 만드는 매크로는 다음과 같다:

(defmacro nil! (x)
(list 'setf x nil))

이 코드는 하나의 인자를 받는 nil!이라는 오퍼레이터를 정의하고 있다. (nil! a)과 같은 코드는 컴파일되거나 평가되기 전에 (setf a nil)과 같은 코드로 변환될 것이다. 탑레벨에서 (nil! x)를 쳐보자,

> (nil! x)
NIL
> x
NIL

(setf x nil)을 친 것과 정확히 같은 결과가 나온다.

함수를 테스트하기 위해서는, 함수를 호출해봐야 한다. 매크로를 테스트해보기 위해서는 매크로가 어떻게 확장되는지를 봐야 한다. 함수 macroexpand-1은 매크로 호출을 받아서 매크로 확장의 결과를 보여준다:

> (macroexpand-1 '(nil! x))
(SETF X NIL)
T

매크로 호출이 또 다른 매크로를 호출할 수 있을 것이다. 컴파일러나 탑레벨이 매크로 호출을 만나면 더 이상 매크로 호출이 일어나지 않을 때까지 계속해서 매크로를 확장한다.

매크로를 이해하기 위해서는 매크로가 어떻게 구현되어 있는지를 살펴보는 것이 도움이 된다. 내부적으로, 매크로는 그저 표현식을 변환하는 함수일 뿐이다. 예를 들어, (nil! a)라는 표현식을 다음 함수에 넘기면

(lambda (expr)
(apply #'(lambda (x) (list 'setf x nil))
(cdr expr)))

함수는 (setf a nil)을 리턴할 것이다. defmacro를 사용하게 되면, 이와 같은 함수를 정의하게 되는 것이다. macroexpand-1이 하는 일은 어떤 표현식을 받아서 표현식의 car가 매크로의 이름이라면 위와 같은 함수에 표현식을 넘기는 것이 전부다.


10.3 Backquote

backquote read-macro 는 템플릿으로부터 리스트를 생성해낸다. backquote는 특히 매크로 정의에서 많이 쓰인다. quote는 닫힌 따옴표(apostrophe 역주: ' 엔터키 바로 왼쪽 키)인 반면에 backquote는 열린 따옴표로 표시된다.(역주: ` 숫자 1 키의 바로 왼쪽 키) backquote라고 불리는 이유는 일반 quote의 방향과 반대 방향을 바라보는 것처럼 생겼기 때문이다.

backquote가 단독으로 쓰이면, quote와 똑같은 역할을 한다:

> `(a b c)
(A B C)

quote와 마찬가지로, backquote 역시 인자를 평가로부터 보호한다.

backquote가 가진 장점은 backquote 표현식 내에서 , (comma) 와 ,@ (comma-at)을 사용해서 일부만 평가가 되도록 할 수 있다는 것이다. backquote 표현식 내에서 어떤 것 앞에 , 를 붙이게 되면 그것은 평가된다. backquote와 콤마를 동시에 사용해서 리스트를 만들어 보자:

> (setf a 1 b 2)
2
> `(a is ,a and b is ,b)
(A IS 1 AND B IS 2)

list를 호출하는 대신에 backquote를 사용하면, 실제로 확장했을 때와 비슷한 모양을 가지도록 매크로를 정의할 수 있다. 예를 들어 nil!은 다음과 같이 정의될 수 있을 것이다:

(defmacro nil! (x)
`(setf ,x nil))

,@은 , 와 비슷하지만 리스트를 인자로 받아서 풀어헤친다는 점에서 다르다. ,@을 붙이면 리스트가 템플릿에 삽입되는 것이 아니라 리스트의 각각의 원소들이 템플릿으로 삽입된다:

> (setf lst '(a b c))
(A B C)
> `(lst is ,lst)
(LST IS (A B C))
> `(its elements are ,@lst)
(ITS ELEMENTS ARE A B C)

,@은 rest parameter를 인자로 가지는 매크로에 유용하다.(예를 들어, 코드의 몸체를 인자로 받는) 테스트 표현식이 참인 동안 몸체 부분을 반복해서 평가하는 while 이라는 매크로를 정의한다고 해보자:

> (let ((x 0))
(while (< x 10)
(princ x)
(incf x)))
0123456789
NIL

while과 같은 매크로를 만들려면 rest parameter를 이용해서 while의 몸체 부분의 표현식들을 리스트로 받은 다음 ,@을 사용해서 리스트를 표현식들로 풀어헤쳐야 한다:

(defmacro while (test &rest body)
'(do ()
((not ,test))
,@body))


10.4 예: 퀵소트

다음 코드는 매크로에 큰 비중을 두고 있는 함수 - 벡터를 퀵소트 알고리즘을 이용해서 정렬하는 - 의 예이다.

퀵소트 알고리즘은 다음과 같다:

1. 일단 pivot이 될 아무 원소나 고른다. 대부분 시퀀스의 중간 정도에 있는 원소를 고르는 경우가 많다.

2. pivot을 경계로 벡터를 나눈다. pivot보다 작은 원소는 모두 pivot의 왼쪽에, pivot보다 같거나 큰 원소는 모두 pivot의 오른쪽에 올 때까지 재배치한다.

3. 나눈 각각의 부분이 2개 이상의 원소로 되어 있다면, 각각의 부분에 대해서 퀵소트 알고리즘을 재귀적으로 수행한다.

알고리즘이 매번 재귀적으로 수행될 때마다, 나뉘어진 부분의 크기가 점점 더 작아지게 되고, 결과적으로 벡터는 정렬된다.

(defun quicksort (vec l r)
(let ((i l)
(j r)
(p (svref vec (round (+ l r) 2)))) ;1
(while (<= i j) ;2
(while (< (svref vec i) p) (incf i))
(while (> (svref vec j) p) (decf j))
(when (<= i j)
(rotatef (svref vec i) (svref vec j))
(incf i)
(decf j)))
(if (> (- j l) 1) (quicksort vec l j)) ;3
(if (> (- r i) 1) (quicksort vec i r)))
vec)

위의 코드에서 함수는 벡터와 두 개의 정수 - 정렬될 범위를 표시하는 - 를 인자로 받는다. 주어진 범위의 가운데에 있는 원소가 pivot p로 정해진다. pivot p를 중심으로 나눠진 파티션 내부가 탐색되면서 p보다 큰 값을 가지는데 p의 왼쪽에 있는 원소와 p보다 작은 값을 가지면서 p의 오른쪽에 있는 원소들이 자리바꿈을 한다. (두 원소를 자리바꿈하기 위해서 rotatef가 사용되었다.) 최종적으로 복수개의 원소를 가지고 있는 파티션이 있다면 그 파티션에 대해서 똑같은 프로세스를 적용한다.

위의 코드에서는 이전 섹션에서 정의했던 while 매크로 외에, 리습에 내장되어 있는 when, incf, decf, rotatef 매크로 등이 사용되었다. 이 같은 매크로의 사용은 코드를 간결하고 명쾌하게 만든다.


10.5 매크로 디자인

매크로를 작성하는 것은 일반적인 프로그래밍과 조금 다르게, 나름의 목표와 문제를 가지고 있다. 매크로를 작성하는 것은 컴파일러가 인식하는 바를 바꾸는 것인데, 이는 어찌보면 컴파일러를 재작성하는 것과도 같다. 따라서 매크로를 작성할 때는 언어 설계자의 입장에서 생각할 필요가 있다.

이 섹션에서는 매크로 작성과 관련된 문제들을 간단히 살펴보고, 그에 대한 해결책을 보이고자 한다. 예를 들어, 숫자 n 을 받아서 몸체 부분을 n 번 평가하는 ntimes 라는 매크로를 만들어보자:

> (ntimes 10
(princ "."))
..........
NIL

다음은 ntimes 매크로의 잘못된 예이다:

(defmacro ntimes (n &rest body) ; 이렇게 하지 말 것!
`(do ((x 0 (+ x 1)))
((>= x ,n))
,@body))

이 코드는 얼핏 보기에 틀린 데가 없어 보인다. 위에서 보인 예와 같이 ntimes 매크로를 사용한다면 문제 없이 작동할 것이다. 하지만 사실 이 코드는 두 가지 문제가 있다.

첫 번째는 매크로를 작성할 때 유의해야 할 점인 '의도하지 않은 변수 캡쳐(inadvertent variable capture)'를 고려하지 않았다는 것이다. 만약에 매크로가 확장되는 부분의 문맥에 매크로에서 사용하는 변수와 똑같은 이름의 변수가 있다면 어떻게 될까? ntimes 매크로에서 잘못된 점은 x 라는 변수를 만든 것이다. 만일 매크로가 불리는 지점에 x라는 이름의 변수가 이미 있다면, 우리가 기대하지 않은 결과를 낳을 것이다.

> (let ((x 10))
(ntimes 5
(setf x (+ x 1)))
x)
10

ntimes가 의도대로 작동한다면, 이 코드는 x를 1씩 다섯 번 증가시킬 것이고 결과적으로 15가 리턴되어야 한다. 하지만 매크로가 x를 이터레이션 변수로 사용하고 있기 때문에 setf 표현식은 우리가 증가시키려고 했던 x가 아니라 이터레이션 변수 x를 증가시키게 된다. 매크로 확장이 일어난 코드는 다음과 같다:

(let ((x 10))
(do ((x 0 (+ x 1)))
((>= x 5))
(setf x (+ x 1)))
x)

생각할 수 있는 방법은 매크로 안에서 변수명으로 어디에서도 사용되지 않을 것 같은 심볼을 사용하는 것이다. 하지만 이런 미봉책 대신에 gensym 이라는 것을 사용할 수 있다. read는 읽는 모든 심볼을 intern시키기 때문에, gensym이 만들어 내는 심볼은 프로그램 내의 어느 심볼과도 같을(eql) 수 없다. gensym을 이용해서 ntimes를 다시 작성한다면, 적어도 '의도하지 않은 변수 캡쳐' 문제는 생기지 않을 것이다:

(defmacro ntimes (n &rest body) ; 하지만 이 코드 역시 문제가 있다.
(let ((g (gensym)))
`(do ((,g 0 (+ ,g 1)))
((>= ,g ,n))
,@body)))

하지만 이 코드 역시 '반복 평가(multiple evaluation)'라는 또 다른 문제를 가지고 있다. 첫번째 인자가 그대로 do 문에 넘겨지기 때문에, do 문의 몸체가 반복될 때마다 n이 새롭게 평가된다. 첫번째 인자로 부효과가 있는 표현식을 넘겨보면 코드에 문제가 있음이 명확하게 드러난다.

> (let ((v 10))
(ntimes (setf v (- v 1))
(princ ".")))
.....
NIL

v가 let에서 10으로 설정되었지만, setf는 두 번째 인자의 값을 리턴하기 때문에 이 코드는 아홉 개의 점을 출력해야 한다. 하지만 실제로 실행시켜 보면 다섯 개의 점만 출력된다.

매크로가 확장된 코드를 보면 왜 이런 결과가 나왔는지 알 수 있다:

(let ((v 10))
(do ((#:g1 0 (+ #:g1 1)))
((>= #:g1 (setf v (- v 1))))
(princ ".")))

종료조건에 해당하는 표현식을 보면 매 반복 주기마다 이터레이션 변수(gensym은 보통 #:가 앞에 붙어 있는 심볼을 출력한다)가 9와 비교되는 것이 아니라, 9에서 1씩 줄어드는 값과 비교되는 것을 알 수 있다. 마치 볼 때마다 점점 다가오는 지평선과도 같다.

이와 같이 의도하지 않게 값을 반복적으로 평가하는 것을 피하기 위해서는, 반복 주기에 들어가기 전에 변수를 만들어 값을 담아두면 된다. 역시 gensym을 사용해서 변수를 생성한다:

(defmacro ntimes (n &rest body)
(let ((g (gensym))
(h (gensym)))
`(let ((,h ,n))
(do ((,g 0 (+ ,g 1)))
((>= ,g ,h))
,@body))))

드디어, 오류없는 ntimes를 만들었다.

의도하지 않은 변수 캡쳐와 반복적인 값의 평가가 매크로를 작성할 때 흔히 범하는 실수이긴 하지만, 그것들이 전부는 아니다. 경험이 쌓이면, 이같은 실수를 피하는 것이 그렇게 어렵지 않다.(숫자를 0으로 나누는 것과 같은 기존의 다른 실수들을 피하는 것보다 어려울 이유가 없다.) 하지만 매크로가 우리에게 새로운 능력을 부여하기 때문에, 우리가 조심해야 하는 문제 역시 새로운 것일 수 밖에 없는 것 뿐이다.

커먼 리습 구현 그 자체가 매크로를 배울 수 있는 좋은 본보기가 될 수 있다. 내장되어 있는 매크로를 펼쳐 봄으로써 그 매크로들이 어떻게 작성되었는지를 이해할 수 있을 것이다. 대부분의 구현에서 cond 표현식을 펼쳐보면 다음과 같다:

> (pprint (macroexpand-1 '(cond (a b)
(c d e)
(t f))))

(IF A
B
(IF C
(PROGN D E)
F))

pprint 함수는 표현식을 들여쓰기 된 코드로 출력하는데, 매크로 확장을 살펴볼 때 사용하면 매우 유용하다.


10.6 일반화된 참조

매크로가 호출되면 호출된 바로 그 자리에서 확장이 일어나기 때문에 매크로 확장의 결과가 setf의 첫번째 인자가 될 수 있다면 그 매크로는 setf의 첫번째 인자로 올 수 있다. 예를 들어, car의 동의어 cah를 정의했다고 하자,

(defmacro cah (lst) `(car ,lst))

car 호출의 결과가 setf의 첫번째 인자로 올 수 있기 때문에 cah 호출 역시 setf의 첫번째 인자로 올 수 있다:

> (let ((x (list 'a 'b 'c)))
(setf (cah x) 44)
x)
(44 B C)

확장한 코드가 setf를 포함하는 매크로는 생각보다 약간 만들기 어려울 수 있다. incf는 다음과 같이 구현할 수 있을 것처럼 보인다:

(defmacro incf (x &optional (y 1)) ; 틀렸다
`(setf ,x (+ ,x ,y)))

이 코드는 제대로 동작하지 않는다. 다음 두 표현식은 동일하지 않다:

(setf (car (push 1 lst)) (1+ (car (push 1 lst))))

(incf (car (push 1 lst)))

lst가 nil이라면, 두 번째 표현식은 (2)가 되지만 첫 번째 표현식은 (1 2)가 된다.

커먼 리습은 setf에 관한 매크로를 작성하기 위한 define-modify-macro 라는 것을 제공한다. define-modify-macro는 세 개의 인자를 받는다: 매크로의 이름, 추가적인 파라미터(변경된 값이 저장될 장소는 암묵적으로 첫번째 인자로 온다고 가정된다), 새로운 값을 만드는데 필요한 함수. define-modify-macro를 이용해서 incf를 다음과 같이 정의할 수 있다:

(define-modify-macro our-incf (&optional (y 1)) +)

그리고 다음은 리스트 끝에 원소를 추가하는 매크로이다

(define-modify-macro append1f (val)
(lambda (lst val) (append lst (list val))))

위의 코드는 다음과 같이 동작한다;

> (let ((lst '(a b c)))
(append1f lst 'd)
lst)
(A B C D)

우연히도, push와 pop 모두 modify-macro로는 정의될 수 없다. push는 값이 저장될 장소가 첫번째 인자로 오지 않기 때문이고, pop은 그 리턴 값이 변경된 객체가 아니기 때문이다.


10.7 예: 매크로 유틸리티

섹션 6.4에서 리습에 내장되어 있는 오퍼레이터들과 같은 일반적인 목적으로 사용될 수 있는 오퍼레이터들을 유틸리티라고 한다고 소개했었다. 함수로 만들기 어려운 유틸리티들을 매크로를 사용해서 만들 수 있다. 우리는 이미 몇 가지 예를 보았다: nil!, ntimes, while 등은 함수가 아니라 매크로로만 작성될 수 있는데, 함수는 그 인자들을 모두 평가하는 반면, nil!, ntimes, while은 모두 그 인자들이 평가되는 방식을 조절할 필요가 있기 때문이다. 이 섹션에서는 매크로로 작성할 수 있는 유틸리티들의 예를 더 살펴보려고 한다. 다음은 실제로 쓸모가 있는 유틸리티 매크로들이다:

(defmacro for (var start stop &body body)
(let ((gstop (gensym)))
`(do ((,var ,start (1+ ,var))
(,gstop ,stop))
((> ,var ,gstop))
,@body)))

(defmacro in (obj &rest choices)
(let ((insym (gensym)))
`(let ((,insym ,obj))
(or ,@(mapcar #'(lambda (c) '(eql ,insym ,c))
choices)))))

(defmacro random-choice (&rest exprs)
`(case (random ,(length exprs))
,@(let ((key -1))
(mapcar #'(lambda (expr)
`(,(incf key) ,expr))
exprs))))

(defmacro avg (&rest args)
`(/ (+ ,@args) ,(length args)))

(defmacro with-gensyms (syms &body body)
`(let ,(mapcar #'(lambda (s)
'(,s (gensym)))
syms)
,@body))

(defmacro aif (test then &optional else)
`(let ((it ,test))
(if it ,then ,else)))

먼저, for는 while과 유사한 매크로이다. 반복 주기마다 변수가 범위 안의 값에 차례대로 바인딩 되면서 몸체 안의 값이 평가된다:

> (for x 1 8
(princ x))
12345678
NIL

같은 일을 do로 하는 것보다 쉽다,

(do ((x 1 (1+ x)))
((> x 8))
(princ x))

이 do 코드는 for 매크로를 확장한 결과와 거의 똑같다:

(do ((x 1 (1+ x))
(#:g1 8))
((> x #:g1))
(princ x))

for 매크로는 범위의 마지막 값을 저장하기 위해서 새로운 변수를 만들 필요가 있다. 위의 예에서 8이라는 값이 한 번 주어지면 우리는 그 값이 반복적으로 평가되는 것을 원하지 않는다. 따라서 의도하지 않은 변수 캡쳐를 피하기 위해 gensym에 의해 새로운 변수가 도입되었음을 볼 수 있다.

두 번째 매크로 in은 첫번째 인자가 나머지 인자 중 어느 하나와 같으면 참을 리턴하는 매크로이다. 매크로를 이용하면 다음과 같이 표현할 수 있다:

(in (car expr) '+ '- '*)

in 매크로가 없다면 같은 일을 하기 위해 다음과 같이 코드를 작성해야 것이다:

(let ((op (car expr)))
(or (eql op '+)
(eql op '-)
(eql op '*)))

in 매크로가 확장되면 op가 gensym에 의해 생성된 변수로 바뀌는 것 빼고는 위와 동일한 코드가 된다.

다음으로, random-choice는 평가할 인자를 랜덤으로 선택한다. 우리는 74 페이지에서 두 대안 중 하나를 랜덤으로 선택할 필요에 맞닥뜨린 적이 있었다. random-choice 매크로는 이와 같은 상황에 대해 일반적인 해결책을 제시한다. 다음과 같은 호출은

(random-choice (turn-left) (turn-right))

이와 같이 확장된다:

(case (random 2)
(0 (turn-left))
(1 (turn-right)))

다음으로, with-gensyms는 매크로의 몸체 부분을 작성하는 데 도움을 주기 위한 매크로이다. 매크로를 작성하다 보면 gensym을 이용해서 여러 개의 변수들을 생성할 일이 종종 있다. with-gensyms를 이용하면 다음과 같이 작성하는 대신에

(let ((x (gensym)) (y (gensym)) (z (gensym)))
...)

다음과 같이 쓸 수 있다

(with-gensyms (x y z)
...)

지금까지 보아온 매크로들은 모두 함수로는 대신할 수 없다. 원칙적으로, 매크로를 작성하는 이유는 함수로 그것을 할 수 없기 때문이다. 하지만 여기에도 몇 가지 예외가 있다. 어떤 일을 런타임이 아니라 컴파일 타임에 하기 원할 때 그것을 매크로로 작성할 수 있다. 다음 매크로 avg는 인자들의 평균을 리턴한다,

> (avg 2 4 8)
14/3

이 매크로가 앞에서 말한 예외의 예가 될 수 있다. avg를 다음과 같이 함수로 작성할 수도 있는데,

(defun avg (&rest args)
(/ (apply #'+ args) (length args)))

하지만 이 함수는 숫자들의 개수를 런타임 시에 구한다. 우리가 avg를 apply에 넘길 것만 아니라면 avg내의 length를 컴파일 타임에 호출하지 않을 이유가 없다.

마지막으로 aif는 의도적인 변수 캡쳐의 예로 포함된 것이다. aif 매크로는 테스트 인자의 반환 값을 변수 it을 사용해서 참조할 수 있도록 한다. 즉, 다음과 같이 표현하는 대신에

(let ((val (calculate-something)))
(if val
(1+ val)
0))

다음과 같이 쓸 수 있다.

(aif (calculate-something)
(1+ it)
0)

잘만 사용한다면 의도적 변수 캡쳐는 유용한 테크닉일 수 있다. 이런 테크닉이 커먼 리습 언어 자체에서도 쓰인 것을 종종 발견할 있다. next-method-p와 call-next-method 등이 의도적인 변수 캡쳐의 예이다.

지금까지 본 매크로들을 통해 프로그램을 작성하는 프로그램이 어떤 것인지 알 수 있을 것이다. 한번 for를 정의해 놓으면 do 문을 길게 풀어 쓰는 수고를 들일 필요가 없다. 단지 타이핑을 줄여주는 것 정도인 것 같은데 그게 과연 가치 있는 일일까? 매우 가치가 있다. 타이핑을 줄여주는 것이 프로그래밍 언어가 하는 일의 전부이다; 컴파일러의 목적은 당신이 프로그램을 기계어로 타이핑하지 않아도 되게 해주는 것이다. 그리고 매크로는 하이레벨 언어로 일반적인 프로그래밍을 할 때 얻을 수 있는 것과 비슷한 이득을 당신이 짜고자 하는 특정한 프로그램에 가져다준다. 매크로를 주의 깊게 사용한다면 당신의 프로그램을 놀랄 정도로 짧게 만들 수 있고 또한 읽고 쓰고 유지하기 쉽게 만들 수 있다.

이런 내용이 믿어지지 않는다면, 언어에 내장된 매크로를 전혀 쓰지 않고 프로그램을 작성할 때 어떤 일이 벌어질지 상상해보라. 매크로들이 확장해 내는 그 코드들을 당신이 스스로 손으로 작성해야 할 것이다. 이 같은 사고 방식을 더욱 확장해서 적용할 수 있다. 프로그램을 짤 때, 매크로가 확장해야 할 부분을 손으로 일일이 타이핑하고 있는 것은 아닌지 스스로에게 물어보라. 만일 그렇다면, 그 코드를 타이핑할 것이 아니라 그런 코드를 만들어내는 매크로를 작성해야 할 때인 것이다.


10.8 On Lisp

이제 매크로가 무엇인지 알았으니, 리습으로 리습 언어 자체를 더욱 확장해 나갈 수 있게 되었다. 함수가 아닌 대부분의 커먼 리습 오퍼레이터들은 매크로이고 그것들은 모두 리습으로 작성된 것이다. 커먼 리습의 내장 특수 오퍼레이터는 25개 뿐이다.

John Foderaro는 리습을 "프로그래밍 가능한 프로그래밍 언어"라고 표현했다. 자신만의 함수와 매크로를 작성함으로써 리습을 원하는 어떤 언어로도 만들 수 있다. (우리는 17장에서 이와 같은 실제 예를 보일 것이다.) 당신이 원하는 프로그램을 짜기에 적합한 언어의 모습이 어떤 것이든지, 리습은 그와 같이 변할 수 있다.

매크로는 이와 같은 유연성을 가능하게 하는 핵심이다. 매크로는 리습을 당신이 상상하지 못하는 데까지 변형시킬 수 있는 가능성을 제공한다. 그리고 이 모든 것들은 잘 정의된 규칙에 따라, 프로그램의 수행 성능을 저하시키지 않고 이루어진다. 리습 커뮤니티에서 매크로에 대한 관심은 계속해서 높아지고 있다. 매크로로 이미 많은 놀라운 일들을 할 수 있다는 것은 명백하지만, 매크로에 대해 연구해야 할 여지는 아직도 많이 남아 있다. 리습은 프로그래머들의 손에 의해 끊임없이 진화해 왔고, 원한다면 당신 역시 그렇게 할 수 있다. 그것이 바로 리습이 지금까지 살아남은 이유이다.


translated by 찬우

댓글 없음: