Особенности CL в контексте освоения языка.

Но, в ответ на это поступило несколько комментариев на тему того, что он большой и сложный, и учится довольно трудно, особенно по сравнению с Python.
Я склоняюсь к мнению, что это не так. Но, тем не менее, в нем есть места, над которыми люди много, часто и подолгу тупят. Поэтому я решил разобрать некоторые такие места, а в будущем, возможно, и большинство из них:
- В Common Lisp не существует понятия "программы", в обычном
смысле этого слова; есть только понятие лисп-системы(ср.
операционная система).
- Поэтому же, в CL, фактически, не существует отдельных стадий
"выполнения" программы, ее компиляции, или парсинга кода. Это все
суть одно и то же, и происходит в рантайме.
Формально, эти стадии разделены, но только формально. Компиляция - то, что происходит во время выполнения функций compile или сompile-file. Парсинг, или "считывание" - то, что происходит во время выполнения функций read, read-from-string и подобных. И время выполнения это даже не то, что все остальное, это вообще все, включая вышеупомянутые стадии(хотя, в некоторых случаях, например, в контексте оператора eval-when, стадии все же достаточно четко отделяются). - По этим причинам, выражение "скомпилировать программу в бинарник", в общепринятом смысле, в контексте CL некорректно. Однако, современные реализации позволяют сохранить текущее состояние лисп-системы(т.н. образ лисп-системы, или "core") в файл, для последующей загрузки, и более того, позволяют добавить к этому файлу-образу рантайм лисп-системы, чтобы сделать этот файл исполняемым(.exe, .dll, .so и т.п.). Коммерческие реализации, и, например, ECL, позволяют разделить состояние на несколько образов, чтобы добиться подобия "разделяемых библиотек"(shared libraries, т.е. .dll, .so и т.п.).
- Любой код на лиспе, представленный в виде текста,
проходит от одной до трех стадий, перед непосредственно
выполнением:
- Стадия считывания(read-time). На этой стадии текст преобразуется в объекты лиспа(про них - ниже). Если более формально - в AST(abstract syntax tree). Да, код на лиспе - это абсолютно любой объект лиспа. Исторически, его принятно представлять в виде связных списков и символов(symbol, про них ниже), да, но вообще говоря, в коде может находиться абсолютно любой объект - начиная со строк и чисел, и заканчивая экземплярами классов CLOS(common lisp object system).
- Опционально - стадия компиляции(compile-time)(когда AST обрабатывается функцией compile, либо когда текстовый файл компилируется с помощью compile-file). В Common Lisp существует понятие "минимальной компиляции". Минимальная компиляция - раскрытие всех макросов в коде(который AST). После нее компилятор может продолжать обрабатывать AST до получения машинных кодов, например.
- И после этого, опять же, опционально - стадия загрузки(load-time). Это время загрузки текстового файла, либо же, файла, полученного с помощью функции compile-file. Во время нее выполняется весь код, определенный в заданном файле "на верхнем уровне"(toplevel).
- Поэтому же, в CL, фактически, не существует отдельных стадий
"выполнения" программы, ее компиляции, или парсинга кода. Это все
суть одно и то же, и происходит в рантайме.
- В Common Lisp практически всё есть объект. В том плане, что
почти каждая сущность в CL обладает состоянием(state),
идентичностью(identity) и поведением(behavior).
- Формально, все объекты - ссылки(т.е. указатели). В функции и макросы аргументы передаются копированием ссылки. Для иммутабельных(неизменяемых) типов, вроде чисел, это не так важно, впрочем.
- Для некоторых примитивных типов, если конкретно - для чисел и литер(character) правило идентичности ослаблено, в целях оптимизации. В том плане, что, к примеру, число 1 в разных местах - не обязательно один и тот же объект. Проверить идентичность можно функцей eq. Проверить идентичность с учетом чисел и литер можно функцией eql.
- Символ(symbol) - отличительная особенность
семейства языков "Лисп", и CL, в частности. Эта простая и удобная
структура данных имеет следующие слоты(поля):
- Имя. Фактически - строка произвольного текста.
- Слот функции. Хранит в себе глобально-определенную(с помощью defun) функцию, связанную с конкретным именем. Или макрос(defmacro).
- Слот макроса компилятора(про них см. ниже).
- Слот, хранящий стек значений динамических переменных(про них ниже). Или же, defconstant-константу или глобально-определенный символьный макрос.
- Ссылка на пакет(package, аналог namespace из, например, Java. Про них ниже), к которому данный символ принадлежит.
- Слот, хранящий тип(deftype) или CLOS-класс(defclass/defstruct), связанный с символом.
- И другие слоты. Например, "список свойств" символа.
- Пакет(package) - фактически, это просто коллекция символов и немного синтаксиса в стандартном алгоритме считывателя(reader). Примерный аналог namespace из Java/C#/C++ etc, то есть, основное назначение - разделение областей видимости символов.
- keyword-символы, или "ключи", это символы, принадлежащие пакету с именем "KEYWORD". В слоте переменной они всегда хранят ссылку на самих себя(подобно символам NIL и T). В стандартном алгоритме считывателя они, для удобства, могут указываться без префикса-имени-пакета(т.е. :my-key это просто синтаксический сахар для keyword:my-key)
- Переменных в Common Lisp два вида - лексические("обычные"
переменные, как в Си или Java) и динамические.
- Глобальных лексических переменных нет, только константы(defconstant). defvar и defparameter определяют динамические переменные. Также, динамические переменные можно определить с помощью декларации special(про декларации см. ниже). let, prog, defun и многие другие формы связывают как лексические, так и динамические переменные. progv связывает только динамические.
- Лексические переменные, а также локальные функции и макросы, не связаны с символами, их значение не хранится в структуре "символ", это просто абстракция.
- Лексические переменные могут захватываться в замыкания.
- Динамические переменные отличаются от лексических тем, что их значение определяется не "статически", т.е. не на этапе компиляции/обработки кода, а во время выполнения. Они тоже образуют "стек значений", как и обычные переменные, но этот стек существует не только "в коде", но и в рантайме. Хранится он в символе, которым переменная обозначается.
- Динамические переменные очень удобны для передачи неявных параметров в функции.
- Имена динамических переменных принято оборачивать в *звездочки*
- Списков нет. Это абстракция над cons-ячейками.
- cons - простая структура данных, хранящая в себе два произвольных объекта - car и cdr.
- Связный список - цепочка cons-ячеек, где cdr каждой указывает на следующую ячейку в цепочке. Последняя cdr указывает на символ с именем nil, принадлежащий пакету common-lisp. Пустой список - просто nil.
- Связные списки и, вообще, деревья cons-ячеек - удобные структуры для метапрограммирования. Исторически, AST кода на лиспе составляется именно из них.
- "Формой"(form) обычно называется некая частью AST, которую предполагается вычислить или преобразовать. "Составная форма"(compound) - форма, являющаяся деревом из cons-ячеек и других объектов. (например (let ((x (random 10))) (+ x x)) - некая составная форма. И (random 10) внутри нее - тоже).
- Никто не заставляет использовать списки для всего! В CL присутствует куча других удобных структур данных.
- Функции, и не только функции, могут возвращать несколько значений. С помощью функций values или values-list. По умолчанию, из всего кортежа берется только первое значение, но получить и другие можно используя макросы multiple-value-bind, nth-value, multiple-value-list и оператор multiple-value-call.
- Макрос - подпрограмма, обрабатывающая код до его выполнения
и/или компиляции. Макросов в CL четыре вида:
- Обычные макросы. Определяются через defmacro или macrolet. Раскрываются во
время компиляции(в интерпретируемом коде их использование -
undefined behavior, могут раскрыться несколько раз, и не там, где
предполагается). Не вычисляют свои аргументы(именно потому, что
работают во время компиляции). Список параметров задается не
как в defun, а скорее как в destructuring-bind. Но
в остальном - эквиваленты обычным функциям. Т.е. внутри них можно
выполнять произвольный код.
- С побочными эффектами в макросах надо быть осторожным, так как порядок их раскрытия для не-toplevel кода в стандарте не оговорен(справа-налево или наоборот и т.д.). Для define-макросов, которые производят побочные эффекты, хорошим стилем является раскрытие их в оператор "eval-when", код в котором уже собственно побочные эффекты и производит.
- Символьные макросы. Определяются define-symbol-macro и symbol-macrolet. Фактически - просто символы, которые во время компиляции раскрываются во что-то другое. Примерно аналогичны простым define из Си(напр. "#define MAXVAL 100" == "(define-symbol-macro maxval 100)").
- Макросы компилятора. Дополняются к глобально определенным функциям, и во время компиляции раскрываются "вместо" них там, где компилятор решает оптимизировать. В остальном эквивалентны обычным макросам.
- Макросы считывателя. Определяются через set-macro-character, get-macro-character и др. Связываются не с определенным символом, но с определенной литерой(character), и вызываются во время выполнения функций read, read-from-string и подобных, то есть в read-time. Позволяют произвольно менять синтаксис. Фактически, пресловутые круглые скобки,"(" и ")", это всего лишь макро-литеры, которые считывают из текстового потока списочную структуру(дерево из cons-яйчеек и других объектов).
- Обычные макросы. Определяются через defmacro или macrolet. Раскрываются во
время компиляции(в интерпретируемом коде их использование -
undefined behavior, могут раскрыться несколько раз, и не там, где
предполагается). Не вычисляют свои аргументы(именно потому, что
работают во время компиляции). Список параметров задается не
как в defun, а скорее как в destructuring-bind. Но
в остальном - эквиваленты обычным функциям. Т.е. внутри них можно
выполнять произвольный код.
- Аргументов функций и макросов в CL - несколько видов:
- Собственно, обычные, или "основные", параметры.
- "Необязательные" параметры. В определении функции/макроса они следуют за символом &optional.
- Именованные("ключевые", &key) параметры - их, как и &optional параметры, необязательно(кстати, не рекомендуется их комбинировать с &optional) передавать функции при её вызове; но, в отличие от &optional, их надо передавать парами ключ-значение, причем пары могут идти в любом порядке(после основных и опциональных параметров, естественно).
- Каждый &optional и &key аргумент в
определениях функций и макросов задается либо списком из нескольких
элементов(От одного до трех -
"имя"(*), опционально - "значение по
умолчанию"(если не указано - NIL) и, опционально, опять же -
имя переменной, значение которой будет указывать на то, передан ли
параметр при вызове функции), либо же просто символом-именем.
(*) В случае с &key "имя" может быть не только символом, указывающим имя переменной, с которым значение аргумента связывается, но и списком вида (имя-ключа имя-аргумента). Для имени ключа, если оно не указывается, берется символ с таким же именем, как и "имя-аргумента", но из пакета keyword. - После ключевых параметров в списке аргументов можно указать спецификатор &allow-other-keys. Он отключает проверку на "лишние" пары ключ-значение. Кроме того, если функция принимает &key аргументы, ей можно передать ключ :allow-other-keys, который отключает проверку в одном конкретном месте вызова.
- "Остаточные"(&rest) параметры - произвольное количество значений, из которых формируется список. &rest-параметр в определении функции /макроса указывается до &key-параметров, но после всех остальных. Список из ключей и значений &key-аргументов, таким образом, если комбинируется с &rest, всегда добавляется к последнему.
- В макросах и destructuring-bind можно указать &whole параметр. Он указывается самым первым, т.е. даже до основных аргументов. Если указан, он содержит в себе форму вызова макроса "как она есть"(включая имя макроса в car).
- Кроме того, в списках аргументов макросов можно указать параметр &environment. Указывается самым последним. Он содержит в себе "лексическое окружение", в котором макрос раскрывается. Сама структура окружения - зависит от реализации CL(implementation-dependent).
- Вот пример определения функции и ее вызова:
(defun foo (a b &optional (c 'c) d &rest keys &key (e 'e) ((:x f) 0 f-present-p) g &allow-other-keys) (list a b c d e (list f f-present-p) g keys)) (foo 0 1 2 3 :g 4 :x 5 :z 123) ;; ==> (0 1 2 3 E (5 T) 4 (:G 4 :X 5 :Z 123))
- Декларации это просто некие указания компилятору или
рантайму. Их можно расставлять в toplevel, с помощью declaim, в начале
некоторых форм, вроде defun, let или locally, используя declare, или же
объявлять в рантайме функцией proclaim. Примеры:
- dynamic-extent - говорит компилятору о том, что объект, на который ссылается некоторая переменная, будет использоваться только во время выполнения определенного участка кода, и, таким образом, его можно разместить на стеке
- type - декларации типов. Помогают компилятору оптимизировать код, и предупреждать об ошибках типов во время компиляции.
- optimize - говорит компилятору о том, что некоторый участок кода необходимо особым образом оптимизировать - по времени(speed), по объему кода и памяти(space), по времени компиляции(compilation-speed), или же облегчить отладку(debug) или старательнее проверять на ошибки в рантайме(safety).
- Сигнальный протокол(condition system) в CL - обобщение систем
обработки исключений из мейнстримных языков.
- Сигналы отделены как от механизма раскрутки стека, так и от "finally"(аналог последнего - оператор unwind-protect)
- Ближайшие аналоги - синхронные сигналы Unix и Windows SEH.
- Java-style try-catch блок реализуется макросом handler-case
- Установка обработчиков, не раскручивающих стек - макрос handler-bind.
- Перезапуски - объекты, которые устанавливаются внизу стека, и содержат в себе, помимо прочего, функцию, вызываемую при активации перезапуска. Вызвать перезапуск можно в любой момент(когда он уже установлен, разумеется), но обычно это делается из обработчиков сигналов, находящихся выше по стеку и отловивших некоторое исключение. Макрос restart-case устанавливает перезапуски, которые при активации прерывают выполнение основного кода и возвращают значение функции перезапуска. Перезапуски же, устанавливаемые макросом restart-bind, в противоположность restart-case, не прерывают выполнение основного кода после отработки своих функций.
- Сигналы выбрасываются функциями signal, error и warn. Операторы throw и catch к сигнальному протоколу никак не относятся, это такие динамические аналоги block и return-from.
upd. Немного поправил разделы про символы и макросы. Добавил пункт про списки аргументов. В следующем постинге подробнее опишу декларации типов, и вообще, систему типов CL. После - будет пост про CLOS.