Регистрация | Войти
Lisp — программируемый язык программирования
Предыдущая Оглавление Следующая

24. Практика. Разбор двоичных файлов

В этой главе я покажу вам как создать библиотеку, которую мы сможем использовать при написании кода для чтения и записи двоичных файлов. Мы воспользуемся этой библиотекой в главе 25 для написания программы разбора тегов ID3, механизма, используемого для хранения метаданных в файлах MP3, таких как исполнитель и название альбома. Эта библиотека является также примером использования макросов для расширения языка новыми конструкциями, превращая его в язык специального назначения, предназначенный для решения специфических задач, в данном случае чтения и записи двоичных файлов. Так как мы разработаем эту библиотеку за один присест, включая несколько промежуточных версий, вам может показаться, что мы напишем очень много кода. Но после того, как все будет сказано и сделано, вся библиотека целиком займет менее 150 строк кода, а самый большой макрос будет длиной всего в 20 строк.

Двоичные файлы

На достаточно низком уровне абстракции все файлы являются "двоичными" в том смысле, что они просто содержат набор чисел, закодированных в двоичной форме. Однако, обычно различают текстовые файлы, в которых все числа могут быть интерпретированы как знаки, представляющие человекочитаемый текст, и двоичные файлы, который содержат данные, которые при интерпретации их как знаков, выдают непечатаемые знаки1)

Двоичные форматы файлов обычно проектируются в целях повышения компактности данных и эффективности их разбора — это и является их главным преимуществом над текстовыми форматами. Для достижения этих критериев двоичные файлы обычно имеют такую структуру на диске (on-disk structures), которая легко отображается на структуры данных, используемые программой для представления в памяти хранящихся в файлах данных2).

Разработанная библиотека предоставит нам простой способ описания соответствия между структурами на диске, определенных двоичным форматом файла, и объектами Lisp в памяти. Использование этой библиотеки cделает легким написание программ, осуществляющих чтение двоичных файлов, преобразование их в объекты Lisp для дальнейших манипуляций и запись в другой двоичный файл.

Основы двоичного формата

Начальной точкой в чтении и записи двоичных файлов является открытие файла для чтения и записи отдельных байтов. Как я описывал в главе 14, и OPEN, и WITH-OPEN-FILE, принимают ключевой аргумент :element-type, который устанавливает базовую единицу передачи данных для потока. Для работы с двоичными файлами нужно указать (unsigned-byte 8). Входной поток, открытый с таким параметром :element-type, будет возвращать числа от 0 до 255 при каждой его передаче в вызов READ-BYTE. И наоборот, мы можем записывать байты в выходной поток с типом элементов (unsigned-byte 8) путем передачи чисел от 0 до 255 в WRITE-BYTE.

Выше уровня отдельных байтов большинство двоичных форматов используют минимальное количество примитивных типов данных: различные представления чисел, текстовые строки, битовые поля и так далее, которые затем комбинируются в более сложные структуры. Поэтому вашим первым заданием будет определение каркаса для написания кода чтения и записи примитивных типов данных, используемых данным двоичным форматом.

В качестве простого примера представим, что мы имеем дело с двоичным форматом, который использует беззнаковые 16-битные целые числа в качестве примитивного типа данных. Для осуществления чтения таких целых нам нужно прочитать два байта, а затем скомбинировать их в одно число путем умножения одного байта на 256 (то есть 2^8) и добавления к нему второго байта. Предположив, например, что двоичный формат определяет хранение таких 16-битных сущностей в обратном порядке байтов (big-endian)3), когда наиболее значащий байт идет первым, мы можем прочитать такое число с помощью следующей функции:

(defun read-u2 (in)
  (+ (* (read-byte in) 256) (read-byte in))
)

Однако, Common Lisp предоставляет более удобный способ осуществления такого рода операций с битами. Функция LDB, чье имя происходит от load byte, может быть использовано для извлечения и присваивания (с помощью SETF) любого/любому числу идущих подряд бит из целого числа4). Число бит и их местоположение в целом числе задается спецификатором байта, создаваемом функцией BYTE. BYTE получает два аргумента, число бит для извлечения (или присваивания) и позицию самого правого бита, где наименее значимый бит имеет нулевую позицию. LDB принимает спецификатор байта и целое, из которого нужно извлечь биты, и возвращает положительное целое, представляющее извлеченные биты. Таким образом мы можем извлечь наименее значащие восемь бит из целого числа подобным образом:

(ldb (byte 8 0) #xabcd) ==> 205 ; 205 is #xcd

Для получения следующих восьми бит нам нужно использовать спецификатор байта (byte 8 8) следующим образом:

(ldb (byte 8 8) #xabcd) ==> 171 ; 171 is #xab

Мы можем использовать LDB с SETF для присваивания заданным битам целого числа, сохраненного в SETFable месте.

CL-USER> (defvar *num* 0)
*NUM*
CL-USER> (setf (ldb (byte 8 0) *num*) 128)
128
CL-USER> *num*
128
CL-USER> (setf (ldb (byte 8 8) *num*) 255)
255
CL-USER> *num*
65408

Итак, мы можем написать read-u2 также следующим образом5):

(defun read-u2 (in)
  (let ((u2 0))
    (setf (ldb (byte 8 8) u2) (read-byte in))
    (setf (ldb (byte 8 0) u2) (read-byte in))
    u2
)
)

Для записи числа как 16-битового целого, нам нужно извлечь отдельные байты и записать их один за одним. Для извлечения отдельных байтов нам просто нужно воспользоваться LDB с такими же спецификаторами байтов.

(defun write-u2 (out value)
  (write-byte (ldb (byte 8 8) value) out)
  (write-byte (ldb (byte 8 0) value) out)
)

Конечно мы также можем закодировать целые множеством других способов: с различным числом байтов, в различном порядке, а также путем использования знакового или беззнакового форматов.

Строки в двоичных файлах

Текстовые строки являются еще одним видом примитивных типов данных, который мы можем найти во многих двоичных форматах. Мы не можем считывать и записывать строки напрямую, читая файлы побайтно — нам нужно побайтно декодировать и кодировать их, как мы делали это для двоично-кодируемых чисел. И, как кодировать целые числа мы можем не одним способом, кодировать строки мы также можем множеством способов. Поэтому для начала формат двоичных чисел должен определять то, как кодируются отдельные знаки.

Для преобразования байт в знаки нам необходимо знать используемые знаковый код (character code) и кодировку знаков (character encoding). Знаковый код определяет отображение множества положительных целых чисел на множество знаков. Каждое число отображения называется единицей кодирования (code point). Например ASCII является знаковым кодом, который отображает числа интервала 0-127 на знаки, использующиеся в латинском алфавите. Кодировка знаков, с другой стороны, определяет как кодовые единицы представляются в виде последовательности байт в байт-ориентированной среде, такой как файл. Для кодов, которые используют восемь или менее бит, таких как ASCII и ISO-8859-1, кодировка тривиальна: каждое численное значение кодируется едиственным байтом.

Почти так же просты чистые двухбайтовые кодировки, такие как UCS-2, которые осуществляют отображение между 16-битными значениями и знаками. Единственной причиной, по которой двухбайтовые кодировки могут оказаться более сложными чем однобайтовые, является то, что нам может понадобиться также знать, подразумевается ли кодирование 16-битных значений в обратном порядке байт, либо же в прямом.

Кодировки с переменной длиной используют различное число октетов для различных численных значений, делая их более сложным, но позволяя им быть более лаконичными в большинстве случаев. Например UTF-8, кодировка, спроектированная для использования с кодом знаков Unicode, использует лишь один октет для кодирования значений из интервала 0-127 и в то же время до четырех октетов для кодирования значений до 1,114,1116).

Так как единицы кодирования из интервала 0-127 отображаются в Unicode на те же знаки, что и в кодировке ASCII, то закодированный кодировкой UTF-8 текст, состоящий только из знаков ASCII, будет эквивалентен этому же тексту, но закодированному кодировкой ASCII. С другой стороны, текст, состоящий преимущественно из знаков, требующих четырех байт в UTF-8, может быть более компактно закодировано простой двухбайтовой кодировкой.

