2010-08-13

ASDF как язык описания систем

Это третья статья в серии про ASDF. Первая часть — об ASDF 2, вторая — об архитектуре ASDF.

Как было замечено ранее, ASDF выполняет 2 связанные, но все же довольно разные по требованиям со стороны пользователей функции: описание систем и управление их сборкой. В этой статье я постараюсь перечислить распространенные (и не очень) шаблоны его применения для этих задач, собранные мной из более 70 open-source Lisp библиотек, с которыми приходилось работать. Я думаю, что систематизация этих знаний сослужит хорошую службу Lisp-сообществу и будет полезна как начинающим, так и экспертам, которые смогут усовешенствовать свои техники.

Изначально в этой статье я планировал разобрать все основные шаблоны использования ASDF, но материала оказалось слишком много, поэтому более сложным темам его применения как build-инструмента будет посвящена следующая серия.

Прямые ссылки на полезные вещи:

Начало работы с ASDF


ASDF нельзя назвать инструментом, который можно освоить за 5 минут. Я сам, например, очень долго вникал в идеологию и особенности его работы, и делал это в основном методом проб и ошибок (мануал мне плохо помогал :). Однако, сейчас мне кажется, что эти трудности связанны не с особенностями ASDF (какими-то неудачными архитектурными решениями и т.п.) или Lisp'а, а, в первую очередь, с отсутствием должной документации и best-practices. Именно этот пробел я и хочу заполнить. Да, всё, что будет показано ниже, почерпнуто из широкодоступного кода Lisp-библиотек, однако часто нужны скорее tutorial'ы, которые позволят быстро начать работу и сразу получить ожидаемый результат. Поэтому начнем именно с такого самого простого примера.

Итак, чтобы создать новый проект с помощью ASDF — нужно создать директорию проекта (пусть будет testprj), в которой поместить имеющиеся lisp-исходники (пусть это будет файл app.lisp) и создать в этой директории файл testprj.asd, в который поместить следующую форму:
(asdf:defsystem #:testprj
:components ((:file "app")))
Для загрузки нового проекта в Lisp-среду нужно, чтобы наш testprj.asd файл был в известных ASDF местах. На данный момент поддерживается 2 способа задания таких мест: аналог PATH (*central-registry*) и source-registry (использующий конфигурационные файлы). Самый распространенный подход (в POSIX-окружении) — это создать ссылку на asd-файл в какой-то специальной директории (например, ~/.lisp), которая добавлена в *central-registry*.

В общем полная настройка такого варианта выглядит так:
$ ln -s <path-to-testprj.asd> ~/.lisp/testprj.asd

;; обычно это делается в rc-файле lisp'а (например, .sbclrc)
CL-USER> (push "~/.lisp/" asdf:*central-registry*)

