"커먼리습 ASDF 불러오기 편하게 하기" 대모험

Posted on Oct 23, 2020

ASDF와 system definition file 검색의 정석

이전에 ASDF을 이용하여 커먼리습 프로젝트의 의존성, 시스템을 로딩하기 에 대해서 설명한 적이 있었다.

이전에 다룬 내용은 정석적으로 $HOME/common-lisp 디렉토리에 .asd 파일을 심볼릭링크를 걸고, (asdf:load-system ..) 을 시도하는 내용이었었다.

Prototyping등 더 편안하게 혼자 코딩을 할 때는…

혼자 커먼리습 코딩을 하면서, 나는 더 간단하게 프로젝트를 시작하는 방법을 선호한다.

커먼리습이 파일 이름이나 경로에 대해서 의존하는 것이 아니고, 컴파일시점, 로딩시점에 순서에 따라 로딩해서 최종적으로 컴파일하여 적재한 결과 이미지를 더 중요하게 여기기 때문에, 그리고 그런 컴파일, 빌드, 로딩과 같은 모든 단계들 자체도 커먼리습 표현식 그자체로 동작하기 때문에 그냥 하나의 소스파일을 섹션별로 나눠서 작업하면 편리하다.

큰 흐름은 대충 다음과 같다.1

  1. 그냥 foo.lisp 와 같이 소스 파일 하나를 만든다.
  2. 소스 파일 하나에 여러 개의 섹션으로,

    1. ASDF 시스템 선언 (asdf:defsystem ...)
    2. 패키지별 패키지 선언 (defpackage ...)
    3. 패키지마다 구현 내용 (in-package ...)
    4. 패키지에 대한 테스트 (in-package ...) (in-suite ...)
    5. …이렇게 한 파일에 전부 넣어서 그냥 주욱 코딩해 나간다.
  3. 나중에 규모가 커지고 어느 정도 완성이 되었을 때,

    1. 시스템 선언 -> /*.asd 파일으로 분리.
    2. 패키지 선언 -> /src/packages.lisp 파일으로 분리.
    3. 패키지의 구현 -> /src/*.lisp 파일들으로 분리.
    4. 테스트 -> /test/*.lisp 파일들으로 분리.

그런데, ASDF 의존성을 그런 프로토타이핑 흐름을 위해 적용하려면

일단 커먼리습에서 내 작업 흐름은 아주 간단하다. 그리고 소스파일 1개으로 작업을 하는 것에도 적합하다.

  1. 소스를 작성하다가, 이맥스 Sly/SLIME에서 C-c C-k 눌러서 바로 컴파일, 로드.
  2. 그리고 C-c C-z 으로 REPL으로 이동해서, 혹은 REPL의 current package을 소스파일의 특정 영역에서 C-c ~ 을 누르면, 해당 패키지로 전환해서 쉽게 테스트.
  3. 테스트 코드가 있다면 그냥 REPL에서 바로 함수로서 실행.
  4. 에러가 있다면, 디버거가 실행될 수도 있고,
  5. 아니면, 디버거를 통하지 않고서도 C-c I 으로 특정한 값의 구조를 sly-inspector으로 이맥스 안에서 편안하게 디렉토리 오가듯이 오가며 파악하고 변경이 가능.
  6. (1)부터 반복.

원래 정석대로 파일을 나눴다면

  1. ASDF을 처음부터 다시 로딩하도록 REPL에서 시도했을 것이다.

    1. 물론 ASDF 파일을 작성해놓았었야 했겠지.
  2. 그리고 가끔 ASDF이나 패키지 선언과 달라진 부분이 너무 크다면,

    1. 완전히 다시 REPL을 시작하고 재로딩을 시도했을 것이다.

그런데 asdf 파일을 작성하기도 귀찮고, 심볼릭링크도 귀찮을 때

  1. 의존하는 .asdf 파일을 심볼릭링크를 걸어줬어야겠지만,
  2. 그냥 새로 만들고 있는 프로토타이핑 프로젝트의 하위 디렉토리로, git submodule add 해놓거나 했을때,
  3. 그냥 (ql:quickload ...) 이나 (asdf:load-system ...) 을 프로토타이핑 소스파일의 처음에 로딩하도록 지정해놓으면 자동으로 로딩하도록 만들고 싶다.

    1. 절대경로를 쓰거나,
    2. git-submodule들의 디렉토리를 일일이 $HOME/common-lisp 등의 레지스트리로 링크를 걸지 않더라도.

그래서 다음과 같이 "조금" 작성해봤다

  (eval-when (:compile-toplevel :load-toplevel :execute)
    ;; 필요한 라이브러리들 로드.
    (ql:quickload :cl-fad)  ; 하위 디렉토리에서 파일 검색 & pathname 조작.
    (ql:quickload :cl-ppcre)  ; regex.
    (ql:quickload :closer-mop)  ; funcall-able metaclass.
    (ql:quickload :equals)  ; CLOS객체의 값 비교 protocol.

    ;; 로딩 하는 소스파일의 디렉토리.
    (defvar *my-dir* nil))

  (unless *my-dir*
    ;; 처음 소스파일을 컴파일, 로딩 할 때 세팅됨.
    (let* ((pn *load-pathname*)
           (dir (cl-fad:pathname-directory-pathname pn)))
      (setf *my-dir* dir)))

  ;;; cl-fad:walk-directory 으로 파일 검색 시 조기 중단을 위해 condition 정의.
  (define-condition cl-fad-matching-found (condition)
    ((found :initarg :found)))

  (defun find-asdf-in-subdir (dir asdf-name)
    "(find-asdf-in-subdir #p\"/home/my/project\" :cl-state-machine)
    ;; => #P\".../cl-state-machine.asd\" or NIL"
    (flet ((signal-found (found)  ; 검색에 성공해서 매칭 결과를 전달.
             (signal 'cl-fad-matching-found :found found)))
      (handler-bind ((cl-fad-matching-found  ; 검색 성공, 매칭 결과를 되돌리며 조기 종료.
                       #'(lambda (c) (with-slots (found) c
                                       (return-from find-asdf-in-subdir found)))))
        ;; `scanner' 으로 regex 매칭을 준비.
        (let ((scanner (cl-ppcre:create-scanner (format nil "~a\.asd(f)?"
                                                        (string asdf-name))
                                                ;; keyword->string하면 기본 설정이 대문자이므로.
                                                :case-insensitive-mode t)))
          (cl-fad:walk-directory dir
                                 (lambda (name)
                                   (let ((base-name (path:basename name)))
                                     (when (cl-ppcre:scan scanner (namestring base-name))
                                       ;; 매칭 성공.
                                       (signal-found name))))
                                 ;; 디렉토리 항목은 매칭 시도 안하도록.
                                 :directories nil)
          ;; not-found
          nil))))

  ;;; `funcallable-standard-class'을 metaclass으로 지정하여, closure으로
  ;;; finder함수를 감싸서 `asdf:*system-definition-search-functions*'
  ;;; 넣지 않도록 했다.
  ;;;
  ;;; closure이 아니라 그냥 CLOS object이므로 `equals:equals'을 구현하기
  ;;; 쉽도록. (또 common-lisp 구현에 따라서도 portable하기도 하고)
  ;;;
  ;;; http://www.metamodulaire.net/CLOS-MOP/funcallable-instances.html
  (defclass asdf-finder ()
    ((base-dir :initarg :base-dir))
    (:metaclass closer-mop:funcallable-standard-class))

  ;;; 반복적으로 `pushnew'해도 동일한 디렉토리에 대한, `asdf-finder'을
  ;;; 중복해서 넣지 않도록, `equals'-protocol을 구현.
  ;;;
  ;;; `pushnew'을 반복적으로 하게 되는 이유는, 같은 live image에
  ;;; 소스코드를 수정하며 컴파일을 반복적으로 하며 점진적으로 개발하는
  ;;; 사이클이 커먼리습에서는 편하니까, 그런 개발 사이클에 적합할 수
  ;;; 있도록 고려해서.
  (defmethod equals:equals ((lhs asdf-finder) (rhs asdf-finder) &rest args)
    (declare (ignore args))
    (equals:equals (slot-value lhs 'base-dir) (slot-value rhs 'base-dir)))

  ;;; 초기화가 끝난 다음에, funcallable한 객체로 만든다.
  (defmethod initialize-instance :after ((an-asdf-finder asdf-finder) &key)
    (with-slots (base-dir) an-asdf-finder
      ;;; `funcall' 이 이 인스턴스에 대해 적용될 때, 어떻게 동작할지.
      (closer-mop:set-funcallable-instance-function
       an-asdf-finder #'(lambda (asdf-name)
                          (find-asdf-in-subdir base-dir asdf-name)))))

  ;;; ASDF3에 연결.
  ;;;
  ;;; 현재 소스파일이 있는 디렉토리 이하에 있는 `*.asdf', `*.asdf' asdf
  ;;; system definition file을 자동으로 로딩한다.
  ;;;
  ;;; 예를 들어, `(asdf:load-system :cl-state-machine)' 을 시도하면,
  ;;; `cl-state-machine.asd' 이나 `cl-state-machine.asdf' 을 하위
  ;;; 디렉토리에서 검색해 로딩할 것이다.
  (pushnew (make-instance 'asdf-finder :base-dir *my-dir*)
           asdf:*system-definition-search-functions*
           :test #'equals:equals)

  ;; EX: (ql:quickload :cl-state-machine)
  ;; EX: (asdf:load-system :cl-state-machine)

감상

  1. closer-mop와 funcallable-standard-class 메타클래스를 이용하여 재밌었다.

    1. closure function을 ASDF 검색 함수 리스트에 넣으면 간단하겠지만, 그럴 경우에 동일한 base directory에 대해서 검색하는 동일한 함수가 이미 들어가 있을 때 중복해서 넣지 않도록 방지하기는 쉽지 않을 것 같아서, CLOS 객체를 만들고, funcall 가능하도록 만들었다.

      1. 물론 closure일 때도 방법이 없는 것은 아니지만…2
    2. 어쨌든 funcall으로 호출해서 검색하고, 또 반대로 CLOS객체이므로 어떤 base directory을 갖는지 다시 알아내기도 편하니까.
    3. pushnew을 할 때, :test #'equals:equals 으로 지정하였고,
    4. equals을 위해 defmethod equals:equals 을 내가 정의한 asdf-finder 클래스에 대해서 프로토콜 구현을 해줬다.
  2. cl-fad:walk-directory 은 기본적으로 하위 디렉토리의 모든 파일에 대해서 실행되는데, 첫 번째 매칭되는 내용을 만날 때 조기종료를 하도록 바꾸고 싶었다.

    1. 커먼리습의 Condition System을 이용하여 아주 간단하게 구현할 수 있었다.
    2. 물론 다른 언어의 try-throw-catch 등으로 동일하게 구현이 가능하다.
  3. CLOS와 initializer-instance :after 등을 이용해서 AOP처럼 구현하기 용이했다.

    • 다른 언어였다면, "constructor"등을 override하고 다시 super의 구현을 호출하고 하는 방식이 보통인데… 그렇게 편리한 방법이 아니다. super constructor이 그대로 동작하도록 파라미터를 전달하거나 하는 식으로 신경을 조금 더 쓰게 된다.

  4. 빌드시스템, 컴파일러, 커먼리습 그 자체 등등이 모두 "거기에 있고", 또 확장이 가능해서 이런 방식으로 쓸 수도 있는 것 같다.

    1. 커먼리습은 다른 언어는 물론, 다른 리습보다도 훨씬 그냥 내가 가장 편안한 형태로 작업하도록 만들 수 있는 것 같다.
    2. 컴파일러, 이미지 빌드 자체가 커먼리습 프로그램일 뿐이고,
    3. 심지어 컴파일 시점, 매크로 확장 시점, 등등의 시점을 분리해 놓고, 또 그 시점별로도 커먼리습 그 자체로 확장해갈 때마다 재밌다.
    4. 첫 인상은 누군가에게는 어쩌면 불친절해보이는 리습이지만, 조금만 이해를 해나가면, 아주 오래전에 설계한 HyperSpec을 정하는 당시에 이렇게 얼마든지 자유롭게 쓸 수 있도록 설계를 해놓았다는 것을 느낄 때마다 놀랍다.
  5. 코드를 작성하고, 빌드하고, 테스트를 실행하고, 디버거를 쓰고, inspector을 읽는 매 사이클을 가장 최적으로 쓸 수 있어서 좋다.

    1. 이맥스에서 Sly, SLIME 같은 커먼리습 개발 환경에서 그냥 다 연동되어 있고,
    2. 그냥 C-c C-k 누르면 바로 전체 컴파일이 되고,
    3. C-c I 으로 특정한 식의 결과를 평가해서 inspector으로 구조를 그냥 이맥스 안에서 계층적으로 살펴보고 바꿔볼 수 있고,

      • 디버거도 마찬가지이고,
    4. …요즘의 자바스크립트를 위한 웹브라우져에 내장되어 있는 developer tools 정도가 그냥 개발환경, 개발 이미지에 바로 직접되어 있고, 그게 정말 빨라서 컴파일이 몇 초나 걸리지도 않는다.
    5. 그냥 생각을 하는대로 작성하고 테스트해볼 수 있다.
    6. 그리고 무엇보다 그런 사이클에 맞춰서 확장, 변경이 용이하니까.

Footnotes


1

처음 Clojure의 프로토타입을 개발할 때도, 커먼리습 파일 하나로 시작했다고 알고 있다. 그리고 Tcl을 사용했지만, Redis의 경우에도 tcl 파일 하나로 프로토타이핑을 시작했다고 한다. https://gist.github.com/antirez/6ca04dd191bdb82aad9fb241013e88a8 처음 프로토타이핑을 할 때 집중도 쉽고 간략히 내가 원하는 내용을 굳이 파일이름을 정하거나 디렉토리 구조를 고민하지 않아도 되는 언어들, 이미지 기반의 언어들이거나, 커먼리습, 스몰톡, 펄, Tcl 같은 경우 더 이렇게 작성하기 편하다.

2

finder 함수에 대해서 심볼을 설정하고, 그 심볼에 (setf (get …)) 하는, property list을 이용하면 된다. 하지만 어디까지나 심볼을 할당해야 하고, 그 심볼이 정말로 매 컴파일 사이클마다 유지될지, 그렇게 되도록 해야할지는 잘 모르겠다. 그다지 예쁜 방법은 아니다.