Common Lisp предоставляет две функции для преобразования между численными кодами знаков и объектами знаков: CODE-CHAR, которая получает численный код и возвращает знак, и CHAR-CODE, которая получает знак и возвращает его численный код. Стандарт языка не определяет, какую кодировку знаков должны использовать реализации языка, поэтому нет гарантии того, что мы сможем представить любой знак, который может быть закодирован в данном формате файла, как знак Lisp. Однако почти все современные реализации Common Lisp используют ASCII, ISO-8859-1 или Unicode в качестве своего внутреннего знакового кода. Так как Unicode является надмножеством ISO-8859-1, который в свою очередь является надмножеством ASCII, то, если ваша реализация Lisp использует Unicode, CODE-CHAR и CHAR-CODE могут быть использованы напрямую для преобразования любого из этих трех знаковых кодов7).

Вдобавок к определению кодирования знаков кодирование строк должно также определять то, как кодируется длина строк. В двоичных форматах файлов обычно используются три техники.

Простейшая заключается в том, чтобы никак не кодировать длину, которая неявно определяется по местоположению строки в некоторой большей структуре: некоторый элемент файла может всегда быть строкой определенной длины, либо строка может быть последним элементом структуры данных переменной длины, общий размер которой определяет как много байт осталось прочитать как данные строки. Оба этих подхода используются в тегах ID3, как мы увидим в следующей главе.

Другие две техники могут использоваться для кодирования строк переменной длины без необходимости полагаться на контекст. Одной из них является кодирование длины строки, за которой следуют данные этой строки — анализатор считывает численное значение (в каком-то заданном целочисленном формате), а затем считывает это число знаков. Другой техникой является запись данных строки, за которыми следует разделитель, который не может появиться внутри строки, такой как нулевой знак (null character).

Различные представления имеют различные преимущества и недостатки, но когда мы имеем дело с уже заданными двоичными форматами, мы не имеем никакого контроля над тем, какая кодировка используется. Однако, никакая из кодировок не является более сложной для чтения/записи, чем любая другая. Вот, например, функция, осуществляющая чтение завершающейся нулевым знаком строки ASCII, подразумевающая, что ваша реализация Lisp использует ASCII либо одно из ее надмножеств, такое как ISO-8859-1 или Unicode, в качестве своей внутренней кодировки:

(defconstant +null+ (code-char 0))

(defun read-null-terminated-ascii (in)
  (with-output-to-string (s)
    (loop for char = (code-char (read-byte in))
          until (char= char +null+) do (write-char char s)
)
)
)

Макрос WITH-OUTPUT-TO-STRING, который упоминался в главе 14, является простым способом построения строки в случае, когда мы не знаем, какой длины она окажется. Этот макрос создает STRING-STREAM и связывает его с указанным именем переменной, в данном случае s. Все знаки, записанные в этот поток, будут собраны в строку, которая затем будет возвращена в качестве значения формы WITH-OUTPUT-TO-STRING.

Для записи строки нам просто нужно преобразовать знаки обратно в численные значения, которые могут быть записаны с помощью WRITE-BYTE, а затем записать признак конца строки после ее содержимого.

(defun write-null-terminated-ascii (string out)
  (loop for char across string
        do (write-byte (char-code char) out)
)

  (write-byte (char-code +null+) out)
)

Как показывает этот пример, главной интеллектуальной задачей (может и не совсем таковой, но все же) чтения и записи базовых элементов двоичных файлов является понимание того, как именно интерпретировать байты файла и отображать их на типы данных Lisp. Если формат двоичного файла хорошо определен, это может оказаться довольно простой задачей. Фактически написание функций для чтения и записи данных, закодированных определенным образом, является просто вопросом программирования.

Теперь мы можем перейти к задаче чтения и записи более сложных структур на диске (on-disk structures) и отображения их на объекты Lisp.

Составные структуры

Так как двоичные форматы обычно используются для представления данных способом, который делает легким их отображение на структуры данных в памяти, не должно вызывать удивление то, что сложные структуры на диске (on-disc structures) обычно определяются схожим способом с тем, как языки программирования определяют структуры данных в памяти. Обычно сложные структуры на диске состоят из некоторого числа именованных частей, каждая их которых является либо примитивным типом, таким как число или строка, либо другой сложной структурой, либо коллекцией таких значений.

Например тег ID3, определенный версией 2.2 спецификации, состоит из заголовка, в свою очередь состоящего из ISO-8859-1 строки длиной в три знака, которыми всегда являются "ID3"; двух однобайтных беззнаковых целых, которые задают старший номер версии и ревизию спецификации; восьми бит, являющихся булевыми флагами; и четырех байт, которые кодируют размер тега в кодировке, особенной для спецификации ID3. За заголовком идет список фреймов, каждый из которых имеет свою собственную внутреннюю структуру. За фреймами идет столько нулевых байт, сколько необходимо для заполнения тега до размера, указанного в заголовке.

Если вы глядите на мир через призму объектной ориентации, сложные структуры выглядят весьма похожими на классы. Например мы можем написать класс для представления тега ID3.

(defclass id3-tag ()
  ((identifier    :initarg :identifier    :accessor identifier)
   (major-version :initarg :major-version :accessor major-version)
   (revision      :initarg :revision      :accessor revision)
   (flags         :initarg :flags         :accessor flags)
   (size          :initarg :size          :accessor size)
   (frames        :initarg :frames        :accessor frames)
)
)

Экземпляр этого класса может быть отличным местом хранения данных, необходимых для представления тега ID3. Затем мы можем написать функции чтения и записи экземпляров этого класса. Например, предположив существование функций чтения соответствующих примитивных типов данных, функция read-id3-tag может выглядеть следующим образом:

