От переводчика: В 2007 году, в поисках веб-движка я наткнулся на очень интересный и необычный диалект лиспа. И после прочтения нескольких статей я был очарован его принципами. Поскольку моя основная работа далека от веб-программирования, то профессионально я его не использую, но время от времени возвращаюсь к нему и понемногу «штурмую».
За всё время знакомства с этим языком он практически нигде не мелькает, и на русском языке информации о нем почти нет. Попробуем восполнить этот пробел. Несмотря на то, что оригинал статьи датируется 2006-ым годом, тема вполне актуальна.
Большое спасибо за помощь в переводе Надежде Захаровой и замечательному сайту Notabenoid.
1. Введение
Я работаю консультантом и разработчиком свободного программного обеспечения. На протяжении двадцати лет я и мои партнеры работали над такими проектами, как обработка изображений, системы автоматизированного проектирования, моделирования, а также различные финансовые и бизнес-приложения.
Почти для всех этих проектов мы использовали Lisp. Моя ежедневная работа — слушать запросы заказчиков, анализировать бизнес-процессы и разрабатывать программное обеспечение соответственно их потребностям.
Обычно — в бизнес-приложениях типа ERP или CRM — это процесс постоянных изменений. В начале нового проекта ни разработчик, ни заказчик не знают точно ни то, что необходимо, ни то, как должен выглядеть конечный продукт.
Это приходит во время итеративного процесса (некоторые называют его «экстремальное программирование»). Клиент оценивает каждую новую версию, затем обсуждаются стратегии дальнейшего развития программы. Нередко, непредвиденные требования вынуждают переписывать большие части проекта. Это не значит, что проект был плохо спланирован, потому что процесс, описываемый мною, и есть планирование. В идеальном мире разработка программного обеспечения — это только планирование, время, потраченное на непосредственное написание кода, должно стремиться к нулю.
Нам нужен язык программирования, который позволяет прямо выразить, что мы хотим, чтобы программа делала. Мы верим, что код должен быть настолько простым, насколько это возможно, чтобы любой программист в любой момент мог понять, что происходит в программе.
За годы система Pico Lisp эволюционировала с минималистской реализации Lisp до специализированного сервера приложений. Прошу заметить, мы не говорим об инструменте быстрого прототипирования. На каждом этапе разработки результатом является полностью функциональная программа, а не прототип, который разрастается до серийной (возможно, последней) версии. Наоборот, это можно назвать мощным инструментом профессионального программиста, который привык держать в порядке свою среду разработки и хочет выражать логику своего приложения и структуры данных в лаконичном представлении.
Сначала мы хотим познакомить вас с Pico Lisp, объяснить, почему Pico на низком уровне значительно отличается от других Lisp-ов или систем разработки, а затем показать его преимущества на более высоких уровнях.
2. Радикальный подход
Возможно, сообщество (Common-) Lisp не будет в восторге от Pico Lisp, потому что он разрушает некоторые убеждения и догмы, ставшие традиционными. Некоторые из них всего лишь мифы, но они могут стать причиной излишней сложности, медлительности Lisp. Практический опыт работы с Pico Lisp доказывает, что легкий и быстрый Lisp является оптимальным для многих видов эффективной разработки приложений.
2.1. Миф 1: Lisp-у необходим компилятор
На самом деле, это самый главный миф. Если вы участвовали в групповых обсуждениях Lisp, то знаете, что компилятор играет главную роль. Может сложиться впечатление, что это почти синоним среды исполнения. Людям важно, какие действия компилятор производит с кодом и насколько эффективно. Если вам кажется, что программа на Lisp медленно выполняется, то вы решите, что вам требуется компилятор получше.
Идея интерпретируемого Lisp расценивается как старое заблуждение. Для современного Lisp требуется компилятор, а интерпретатор — это лишь полезное дополнение, и нужен для интерактивной отладки приложения. Он слишком медленный и раздутый для выполнения программ, которые уже находятся на этапе готовности к выпуску.
Мы же уверены, что верна противоположная точка зрения. С одной стороны (и не только с философской точки зрения) скомпилированная программа на Lisp больше не является Lisp вообще. Это нарушает фундаментальное правило «формальной эквивалентности кода и данных». Получившийся код не содержит S-выражений и не может быть обработан Lisp. Исходный язык (Lisp) преобразовывается в другой язык (машинный код) с неизбежными несовместимостями на разных машинах.
На практике, компилятор усложняет систему в целом. Такие особенности, как стратегии множественного связывания, типизированные переменные и макросы были разработаны для удовлетворения нужд компиляторов. Система получается раздутой, потому что она должна поддерживать также и интерпретатор, и соответственно, две различные архитектуры.
Но стоит ли это затраченных усилий? Конечно, скорость выполнения выше, и создание компилятора интересно в целях обучения. Но мы утверждаем, что в повседневной жизни хорошо спроектированный «интерпретатор» часто может превзойти скомпилированную систему.
Вы понимаете, что на самом деле мы не говорим об «интерпретации». Lisp-система сразу конвертирует передаваемые ей данные во внутренние структуры указателей, которые называются «S-выражениями». Истинная «интерпретация» работает с одномерными кодами символов, и это существенно замедляет процесс выполнения. Lisp же «вычисляет» S-выражения быстро следуя по этим структурам указателей. Нет никаких поисков, так что ничего на самом деле не «интерпретируется». Но мы будем придерживаться этого привычного термина.
Программа на Lisp как S-выражение образует дерево исполняемых узлов. Код этих узлов написан обычно на оптимизированном С или ассемблере, поэтому задача интерпретатора состоит в том, чтобы передать управление от одного узла другому. Поскольку многие из этих встроенных функций Lisp очень мощные и выполняют много вычислений, большая часть времени выполнения приходится на узлы. Само дерево функционирует как своего рода клей.
Компилятор Lisp убирает немного этого клея и заменяет некоторые узлы с примитивной или потоковой функциональностью напрямую в машинный код. Но, поскольку в любом случае большая часть времени выполнения приходится на встроенные функции, эти улучшения не столь драматичны, как например, у компилятора байт-кода Java, для которого каждый узел (байт-код) обладает сравнительно примитивной функциональностью.
Конечно, компиляция сама по себе также требует довольно много времени. Сервер приложений часто исполняет исходные файлы Lisp на лету за один прогон и немедленно отбрасывает код, как только он выполнился. В таких случаях, либо изначально более медленный интерпретатор Lisp-системы, основанной на компиляторе, либо дополнительное время, затрачиваемое компилятором, будут значительно снижать общую производительность.
Внутренние структуры Pico Lisp были изначально разработаны для удобства интерпретации. Несмотря на то, что они были полностью написаны на C, и не были специально оптимизированы для скорости выполнения, никогда не было проблемы недостаточной производительности. Первая коммерческая система, написанная на Pico Lisp, представляла собой систему для обработки и ретуширования изображений, а также для создания макетов страниц для печати. Она была создана в 1988 году и использовалась на Mac II с ЦПУ 12 МГц и оперативной памятью 8 МБ.
Конечно, не было компилятора Lisp, были только низкоуровневые манипуляции с пикселями и функции безье, написанные на C. Даже тогда, при работе на компьютере, который в сотни раз медленнее современных, никто не жаловался на производительность.
Чисто ради интереса я установил CLisp и сравнил его с Pico Lisp на примере простых тестов. Конечно, это не означает, что результаты тестов показывают полезность той или иной системы в качестве сервера приложений, но они дают приблизительное представление о производительности этих систем. Сначала, я попытался выполнить простую рекурсивную функцию Фибоначчи.
(defun fibo (N)
(if (< N 2)
1
(+ (fibo (- N 1)) (fibo (- N 2)) ) ) )
При вызове этой функции с параметром 30 (fibo 30), я получил следующие результаты (тестирование выполнялось на ноутбуке Pentium-I 266 МГц):
Pico (интерпретация) | 12 секунд |
CLisp интерпретация | 37 секунд |
CLisp компилированный | 7 секунд |
Интерпретатор CLisp почти в три раза медленнее, а компилятор чуть ли не в два раза быстрее Pico Lisp.
Однако функция Фибоначчи не очень хороший пример типичной Lisp-программы. Она состоит только из примитивного потока и арифметических функций, что легко оптимизируется компилятором и может быть написано прямо на C, если это критично по времени (в этом случае выполнение заняло бы всего 0.2 с)
Поэтому я взял другой крайний случай, с функцией, выполняющей обширную обработку списков:
(defun tst ()
(mapcar
(lambda (X) (cons (car X) (reverse (delete (car X) (cdr X)))))
'((a b c a b c) (b c d b c d) (c d e c d e) (d e f d e f)) ) )
Вызвав эту функцию 1 млн раз, я получил:
Pico (интерпретация) | 31 секунд |
CLisp интерпретация | 196 секунд |
CLisp компилированный | 80 секунд |
Теперь интерпретатор CLisp более чем в 6 раз медленнее, но к моему удивлению даже скомпилированный код в 2.58 раз медленнее чем Pico Lisp.
Может быть, у CLisp медленный компилятор? И возможно код может быть ускорен с помощью некоторых трюков. Но эти результаты все равно оставляют много сомнений в том, могут ли быть оправданы накладные расходы на компиляцию. Возиться с оптимизацией компиляторов это последнее, что я хочу делать, когда дело касается логики приложения, и когда пользователь все равно не заметит задержек.
2.2. Миф 2: Лиспу Необходимо Множество Типов Данных
Функцию Фибоначчи, описанную в примере выше можно ускорить, объявив переменную N как целое число. Но тогда данный пример покажет, насколько сильно влияют на Lisp требования поддержки компилятора. Компилятор может выдавать более эффективный код, если типы данных жестко заданы. Common Lisp поддерживает много различных типов данных, включая различные целочисленные типы, типы с фиксированной/плавающей точкой, дробные числа, символы, строки, структуры, хэш-таблицы, а также векторные типы в дополнение к спискам.
С другой стороны, Pico Lisp поддерживает только три встроенных типа данных — числа, символы и списки, и вполне прекрасно обходится только этими типами. Lisp-система работает быстрее с меньшим количеством типов данных, потому что меньшее количество опций необходимо проверять во время выполнения. Может быть, это повлечет за собой менее эффективное использование памяти, но зато меньшее количество типов позволяет сохранить место за счет того, что требуется меньше бит для тэгов.
Главная причина использования всего трех типов данных заключается в простоте, и преимущество в простоте превышает пользу от компенсации в скорости и занимаемой памяти.
На самом деле, Pico Lisp на самом низком уровне использует только один тип данных, ячейки, которые используются для формирования чисел, символов и списков. Небольшое число или минимальный символ занимают только одну ячейку памяти, динамически увеличивающуюся при необходимости. Эта модель памяти позволяет проводить эффективную сборку мусора и полностью избежать фрагментации (как это было бы, например, с векторами).
На самом высоком уровне всегда можно эмулировать другие типы данных, используя эти три примитивных типа данных. Так, мы эмулируем деревья с помощью списков, строки, классы и объекты с помощью символов. Пока не наблюдается проблем с производительностью, зачем усложнять?
2.3. Миф 3: Динамическое связывание – это плохо
Pico Lisp использует простую реализацию динамического поверхностного связывания. Содержимое ячейки, хранящей значение символа, сохраняется при входе в лямбда-выражение или окружение связывания, и затем устанавливается новое значение. При возврате, оригинальное значение восстанавливается. В результате, текущее значение символа определяется динамически по истории и состоянию выполнения, а не по статическим проверкам лексической среды.
Возможно, для интерпретируемой системы это самая простая и быстрая стратегия. Для просмотра значения ячейки не требуется никаких поисков (требуется только доступ к значению ячейки) и все символы (локальные или глобальные) обрабатываются одинаково. С другой стороны, компилятор может выдавать более эффективный код для лексического связывания, таким образом, скомпилированный код на Lisp обычно все усложняет из-за поддержки нескольких типов стратегий связывания.
Динамическое связывание — это очень мощный механизм. Получить доступ к текущему значению можно из любого места, сама переменная и ее значение — это всегда физически существующие «реальные вещи», а не то что «кажутся» ( как в случае с лексическим связыванием, и в какой-то степени с использованием транзитных символов в Pico Lisp (смотри ниже)).
К сожалению, большие возможности невозможны без больших рисков. Программист должен быть хорошо знаком с основными принципами, чтобы использовать их преимущества и избежать ловушек. Однако, пока мы будем придерживаться соглашений, рекомендованных Pico Lisp, риски будут минимальны.
Существуют два типа ситуаций, когда результаты вычислений, использующих динамическое связывание, могут выйти из под контроля программиста:
- символ связан с самим собой, и мы пытаемся изменить значение символа;
- проблема фунарга (функционального аргумента), когда значение символа динамически изменяется сквозным кодом, который невидим в окружении текущего исходного кода.
Таких ситуаций можно избежать, используя транзитные символы.
Транзитные символы — это символы в Pico Lisp, которые выглядят, как строки (и часто используются в качестве строк), и которые лишь временно интернированы на время выполнения одного файла с исходным кодом (или только его части). Таким образом, они обладают лексическими возможностями, сопоставимыми со статическими идентификаторами в программах на языке C, только их поведение полностью динамическое, потому что они представляют собой нормальные символы во всех остальных отношениях.
Итак, правила просты: всякий раз, когда функция должна изменить значение переданной ей переменной или вычислить результат переданного выражения Lisp (прямо или косвенно), параметры этой функции должны быть записаны с помощью транзитных символов. Практический опыт показывает, что такие случаи редки в процессах высокоуровневой разработки программного обеспечения и имеют место в основном во вспомогательных библиотеках и системных инструментах.
2.4. Миф 4: Списки свойств — это плохо
Свойства — изящный, понятный способ ассоциировать информацию с символами в дополнение к ячейке значения/функции. Они крайне гибки, так как количество и тип данных статически не фиксированы.
Кажется, многие думают, что списки свойств слишком устарели и примитивны, чтобы их использовать в наше время. Вместо этого должны быть использованы более продвинутые структуры данных. Хотя это верно в некоторых случаях, в зависимости от общего количества свойств в символе, порог окупаемости может оказаться выше, чем ожидается.
Предыдущие версии Pico Lisp экспериментировали с хэш-таблицами и самобалансирующимися двоичными деревьями для хранения свойств, но мы обнаружили, что обычные списки более эффективны. Мы должны принять во внимание суммарный эффект всей системы, и накладные расходы как для поддержки большого количества внутренних структур данных (смотри выше), так и более сложных алгоритмов поиска часто больше чем при использовании простого линейного поиска. А когда мы также касаемся вопроса эффективности использования памяти, преимущества списков свойств однозначно выигрывают.
Pico Lisp реализует свойства в виде списка пар ключ-значение. Единственная уступка в пользу оптимизации скорости — схема «наиболее недавно использовавшийся», немного ускоряющая повторяющийся доступ, но у нас нет конкретных признаков, что это было на самом деле необходимо.
Другой аргумент против свойств — их заявленная глобальная видимость. Это верно в той же степени, как то, что глобальны элемент в C-структуре или переменная экземпляра в Java-объекте.
Конечно, в глобальном символе свойство тоже глобально, но в типичной разработке приложений свойства хранятся в анонимных символах, объектах или элементах базы данных, которые доступны только в четко определенном контексте. Поэтому свойство «цвет» может быть использовано в определенном смысле в одном контексте, и в совершенно другом смысле в другом контексте, без всяких взаимных помех.
3. Сервер приложений
На базе этой простой машины Pico Lisp мы разработали вертикально структурированный сервер приложений. Он унифицирует движок базы данных (основанный на PicoLisp-овской реализации сохраняемых (персистентных) объектов как первоклассного типа данных) и абстрактный графический интерфейс (генерирующий, например, HTML или Java-апплеты).
Ключевой элемент в этой унифицированной системе — основанный на Lisp язык разметки, который используется для реализации отдельных модулей приложения.
Всякий раз, когда у сервера приложений запрашивается новый вид из БД, документ или отчет, или какой-то другой сервис, файл с исходным кодом Lisp загружается и выполняется на лету. Это аналогично URL-запросу с последующей отправкой HTML-файла в традиционном веб-сервере.
Однако, Lisp-выражения, вычисляемые в таком сценарии, обычно имеют побочный эффект построения и обработки интерактивного интерфейса пользователя.
Эти Lisp-выражения описывают структуру GUI-компонентов, их поведение в ответ на действия пользователя и их взаимодействие с объектами базы данных. Короче говоря, они содержат полное описание программного модуля. Чтобы это было возможно, мы обнаружили, что важно строго придерживаться Принципа Локальности, и использовать механизмы «Префикс-классы» и «Демоны поддержки связей» (последние два описаны в другом документе).
3.1. Принцип Локальности
Как мы говорили, разработка бизнес приложений — это процесс постоянных изменений. Принцип Локальности оказался большим подспорьем в развитии таких проектов. Этот принцип требует, чтобы вся информация, касающаяся одного модуля должна храниться с этим модулем в одном месте. Это позволяет программисту сфокусироваться только на одном месте, где все это хранится.
Конечно, все это кажется вполне очевидным, но в противоположность этому, методологии разработки программного обеспечения предписывают инкапсулировать поведение и данные, и скрывать их от остальных частей приложения. Обычно, это приводит к тому, что логика приложения написана в одном месте (исходном файле), но функции, классы и методы для осуществления этой логики, определены где-то еще. Конечно, это хорошая рекомендация, но она приносит множество проблем, проявляющихся в необходимости постоянно переходить по различным местам хранения: модификации и переключение контекста происходят одновременно в нескольких местах. Если какая-то функция устарела, некоторые модули тоже могут устареть, но мы забываем удалять их.
Таким образом, мы считаем, что оптимальным является создание абстрактной библиотеки функций, классов и методов — универсальных настолько, насколько это возможно, постоянных во времени и различных приложениях, и используемых для построения строгого языка разметки, обладающего высокой степени выразительности для создания приложений.
Этот язык должен иметь компактный синтаксис и позволять описывать все статические и динамические аспекты приложения. Локально, в одном месте. Без необходимости определять поведение в отдельных файлах.
3.2. Lisp
И это и есть та самая главная причина, по которой мы с самого начала утверждали, что Lisp — это единственный язык, который нам подходит.
Только Lisp позволяет одинаково обрабатывать код и данные, и это основа модели разработки приложений на Pico Lisp. Он позволяет интенсивно использовать функциональные блоки и вычисляемые выражения, свободно смешиваемые со статическими данными и которые можно как передавать куда-либо, так и хранить во внутренних структурах данных во время выполнения.
Насколько нам известно, в других языках это невозможно, по крайней мере, с такой же простотой и элегантностью. В некоторой степени это может быть сделано с помощью скриптовых языков, с использованием интерпретируемых строк текста, но это решение будет довольно ограниченным и неуклюжим. И, как мы описывали выше, системы на компилируемом Лиспе могут быть слишком тяжелыми и негибкими. Для того, чтобы все эти структуры данных и фрагменты кода работали слаженно, стратегия динамической поверхностной привязки является большим преимуществом, поскольку выражения могут вычисляться без необходимости в привязке настроек среды.
Другая причина заключается в том, что Lisp позволяет напрямую манипулировать сложными структурами данных, такими как символы и вложенные списки, без необходимости явного объявления, выделения, инициализации или освобождения из памяти этих структур. Это способствует компактности и читабельности кода и дает программисту мощный инструмент выражения, который позволяет выполнять разные вещи в одной строке там, где другие языки потребуют написания отдельного модуля.
В дополнение ко всему, поскольку Pico Lisp не делает формального различия между объектами базы данных и внутренними символами, все эти преимущества также применимы к работе с базой данных, приводя к прямой связи операций с GUI и БД в одном локальном контексте, используя идентичный код.
4. Заключение
Сообщество Lisp похоже страдает от паранойи «неэффективного» Lisp. Вероятно это из-за того факта, что десятилетиями они были вынуждены защищать свой язык от претензий, что «Lisp является медленным» и «Lisp является раздутым».
Отчасти это было правдой. Но на сегодняшнем оборудовании скорость выполнения не имеет значения для многих практических приложений. А в тех случаях, когда имеет, кодирование нескольких критически важных функций на C обычно решает эту проблему.
Теперь сфокусируемся на более практических аспектах. Некоторые могут быть удивлены, каким компактным и быстрым может быть якобы «древняя» Lisp-система. Таким образом, мы должны быть осторожны, чтобы не сделать Lisp действительно «раздутым», перегружая ядро языка все большими и большими возможностями, а должны решиться использовать простые решения, которые дают полную гибкость для программиста.
Pico Lisp можно рассматривать как доказательство концепции «Меньше может быть больше».