2007년 12월 27일 목요일

8. 언제 매크로를 사용해야 하는가 - On Lisp 번역

8. 언제 매크로를 사용해야 하는가

어떤 것을 함수로 만들어야 할지 매크로로 만들어야 할지를 어떻게 알 수 있을까? 대부분의 경우에는 매크로가 쓰여야 할지 아닐지를 어렵지 않게 판단할 수 있다. 일단 가능하다면 함수를 사용한다: 함수가 할 수 있는 일을 매크로로 하는 것은 바람직하지 않다. 매크로를 사용하는 것이 함수를 사용하는 것에 비해 나은 점이 있을 때 매크로를 사용한다.

그렇다면 언제 매크로를 사용하는 것이 이득인가? 그것이 이 장의 주제이다. 사실 매크로를 사용하는 것이 함수를 사용하는 것에 비해 더 나은 이익을 가져다 주기 때문에 매크로를 사용하는 경우보다는, 어떤 일을 함수로는 할 수 없고 매크로로만 할 수 있기 때문에 매크로를 사용하게 되는 경우가 많다. 섹션 8.1에서 매크로로만 구현될 수 있는 오퍼레이터들이 어떤 것인지를 설명한다. 하지만 때때로 함수를 사용해야 할지 매크로를 사용해야 할지 판단하기 어려운 경우도 있다. 이런 경우에 도움을 주기 위해서 섹션 8.2 에서는 매크로를 사용하는 데 따르는 장단점이 무엇인지 설명한다. 마지막으로 섹션 8.3에서는 매크로를 가지고 어떤 일을 할 수 있는가에 대해서 알아보겠다.


8.1 다른 것들로 해결할 수 없을 때

유사한 형태의 코드가 반복적으로 눈에 띄인다면, 반복이 발생하는 부분을 하나의 서브루틴으로 묶고 서브루틴을 호출하는 것이 일반적인 디자인의 원칙이다. 이 원칙을 리습 프로그램에 적용할 때는 그 서브루틴이 함수가 될 것인지 매크로가 될 것인지를 결정해야 한다.

함수가 아니라 매크로만으로 어떤 일을 할 수 있을 경우에는 고민이 필요없다. 1+ 와 같은 오퍼레이터는 함수와 매크로 둘 다로 작성될 수 있지만:

(defun 1+ (x) (+ 1 x))

(defmacro 1+ (x) `(+ 1 ,x))

섹션 7.3에서 나왔던 while과 같은 경우에는 매크로로만 정의될 수 있다:

(defmacro while (test &body body)
`(do ()
((not ,test))
,@body))

함수로 이와 같은 일을 할 수 있는 방법은 없다. while은 body로 넘어온 표현식을 쪼개서 do 문의 몸체 부분에 넘기는데 이 부분은 test 표현식이 nil을 리턴할 동안만 평가된다. 이런 일을 할 수 있는 함수는 없는데, 함수는 호출되기 전에 모든 인자를 평가해버리기 때문이다.

언제 매크로가 정말로 필요하고, 무엇을 위해 필요할까? 매크로는 함수가 할 수 없는 일을 두 가지 할 수 있다: 매크로는 인자들의 평가 여부를 조절할 수 있고, 매크로가 호출된 문맥 속으로 풀어 헤쳐질 수 있다. 매크로를 필요로 하는 경우는 결국 이 두 가지 중의 하나를 필요로 하는 것이다.

"매크로는 인자를 평가하지 않는다"는 통념은 잘못된 것이다. 정확하게 말하면, 매크로는 그 인자가 평가될지를 조절할 수 있다. 인자가 매크로 확장의 어느 부분에 오느냐에 따라서, 인자는 한 번 또는 여러 번 평가되거나 또는 전혀 평가되지 않을 수 있다. 매크로는 다음의 네 가지 목적을 위해 인자의 평가 여부를 조절할 수 있다.

1. 변형