(defun read-id3-tag (in)
  (let ((tag (make-instance 'id3-tag)))
    (with-slots (identifier major-version revision flags size frames) tag
      (setf identifier    (read-iso-8859-1-string in :length 3))
      (setf major-version (read-u1 in))
      (setf revision      (read-u1 in))
      (setf flags         (read-u1 in))
      (setf size          (read-id3-encoded-size in))
      (setf frames        (read-id3-frames in :tag-size size))
)

    tag
)
)

Функция write-id3-tag будет структурирована схожим образом: мы будем использовать соответствующие функции write-* для записи значений, хранящихся в слотах объекта id3-tag.

Несложно увидеть, как мы можем написать соответствующие классы для представления всех сложных структур данных спецификации наряду с функциями read-foo и write-foo для каждого класса и необходимых примитивных типов. Но также легко заметить, что все функции чтения и записи будут весьма похожими, отличающимися только тем, данные каких типов они читают, и именами слотов, в которые они сохраняют эти данные. Это станет особенно утомительным, когда мы учтем тот факт, что описание структуры тега ID3 заняло почти четыре строки текста, в то время как мы уже написали одиннадцать строк кода все еще не написав write-id3-tag.

Что нам действительно нужно, так это способ описания структур наподобие тега ID3 в форме, которая лаконична так же, как и псевдокод спецификации, и чтобы это описание раскрывалось в код, который определяет класс id3-tag и функции, осуществляющие преобразование между байтами на диске и экземплярами этого класса. Звучит как работа для системы макросов.

Проектирование макросов

Так как мы уже имеем примерное представление о том, какой код ваш макрос должен генерировать, следующим шагом в соответствии с процессом написания макросов, описанным мною в главе 8, является смена ракурса и размышления о том, как должен выглядить вызов этого макроса. Так как целью является иметь возможность написания чего-то столь же краткого, как и псевдокод спецификации ID3, мы можем начать с него. Заголовок тега ID3 определяется следующим образом:

ID3/file identifier      "ID3"
ID3 version $02 00
ID3 flags %xx000000
ID3 size 4 * %0xxxxxxx

В нотации спецификации это означает, что слот "file identifier" тега ID3 является строкой "ID3" в кодировке ISO-8859-1. Слот version состоит из двух байт, первый из которых, для данной версии спецификации, имеет значение 2 и второй, опять же для данной версии, — 0. Слот flags имеет размер в восемь бит, все из которых, кроме первых двух, имеют нулевое значение, а size состоит из четырех байт, каждый из которых содержит 0 в своем старшем разряде.

Некоторая часть информации не охватывается этим псевдокодом. Например то, как именно интерпретируются четыре байта, кодирующие размер, описывается несколькими строками текста. Схожим образом спецификация описывает текстом то, как после заголовка сохраняются фрейм и последующие байты заполнения. Но, все же, большая часть того, что нам нужно знать, для того, чтобы написать код чтения и записи тега ID3, задается этим псевдокодом. Таким образом, мы должны иметь возможность написания варианта этого псевдокода s-выражением, которое раскроется в класс и определения функций, которые нам иначе бы пришлось писать вручную: что-то, возможно, вроде этого:

(define-binary-class id3-tag
  ((file-identifier (iso-8859-1-string :length 3))
   (major-version   u1)
   (revision        u1)
   (flags           u1)
   (size            id3-tag-size)
   (frames          (id3-frames :tag-size size))
)
)

Основной идеей является то, что эта форма определяет класс id3-tag подобно тому, как мы можем сделать сами с помощью DEFCLASS, но вместо определения таких вещей как :initarg и :accessor, каждое определение слота состоит из имени слота — file-identifier, major-version, и т.д. — и информации о том, как этот слот представляется на диске. Так как мы всего лишь немного пофантазировали, нам не нужно беспокоиться о том, как именно макрос define-binary-class будет знать, что делать с такими выражениями как (iso-8859-1-string :length 3), u1, id3-tag-size и (id3-frames :tag-size size); пока каждое выражение содержит информацию, необходимую для знания того, как читать и записывать определенные данные, все должно быть хорошо.

Делаем мечту реальностью

Хорошо, достаточно фантазий о хорошо выглядящем коде; теперь нужно приступать к работе по написанию define-binary-class: написанию кода, который будет преобразовывать краткое выражение, описывающее как выглядит тег ID3, в код, который может представлять этот тег в памяти, считывать с диска и записывать его обратно.

Для начала нам стоит определить пакет для нашей библиотеки. Вот файл пакета, который поставляется с версией, которую вы можете скачать с web-сайта книги:

(in-package :cl-user)

(defpackage :com.gigamonkeys.binary-data
  (:use :common-lisp :com.gigamonkeys.macro-utilities)
  (:export :define-binary-class
           :define-tagged-binary-class
           :define-binary-type
           :read-value
           :write-value
           :*in-progress-objects*
           :parent-of-type
           :current-binary-object
           :+null+
)
)

Пакет COM.GIGAMONKEYS.MACRO-UTILITIES содержит макросы with-gensyms и once-only из главы 8.

Так как мы уже имеем написанную вручную версию кода, который хотим сгенерировать, не должно быть очень сложно написать такой макрос. Просто разберем его на небольшие части, начав с версии define-binary-class, которая просто генерирует форму DEFCLASS.

Если мы вновь взглянем на форму define-binary-class, то увидим, что она принимает два аргумента: имя id3-tag и список спецификаторов слотов, каждый из которых сам является двух-элементным списком. По этим частям нам нужно построить соответствующую форму DEFCLASS. Очевидно, что наибольшее различие между формой define-binary-class и правильной формой DEFCLASS заключается в спецификаторах слотов. Одиночный спецификатор слота из define-binary-class выглядит подобным образом:

(major-version u1)

Но это не является верным спецификатором слота для DEFCLASS. Вместо этого нам нужно что-то вот такое:

(major-version :initarg :major-version :accessor major-version)

Достаточно просто. Для начала определим простую функцию преобразования символа в соответствующий ключевой символ.

(defun as-keyword (sym) (intern (string sym) :keyword))

Теперь определим функцию, которая получает спецификатор слота define-binary-class и возвращает спецификатор слота DEFCLASS.

(defun slot->defclass-slot (spec)
  (let ((name (first spec)))
    `(,name :initarg ,(as-keyword name) :accessor ,name)
)
)

Мы можем протестировать эту функцию в REPL после переключения в наш новый пакет путем вызова IN-PACKAGE.

BINARY-DATA> (slot->defclass-slot '(major-version u1))
(MAJOR-VERSION :INITARG :MAJOR-VERSION :ACCESSOR MAJOR-VERSION)

Выглядит неплохо. Теперь написание первой версии define-binary-class тривиально.

(defmacro define-binary-class (name slots)
  `(defclass ,name ()
     ,(mapcar #'slot->defclass-slot slots)
)
)

Это простой макрос, написанный в template-стиле: define-binary-class генерирует форму DEFCLASS путем подстановки (interpolating) имени класса и списка спецификаторов слотов, сконструированного путем применения slot->defclass-slot к каждому элементу списка спецификаторов слотов формы define-binary-class.

Для просмотра кода, который генерирует этот макрос, мы можем вычислить в REPL следующее выражение:

(macroexpand-1 '(define-binary-class id3-tag
  ((identifier      (iso-8859-1-string :length 3))
   (major-version   u1)
   (revision        u1)
   (flags           u1)
   (size            id3-tag-size)
   (frames          (id3-frames :tag-size size))
)
)
)

Результат, слегка переформатированный в целях улучшения читаемости, должен казаться вам знакомым, так как это в точности то определение класса, которое мы написали вручную ранее:

(defclass id3-tag ()
  ((identifier      :initarg :identifier    :accessor identifier)
   (major-version   :initarg :major-version :accessor major-version)
   (revision        :initarg :revision      :accessor revision)
   (flags           :initarg :flags         :accessor flags)
   (size            :initarg :size          :accessor size)
   (frames          :initarg :frames        :accessor frames)
)
)

Чтение двоичных объектов

Следующим шагом нам нужно заставить define-binary-class также генерировать функцию, которая может прочитать экземпляр нового класса. Учитывая функцию read-id3-tag, написанную нами ранее, кажется, это будет немного сложнее, так как read-id3-tag не является столь же однородной: для чтения значений каждого слота, нам приходится вызывать различные функции, не говоря уже о том, что имя функции, read-id3-tag, хоть и получается из имени определяемого нами класса, не является одним из аргументов define-binary-class, а следовательно не может быть просто подставлено в шаблон.

Мы можем решить обе эти проблемы следуя такому соглашению по именованию, при котором макрос сможет вычислять имя функции, основываясь на имени типа в спецификаторе слота. Однако тогда define-binary-class придётся генерировать имя read-id3-tag, что возможно, но является плохой идеей. Макросам, создающим глобальные определения, следует в общем случае использовать только имена, переданные им; макросы, сами генерирующие имена, могут привести к сложнопредсказуемым и трудноотлаживаемым конфликтам имен, когда сгенерированные имена оказываются теми же, что уже где-нибудь используются8).

Мы можем избежать оба этих неудобства заметив, что все функции, считывающие значения определенного типа, имеют в своей сути одинаковую цель: считывание значение определенного типа из потока. Говоря просто, мы можем увидеть, что все они являются экземплярами одной обобщенной операции. И простое использование слова "обобщенный" должно подтолкнуть вас прямо к решению проблемы: вместо определения множества независимых функций, имеющих различные имена, мы можем определить одну обобщенную функцию read-value с методами, специализированными для чтения значений различных типов.

Таким образом, вместо определения функций read-iso-8859-1-string и read-u1, мы можем определить read-value как обобщенную функцию, принимающую два обязательных аргумента: тип и поток, а также, возможно, некоторые ключевые аргументы.

(defgeneric read-value (type stream &key)
  (:documentation "Read a value of the given type from the stream.")
)

Путем указания &key без самих ключевых параметров, мы позволяем различным методам определять свои собственные &key параметры, но не требуя этого от них. Это значит, что каждый метод, специализирующий read-value, должен будет включить либо &key, либо &rest в свой список параметров, чтобы быть совместимым с обобщенной функцией.

Затем мы определяем методы, использующие специализаторы EQL для специализации аргумента типа по имени типа значений, которые хотим считывать.

(defmethod read-value ((type (eql 'iso-8859-1-string)) in &key length) ...)

(defmethod read-value ((type (eql 'u1)) in &key) ...)

Затем мы можем изменитьdefine-binary-class так, чтобы он генерировал метод read-value, специализированный по имени типа id3-tag, и реализованный в терминах вызовов read-value с соответствующими типами слотов в качестве первого аргумента. Код, который мы хотим сгенерировать, выглядит следующим образом:

(defmethod read-value ((type (eql 'id3-tag)) in &key)
  (let ((object (make-instance 'id3-tag)))
    (with-slots (identifier major-version revision flags size frames) object
      (setf identifier    (read-value 'iso-8859-1-string in :length 3))
      (setf major-version (read-value 'u1 in))
      (setf revision      (read-value 'u1 in))
      (setf flags         (read-value 'u1 in))
      (setf size          (read-value 'id3-encoded-size in))
      (setf frames        (read-value 'id3-frames in :tag-size size))
)

    object
)
)

Теперь, так же, как для генерации формы DEFCLASS нам нужна была функция, транслирующая спецификатор слота define-binary-class в спецификатор слота DEFCLASS, теперь нам нужна функция, получающая спецификатор слота define-binary-class и генерирующая соответствующую форму SETF, то есть что-то, получающее вот такое:

(identifier (iso-8859-1-string :length 3))

и возвращающее это:

(setf identifier (read-value 'iso-8859-1-string in :length 3))

Однако, существует различие между этим кодом и спецификатором слота DEFCLASS: этот код включает в себя ссылку на переменную in, параметр метода read-value, который не был получен из спецификатора слота. Он не обязательно должен называться in, но какое бы имя мы не использовали, оно должно быть тем же, что используется в списке параметров метода, а также в других вызовах read-value. Сейчас мы можем уклониться от проблемы того, откуда получается это имя, определив slot->read-value таким образом, чтобы она принимала второй аргумент, содержащий имя переменной потока.

(defun slot->read-value (spec stream)
  (destructuring-bind (name (type &rest args)) (normalize-slot-spec spec)
    `(setf ,name (read-value ',type ,stream ,@args))
)
)

Функция normalize-slot-spec нормализует второй элемент спецификатора слота, преобразуя символ, такой как u1, в список (u1), так что DESTRUCTURING-BIND может осуществить его разбор. Она выглядит так:

(defun normalize-slot-spec (spec)
  (list (first spec) (mklist (second spec)))
)


(defun mklist (x) (if (listp x) x (list x)))

Мы можем протестировать slot->read-value с каждым типом спецификаторов слотов.

BINARY-DATA> (slot->read-value '(major-version u1) 'stream)
(SETF MAJOR-VERSION (READ-VALUE 'U1 STREAM))
BINARY-DATA> (slot->read-value '(identifier (iso-8859-1-string :length 3)) 'stream)
(SETF IDENTIFIER (READ-VALUE 'ISO-8859-1-STRING STREAM :LENGTH 3))

Со всеми этими функциями мы уже готовы добавить read-value в define-binary-class. Если мы возьмем вручную написанный метод read-value и удалим из него все то, что касается определенного класса, у нас останется следующий каркас:

(defmethod read-value ((type (eql ...)) stream &key)
  (let ((object (make-instance ...)))
    (with-slots (...) object
      ...
    object
)
)
)

Все, что нам нужно сделать, это добавить этот каркас в шаблон define-binary-class, заменив многоточия кодом, который заполнит этот каркас подходящими именами и кодом. Мы также захотим заменить переменные type, stream и object сгенерированными GENSYM именами для избежания потенциальных конфликтов с именами слотов9), что мы можем сделать с помощью макроса with-gensyms, рассмотренного в главе 8.

Также, так как макрос должен раскрываться в одиночную форму, мы должны "обернуть" какую-то вокруг DEFCLASS и DEFMETHOD. Обычно для макросов, которые раскрываются в несколько определений, используется PROGN из-за специальной трактовки, которую она получает от компилятора, когда находится на верхнем уровне файла, что было обсуждено в главе 20.

Таким образом, мы можем изменить define-binary-class следующим образом:

(defmacro define-binary-class (name slots)
  (with-gensyms (typevar objectvar streamvar)
    `(progn
       (defclass ,name ()
         ,(mapcar #'slot->defclass-slot slots)
)


       (defmethod read-value ((,typevar (eql ',name)) ,streamvar &key)
         (let ((,objectvar (make-instance ',name)))
           (with-slots ,(mapcar #'first slots) ,objectvar
             ,@(mapcar #'(lambda (x) (slot->read-value x streamvar)) slots)
)

           ,objectvar
)
)
)
)
)

Запись двоичных объектов

Генерация кода для записи экземпляра двоичного класса происходит схожим образом. Для начала мы можем определить обобщенную функцию write-value.

(defgeneric write-value (type stream value &key)
  (:documentation "Write a value as the given type to the stream.")
)

Затем мы определяем вспомогательную функцию, которая транслирует спецификатор слота define-binary-class в код, который записывает этот слот с помощью write-value. Как и для функции slot->read-value, эта вспомогательная функция принимает имя переменной потока в качестве параметра.

(defun slot->write-value (spec stream)
  (destructuring-bind (name (type &rest args)) (normalize-slot-spec spec)
    `(write-value ',type ,stream ,name ,@args)
)
)

После этого мы можем добавить шаблон write-value в макрос define-binary-class.

(defmacro define-binary-class (name slots)
  (with-gensyms (typevar objectvar streamvar)
    `(progn
       (defclass ,name ()
         ,(mapcar #'slot->defclass-slot slots)
)


       (defmethod read-value ((,typevar (eql ',name)) ,streamvar &key)
         (let ((,objectvar (make-instance ',name)))
           (with-slots ,(mapcar #'first slots) ,objectvar
             ,@(mapcar #'(lambda (x) (slot->read-value x streamvar)) slots)
)

           ,objectvar
)
)


       (defmethod write-value ((,typevar (eql ',name)) ,streamvar ,objectvar &key)
         (with-slots ,(mapcar #'first slots) ,objectvar
           ,@(mapcar #'(lambda (x) (slot->write-value x streamvar)) slots)
)
)
)
)
)

Добавление наследования и помеченных (tagged) структур

Хотя эта версия define-binary-class будет обрабатывать автономные (stand-alone) структуры, двоичные форматы файлов часто определяют такие структуры на диске (on-disk structures), которые было бы естественно моделировать отношениями подклассов и суперклассов. Поэтому мы можем захотеть расширить define-binary-class для поддержки наследования.

Родственной техникой, используемой во многих двоичных форматах, является такая, при которой имеется множество структур данных на диске, точный тип которых может быть определен только путем считывания некоторых данных, которые указывают, как осуществлять разбор последующих байтов. Например, фреймы, которые составляют большую часть тега ID3, все разделяют общую структуру заголовка, состоящую из строкового идентификатора и длины. Для чтения фрейма нам нужно прочитать идентификатор и использовать его значение для определения вида просматриваемого фрейма, а следовательно того, как осуществлять разбор его тела.

Текущая версия макроса define-binary-class не предоставляет способа осуществления такого рода считывания: мы можем использовать define-binary-class для определения класса, представляющего любой вид фрейма, но мы не имеем возможности узнать, какой тип фрейма считывать, без считывания по меньшей мере идентификатора. И если другой код считывает идентификатор, чтобы определить какой тип передавать функции read-value, то это нарушит работу read-value, поскольку она ожидает возможности считать все данные, составляющие экземпляр класса, создаваемый ею.

Мы можем решить эту проблему добавив возможность наследования в define-binary-class, а затем написав другой макрос define-tagged-binary-class, предназначенный для определения "абстрактных" классов, экземляры которых не создаются напрямую, но по которым могут быть специализированы методы read-value, которые знают, как считывать достаточно данных для определения конкретного класса, экземпляр которого нужно создать.

Первым шагом добавления возможности наследования в define-binary-class является добавление в макрос параметра, принимающего список суперклассов.

(defmacro define-binary-class (name (&rest superclasses) slots) ...

Затем, в шаблоне DEFCLASS подставим это значение вместо пустого списка.

(defclass ,name ,superclasses
  ...
)

Однако нужно сделать немного больше. Нам также нужно изменить методы read-value и write-value так, чтобы методы, сгенерированные при определении суперкласса, могли бы быть использованы методами, сгенерированными как часть подкласса, для чтения и записи наследуемых слотов.

Способ, которым работает текущая версия read-value, совершенно не подходит, так как он инстанцирует объект перед его заполнением. То есть, у вас есть метод, ответственный за чтение полей суперкласса, инстанцирующий один объект, и метод подкласса, инстанцирующий и заполняющий другой объект.

Мы можем исправить эту проблему путем разделения read-value на две части: одну ответственную за инстанцирование правильного вида объекта, а другую — за заполнение слотов уже существующего объекта. На стороне записи все несколько проще, но и там мы можем использовать схожую технику.

Поэтому мы определим две новые обобщенные функции: read-object и write-object, обе получающие существующий объект и поток. Методы этих обобщенных функций будут ответственны за чтение и запись слотов, специфичных для классов, для которых они специализированы.

(defgeneric read-object (object stream)
  (:method-combination progn :most-specific-last)
  (:documentation "Fill in the slots of object from stream.")
)


(defgeneric write-object (object stream)
  (:method-combination progn :most-specific-last)
  (:documentation "Write out the slots of object to the stream.")
)

Определение этих обобщенных функций с использованием комбинатора методов PROGN с опцией :most-specific-last позволяет нам определять методы, специализированные для каждого двоичного класса и работающие только со слотами, действительно определенными в таком классе; комбинатор методов PROGN скомбинирует все применимые методы так, что метод, специализированный для наименее специфичного класса в иерархии, выполнится первым, считывая или записывая слоты, определенные в этом классе, затем выполнится метод, специализированный для следующего наименее специфичного класса, и т.д. И, так как теперь вся тяжелая, специфичная для класса работа осуществляется методами read-object и write-object, нам даже не нужно определять специализированные методы read-value и write-value: мы можем определить методы по умолчанию, которые считают аргумент типа именем двоичного класса.

(defmethod read-value ((type symbol) stream &key)
  (let ((object (make-instance type)))
    (read-object object stream)
    object
)
)


(defmethod write-value ((type symbol) stream value &key)
  (assert (typep value type))
  (write-object value stream)
)

Обратите внимание на то, как мы можем использовать MAKE-INSTANCE в качестве обобщенной фабрики объектов (generic object factory): хотя обычно мы вызываем MAKE-INSTANCE с закавыченым (quoted) символом в качестве первого аргумента, так как обычно знаем, экземпляр какого именно класса хотим создать, мы можем использовать любое выражение, которое вычисляется в имя класса, как в данном случае используем параметр type метода read-value.

Действительные изменения, внесенные в define-binary-class для определения методов read-object и write-object вместо read-value и write-value, довольно незначительны.

(defmacro define-binary-class (name (&rest superclasses) slots)
  (with-gensyms (objectvar streamvar)
    `(progn
       (defclass ,name ,superclasses
         ,(mapcar #'slot->defclass-slot slots)
)


       (defmethod read-object progn ((,objectvar ,name) ,streamvar)
         (with-slots ,(mapcar #'first slots) ,objectvar
           ,@(mapcar #'(lambda (x) (slot->read-value x streamvar)) slots)
)
)


       (defmethod write-object progn ((,objectvar ,name) ,streamvar)
         (with-slots ,(mapcar #'first slots) ,objectvar
           ,@(mapcar #'(lambda (x) (slot->write-value x streamvar)) slots)
)
)
)
)
)

Отслеживание унаследованных слотов

Это определение будет работать для многих случаев. Однако, оно не обрабатывает одну достаточно распространенную ситуацию, а именно когда нам нужен подкласс, которому необходимо ссылаться на унаследованные слоты в своих собственных определениях слотов. Например, имея текущее определение define-binary-class, мы можем определить следующий одиночный класс:

(define-binary-class generic-frame ()
  ((id (iso-8859-1-string :length 3))
   (size u3)
   (data (raw-bytes :bytes size))
)
)

Ссылка на size в определении data работает ожидаемым образом, так как выражения, считывающие и записывающие слот data, обернуты формой WITH-SLOTS, которая перечисляет все слоты объекта. Однако, если мы попытаемся разделить этот класс на два класса следующим образом:

(define-binary-class frame ()
  ((id (iso-8859-1-string :length 3))
   (size u3)
)
)


(define-binary-class generic-frame (frame)
  ((data (raw-bytes :bytes size)))
)

мы получим предупреждение времени компиляции при компиляции определения generic-frame и ошибку времени выполнения при попытке его использования, так как в методах read-object и write-object, специализированных для generic-frame, не будет лексически видимой переменной size.

Что нам нужно, так это отслеживание слотов, определенных каждым двоичным классом, а затем включение всех наследуемых слотов в формы WITH-SLOTS методов read-object и write-object.

Наиболее простым способом отслеживания подобной информации является связывание ее с символом, именующим класс. Как мы обсуждали в главе 21, каждый символьный объект имеет ассоциированный с ним список свойств, доступ к которому можно получить с помощью функций SYMBOL-PLIST и GET. Мы можем связать произвольную пару ключ/значение с символом, добавив их в список свойств этого символа с помощью вызова SETF для результата GET. Например, если двоичный класс foo определяет три слота x, y и z, мы можем отследить этот факт, добавив в список свойств символа foo ключ slots со значением (x y z) с помощью следующего выражения:

(setf (get 'foo 'slots) '(x y z))

Мы хотим осуществлять этот учет как часть вычисления define-binary-class для foo. Однако, не совсем очевидно, куда поместить это выражение. Если мы будем вычислять его при вычислении раскрытий макросов, это выражение вычислится при компиляции формы define-binary-class, но не во время последующей загрузки файла, содержащего полученный скомпилированный код. С другой стороны, если мы включим это выражение в раскрытие макроса, то оно не будет вычисляться во время компиляции, а это означает, что при компиляции файла с несколькими формами define-binary-class никакой информации о том, какие классы определяют какие слоты, не будет доступно до полной загрузки файла, что слишком поздно.

Это как раз тот случай, для которого предназначен специальный оператор EVAL-WHEN, который мы обсуждали в главе 20. Обернув форму в EVAL-WHEN мы можем контролировать то, вычисляется ли она во время компиляции, либо во время загрузки скомпилированного кода, либо в обоих случаях. Для таких случаев, как данный, когда мы хотим собрать некоторую информацию во время компиляции формы макроса, к которой мы хотим также иметь доступ после загрузки скомпилированной формы, нам следует обернуть выражения сбора этой информации в EVAL-WHEN следующим образом:

(eval-when (:compile-toplevel :load-toplevel :execute)
  (setf (get 'foo 'slots) '(x y z))
)

и включить форму EVAL-WHEN в раскрытие, генерируемое макросом. Итак, мы можем сохранить слоты и прямые суперклассы двоичного класса добавив следующую форму в раскрытие, генерируемое define-binary-class:

(eval-when (:compile-toplevel :load-toplevel :execute)
  (setf (get ',name 'slots) ',(mapcar #'first slots))
  (setf (get ',name 'superclasses) ',superclasses)
)

Теперь мы можем определить три вспомогательные функции для осуществления доступа к этой информации. Первая просто возвращает слоты, определенные двоичным классом. Хорошей идеей является возвращение копии списка, так как мы не хотим, чтобы посторонний код модифицировал список слотов после того, как двоичный класс уже был определен.

(defun direct-slots (name)
  (copy-list (get name 'slots))
)

Следующая функция возвращает слоты, унаследованные от других двоичных классов.

(defun inherited-slots (name)
  (loop for super in (get name 'superclasses)
        nconc (direct-slots super)
        nconc (inherited-slots super)
)
)

И наконец, мы можем определить функцию, которая возвращает список, содержащий имена всех определенных и унаследованных слотов.

(defun all-slots (name)
  (nconc (direct-slots name) (inherited-slots name))
)

Итак, мы хотим, чтобы при вычислении раскрытия формы define-binary-class, генерировалась форма WITH-SLOTS, содержащая имена всех слотов, определенных в новом классе и во всех его суперклассах. Однако, мы не можем использовать all-slots во время генерации раскрытия, так как информация не будет доступна до того момента, когда это раскрытие будет скомпилировано. Вместо этого нам следует воспользоваться следующей функцией, получающей список спецификаторов слотов и список суперклассов, переданных в define-binary-class, и использующую их для вычисления списка всех слотов нового класса:

(defun new-class-all-slots (slots superclasses)
  (nconc (mapcan #'all-slots superclasses) (mapcar #'first slots))
)

Имея эти функции мы можем изменить define-binary-class таким образом, чтобы он сохранял информацию об определяемом в данный момент классе и использовал уже сохраненную информацию о слотах суперклассов для генерации форм WITH-SLOTS.

(defmacro define-binary-class (name (&rest superclasses) slots)
  (with-gensyms (objectvar streamvar)
    `(progn
       (eval-when (:compile-toplevel :load-toplevel :execute)
         (setf (get ',name 'slots) ',(mapcar #'first slots))
         (setf (get ',name 'superclasses) ',superclasses)
)


       (defclass ,name ,superclasses
         ,(mapcar #'slot->defclass-slot slots)
)


       (defmethod read-object progn ((,objectvar ,name) ,streamvar)
         (with-slots ,(new-class-all-slots slots superclasses) ,objectvar
           ,@(mapcar #'(lambda (x) (slot->read-value x streamvar)) slots)
)
)


       (defmethod write-object progn ((,objectvar ,name) ,streamvar)
         (with-slots ,(new-class-all-slots slots superclasses) ,objectvar
           ,@(mapcar #'(lambda (x) (slot->write-value x streamvar)) slots)
)
)
)
)
)

Помеченные структуры

Имея возможность определения двоичных классов, которые расширяют другие двоичные классы, мы готовы определить новый макрос, предназначенный для определения классов, представляющих "помеченные" структуры. В основе нашего способа чтения помеченных структур будет определение специализированного метода read-value, который знает как считывать значения, составляющие начало структуры, и как затем использовать эти значения для определения подкласса, экземпляр которого нужно создать. Затем этот метод создает экземпляр класса с помощью MAKE-INSTANCE, передавая уже прочитанные значения в качестве аргументов инициализации (initags), и передает объект в read-object, позволяя действительному классу объекта определять как именно считывается остальная часть структуры.

Новый макрос define-tagged-binary-class будет выглядеть как define-binary-class с добавочной опцией :dispatch, используемой для указания формы, которая должна вычисляться в имя двоичного класса. Форма :dispatch будет вычислена в том контексте, в котором имена слотов, определенных помеченным классом, связываются с переменными, содержащими значения, прочитанные из файла. Класс, чье имя возвращается этой формой, должен принимать аргументы инициализации (initargs), соответствующие именам слотов, определенных помеченным классом. Это легко обеспечивается в том случае, если форма :dispatch всегда вычисляется в имя класса, являющегося подклассом помеченного класса.

Например, представив, что мы имеем функцию find-frame-class, которая отображает строковый идентификатор на двоичный класс, представляющий определенный вид фрейма ID3, мы можем определить помеченный двоичный класс id3-frame следующим образом:

(define-tagged-binary-class id3-frame ()
  ((id   (iso-8859-1-string :length 3))
   (size u3)
)

  (:dispatch (find-frame-class id))
)

Раскрытие define-tagged-binary-class будет содержать DEFCLASS и метод write-object так же, как и раскрытие define-binary-class, но вместо метода read-object оно будет содержать метод read-value, выглядящий следующим образом:

(defmethod read-value ((type (eql 'id3-frame)) stream &key)
  (let ((id (read-value 'iso-8859-1-string stream :length 3))
        (size (read-value 'u3 stream))
)

    (let ((object (make-instance (find-frame-class id) :id id :size size)))
      (read-object object stream)
      object
)
)
)

Так как раскрытия define-tagged-binary-class и define-binary-class будут идентичными за исключением метода чтения, мы можем вынести общие части во вспомогательный макрос define-generic-binary-class, который принимает метод чтения в качестве параметра и подставляет его в свое раскрытие.

(defmacro define-generic-binary-class (name (&rest superclasses) slots read-method)
  (with-gensyms (objectvar streamvar)
    `(progn
       (eval-when (:compile-toplevel :load-toplevel :execute)
         (setf (get ',name 'slots) ',(mapcar #'first slots))
         (setf (get ',name 'superclasses) ',superclasses)
)


       (defclass ,name ,superclasses
         ,(mapcar #'slot->defclass-slot slots)
)


       ,read-method

       (defmethod write-object progn ((,objectvar ,name) ,streamvar)
         (declare (ignorable ,streamvar))
         (with-slots ,(new-class-all-slots slots superclasses) ,objectvar
           ,@(mapcar #'(lambda (x) (slot->write-value x streamvar)) slots)
)
)
)
)
)

Теперь мы можем определеить define-binary-class и define-tagged-binary-class так, чтобы они раскрывались в вызов define-generic-binary-class. Вот новая версия define-binary-class, которая генерирует при полном своем раскрытии тот же код, чти и более ранняя:

(defmacro define-binary-class (name (&rest superclasses) slots)
  (with-gensyms (objectvar streamvar)
    `(define-generic-binary-class ,name ,superclasses ,slots
       (defmethod read-object progn ((,objectvar ,name) ,streamvar)
         (declare (ignorable ,streamvar))
         (with-slots ,(new-class-all-slots slots superclasses) ,objectvar
           ,@(mapcar #'(lambda (x) (slot->read-value x streamvar)) slots)
)
)
)
)
)

А вот define-tagged-binary-class вместе с двумя вспомогательными функциями, которые он использует:

(defmacro define-tagged-binary-class (name (&rest superclasses) slots &rest options)
  (with-gensyms (typevar objectvar streamvar)
    `(define-generic-binary-class ,name ,superclasses ,slots
      (defmethod read-value ((,typevar (eql ',name)) ,streamvar &key)
        (let* ,(mapcar #'(lambda (x) (slot->binding x streamvar)) slots)
          (let ((,objectvar
                 (make-instance
                  ,@(or (cdr (assoc :dispatch options))
                        (error "Must supply :dispatch form.")
)

                  ,@(mapcan #'slot->keyword-arg slots)
)
)
)

            (read-object ,objectvar ,streamvar)
            ,objectvar
)
)
)
)
)
)


(defun slot->binding (spec stream)
  (destructuring-bind (name (type &rest args)) (normalize-slot-spec spec)
    `(,name (read-value ',type ,stream ,@args))
)
)


(defun slot->keyword-arg (spec)
  (let ((name (first spec)))
    `(,(as-keyword name) ,name)
)
)

Примитивные двоичные типы

Хотя define-binary-class и define-tagged-binary-class делают легким определение сложных структур, мы все еще должны написать методы read-value и write-value для примитивных типов данных вручную. Мы могли бы смириться с этим, специфицировав, что пользователь билиотеки должен написать соответствующие методы read-value и write-value для поддержки примитивных типов, используемых его двоичными классами.

Однако, вместо документирования того, как написать подходящую пару методов read-value/write-value, мы можем предоставить макрос, делающий это автоматически. Это также даст пользу уменьшения "протечки" абстракции, созданной define-binary-class. Сейчас define-binary-class зависит от наличия методов read-value и write-value, определенных неким специфическим образом, который в действительности является деталью реализации. Определив макрос генерации методов read-value и write-value для примитивных типов мы скроем эти детали за управляемой нами абстракцией. Приняв позднее решение изменить реализацию define-binary-class, мы сможем изменить наш макрос определения примитивных типов так, что не потребуется вносить изменения в код, использующий нашу библиотеку двоичных данных.

Итак, мы должны определить последний макрос, define-binary-type, который будет генерировать методы read-value и write-value для чтения и записи значений, предствляющих экземпляры уже существующих, а не определенных с помощью define-binary-class, классов.

В качестве конкретного примера рассмотрим тип, используемый классом id3-tag: строку знаков фиксированной длины, закодирированную с помощью ISO-8859-1. Я подразумеваю, как делал это и раньше, что внутренней кодировкой вашей реализации Lisp является ISO-8859-1 или ее надмножество, так что вы можете использовать функции CODE-CHAR и CHAR-CODE для преобразования байт в знаки и обратно.

Как всегда, нашей целью является написание макроса, который позволит нам ограничиться выражением только самой существенной информации, необходимой для генерации требуемого кода. В данном случае есть четыре части такой существенной информации: имя типа, iso-8859-1-string; &key параметры, которые должны приниматься методами read-value и write-value, в нашем случае это length; код считывания из потока; код записи в поток. Вот выражение, содержащее все четыре части информации:

(define-binary-type iso-8859-1-string (length)
  (:reader (in)
    (let ((string (make-string length)))
      (dotimes (i length)
        (setf (char string i) (code-char (read-byte in)))
)

      string
)
)

  (:writer (out string)
    (dotimes (i length)
      (write-byte (char-code (char string i)) out)
)
)
)

Теперь все, что нам нужно, так это макрос, осуществляющий разбор такой формы и преобразующий ее в две DEFMETHOD, обернутых в форму PROGN. Если мы определим список параметров define-binary-type следующим образом:

(defmacro define-binary-type (name (&rest args) &body spec) ...

то внутри макроса параметр spec будет списком, содержащим определения процедур чтения и записи. Вы можете использовать функцию ASSOC для извлечения элементов spec с помощью меток :reader и :writer, а затем DESTRUCTURING-BIND для разбора REST-части каждого элемента10).

Остальная работа является Всего лишь делом подстановки извлеченных значений в шаблоны квазицитирования (backquoted templates) методов read-value и write-value.

(defmacro define-binary-type (name (&rest args) &body spec)
  (with-gensyms (type)
    `(progn
      ,(destructuring-bind ((in) &body body) (rest (assoc :reader spec))
        `(defmethod read-value ((,type (eql ',name)) ,in &key ,@args)
          ,@body
)
)

      ,(destructuring-bind ((out value) &body body) (rest (assoc :writer spec))
        `(defmethod write-value ((,type (eql ',name)) ,out ,value &key ,@args)
          ,@body
)
)
)
)
)

Обратите внимание на вложенность шаблонов квазицитирования: самый внешний шаблон начинается с закавыченной формы PROGN. Этот шаблон состоит из символа PROGN и двух "раскавыченных" (comma-unquoted) выражений DESTRUCTURING-BIND. Таким образом, внешний шаблон заполняется путем вычисления выражений DESTRUCTURING-BIND и подстановки значений их результатов. Каждое выражение DESTRUCTURING-BIND, в свою очередь, также содержит шаблон квазицитирования, каждый из которых используется для генерации определения метода для подстановки его во внешний шаблон.

Теперь данная ранее форма define-binary-type раскрывается в такой код:

(progn
  (defmethod read-value ((#:g1618 (eql 'iso-8859-1-string)) in &key length)
    (let ((string (make-string length)))
      (dotimes (i length)
        (setf (char string i) (code-char (read-byte in)))
)

      string
)
)

  (defmethod write-value ((#:g1618 (eql 'iso-8859-1-string)) out string &key length)
    (dotimes (i length)
      (write-byte (char-code (char string i)) out)
)
)
)

Конечно, теперь, когда у нас есть такой хороший макрос для определения двоичных типов, привлекательной кажется мысль об улучшении этого макроса таким образом, чтобы он проделывал больше работы. Сейчас нам следует сделать лишь одно небольшое улучшение, которое окажется весьма полезным, когда мы начнем использовать нашу библиотеку при работе с реальными форматами файлов, такими как, например, теги ID3.

Теги ID3, подобно многим другим двоичным форматам, используют множество примитивных типов, являющихся небольшими вариациями на одну тему, такими как беззнаковые целые размерами один, два, три и четыре байта. Конечно же мы можем определить каждый такой тип с помощью define-binary-type. Но мы также можем вынести общий алгоритм чтения и записи n-байтных беззнаковых целых во вспомогательную функцию.

Но представим, что мы уже определили двоичный тип, unsigned-integer, который принимает параметр :bytes для указания того, как много байт нужно считывать и записывать. Используя этот тип мы можем определить слот, представляющий однобайтное целое, с помощью спецификатора (unsigned-integer :bytes 1). Но, если определенный двоичный формат определяет множество слотов этого типа, было бы неплохо иметь возможность легкого определения нового типа, скажем u1, означающего то же, что и используемый тип. На самом деле, несложно изменить define-binary-type так, чтобы он поддерживал две формы: длинную форму, состоящую из пары :reader и :writer, и короткую форму, которая определяет новый двоичный тип в терминах уже существующего типа. Используя короткую форму define-binary-type вы можете определить u1 следующим образом:

(define-binary-type u1 () (unsigned-integer :bytes 1))

что раскроется в следующее:

(progn
  (defmethod read-value ((#:g161887 (eql 'u1)) #:g161888 &key)
    (read-value 'unsigned-integer #:g161888 :bytes 1)
)

  (defmethod write-value ((#:g161887 (eql 'u1)) #:g161888 #:g161889 &key)
    (write-value 'unsigned-integer #:g161888 #:g161889 :bytes 1)
)
)

Для поддержки и длинной, и короткой форм define-binary-type нам нужно различать эти два случая основываясь на значении аргумента spec. Если spec содержит два элемента, он представляет длинную форму, а эти два элемента должны быть определениями :reader и :writer, извлечение которых было реализовано нами раньше. Если же этот аргумент содержит лишь один элемент, этот элемент должен быть спецификатором типа, разбор которого будет отличаться. Мы можем использовать ECASE для осуществления дифференциации по длине аргумента spec с целью дальнейшего осуществления разбора этого аргумента и генерации соответствующего форме (длинной или короткой) раскрытия.

(defmacro define-binary-type (name (&rest args) &body spec)
  (ecase (length spec)
    (1
     (with-gensyms (type stream value)
       (destructuring-bind (derived-from &rest derived-args) (mklist (first spec))
         `(progn
            (defmethod read-value ((,type (eql ',name)) ,stream &key ,@args)
              (read-value ',derived-from ,stream ,@derived-args)
)

            (defmethod write-value ((,type (eql ',name)) ,stream ,value &key ,@args)
              (write-value ',derived-from ,stream ,value ,@derived-args)
)
)
)
)
)

    (2
     (with-gensyms (type)
       `(progn
          ,(destructuring-bind ((in) &body body) (rest (assoc :reader spec))
             `(defmethod read-value ((,type (eql ',name)) ,in &key ,@args)
                ,@body
)
)

          ,(destructuring-bind ((out value) &body body) (rest (assoc :writer spec))
             `(defmethod write-value ((,type (eql ',name)) ,out ,value &key ,@args)
                ,@body
)
)
)
)
)
)
)

Стек обрабатываемых в данный момент объектов

Последней частью функциональности, которая нам понадобится в следующей главе, является возможность обращения к двоичному объекту, чтение или запись которого производится в данный момент. То есть, во время чтения или записи вложенных сложных объектов было бы удобно иметь возможность получения доступа к объекту, чтение или запись которого производится в данный момент. Благодаря динамическим переменным и методам :around мы можем добавить это улучшение, написав всего лишь около дюжины строк кода. Для начала нам нужно определить динамическую переменную, которая будет содержать стек объектов, чтение или запись которых осуществляется в данный момент.

(defvar *in-progress-objects* nil)

Затем мы можем определить методы :around для read-object и write-object, которые помещают объект, чтение или запись которого будет осуществляться, в определенную ранее переменную перед вызовом CALL-NEXT-METHOD.

(defmethod read-object :around (object stream)
  (declare (ignore stream))
  (let ((*in-progress-objects* (cons object *in-progress-objects*)))
    (call-next-method)
)
)


(defmethod write-object :around (object stream)
  (declare (ignore stream))
  (let ((*in-progress-objects* (cons object *in-progress-objects*)))
    (call-next-method)
)
)

Обратите внимание как мы пересвязали *in-progress-objects* со списком, содержащим новый элемент в своем начале, вместо присвоения ему нового значения. Поступив так, мы получили тот эффект, что в конце LET, после возврата из CALL-NEXT-METHOD, старое значение *in-progress-objects* будет восстановлено (то есть, последний помещенный в стек элемент будет из него удален).

Имея эти определения методов, мы можем предоставить две удобные функции для получения отдельных объектов из стека обрабатываемых объектов. Функция current-binary-object будет возвращать вершину стека, то есть объект, чей метод read-object или write-object был вызван наиболее недавно. Вторая, parent-of-type, получает аргумент, который должен быть именем класса двоичного типа, и возвращает наиболее недавно помещенный в стек объект данного типа, используя функцию TYPEP, которая проверяет, является ли переданный ей объект экземпляром определенного типа.

(defun current-binary-object () (first *in-progress-objects*))

(defun parent-of-type (type)
  (find-if #'(lambda (x) (typep x type)) *in-progress-objects*)
)

Эти две функции могут быть использованы из любого кода, который будет вызван в динамической протяженности вызова read-object или write-object. Мы увидим один пример использования current-binary-object в следующей главе11).

Теперь у нас есть все инструменты, нужные для создания библиотеки разбора ID3, поэтому мы готовы перейти к следующей главе, где мы именно этим и займемся.

1)В ASCII первые 32 знака являются непечатаемыми управляющими знаками, изначально использовавшимися для управления работой телетайпа, и осуществляющие такие действия как выдача звукового сигнала, возврат на один знак назад, перемещение на следующую строку, возврат каретки в начало строки. Из этих 32 управляющих знаков только три, знак новой строки, возврат каретки и горизонтальная табуляция, типичны для текстовых файлов.
2)Некоторые форматы двоичных файлов сами являются структурами данных в памяти (in-memory data structures): во многих операционных системах существует возможность отображения файла в память, и низкоуровневые языки, такие как C, могут рассматривать область памяти, содержащую данные файла, так же, как и любую другую область памяти; данные, записанные в эту область памяти, сохраняются в нижележащий файл при отключении его отображения в память. Однако такие форматы файлов являются платформенно-зависимыми, так как представление в памяти даже таких простых типов данных, как числа, зависит от аппаратного обеспечения, на котором выполняется программа. Поэтому любой формат файла, претендующий на платформенно-независимость, должен определять каноническое представление всех используемых им типов данных, которое может быть отображено в представление в памяти фактических данных для определенного вида машин или для определенного языка
3)Термин big-endian и его противоположность little-endian заимствованы из Путешествий Гулливера Джонатана Свифта и указывают на способ представления многобайтового числа упорядоченной последовательностью байт в памяти или в файле. Например число 43981, abcd в шестнадцатиричной системе счисления, представленное как 16-битная сущность, состоит из двух байт: ab и cd. Для компьютера не имеет значения в каком порядке эти два байта хранятся, пока все следуют этому соглашению. Конечно, в случае, когда определенный выбор должен быть сделан между двумя одинаково хорошими вариантами, одна вещь, в которой вы можете не сомневаться, это то, что не все будут согласны. Для получения большей, чем вы даже хотите знать, информации, и для того, чтобы увидеть где термины bin-endian и little-endian были впервые применены в таком контексте, прочитайте статью "On Holy Wars and a Plea for Peace" Дэнни Кохена, доступную по адресу http://khavrinen.lcs.mit.edu/wollman/ien-137.txt.
4)LDB и связанная функция DPB были названы по функциям ассемблера DEC PDP-10, которые осуществляли в точности эти же вещи. Обе функции осуществляют операции над целыми числами, как если бы они хранились в формате дополнения до двух, вне зависимости от внутреннего представления, используемого определенной реализацией Common Lisp.
5)Common Lisp также предоставляет функции для сдвига и маскирования битов целых чисел способом, который может быть более знаком программистам на C и Java. Например мы можем написать read-u2 третим способом путем использования этих функций следующим образом:
(defun read-u2 (in)
  (logior (ash (read-byte in) 8) (read-byte in))
)

что будет примерным эквивалентом следующего метода Java:

public int readU2 (InputStream in) throws IOException {
 return (in.read() << 8) | (in.read());
}

Имена LOGIOR и ASH являются сокращенифми для LOGical Inclusive OR и Arithmetic SHift. ASH производит сдвиг целого числа на заданное количество бит влево, если ее второй аргумент является положительным, или вправо, если ее второй аргумент отрицателен. LOGIOR комбинирует целые путем осуществления логической операции ИЛИ над каждым их битом. Другая функция, LOGAND, осуществляет побитовое И, которое может быть использовано для маскирования определенных битов. Однако, для тех видов операций манипулирования с битами, которые мы будем осуществлять в следующей главе и далее, LDB и BYTE обе будут более удобными и более идиоматичными для стиля Common Lisp.
6)Изначально UTF-8 была спроектирована для представления 31-битного знакового кода и использовала до шести байт на единицу кодирования. Однако, максимальной единицей кодирования Unicode является #x10ffff, и поэтому Unicode кодировка UTF-8 требует максимум четыре байта на единицу кодирования.
7)Если нам нужно производить разбор формата файлов, который использует другие знаковые коды, или делать тоже самое для файлов, содержащих произвольные строки Unicode, используя не-Unicode реализацию Common Lisp, мы всегда можем представить такие строки в памяти как векторы целочисленных единиц кодирования. Они не будут строками Lisp, и поэтому мы не сможем манипулировать ими или сравнивать их с помощью строковых функций, но мы по прежнему сможем делать с ними все то, что мы можем делать с произвольными векторами.
8)К сожалению, сам язык не всегда подает хороший пример в этом отношении: макрос DEFSTRUCT, который я не обсуждал, так как он почти полностью вытеснен DEFCLASS, генерирует функции с именами, получающимися на основе имени, данному структуре. Плохой пример DEFSTRUCT сбивает с истинного пути многих новичков.
9)Технически, для type или object не существует возможности конфликтования с именами слотов: в худшем случае они будут скрыты внутри формы WITH-SLOTS. Но все же не будет ничего плохого в том, что бы просто сгенерировать с помощью GENSYM все локальные переменные, используемые внутри шаблона макроса.
10)Использование ASSOC для извлечения элементов :reader и :writer параметра spec позволяет пользователям макроса define-binary-type включать эти элементы в любом порядке; Решив, что элемент :reader всегда будет на первом месте, мы могли бы использовать (rest (first spec)) для извлечения части для чтения данных, и (rest (second spec)) для извлечения части для их записи. Однако, так как мы решили использовать ключевые слова :reader и :writer для улучшения читаемости форм define-binary-type, мы также можем использовать их для извлечения правильных данных.
11)Формат ID3 не требует исопользования функции parent-of-type, так как имеет сравнительно "плоскую" структуру. Эта функция становится очень полезной если вы осуществляете разбор формата, состоящего из множества глубоковложенных структур, чей разбор зависит от информации, сохраненной в структурах более высокого уровня. Например, в формате файлов классов Java структура файлов классов верхнего уровня содержит пул констант, отображающий числовые значения, используемые в других подструктурах внутри файла класса, на константные значения, которые нужны во время разбора этих подструктур FIXME(знатоки Java, улучшите, пожалуйста, этот перевод). Если бы мы писали программу разбора файлов классов, мы могли бы использовать parent-of-type в коде чтения и записи этих подструктур для обращения к объекту файла класса верхнего уровня, а по нему — к пулу констант.
Предыдущая Оглавление Следующая
@2009-2013 lisper.ru