2019년 9월 11일 수요일

[on lisp] 10 Other Macro Pitfalls

10.1 Number of Evaluations

; Fig 10.1 : Controlling argument evaluation.
; A correct version
(defmacro for ((var start stop) &body body)
  (let ((gstop (gensym)))
    `(do ((,var ,start (1+ ,var))
          (,gstop ,stop))
         ((> ,var ,gstop))
       ,@body)))

;; subject to multiple evaluations:
(defmacro for ((var start stop) &body body)
  `(do ((,var ,start (1+ ,var)))
       ((> ,var ,stop))
     ,@body))

;; Incorrect order of evaluation:
(defmacro for ((var start stop) &body body)
  (let ((gstop (gensym)))
    `(do ((,gstop ,stop)
          (,var ,start (1+ ,var)))
         ((> ,var ,gstop))
       ,@body)))
2번째 for문에는 버그가 있다.
; 무한에 가까운 출력
(let ((x 2))
  (for (i 1 (incf x))
    (princ i)))
stop 매개변수로 들어온 녀석이 각 루프를 돌 때마다 평가된다.
이 말은 (incf x)가 매번 평가되면서 끝나지 않는 것이다.
어떻게 다른애들은 괜찮고 얘는 괜찮지 않을까?
다른 애들은 어떻게 한번만 실행되고 안되는 것일까?
이 생각은 일단 접어두고

매개변수로 들어온 stop에 side-effect가 있다면, 결과값은 위처럼 문제가 생길 수 있다는 것이다.
for같은 매크로를 만들 때, 꼭 알아야 할 것이 있다. 매크로는 값을 받아서 실행하는 녀석이 아니다. 그럴거면 함수를 만들었을 것이다.
매크로는 표현식을 받는다. 그것도 자신의 리스트 자료구조로 말이다.
그리고 이 표현식이 확장시 있는 곳에 따라, 평가는 한번 이상 평가될 수 있다.

이 경우 값을 바인딩 해놓는 것이다. stop으로 들어오는 표현식을 미리 평가하여 값으로 받은 후 loop에는 해당 값이 들어가도록 하는 것이다.
위에 내용들 보면 gstop이 그런 역할을 하는 녀석.

매크로는 반복을 위한 녀석임이 확실하지 않으면, 매크로 호출에 나타나는 횟수만큼 정확하게 평가되도록 해야 한다.
이 경우가 적용되지 않는 명백한 사례가 있다: 커먼리습의 or에서 들어온 매개변수가 모든 평가되어야 한다면 꽤나 쓸모없는 녀석일 것이다.
그러나 그런 경우 유저는 얼마나 많은 평가가 일어날지 예상할 수 있는지 안다.
하지만 for의 두번째 경우는 그렇지 않다.
사용자는 stop표현식이 두번 이상 평가된다고 가정할 이유가 없으며, 실제로 평가할 이유도 없다. (그런 내용이 매크로를 호출할 때 보이지 않는다는 것이다)
바로 이 두번째 for문 매크로가 가장 실수를 많이하는 경우일 것이다. 단서가 보이지 않으니까.

setf를 이용한 매크로의 경우 Unintended multiple evaluation(의도치 않은 다중 평가)라는 아주 어려운 문제를 직면하는 경우가 많다.
커먼리습은 이런 매크로를 만드는 것이 쉽도록 여러가지 유틸리티를 제공한다. (챕터 12에서 보여줌)

10.2 Order of Evaluation(평가의 순서)
표현식 평가의 횟수만이 문제가 아니라, 평가의 순서가 문제를 일으킬 수 있다.
커먼리습 함수 호출에서, 매개변수는 왼쪽에서 오른쪽 순서로 평가된다.
(setq x 10) ; 10
(+ (setq x 3) x) ; 6
이렇게 다르다. 매크로도 동일한 일이 일어난다.
매크로는 일반적으로 매크로 호출을 할 때 보이는 순서와 동일한 순서로 내부적으로 평가되어야 한다.
아래 for문이 그 법칙을 무시한 녀석이다. 잘 보면 var이 첫번째 매개변수고 stop이 두번째 매개변수인데
뒤바뀌어 있다. 호출할 때는 이렇게 된다는 힌트가 전혀 없다. 다들 평가가 매개변수의 순서대로 될 것이라고 생각할 것이다.
(defmacro for ((var start stop) &body body)
  (let ((gstop (gensym)))
    `(do ((,gstop ,stop)
          (,var ,start (1+ ,var)))
         ((> ,var ,gstop))
       ,@body)))
하여 for문에는 작은 버그가 있는데, stop이 start보다 먼저 평가되는 것이다.
(let ((x 1))
  (for (i x (setq x 13))
    (princ i)))
13
NIL
보면 알겠지만, 123456789010111213 이렇게 보여야 하는데 (setq x 13)이 먼저 세팅되면서
루프가 돌 기회가 없게 된 것이다.

10.3 Non-functional Expanders
리스프에서는 매크로로 확장된 코드가 순수 함수적이길 바란다. 확장코드(expander code)는 매개변수말고는 영향을 받지 말아야 하며 리턴값 외에는 다른 영향을 주면 안된다.
컴파일된 코드의 매크로 호출은 런타임에 다시(또) 확장되지 않는다고 봐도 무방하다.
만약 그렇게 되지 않으면 커먼리습은 이 확장이라는 것이 언제, 어디서, 얼마나 자주 매크로 호출이 확장되는지 알 수 없게 된다.
이런 것들 중 하나로 매크로의 확장이 달라지면 에러로 간주한다. (상관없어야 한다)
아래를 보자.
(defmacro nil! (x)  ;;wrong
  (incf *nil!s*)
    `(setf ,x nil))
글로벌 값 *nil!s*가 정말 매크로가 호출될 때 1번씩 호출되서 카운팅 해줄까? 그렇게 생각하면 오산이다.
주어진 호출은 한번이상 확장될 수 있고, 종종 그렇게 된다.
소스코드를 변환을 수행할 프리프로세서는 일단 변환을 하기 전에 평가를 먼저 해서 변환을 할지 안할지를 결정한다.

다시 말하지만, 일반적으로 확장자코드는 인수 외의 것에 의존하면 안된다.
예를들어, 문자열에서 확장을 빌드하는 매크로가 있다하자.
이 매크로가 확장시 패키지가 무엇인지 그런건 가정하지 않도록 하자.
(defmacro string-call (opstring &rest args) ; wrong
  `(,(intern opstring) ,@args))

(defun our+ (x y) (+ x y))
OUR+
(string-call "OUR+" 2 3)
5
보면 intern을 이용하여 문자열을 가져와 연관된 심볼를 리턴한다. 하지만 우리가 optional package argument을 안쓰면(제거하면), 이건 현재 패키지에서 일어날 것이다.
따라서, 확장이 생성될 때 패키지에 의존한다. 해당 패키지에 our+가 표시되지 않으면 해당 내용은 nil을 리턴할 것이다.
이 말은 환경에 따라 값이 달라질 것이란 말임..

Miller and Benson's Lisp Style and Design에선 expander code안에 side-effect가 있는 것을 특별히 멍청한 예로 제시했다.

또 다른 문제가 있는데 그것은 &args에서 온다.
&rest 파라미터에 들어오는 값은 새로 만들어진 거라고 개런티할 수 없다.
결론만 먼저 말하면, &rest 파라미터로 오는 값을 파괴적으로 수정하면 안된다(값을 바꾸면 안대!)
이런 경우는 함수와 매크로 모두에게 해당하는 말이다. 함수로 쓸 때는 apply와 함께 쓸 때 문제가 나타난다.
(defun et-el (&rest args)
  (nconc args (list 'et 'al)))

(et-al 'smith 'jones)
; (SMITH JONES ET AL)
하지만 여기서 apply를 이용해서 호출하면 어떻게 될까. 이미 존재하는 자료구조를 바꾼다.
(setq greats '(l m))
(apply #'et-el greats)
; (l m et el)
greats
; (l m et el)

매크로에서는 문제가 더 심각해진다. 매크로에서 &rest를 변경한다면 매크로 호출을 변경하는 일이 생기게 된다.
이 말은 의도치 않게 매크로를 자기자신이 재작성하는 일이 벌어진다.
여기서 문제는 더 심각해진다. 이 일은 실제로 이미 존재하는 구현 위에서 일어난다.
만약 nconc를 &rest매개변수에 적용하는 매크로를 정의한다고 해보자.
(defmacro echo (&rest args)
  `',(nconc args (list 'amen))) ; `',(foo) is equivalent to `(quote ,(foo)).
그리고 이제 함수를 정의하고 호출한다.
(defun foo () (echo x))

(foo) ; (X AMEN AMEN)
(foo) ; (X AMEN AMEN AMEN)
뭐지 계속 foo가 바뀐다. 왜냐하면 각 매크로 확장이 foo의 정의를 변경하는 것이다.
바꿔보자.
(defmacro echo (&rest args)
  `'(,@args amen))
@(comman-at)은 append와 같기 때문에 파괴적이지 않다. 그러므로 안전하다.

그런데 이걸로 끝일까.
매크로에서는 &rest이것만 조심한다고 끝나는 것이 아니다.
아무 매크로 매개변수가 그렇게 될 수 있다. 그 녀석이 리스트라면
(defmacro crazy (expr) (nconc expr (list t)))

(defun foo () (crazy (list)))

(foo) ; (T T)

10.4 Recursion
함수를 재귀적으로 만드는 것은 자연스러운 일이다.
(defun our-length (x)
  (if (null x)
      0
      (1+ (our-length (cdr x)))))
(defun our-length (x)
  (do ((len 0 (1+ len))
       (y x (cdr y)))
      ((null y) len)))
첫번째는 재귀적이고 두번째는 아니다.
그런데 매크로에서는 그저 backquotes나 commas를 넣는다고 쉽게 재귀가 되지 않는다.
; Fig 10.2 : Mistaken analogy to a recursive function.
; this will work
(defun ntha (n lst)
  (if (= n 0)
      (car lst)
      (ntha (- n 1) (cdr lst))))

; this won't work
(defmacro nthb (n lst)
  `(if (= ,n 0)
       (car ,lst)
       (nthb (- ,n 1) (cdr ,lst))))
왜 nthb가 문제가 되는 걸까. 바로 확장할때마다 자신을 가지고 있기 때문인데
함수의 경우 확장을 하면 매개변수의 값이 평가가 되기 때문에 언제 멈출지 안다. 하지만 매크로는 형태만 있기 때문에 계속 무한 확장하는 수가 있다.
; (nthb x y) 를 확장하자.
(if (= x 0)
    (car y)
    (nthb (- x 1) (cdr y)))
; 위 녀석은 다시 확장된다.
(if (= x 0)
    (car y)
    (if (= (- x 1) 0)
        (car (cdr y))
        (nthb (- (- x 1) 1) (cdr (cdr y)))))
그럼 어떻게 해야 하나. 일단 재귀적으로 풀지 않으면 된다. 아래처럼
(defmacro nthc (n lst)
  `(do ((n2 ,n (1- n2))
        (lst2 ,lst (cdr lst2)))
       ((= n2 0) (car lst2))))

아래 다른 예시가 있다.
(defmacro nthd (n lst)
  `(nth-fn ,n ,lst))

(defun nth-fn (n lst)
  (if (= n 0)
      (car lst)
      (nth-fn (- n 1) (cdr lst))))

(defmacro nthe (n lst)
  `(labels ((nth-fn (n lst)
              (if (= n 0)
                  (car lst)
                  (nth-fn (- n 1) (cdr lst)))))
     (nth-fn ,n, lst)))
보면 알겠지만 재귀는 함수로 만들어서 그걸 사용하는 것이다.
혹은 labels를 이용하여 로컬 함수를 정의하여, 그 녀석을 실행하는 것이다.
결국 두 경우 모두 함수와 매크로를 더해서 실행하는 방식이다.
물론 매크로가 전부 확장 할 수는 없지만 어쨋든 재귀는 값을 평가하긴 해야 한다.
그러므로 재귀적으로 평가는 가능하게 만들 수는 있는 것이다.

매크로의 확장함수가 일반 리스프 함수이니까, 우리는 이걸로 재귀적으로 만들 수 있을 것이다.(그러니까 매크로의 확장이 매크로일 수 있겠냐 라는 것이다. 지금까지 안되었는데)
built-in or함수를 정의하면서 봐보자.
macro ora는 or-expand라는 재귀함수를 호출하여 확장을 생성한다.
orb도 같은 일을 한다. orb는 특이하게 값이 아니라 매크로에 대한 인수에서 반복한다 (??)
이렇게 되면 무한으로 펼쳐질 것 같지만, 하나의 매크로 확장단계에서 생성된 orb에 대한 호출은 다음 단계에서 let으로 대체된다(?) 하여 최종 확장에서는 let들의 중첩들에 지나지 않는다.
아래를 보자.
; (orb x y) 확장
(let ((g2 x))
  (if g2
      g2
      (let ((g3 y))
        (if g3 
            g3 
            nil))))
;; 여기서 마지막 nil이 있는데 거기서 재귀가 잘 멈춘것.
;; 아래는 코드
(defmacro ora (&rest args)
  (or-expand args))
(defun or-expand (args)
  (if (null args)
      nil
      (let ((sym (gensym)))
        `(let ((,sym ,(car args)))
           (if ,sym
               ,sym
               ,(or-expand (cdr args)))))))

(defmacro orb (&rest args)
  (if (null args)
      nil
      (let ((sym (gensym)))
        `(let ((,sym ,(car args)))
           (if ,sym
               ,sym
               (orb ,@(cdr args)))))))

댓글 없음:

댓글 쓰기