setf는 인자의 평가가 일어나기 전에 인자를 살펴보고 조작하는 매크로의 일종이다. 커먼 리습의 내장형 함수 중, 값에 접근하기 위해서 쓰이는 함수는 보통 접근한 값을 변화시키기 위한 함수도 쌍으로 가지고 있는 경우가 많다. 리스트의 첫번째 원소에 접근하는 car는 값을 변화시키기 위한 rplaca를 쌍으로 가지고 있고, cdr은 rplacd를 쌍으로 가지고 있는, 그런 식이다. setf는 마치 값에 접근하는 데 사용되는 함수를 값이 저장될 변수처럼 사용할 수 있게 해 주는 매크로이다. 즉, (setf (car x) 'a)를 매크로 확장하면 (progn (rplaca x 'a) 'a)와 같이 될 것이다.

이런 트릭을 위해서는, setf가 첫번째 인자의 표현식을 들여다볼 수 있어야 한다. 위의 경우를 보면, 첫 인자의 표현식이 car로 시작했기 때문에 매크로 확장에는 rplaca가 왔다. cdr로 시작한다면 rplacd가 와야 할 것이다. 즉, setf는 첫번째 인자의 표현식이 car로 시작하는지 cdr로 시작하는지를 들여다 볼 수 있어야 한다. 이와 같이 인자를 살펴보고 그 인자를 변형시켜야 하는 오퍼레이터는 매크로로 작성되어야 한다.


2. 바인딩(binding)

렉시컬 변수(lexical variable - 문자 변수)는 평가된 값이 아니라 문자 자체가 그대로 소스 코드에 나타나야 한다. 예를 들어 setq의 첫 번째 인자로는 렉시컬 변수를 넘겨야 하는데 인자를 모두 평가해 버리는 함수로는 렉시컬 변수를 넘길 방법이 없다. 따라서 setq를 사용해서 무언가를 만들고자 하는 경우에는 매크로로 작성해야 한다. 이와 유사하게, let 속으로 렉시켤 변수를 확장해야 하는 do 같은 것도 역시 매크로로 작성될 수 밖에 없다. 인자들의 렉시컬 바인딩(lexical binding)을 조작해야 하는 경우는 모두 매크로로 작성되어야 한다.


3. 조건부 평가

함수의 모든 인자는 무조건 평가된다. when과 같이, 특정한 조건 하에서만 인자의 일부가 평가되기를 원하는 경우라면, 매크로로만 작성될 수 있다.


4. 반복적 평가(multiple evaluation)

함수의 인자들은 무조건 평가될 뿐 아니라, 정확히 한 번씩만 평가된다. do문에서처럼 어떤 인자를 반복적으로 평가하고 싶을 때는 매크로가 필요하다.


위에서 살펴본 것들 말고도 매크로 확장의 이점을 얻을 수 있는 경우가 몇 가지 더 있다. 매크로 확장이 매크로가 불린 바로 그 지점의 문맥 속에서 일어난다는 것이 중요한데, 매크로 사용의 2/3는 이같은 성질을 이용한 것이다. 이런 경우를 살펴보면:


5. 호출된 곳 주위의 문맥을 사용하는 경우

매크로는 매크로가 불린 문맥에서 바인딩 될 변수를 사용해서 작성될 수 있다. 다음과 같은 매크로는:

