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

топ 100 блогов love5an30.12.2010 В предыдущем постинге упоминалось, что 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).
  • В Common Lisp практически всё есть объект. В том плане, что почти каждая сущность в CL обладает состоянием(state), идентичностью(identity) и поведением(behavior).
    • Формально, все объекты - ссылки(т.е. указатели). В функции и макросы аргументы передаются копированием ссылки. Для иммутабельных(неизменяемых) типов, вроде чисел, это не так важно, впрочем.
    • Для некоторых примитивных типов, если конкретно - для чисел и литер(character) правило идентичности ослаблено, в целях оптимизации. В том плане, что, к примеру, число 1 в разных местах - не обязательно один и тот же объект. Проверить идентичность можно функцей eq. Проверить идентичность с учетом чисел и литер можно функцией eql.
  • Символ(symbol) - отличительная особенность семейства языков "Лисп", и CL, в частности. Эта простая и удобная структура данных имеет следующие слоты(поля):
    • Имя. Фактически - строка произвольного текста.
    • Слот функции. Хранит в себе глобально-определенную(с помощью defun) функцию, связанную с конкретным именем. Или макрос(defmacro).
    • Слот макроса компилятора(про них см. ниже).
    • Слот, хранящий стек значений динамических переменных(про них ниже). Или же, defconstant-константу или глобально-определенный символьный макрос.
    • Ссылка на пакет(package, аналог namespace из, например, Java. Про них ниже), к которому данный символ принадлежит.
    • Слот, хранящий тип(deftype) или CLOS-класс(defclass/defstruct), связанный с символом.
    • И другие слоты. Например, "список свойств" символа.
    Каждый символ может принадлежать к какому-либо пакету. Но, может и не принадлежать. Символы, связанные с пакетами - синглтоны. Т.е. в каждом пакете может быть только один символ с конкретным именем. Это очень удобно, потому что позволяет использовать символы там, где в Си, например, используются перечисления(enum), не теряя в эффективности(eq-сравнение символов это, фактически, сравнение указателей на равенство). Также, символы часто используются там, где в других языках используются строки - например, как ключи в хэш-таблицах. Кроме того, различные имена(функций, переменных и т.д.) в коде на лиспе исторически принято представлять именно символами, так как они очень удобны для метапрограммирования.
  • Пакет(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-яйчеек и других объектов).
  • Аргументов функций и макросов в 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.

Оставить комментарий

Архив записей в блогах:
APD Всё пропало, пока я тут хвастался, мне пришло вот такое письмо: Ну не д*билы, конечно трафик, я же их в свой блог загрузил. Но пост был не об этом, а о том, что для меня всегда было загадкой, почему загруженные в сеть фотографии превращаются в УГ, вот вам простой пример: Фотогр ...
Несчастный случай - С 1 по 13 03:42    Surge Blavat ...
Как вам идея дать Владимиру Александровичу Зеленскому нобелевскую премию мира? Мне очень нравится эта инициатива, особенно в свете его последних высказываний - что-то там о ...
В рамках предприятия «прочти всего Варгаса Льосу, до которого дотянешься», я взяла в городской библиотеке перечитать его первый и, наверное, самый скандальный роман «Город и псы». Тот самый «Город и псы», который, согласно легенде, в присутствии всех курсантов и преподавателей ...
Железная дорога расположена на полуострове Ямал. Начинается от станции «Обская» (город Лабытнанги) и идёт до станции «Карская» (Бованенковское месторождение). Длина магистрали 572 км, и она целиком расположена за Северным Полярным кругом — начинается в 18 км от него и заканчивается на ...