;; далее можно выполнять (для ASDF 2):
CL-USER> (asdf:load-system :testprj)
;; или для любой версии ASDF:
CL-USER> (asdf:oos 'asdf:load-op :testprj)
;; или в большинстве окружений (например, SBCL), даже:
CL-USER> (require :testprj)
Вот и всё.

Другие формы в ASD-файле


На самом деле, ASD-файл — это обычный lisp-исходник, просто с другим расширением, что помогает найти его ASDF'у, поэтому в нем могут находится и другие lisp-формы. Впрочем, прежде, чем помещать туда произвольные формы, стоит очень хорошо подумать. Во всяком случае, ASD-файл следует сохранить полностью декларативным, поскольку большинство инcтрументов, созданных вокруг ASDF, должны иметь возможность просто читать этот файл, не исполняя.

Пакет для defsystem


Единственный класс форм, размещение которых в ASD-файле необходимо (помимо defsystem и некоторых других ASDF-специфичных форм, о которых будет сказано далее) — это формы работы с пакетом.

Сейчас в Lisp-сообществе есть 2 конкурирующих взгляда на то, как это делать:
  • простой — определять системы в пакете ASDF (in-package :asdf)
  • скурпулезный — определять отдельный пакет только для ASDF-описания системы:
    ;; Пример из описания системы ARCHIVE:
    (defpackage :archive-system (:use :cl :asdf))
    (in-package :archive-system)
Лично я являюсь сторонником первого подхода, поскольку он не добавляет избыточной сложности. Его единственный недостаток — в возможном конфликте символов, однако он решается использованием особых символов (#:testprj или :testprj). Единственным случаем, когда вариант отдельного пакета может оказаться препочтительнее — это какие-то очень сложные описания систем с зависимостями от дополнительных пакетов. Впрочем, это верный признак того, что вы делаете что-то не так и, просто, не пользуетесь всеми возможностями ASDF.

Использование символов


Имена ASDF-систем в форме defsystem, как и CL пакетов в defpackage, можно задавать несколькими способами:
  • символом: (defsystem testprj ...)
  • кивордом: (defsystem :testprj ...)
  • неинтернированным символом: (defsystem #:testprj ...)
  • строкой: (defsystem "TESTPRJ" ...)
Мне не сразу стал понятен принцип выбора, но он прост: нужно использовать неинтернированные символы (а все остальные встречающиеся варианты связанны с тем, что авторы просто не знали или не понимали этот вариант :).

Вариант строки, в принципе, эквивалентен, но не эстетичен, киворда — приводит к "засорению" пакета keywords,— а просто символа — подвержен риску конфликта имен (его точно не стоит использовать, если описывать систему внутри ASDF-пакета).

Задание зависимостей


Я бы сказал, что ключевой функцией ASDF является разрешени зависимостей между исходными файлами в рамках одной системы и между разными системами. Все зависимости в ASDF задаются ключевым словом :depends-on в описании соответствующего компонента (файла, модуля, системы и т.д.)

Зависимости от других систем


Разумеется, любая серьезная система существует не в вакууме, а использует множество других библиотек. Несмотря на миф об их отсутствии в lisp-экосистеме :), большие проекты, над которыми мне приходилось работать, как правило, использовали порядка 20-30 сторонних библиотек (включая рекурсивные зависимости).

Предположим, что наш проект использует библиотеку утилит RUTILS. В таком случае нам нужно немного расширить его описание:
(asdf:defsystem #:testprj
:depends-on (#:rutils)
:components ((:file "app")))
Опять же для задания имени зависимости мы используем неинтернированный символ. ASDF позволяет дать и более точное описание такой зависимости (которое пока используется крайне редко, и о котором в отдельной статье, посвященной версиям).

Зависимости между файлами


Если в проекте больше одного lisp-файла, то стандартной практикой является добавления файла packages.lisp, в котором описываются 1 или несколько пакетов, которые будут использоваться (создавать любой неигрушечный проект в рамках cl-user пакета строго не рекомендуется :).

Предположим, что помимо app.lisp, мы также используем файл support.lisp. В таком случае наше описание приобритет следующую форму:
(asdf:defsystem #:testprj
:depends-on (#:rutils)
:components ((:file "packages")
(:file "support")
(:file "app")))
Однако мы не задалт порядок загрузки отдельных файлов, что может привести к неожиданным результатам (скажем, Lisp будет ругаться на форму (in-package #:testprj) в файле support.lisp, поскольку файл packages.lisp еще не загружен. Поэтому эту форму нужно доработать:
(asdf:defsystem #:testprj
:depends-on (#:rutils)
:components ((:file "packages")
(:file "support" :depends-on "packages")
(:file "app" :depends-on "support")))
Тут мы не пишем, что app зависит от packages, поскольку зависимости транзитивны.

Такой последовательный (serial) вариант зависимостей характерен для доброй половины проектов, поэтому для него есть специальная декларация для defsystem: :serial t. С ней наше описание снова упростится:
(asdf:defsystem #:testprj
:depends-on (#:rutils)
:serial t
:components ((:file "packages")
(:file "support")
(:file "app")))
В основе ASDF лежит расширяемая объектная модель компонентов и операций над ними. Некоторые разработчики используют в описании систем прямое имя класса :cl-source-file, а не :file. А вот класса file в ASDF как раз нет: конкретный класс определяется из слота default-component-class модуля (система — потомок модуля) и по умолчанию, конечно, является как раз lisp-исходником.

Помимо этого описан ряд других вариантов, компонент, а также всегда можно описать свой собственный (об этом — в следующей статье).

Например, интересной практикой является подобная декларация компонента:
:components ((:static-file "cl-oauth.asd")
...
(static-file — это любой статичный файл, который не обрабатывается компилятором, например файл лицензии или, как в данном случае, собственно файл описания системы — ведь он обрабатывается только ASDF. Зачем его добавлять? Например, если мы расчитываем, что будем реализовывать какую-нибудь операцию, типа publish-op, для создания дистрибутива из исходных файлов).

Модули


Модули ASDF — это логические компоненты системы, которые объединяют несколько других компонент. Использование модулей позволяет решить 2 задачи:
  • аггрегированно управлять зависимостями

    Скажем, у нас есть 2 части системы: бэкенд и фронтенд, которые зависят от общего файла утилит. И при изменении каждой из них мы не хотим перекомпилировать другую. В таком случае логично будет описать каждую часть в виде отдельного модуля
  • распределить исходники по разным директориям (в какой-то степени это аналог модулей в Python, но без управления видимостью — об этом в следующей статье)
    (defsystem :arnesi
    ...
    :components
    ((:module :src
    :components ((:file "accumulation"
    :depends-on ("packages" "one-liners"))
    (:file "asdf" :depends-on ("packages" "io"))
    (:file "csv" :depends-on ("packages" "string"))
    (:file "compat" :depends-on ("packages"))
    (:module :call-cc
    :components ((:file "interpreter")
    (:file "handlers")
    (:file "apply")
    (:file "generic-functions")
    (:file "common-lisp-cc"))
    :serial t
    :depends-on ("packages" "walk"
    "flow-control" "lambda-list"
    "list" "string"
    "defclass-struct"))))
    ...))
А можно ли разложить файлы по разным директориям не привязываясь к модели зависимостей, которая накладывается модулями? Да. У любого компонента есть слот :pathname, который позволяет явно задать путь к нему. Однако его использование имеет свои особенности — об этом дальше.

Имена компонент и путь к ним


У любого ASDF-компонента есть обязательный аттрибут имя, который используется при его поиске и разрешении зависимотстей. Однако этот аттрибут *не задается* декларацией :name в описании компонента.
(defsystem :ch-image
:name "ch-image"
...)
В этом коде :name "ch-image" несет чисто эстетический смысл (не верите? :)

Имя задается первым символом в декларации компонента: в данном случае ch-image, или же в случае модуля выше — :src, или же "accumulation" для файла там же. Все внутренние функции ASDF умеют работать как с символьным, так и со строковым представлением имен, описанных выше.

Кроме того, у каждого компонента есть аттрибут pathname, который определяет его положение в файловой системе. Однако, в отличие от имени, его как раз можно задать соответствующей декларацией. Например, этот пример задает относительный собственно ASD-файла путь к модулю io.multiplex библиотеки IOLIB: :pathname (merge-pathnames #p"io.multiplex/" *load-truename*).

Если же путь не задавать явно, то он вычисляется из имени, расширения (которое определяется типом компонента) и положения в иерархии модулей. Таким образом для примера задания модуля в arnesi (выше) для компонента :file "interpreter" будет вычислен такой путь: src/call-cc/interpreter.lisp.

Ну и ответ на вопрос, как разбросать файлы по директориям, не используя модули: задать для всех файлов явный :pathname.

Мета-информация


Defsystem-форма позволяет задать большое количество полезных метаданных для системы. Очень важно указать как минимум следующие:
  • :version, например :version "0.0.1" (подробнее о версиях — в отдельной статье)
  • :author или :maintainer (с указанием e-mail'а, чтобы к вам впоследствии смогли обратиться и предложить миллионы за доработку и поддержку вашей прекрасной библиотеки :)
  • :licence — чтобы люди знали, как они могут пользоваться вашими поделками

Платформо-зависимое описание систем


CL предоставляет исключительно удобный механизм условной компиляции и выполнения кода (#+/#-). И как раз в описаниях систем он, разумеется, находит широкое применение:
  • предотвращение загрузки системы в целом — тут интересны примеры из двух альтернативных библиотек для FFI:
    #+(or allegro lispworks cmu openmcl digitool cormanlisp sbcl scl)
    (defsystem uffi ...)

    ;; CFFI: этот вариант, безусловно, правильнее, чем просто тихо ничего не сделать
    #-(or openmcl sbcl cmu scl clisp lispworks ecl allegro cormanlisp)
    (error "Sorry, this Lisp is not yet supported. Patches welcome!")
  • вынесение функций, зависящих от конкретной lisp-среды в отдельные файлы — пример из все того же CFFI:
    :components (#+openmcl    (:file "cffi-openmcl")
    #+sbcl (:file "cffi-sbcl")
    #+cmu (:file "cffi-cmucl")
    #+scl (:file "cffi-scl")
    #+clisp (:file "cffi-clisp")
    #+lispworks (:file "cffi-lispworks")
    #+ecl (:file "cffi-ecl")
    #+allegro (:file "cffi-allegro")
    #+cormanlisp (:file "cffi-corman")
  • закладка нескольких вариантов построения библиотеки, в зависимсоти от каких-то условий. Тут проще всего привести примеры:
    ;; использовать ли acl-regexp2-engine?
    (defsystem :cl-ppcre
    :version "2.0.3"
    :serial t
    :components ((:file "packages")
    (:file "specials")
    (:file "util")
    (:file "errors")
    (:file "charset")
    (:file "charmap")
    (:file "chartest")
    #-:use-acl-regexp2-engine
    (:file "lexer")
    #-:use-acl-regexp2-engine
    (:file "parser")
    #-:use-acl-regexp2-engine
    (:file "regex-class")
    #-:use-acl-regexp2-engine
    (:file "regex-class-util")
    #-:use-acl-regexp2-engine
    (:file "convert")
    #-:use-acl-regexp2-engine
    (:file "optimize")
    #-:use-acl-regexp2-engine
    (:file "closures")
    #-:use-acl-regexp2-engine
    (:file "repetition-closures")
    #-:use-acl-regexp2-engine
    (:file "scanner")
    (:file "api")))
    ;; какие бэкенды генерации графических файлов доступны?
    (defsystem :ch-image
    ...
    (:module
    :io
    :components
    (#+ch-image-has-tiff-ffi
    (:cl-source-file "tiffimage")
    #+ch-image-has-cl-jpeg
    (:cl-source-file "jpegimage")
    #+(and ch-image-has-zpng)
    (:cl-source-file "pngimage")
    (:cl-source-file "imageio"
    :depends-on (#+ch-image-has-tiff-ffi
    "tiffimage"
    #+ch-image-has-cl-jpeg
    "jpegimage")))
    :depends-on (:src))



;; :name не имеет значения
CL-USER> (defsystem :ch-image
:name "ch-image1")
#<SYSTEM "ch-image" {AA7A911}>
CL-USER> (describe *)
#<SYSTEM "ch-image" {AA7A911}>
[standard-object]

Slots with :INSTANCE allocation:
NAME = "ch-image"
VERSION = #<unbound slot>
IN-ORDER-TO = NIL
DO-FIRST = ((COMPILE-OP (LOAD-OP)))
INLINE-METHODS = NIL
PARENT = NIL
RELATIVE-PATHNAME = #P"/home/vs/lib/lisp/ch-image_0.4.1/"
OPERATION-TIMES = #<HASH-TABLE :TEST EQL :COUNT 0 {AA7AB61}>
PROPERTIES = NIL
COMPONENTS = NIL
IF-COMPONENT-DEP-FAILS = :FAIL
DEFAULT-COMPONENT-CLASS = NIL
DESCRIPTION = #<unbound slot>
LONG-DESCRIPTION = #<unbound slot>
AUTHOR = #<unbound slot>
MAINTAINER = #<unbound slot>
LICENCE = #<unbound slot>