(defmacro foo (x)
`(+ ,x y))

foo가 불린 문맥에서 y가 어떻게 바인딩 되어 있느냐에 따라 결과가 달라진다.

이런 식으로 주위 문맥을 사용하는 것은 바람직하지 않은 경우가 많다. functional programming의 원칙은 매크로에도 적용되어야 한다: 매크로에 넘겨지는 매개 변수를 통해 매크로와 상호작용하는 것이 바람직하다. 실수로 일어나는 경우를 제외하면(9장을 보라.), 매크로를 호출한 주위 환경을 이용하는 매크로를 작성하게 되는 경우는 매우 드물다. 이 책에 있는 모든 매크로 중에서, continuation passing 매크로와(20장) ATN 컴파일러의 일부 매크로(23장)만이 이런 식으로 매크로를 호출한 주위 문맥을 이용한다.


6. 새로운 환경을 감싸는 경우

매크로는 그 인자가 새로운 문맥 속에서 평가되도록 할 수 있다. 전형적인 예는 lambda를 사용해서 구현된 매크로인 let이다.(144 페이지를 보라.) 다음과 같은 표현식 내부에서:

(let ((y 2)) (+ x y))

y는 새로운 변수를 참조하게 된다.


7. 함수 호출을 아끼기 위해서

매크로 확장은 매크로가 불려진 곳에서 인라인으로 일어나기 때문에, 이미 컴파일이 끝난 코드는 실행시에 매크로 호출에 따르는 오버헤드가 없다. 런타임에는 매크로가 이미 확장되어 코드로 삽입돼 있기 때문이다.(인라인 함수가 실행시에 함수 호출에 따른 오버헤드가 없는 것과 마찬가지이다.)


위에서 5번과 6번의 경우는, 의도하지 않았는데 실수로 일어나게 된다면, 변수 캡쳐(variable capture)의 문제를 발생시킨다. 의도하지 않은 변수 캡쳐는 매크로를 작성하는 사람이 반드시 피해야 할 실수이다. 변수 캡쳐에 대해서는 9장에서 설명한다.

매크로를 사용하는 7가지 경우가 아니라, 6가지 하고도 반의 경우가 있다고 말하는 것이 더 정확할 것이다. (현실에서는 리습 컴파일러가 inline 옵션을 줘도 무시하는 경우가 있지만) 이상적인 세계에서는 모든 리습 컴파일러가 inline 옵션을 받아들여서, 함수 호출을 아끼기 위해서는, 인라인 함수를 사용하면 되고 매크로를 사용할 필요가 없을 것이기 때문이다.


8.2 매크로와 함수 중 어느 것을?

이전 섹션에서는 명백한 경우들만을 다루었다. 인자들이 평가되기 전에 접근할 필요가 있는 경우는 다른 선택의 여지가 없이 매크로로 작성되어야 한다. 매크로와 함수 둘 다로 작성될 수 있는 경우라면 어떨까? 인자들의 평균을 리턴하는 avg 오퍼레이터의 예를 살펴보자. avg는 다음과 같은 함수로 구현될 수 있다:

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

하지만 매크로로 작성하는 것이 더 바람직한 경우이기도 하다:

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

이 경우에 함수 avg는 매번 불릴 때마다 length를 호출하는데, 이는 불필요한 것이다. 컴파일 타임에 인자들의 값을 알 수는 없지만, 인자들의 개수는 알 수 있기 때문에 length 호출은 컴파일 타임에 이루어지는 것이 좋다. 이와 같이 함수와 매크로 사이에서 선택을 해야 할 때 고려해야 할 점들을 나열하면 다음과 같다:


매크로를 사용했을 때의 장점

1. 연산이 컴파일 타임에 이루어진다.

매크로 호출은 두 단계에 걸쳐 처리된다: 먼저 매크로가 확장된다, 그리고 확장된 코드가 평가된다. 모든 매크로 확장은 컴파일이 끝나기 전에 이루어지기 때문에, 런타임 시에 이루어질 연산이 컴파일 타임에 미리 일어난다면 그만큼 프로그램의 수행 성능 면에서 이득을 보게 된다. 어떤 오퍼레이터가 해야 되는 일을 부분적으로나마 매크로 확장시에 할 수 있다면 그 오퍼레이터는 매크로로 작성되는 것이 효율적이다. 런타임 시에 해야 할 일을 컴파일 타임에 할 수 있기 때문이다. avg와 같이 오퍼레이터가 해야 할 일의 일부를 매크로 확장 시에 처리하는 예들을 13장에서 살펴볼 것이다.


2. 리습과의 긴밀한 통합

때때로 함수보다 매크로를 사용하는 것이 리습과의 통합을 높여줄 때가 있다. 어떤 문제를 해결하는 프로그램을 짜는 대신에, 그 문제를 리습이 이미 해결할 줄 아는 문제로 바꾸는 매크로를 작성하는 것이다. 이런 접근 방법은, 가능한 모든 경우에, 프로그램을 더 작고 효율적으로 만든다: 리습이 당신이 해야 할 일을 대신 해주기 때문에 프로그램이 작아지고, 효율적이 된다. 이런 장점은 리습으로 임베디드 언어를 작성할 때 가장 잘 나타나는데, 이에 대해서는 19장에서 살펴본다.


3. 함수 호출을 아낄 수 있다.

매크로는 호출된 지점의 코드 속으로 확장된다. 따라서 자주 사용되는 부분의 코드를 함수가 아니라 매크로로 만든다면, 그 부분이 사용될 때마다 함수 호출이 일어나는 것을 막을 수 있다. 커먼 리습 이전의 초기 리습들에서는 이런 특징을 이용해서 런타임에 일어나는 함수 호출을 줄이곤 했었다. 하지만 커먼 리습에서는 이런 일을 함수를 inline으로 선언함으로써 할 수 있다.

함수를 inline으로 선언함으로써, 마치 매크로를 사용할 때처럼, 컴파일러로 하여금 함수가 호출된 곳으로 코드가 삽입되도록 할 수 있다. 하지만 이론과 실제는 조금 다르다; CLTL2 (p. 229)에서는 inline 선언을 컴파일러가 무시할 수 있다고 말하고 있고, 실제로 몇몇 커먼 리습 컴파일러들은 그렇게 한다. inline 선언을 무시하는 컴파일러를 사용하고 있다면, 함수 호출을 줄이기 위해 매크로를 사용할 수 있다.


몇몇 경우에는 매크로를 사용함으로써 효율성과 리습과의 긴밀한 통합이라는 두 가지 이점을 모두 얻을 수 있다. 19장의 쿼리 컴파일러의 예에서는, 모든 연산을 컴파일 타임으로 옮겨 프로그램의 수행 효율성을 높일 수 있다는 이점이 너무나 크기 때문에 프로그램 전체가 하나의 거대한 매크로로 짜여지게 된다. 이러한 효율성 외에도, 연산을 컴파일 타임으로 옮기는 것이 프로그램과 리습 사이의 통합 정도를 높이는 - 쿼리 컴파일러의 경우에 쿼리 안에서 산술 연산과 같은 리습 표현식을 쉽게 사용할 수 있게 하는 - 결과를 낳게 된다.


함수를 사용했을 때의 장점

4. 매크로는 컴파일러에게 내리는 명령에 가까운 반면에, 함수는 데이터이다.

함수는 인자로 넘겨질 수도 있고(예를 들면, apply같은 함수에), 함수에 의해 리턴될 수도 있고, 자료 구조 안에 저장될 수도 있다. 매크로는 이와 같이 할 수 없다. 하지만 어떤 경우에는, 매크로 호출을 lambda로 감싸서 함수처럼 취급하는 테크닉이 가능하다. 예를 들어, apply나 funcall을 어떤 매크로에 적용하고 싶다고 하면 다음과 같이 할 수 있다:

> (funcall #'(lambda (x y) (avg x y)) 1 3)
2

위와 같이 하는 것이 가능하긴 하지만, 꽤 불편하고, 언제나 가능한 것도 아니다. 매크로가 &rest 매개변수를 가지고 있을 경우에는 위와 같은 식으로는 가변적인 개수의 인자를 넘길 방법이 없다.


5. 소스 코드의 명확성

매크로 정의는 같은 일을 하는 함수의 정의에 비해 코드를 읽기가 어렵다. 따라서 매크로를 사용해서 얻을 수 있는 이득이 미미하다면, 함수를 사용하는 것이 낫다.


6. 함수 호출은 추적 가능하고, 따라서 디버그하기가 쉽다.

매크로는 대체로 함수보다 디버그하기가 어렵다. 매크로가 많은 코드는 실행시 에러가 발생하여 백트레이스(backtrace)를 살펴볼 때, 매크로가 모두 확장되어 있을 것이기 때문에, 자신이 애초에 짰던 코드와는 모양이 많이 다를 것이다.

매크로가 확장되면서 매크로 호출은 없어져 버린다. 따라서 프로그램의 실행시에 trace 를 사용해서 매크로 호출을 추적하는 것은 불가능하다. trace를 사용하게 되면 매크로 호출이 아니라 매크로가 확장되어 생긴 함수들에 대한 호출을 보여줄 것이다.


7. 함수는 재귀적 호출이 가능하다.

매크로에서 재귀를 사용하는 것은 함수에서처럼 그리 간단하지가 않다. 매크로를 확장하는 함수는 재귀적일 수 있을지라도, 매크로 확장 자체는 재귀적일 수 없다. 섹션 10.4에서 매크로의 재귀에 대한 주제를 다룬다.


여태까지 살펴본 장단점들을 고려하여 언제 매크로를 사용할지 결정할 수 있을 것이다. 정확한 결정을 내리기 위해서는 많은 경험이 필요한 게 사실이다. 하지만, 이후의 장들에서 매크로가 유용하게 사용되는 전형적인 예들을 많이 보게 될 것이고, 여러분이 매크로를 작성할 때 자신이 작성하려는 매크로가 그런 예들과 비슷한지를 고려함으로써 판단에 도움을 받을 수 있을 것이다.

마지막으로 위에서 나열한 장단점 중 6번은 그다지 문제가 되지 않는다는 것을 말하고 싶다. 매우 많은 매크로를 사용한 코드라 하더라도 디버그 하기가 그렇게 어렵지는 않다. 매크로 정의가 몇백줄 정도 된다면, 그런 매크로가 확장된 코드를 디버그하는 것은 매우 어려울 수 있다. 하지만 매크로를 사용해서 만드는 유틸리티들은 대부분 그 크기가 작고, 신뢰할만한 층으로서 구축된다. 긴 유틸리티라 하더라도 15줄 미만의 것이 대부분이다. 따라서 백트레이스를 살펴볼 때 이 같은 매크로들의 확장이 크게 눈을 어지럽히지는 않을 것이다.


8.3 매크로의 적용

매크로로 어떤 일들을 할 수 있는지 살펴보았다. 그렇다면 대체 어떤 종류의 어플리케이션이 매크로를 필요로 할까? 매크로의 가장 일반적인 용도는 코드의 변환이다. 코드의 변환이라는 말은 매우 단순한 일만을 가리키는 것처럼 느껴질 수 있는데, 리습의 코드는 리습의 데이터 타입 중 하나인 리스트로 되어 있기 때문에 그것은 생각보다 많은 것을 의미한다. 실제로 19장에서 24장 사이에 다루는 프로그램들의 목적은 모두 코드의 변환이기 때문에, 프로그램 전체가 매크로로 짜여지게 된다.

매크로의 종류는 while과도 같은 일반적인 목적의 작은 매크로로부터, 보다 특수한 목적을 가진 큰 매크로까지 천차만별이다. 이런 스펙트럼의 한쪽 끝에 있는 것이 리습에 내장되어 있는 매크로들과 유사한 일반적인 유틸리티들이다. 이런 유틸리티들은 대체로 작고, 일반적인 목적에 사용되며, 독립적으로 작성되어 있다. 하지만 특정 프로그램에 초점을 맞춘 유틸리티들을 작성하는 것도 가능하다. 예를 들어, 그래픽 프로그램에 적합한 유틸리티들을 매크로로 작성할 수 있다. 그런 매크로들이 많이 작성되면, 마치 리습이 그래픽 프로그램을 작성하기 위한 언어처럼 보일 것이다. 매크로가 이런 방식으로 사용되면, 마치 리습과는 전혀 다른 새로운 언어로 프로그램을 짜는 것 같이 느껴질 수도 있다. 즉, 매크로를 사용해서 임베디드 언어를 구현할 수 있는 것이다.

유틸리티는 바텀-업 스타일 프로그래밍의 결과이다. 유틸리티 층 위에서 구현되기에는 너무 작아 보이는 프로그램조차, 리습이라는 가장 낮은 레이어 위에 몇 가지 유틸리티를 추가함으로써 덕을 볼 수 있다. 인자를 nil로 셋팅하는 nil! 유틸리티는 매크로를 사용하지 않고는 만들 수 없다:

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

nil!을 보고는 '그저 타이핑을 줄여주는 것 뿐이지 않나?'라고 생각할지도 모르겠다. 맞다. 모든 매크로가 하는 일은 그저 타이핑을 줄여주는 것이다. 하지만 컴파일러가 하는 일 역시 생각을 기계어로 옮기지 않아도 되도록 타이핑을 줄여주는 것 뿐이다. 유틸리티 각각을 보면 무시하고픈 생각이 들지 몰라도, 유틸리티들의 효과가 중첩되면 전혀 다른 결과를 만들어낸다. 간단한 매크로들이라도 몇 계층이 쌓이게 되면, 그 계층을 기반으로 똑같은 프로그램을 보다 우아하고 명쾌하게 표현할 수 있다.

대부분의 유틸리티는 패턴을 반영하고 있다. 만일 당신의 코드 속에 반복적으로 같은 패턴이 나타난다면, 그 부분을 유틸리티로 바꾸는 것이 좋다. 반복적인 패턴을 다루는 것은 컴퓨터의 일이다. 패턴을 만들어내는 프로그램을 짜는 것이 당신 스스로 매번 패턴을 작성하는 것보다 낫지 않겠는가? 프로그램을 짜는 도중에 다음과 같은 do 루프가 여러 곳에서 반복적으로 사용되고 있음을 깨달았다고 해보자:

(do ()
((not 'condition'))
'body of code')

코드에서 패턴이 반복되고 있는 걸 알았다면, 그 패턴에는 이름을 붙일 수 있는 경우가 많다. 이 패턴의 이름은 while이다. 그리고 이것을 유틸리티로 만든다면, 조건부 평가와 반복적인 평가가 필요하기 때문에 매크로로 작성해야 할 것이다. 91페이지의 내용을 바탕으로 해서 while을 정의하면 다음과 같이 정의하면:

(defmacro while (test &body body)
`(do ()
((not ,test))
,@body))

위에서 보았던 do 루프 패턴들을 while을 사용해서 치환할 수 있다:

(while 'condition'
'body of code')

결과적으로 코드는 더 짧아지고 그 의도를 더 잘 표현하게 된다.

인자들을 변환할 수 있는 매크로의 능력은 인터페이스를 작성하기에도 적합하다. 적절한 매크로는 길고 복잡한 표현식으로 되어 있는 인터페이스를 짧고 간단하게 만들 수 있다. 비록 GUI를 사용하는 엔드 유저들에게는 이런 매크로가 필요 없을지라도, 프로그래머들에게는 언제까지나 매우 유용할 것이다. 이런 예로는 defun이 있다. defun은 이름 없는 함수를 만들어 함수 이름에 바인딩하는 일을 마치 C나 파스칼에서 함수를 정의하듯이 하게 해 준다. 2장에서 살펴보았듯이 다음 두 가지 표현식은 동일한 효과를 가진다:

(defun foo (x) (* x 2))

(setf (symbol-function 'foo)
#'(lambda (x) (* x 2)))

defun은 첫번째 표현식을 두 번째 표현식으로 바꿔주는 매크로로 구현될 수 있다. 아마도 다음과 같은 매크로가 될 것이다:

(defmacro our-defun (name parms &body body)
`(progn
(setf (symbol-function ',name)
#'(lambda ,parms (block ,name ,@body)))
',name))

while이나 nil!은 일반적인 목적을 가진 유틸리티들이다. 어느 리습 프로그램이든 그것들을 사용할 수 있다. 하지만 특정한 분야를 위한 유틸리티들도 있을 수 있다. 만일 CAD 프로그램을 작성한다면, 두 개의 층으로 만드는 것이 좋을 수 있다: 먼저 CAD 프로그램을 위한 언어를 만들고(완곡한 표현을 선호한다면 툴킷이라고 할 수도 있겠다), 그 언어 위에서 CAD 프로그램을 작성하는 것이다.

리습은 다른 언어들이 당연하게 여기는 경계선들을 흐릿하게 만든다. 다른 언어들에서는 컴파일 타임과 런타임, 프로그램과 데이터, 언어와 프로그램 사이의 경계가 명확하다. 리습에서는 이런 경계가 용어의 편의를 위해서만 존재한다. 실제로 그런 경계는, 예를 들어 언어와 프로그램 사이의 경계 같은 것은, 존재하지 않는다. 문제를 해결하기 위해서 어디까지를 언어로 작성하고 어디까지를 프로그램으로 작성할 것인지는 전적으로 선택에 달려 있다. 기반이 되는 층을 언어라고 할 것이냐 툴킷이라고 부를 것이냐는 그저 용어의 문제일 뿐이다. 하지만 언어라는 용어를 사용하게 되면 리습에 유틸리티를 추가하여 특정 언어처럼 만들었듯이 해당 언어도 계속해서 확장해 나갈 수 있다는 느낌을 준다.

2D 드로잉 프로그램을 만든다고 해보자. 프로그램이 다루는 것은 시점 와 벡터 로 표현되는 선 뿐이라고 하자. 이런 프로그램은 여러 개의 개체들을 한꺼번에 움직일 수 있어야 하는데 아래의 move-objs 함수가 이런 일을 담당한다. 성능을 위해서, 개체들이 움직일 때마다 화면 전체를 갱신하지 않고, 변하는 부분만 갱신하려고 한다.

(defun move-objs (objs dx dy)
(multiple-value-bind (x0 y0 x1 y1) (bounds objs)
(dolist (o objs)
(incf (obj-x o) dx)
(incf (obj-y o) dy))
(multiple-value-bind (xa ya xb yb) (bounds objs)
(redraw (min x0 xa) (min y0 ya)
(max x1 xb) (max y1 yb)))))

(defun scale-objs (objs factor)
(multiple-value-bind (x0 y0 x1 y1) (bounds objs)
(dolist (o objs)
(setf (obj-dx o) (* (obj-dx o) factor)
(obj-dy o) (* (obj-dy o) factor)))
(multiple-value-bind (xa ya xb yb) (bounds objs)
(redraw (min x0 xa) (min y0 ya)
(max x1 xb) (max y1 yb)))))

개체들을 움직이기 전과 후에 bounds(개체의 틀이 되는 사각형의 네 꼭지점의 좌표를 리턴하는 함수)를 호출하여 비교해 보면, 변한 부분을 알아낼 수 있다. 따라서 move-objs는 개체들을 움직이기 전과 후에 bounds를 호출하고, 움직임에 따라 영향을 받은 부분만을 새로 그려야 한다.

scale-objs 함수는 개체들의 크기를 변경한다. 이 함수 역시 개체의 크기를 변경시키기 전과 후에 bounds를 호출하고 변경된 부분만을 새로 그려야 할 것이다. 우리가 계속해서 프로그램을 작성해 나감에 따라, 이런 패턴들을 무수히 많이 보게 될 것이다: 개체를 회전시키거나, 뒤집거나, 바꿔치기하는 함수들 속에서.

이 모든 함수들이 공통적으로 가지고 있는 패턴을 매크로를 사용해서 추상화시킬 수 있다. with-redraw 매크로는 위에서 본 함수들이 공통적으로 가지고 있는 패턴들을 추상화한다. 이 매크로를 사용해서, move-objs와 scale-objs를 다음과 같이 각각 네 줄로 줄일 수 있다.

(defmacro with-redraw ((var objs) &body body)
(let ((gob (gensym))
(x0 (gensym)) (y0 (gensym))
(x1 (gensym)) (y1 (gensym)))
`(let((,gob ,objs))
(multiple-value-bind (,x0 ,y0 ,x1 ,y1) (bounds ,gob)
(dolist (,var ,gob) ,@body)
(multiple-value-bind (xa ya xb yb) (bounds ,gob)
(redraw (min ,x0 xa) (min ,y0 ya)
(max ,x1 xb) (max ,y1 yb)))))))

(defun move-objs (objs dx dy)
(with-redraw (o objs)
(incf (obj-x o) dx)
(incf (obj-y o) dy)))

(defun scale-objs (objs factor)
(with-redraw (o objs)
(setf (obj-dx o) (* (obj-dx o) factor)
(obj-dy o) (* (obj-dy o) factor))))

with-redraw 매크로가 아직 두 개의 함수에 사용되었을 뿐이지만, 이미 각각의 함수에 패턴을 써 넣을 때보다 타이핑 면에서 이익이라는 것을 알 수 있다. with-redraw 매크로를 사용하는 함수가 늘어갈수록 그 이익은 더욱 커질 것이다. 그리고 가독성 면에서, 매크로를 사용하여 추상화한 코드가, 코드의 의도가 훨씬 명확하게 드러나는 것을 알 수 있다.

with-redraw를 그리기 프로그램을 작성하기 위한 언어의 일부라고 생각할 수도 있을 것이다. with-redraw와 같은 매크로가 늘어감에 따라 리습은 해당 분야에 적합한 언어처럼 변해가고, 프로그램은 애초에 그 분야만을 위한 언어로 짜여진 것 같이 우아하게 표현될 수 있다.

매크로의 주요한 용도 중 하나는 임베디드 언어를 구현하는 것이다. 리습은 프로그래밍 언어를 만들기에 매우 좋은 언어다. 리습 프로그램은 리스트로 표현되고 리습은 자체적으로 이를 다룰 수 있는 파서(read)와 컴파일러(compile)를 가지고 있기 때문이다. 대부분의 경우에는 굳이 compile을 호출할 필요도 없다; 매크로 확장이 일어난 리습 코드를 컴파일하면, 간접적으로 임베디드 언어로 짜여진 코드를 컴파일하는 것이나 마찬가지이기 때문이다(p. 225).

리습을 기반으로 구현된 임베디드 언어는 일반적인 리습의 문법과, 해당 분야에 적합한 특수한 오퍼레이터에 대한 문법이 뒤섞인 형태가 된다. 임베디드 언어를 만들기 위해서는, 리습으로 인터프리터를 작성할 수 있을 것이다. 하지만 보다 나은 방법이 있는데, 바로 변환 - 언어의 표현을 리습이 해석할 수 있는 코드로 바꾸는 - 을 사용해서 언어를 구현하는 것이다. 그리고 이런 변환을 위해서는 매크로가 필요한데, 매크로가 하는 일이 바로 어떤 표현을 다른 것으로 바꾸는 것이기 때문이다.

일반적으로 변환에 기반해서 임베디드 언어를 구현하는 것이 낫다. 그렇게 하는 것이 일단, 일이 적다. 예를 들어, 새로운 언어에 산술 연산이 필요하다면, 그것을 처음부터 모두 구현할 필요는 없을 것이다. 리습의 산술 연산 능력으로 충분하다면, 새 언어의 표현을 리습의 표현으로 바꾸는 부분만 작성하고, 실제 연산은 리습에게 맡겨두면 된다.

변환을 사용하는 것은 임베디드 언어를 속도 면에서도 빠르게 만든다. 인터프리터는 천성적으로 느릴 수 밖에 없는데, 예를 들어 코드에 루프가 있다고 하면, 컴파일 시에는 한 번만 해도 될 일들을 인터프리터는 매 루프마다 하기 때문이다. 따라서 인터프리터를 가진 임베디드 언어는, 설사 인터프리터 자체는 컴파일되어 있다 해도, 느릴 수 밖에 없다. 변환을 사용해서 언어를 구현하면, 이런 문제가 없다. 매크로를 사용해서 임베디드 언어를 구현하게 되면, 마치 작은 컴파일러를 작성한 것처럼, 성능면에서 좋은 결과를 낳는다. 사실, 임베디드 언어를 변환하는 매크로는 그 언어를 위한 컴파일러 - 단지 대부분의 일을 리습 컴파일러에게 의존하긴 하지만 - 라고 생각해도 무방하다.

19장에서 25장까지가 모두 임베디드 언어의 예를 다루고 있다. 특히 19장에서는 하나의 임베디드 언어를 인터프리터를 사용하는 방법과 변환을 사용하는 각각의 방법으로 구현하는 것을 보이고 그 차이에 대해 논의할 것이다.

어떤 커먼 리습 책에서는 CLTL1에 정의된 오퍼레이터 중 매크로의 비율이 10%도 안 된다는 것을 이유로 들어, 매크로의 사용을 예외적인 것으로 단언하고 있다. 하지만 이것은 마치 집이 벽돌로 지어져 있으니, 가구도 벽돌로 만들어져야 한다는 말과 같다. 커먼 리습 프로그램에서 매크로가 차지하는 비중은 그 프로그램의 목적이 무엇이냐에 따라 다르다. 어떤 프로그램에는 매크로가 하나도 없을 수도 있지만, 어떤 프로그램은 전체가 매크로로 짜여질 수도 있다.


translated by 찬우

댓글 없음: