Некоторое время назад (года три) решил почитать учебник по Лиспу. Без всякой конкретной цели, просто ради общего развития и возможности шокировать собеседников экзотикой (один раз кажется, даже получилось).

Но при ближайшем рассмотрении Лисп оказался действительно мощным, гибким и как, ни странно, полезным в «быту». Все мелкие задачи автоматизации быстро перекочевали в скрипты на Лиспе, а так же появились возможности для автоматизации более сложной.

Здесь стоить отметить, что под «возможностью автоматизации» я подразумеваю ситуацию, когда суммарное время на написание и отладку программы меньше, чем время, затрачиваемое на решение той же задачи вручную.

Пол Грэм написал не одну статью и даже книгу о преимуществах Лиспа. На момент написания этой статьи Lisp занимает 33-е место в рейтинге TOIBE (в три раза мертвее мёртвого Delphi). Возникает вопрос: почему язык так мало распространён если он так удобен? Приблизительно два года использования дали несколько намёков на причины.

Недостатки


1. Разделяемые структуры данных
Концепция, позволяющая оптимизировать функциональные программы, но чреватая трудноуловимыми ошибками в императивных. Возможность случайного повреждения посторонней структуры данных при модификации переменной, не имеющей видимой связи со структурой, требует от программиста постоянного контроля происходящего за кулисами и знания внутренней реализации каждой используемой функции (и системной, и пользовательской). Самое удивительное — это возможность повредить тело функции, модификацией её возвращаемого значения.

2. Отсутствие инкапсуляции
Понятие пакета хотя и существует, но не имеет ничего общего с package в Ada или unit в Delphi. Любой код может добавить что угодно в любой пакет (кроме системных). Любой код может извлечь что угодно из любого пакета, используя оператор ::.

3. Бессистемные сокращения
Чем отличается MAPCAN от MAPCON? Почему в SETQ, последняя буква Q? С учётом возраста языка можно понять причины такого состояния дел, но хочется языка немного почище.

4. Многопоточность
Этот недостаток косвенно относится к Лиспу и, в основном, касается используемой мной реализации — SteelBank Common Lisp. Стандартом Common Lisp многопоточность не предусмотрена. Попытка использования реализации, предоставляемой SBCL, к успеху не привела.

Отказываться от такого удобного инструмента жалко, но неудовлетворённость постепенно накапливается.

Поиск решения


Сначала можно зайти на Википедию на страницу Лиспа. Осмотреть раздел «Диалекты». Прочитать краткое введение к каждому. И осознать, что на вкус и цвет все фломастеры разные.
Если хочешь что-то сделать, нужно это делать обязательно самому
— Жан Батист Эммануэль Зорг
Попробуем создать свой правильный Лисп, добавив в него немного Ады, много Delphi и совсем каплю Оберона. Назовём полученную смесь Лися.

Основные концепции


1. Никаких указателей
В рамках борьбы с ПРОБЛЕМОЙ-1 все операции должны производиться путём копирования значений. По виду структуры данных в коде или при выводе на печать должны быть полностью видны все её свойства, внешние и внутренние связи.

2. Добавим модули
В рамках борьбы с проблемой-2 импортируем из Ады операторы package, with и use. В процессе, отбросим избыточно сложную схему импорта/затенения символов Лиспа.
(package имя-пакета (список экспортируемых символов)
	(реализации)
	(функций))

(with имя-пакета) ;поиск файла «имя-пакета.lisya» и импорт содержимого

(use имя-пакета)  ;аналогично, но символы импортируются без имени пакета 


3. Меньше сокращений
Наиболее частые и общеупотребительные символы всё равно будут с сокращениями, но преимущественно наиболее очевидные: const, var. Функция форматированного вывода — FMT требует сокращения, поскольку часто встречается внутри выражений. Elt — взятие элемента — просочился из Common Lisp и прижился, хотя необходимости в сокращении нет.

4. Регистронезависимые идентификаторы
Я считаю, что правильный язык (и файловая система) {$HOLYWAR+} должен быть регистронезависимым {$HOLYWAR-}, чтобы не ломать лишний раз голову.

5. Удобство использования с русской раскладкой клавиатуры
Синтаксис Лиси всячески избегает использования символов, недоступных в одной из раскладок. Нет квадратных и фигурных скобок. Нет #, ~, &, <, >, |. При чтении численных литералов правильными десятичными разделителями считаются как запятая, так и точка.

6. Расширенный алфавит
Одной из приятных черт SBCL оказался UTF-8 в коде. Возможность объявлять константы МЕДВЕДЬ, ВОДКА и БАЛАЛАЙКА значительно упрощает написание прикладного кода. Возможность вставлять ?, ? и ? делает формулы в коде наглядней. Хотя теоретически существует возможность использовать любые символы юникода, гарантировать корректность работы с ними сложно (скорее лень, чем сложно). Ограничимся кириллицей, латиницей и греческим.

7. Численные литералы
Это наиболее полезное для меня расширение языка.

10_000 ;разделители разрядов для удобочитаемости
10k ;десятичные приставки для целых и дробных чисел
10к ;русские десятичные приставки для минимизации переключений раскладки
10° 10pi 10deg 10гр ;не десятичные приставки
10? ;приставка pi в более эстетичном варианте
10+i10 ;литерал комплексного числа 
10+м10 ;ещё раз комплексное число 
10а10deg ;литерал комплексного числа в показательной форме с аргументом в градусах

Последний вариант мне кажется самым не эстетичным, но он самый востребованный.

8. Циклы
Циклы в Лиспе нестандартны и изрядно запутаны. Упростим до минимального стандартного набора.

(for i 5  
	;повторить пять раз i = 0..4
	)
(for i 1..6 
	 ;повторить пять раз i = 1..5
	)
(for i список 
	 ;повторить для каждого элемента списка 
	;допускается присваивание нового значения переменной цикла
)
(for i (subseq список 2) 
	 ;повторить для элементов списка начиная со второго элемента и до конца
	)

Переменная цикла за его пределами не видна.

(while условие
	)

9. GOTO
Не очень нужный оператор, но без него сложно продемонстрировать пренебрежение правилами структурного программирования.

(block
	:метка
	(goto :метка)) ;этот блок кода иногда зависает

10. Унификация областей видимости
В Лиспе есть два различных типа областей видимости: TOPLEVEL и локальная. Соответственно есть два разных способа объявления переменных.

(defvar A 1)
(let ((a 1)) …)

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

(var A 1)

При необходимости ограничить область видимости используется оператор

(block 
	(var A 1)
	(set A 2)
	(fmt nil A))

Тело цикла содержится в неявном операторе BLOCK (как и тело функции/процедуры). Все объявленные в цикле переменные уничтожаются в конце итерации.

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

12. Удобный ELT
Типичный доступ к элементу сложной структуры в Лиспе выглядит так

(elt (slot-value (elt структура 1) 'слот-2) 3) 

В Лисе реализован унифицированный оператор ELT, обеспечивающий доступ к элементам любых составных типов (списков, строк, записей, массивов байт, хэш-таблиц).

(elt структура 1 \слот-2 3)

Идентичную функциональность можно получить и макросом на Лиспе

(defmacro field (object &rest f)
	"Извлекает элемент из сложной структуры по указанному пути.
	(field *object* 0 :keyword symbol \"string\")
	Каждый числовой параметр трактуется как индекс массива.
	Каждое ключевое слово трактуется как свойство в plist.
	Каждый символ (не ключевой) трактуется как функция доступа.
	Каждая строка трактуется как ключ в ассоциативном массиве."
	(if f 	(symbol-macrolet ((f0 (elt f 0))(rest (subseq f 1)))		
			(cond 
				((numberp f0) `(field (elt ,object ,f0) ,@rest))
				((keywordp f0) `(field (getf ,object ,f0) ,@rest))
				((stringp f0) `(field (cdr (assoc ,f0 ,object :test 'equal)) ,@rest))
				((and (listp f0) (= 2 (length f0)))
					`(field (,(car f0) ,(cadr f0) ,object) ,@rest))
				((symbolp f0) `(field (,f0 ,object) ,@rest))
				(t `(error "Ошибка форматирования имени поля"))))
		object))

13. Ограничение режимов передачи параметров подпрограмм
В Лиспе имеется, как минимум пять режимов передачи параметров: обязательные, &optional, &rest, &key, &whole и разрешена их произвольная комбинация. В действительности, большинство комбинаций дают странные эффекты.
В Лисе разрешено использовать только комбинацию из обязательных параметров и одного из следующих режимов на выбор :key, :optional, :flag, :rest.

14. Многопоточность
С целью предельного упрощения написания многопоточных программ, была принята концепция отделения памяти. При порождении потока все переменные, доступные новому потоку, копируются. Все ссылки на эти переменные заменяются ссылками на копии. Передача информации между потоками возможна только посредством защищённых объектов или через результат, возвращаемый потоком при завершении.

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

Создание потоков возможно многопоточной функцией отображения

(map-th (function (x) …) данные-для-обработки) 

Map-th автоматически запускает количество потоков, равное количеству процессоров в системе (или в два раза больше, если у вас Intel inside). При рекурсивном вызове, последующие вызовы map-th работают в один поток.

Дополнительно есть встроенная функция thread, выполняющая процедуру/функцию в отдельном потоке.

;пример асинхронного исполнения
(var поток (thread  длительные-вычисления-1))
(+ (длительные-вычисения-2) (wait поток))

15. Функциональная чистота в императивном коде
В Лисе есть функции для функционального программирования и процедуры для процедурного. На подпрограммы, объявленные с использованием ключевого слова function, налагаются требования отсутствия побочных эффектов и независимости результата от внешних факторов.

Нереализованное


Некоторые интересные возможности Лиспа остались не реализованными в силу низкого приоритета.

1. Обобщённые методы
Возможность выполнять перегрузку функций при помощи defgeneric/defmethod.

2. Наследование

3. Встроенный отладчик
При возникновении исключения интерпретатор Лиспа переключается в режим отладчика.

4. UFFI
Интерфейс для подключения модулей, написанных на других языках.

5. BIGNUM
Поддержка целых чисел произвольной разрядности

Отброшенные

Некоторые возможности Лиспа были рассмотрены и сочтены бесполезными/вредными.

1. Управляемая комбинация методов
При вызове метода для класса выполняется комбинация методов родителей и существует возможность изменять правила комбинации. Итоговое поведение метода представляется слабо предсказуемым.

2. Перезапуски
Обработчик исключения может внести изменения в состояние программы и послать команду перезапуска коду, сгенерировавшему исключение. Эффект от применения аналогичен использованию оператора GOTO для перехода из функции в функцию.

3. Римский счёт
Лисп поддерживает систему счисления, которая устарела незадолго до его появления.

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


Приведу несколько простых примеров кода

(function crc8 (data :optional seed)
    (var result (if-nil seed 0))
    (var s_data data)

    (for bit 8
        (if (= (bit-and (bit-xor result s_data) $01) 0)
            (set result (shift result -1 8))
        (else
            (set result (bit-xor result $18))
            (set result (shift result -1 8))
            (set result (bit-or result $80))))
        (set s_data (shift s_data -1 8)))
    result)

;поэлементное возведение списка в квадрат
(map (function (x) (** x 2)) \(1 2 3))

;извлечение из списка строк, начинающихся с qwe и длиной более пяти символов
(filter (function (x) (regexp:match x «^qwe...»)) список-строк)

;но если строк много, а процессор шестиядерный, то лучше так
(filter-th (function (x) (regexp:match x «^qwe...»)) список-строк)

Реализация


Интерпретатор написан на Delphi (FreePascal в режиме совместимости). Собирается в Lazarus 1.6.2 и выше, под Windows и Linux 32 и 64 бита. Из внешних зависимостей требует libmysql.dll. Содержит около 15_000..20_000 строк. Имеются около 200 встроенных функций различного назначения (некоторые перегружены по восемь раз).

Хранится здесь

Поддержка динамической типизации выполнена тривиальным образом — все обрабатываемые типы данных представлены наследниками одного класса TValue.

Важнейший для Лиспа тип — список является, как и принято в Delphi, классом, содержащим динамический массив объектов типа TValue. Для данного типа реализован механизм CopyOnWrite.

Управление памятью автоматическое на основе подсчёта ссылок. Для рекурсивных структур выполняется подсчёт всех ссылок в структуре одновременно. Освобождения памяти запускается сразу при выходе переменных из области видимости. Механизмы отложенного запуска сборщика мусора отсутствуют.

Обработка исключений работает на механизме встроенном в Delphi. Таким образом, ошибки, возникающие в коде интерпретатора, могут быть обработаны выполняемым кодом на Лисе.

Каждый оператор или встроенная функция Лиси реализован как метод или функция в коде интерпретатора. Выполнение скрипта осуществляется путём взаимно-рекурсивного вызова реализаций. У кода интерпретатора и скрипта общий стек вызовов.

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

Особую сложность представляет собой реализация оператора присваивания (set) для элементов структур. Непосредственное вычисление указателя на требуемый элемент приводит к риску появления висячих ссылок, поскольку синтаксис Лиси не запрещает модификацию структуры в процессе вычисления требуемого элемента. Как компромиссное решение реализован «цепочечный указатель» — объект, содержащий ссылку на переменную и массив числовых индексов для указания пути внутри структуры. Такой указатель так же подвержен проблеме висячих ссылок, но в случае сбоя генерирует осмысленное сообщение об ошибке.

Инструменты разработки


1. Консоль

2. Текстовый редактор
Оборудован подсветкой синтаксиса и возможностью запуска редактируемого скрипта по F9.


Заключение


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

Комментарии (30)


  1. SSSerg
    26.09.2018 12:21
    -1

    … и не одного коммента.


    1. SUA
      26.09.2018 12:39
      +1

      в три раза мертвее мёртвого Delphi

      таки да


    1. immaculate
      26.09.2018 12:59
      +1

      Почему же, есть очень важные комментарии вида:


      {TVCompound}
      
      TVCompound = class (TValue)

      Это самые лучшие комментарии из разряда таких:


      # Increment value of i
      i = i + 1

      Без них ведь не разобраться.


      1. andrey_ssh Автор
        26.09.2018 13:06

        Я очень рад, что вам оказалось не лень заглянуть в мой проект.

        По секрету скажу, что комментарии вида {TVCompaund} IDE вставляет автоматически. На самом деле это маркер, который используется функцией автодополнения.


        1. immaculate
          26.09.2018 13:09
          +1

          Тогда извините. Странная IDE, которая ориентируется по комментариям, а не по исходному коду. Но я Pascal в последний раз трогал 20 лет назад, тогда еще не было IDE с автодополнением. А сейчас и вообще синтаксис паскаля забыл.


          На Лиспе писал немного, но тоже мало и очень давно. Чем вам вариант Clojure не угодил?


          1. andrey_ssh Автор
            26.09.2018 14:35

            В Clojure особенных фатальных недостатков вроде нет, но много мелочей, которые не понравились (в том числе необходимость в Java Runtime). В сумме получается смена шила, на мыло.


  1. dim2r
    26.09.2018 16:09

    Лисп хорош тем, что на нем хорошо доказываются математические теоремы из теории операторов и предикатов. Плюс можно написать конвертор из какого-нибудь языка в лисп.


  1. vasiliy404alfertev
    26.09.2018 16:29

    Меня тоже многим оттолкнул Common Lisp. Но я думал, для чего-то простого лучше подойдёт Scheme. Там и лексическая область видимости, и минимализм встроенных конструкций, и tailcall optimization, continuations. Система макросов позволяет создать любой удобный DSL со своими for, var, etc.


    1. demsp
      27.09.2018 10:45

      А сейчас вы так думаете?


  1. amarao
    26.09.2018 16:33

    (Я (не (фанат (лиспов))), но почему не guile?


    1. andrey_ssh Автор
      26.09.2018 16:56

      Guile — расширение для Си, со всеми вытекающими последствиями в мировоззрении.

      Меня от SBCL оттолкнуло в том числе то, что это древний код на Си. Я не могу в нём разобраться и не могу ничего исправить.

      Хороший маркер годности языка для меня — регистрочувствительность. Если язык чувствителен к регистру, значит авторы исповедуют несовместимые с моими привычками подходы.

      Ну и не стоит забывать NIH-синдром.


      1. amarao
        26.09.2018 17:29
        -1

        Регистрочувствительность в каком языке? ???????????? это то же самое, что ????????? Каждый раз, когда мне говорят про «регистрочувствительность», я вижу три варианта: 7-битного неандертальца, человека с бНОПНЯ по CP-1251 или бездну юникода у которой нет дна.

        Вы из какой категории?


        1. andrey_ssh Автор
          27.09.2018 02:31

          Тут четыре варианта ответа.

          1. Вы не видите решения проблемы, а потому отказываетесь от её решения. Но проблема никуда не девается.

          2. Юникод действительно бездна, а потому плюём в неё, и постулируем поддержку кириллицы, латиницы и греческого (тут проблем нет). Это конечно некрасиво, но предпочтительнее чем упасть в бездну.

          3. Юникод содержит какие-то правила приведения регистров. Лися использует встроенную в FreePascal реализацию этих правил — UnicodeUpperCase. Если возникает проблема, то это недоработка в Unicode (тут ничего не поделать) или библиотеках.

          4. Если для какого-то языка не определены правила регистронезависимости, то можно просто писать идентификаторы в одном регистре.


          1. amarao
            27.09.2018 13:23

            … А можно не писать и спокойно жить вот с такой нотацией:

            class Example():


            example = Example()


            1. andrey_ssh Автор
              27.09.2018 14:22

              Эх. Что-то тема для холивара настолько обсосанная, что даже аргументы вспоминать лень. Может быть по какому другому поводу поругаемся?


  1. easty
    26.09.2018 22:29

    Вы где-то пишите лисп, а где то лис. Это опечатка и жаргонное имя лиспа?)


    1. andrey_ssh Автор
      27.09.2018 02:33

      Это не «лис». Это «Лися» — название диалекта.


      1. 3aicheg
        27.09.2018 06:09

        Надо было больше аллюзий на дефекты речи в названии. Например, «Лифифька»…


  1. 0xd34df00d
    27.09.2018 01:25

    В рамках борьбы с ПРОБЛЕМОЙ-1 все операции должны производиться путём копирования значений. По виду структуры данных в коде или при выводе на печать должны быть полностью видны все её свойства, внешние и внутренние связи.

    Двухсвязный список, похоже, не написать?


    1. andrey_ssh Автор
      27.09.2018 02:41

      Нет, не написать.

      Но есть нюансы:
      1. Если двусвязный список нужен ради списка, то есть встроенный тип.
      2. Если двусвязный список нужен ради оптимизации, то никак. Оптимизация принесена в жертву прозрачности кода.
      3. Если есть алгоритм, который принципиально работает только на двусвязных списках, то можно вывернуться. Размещать объекты во встроенном списке/хэш-таблице и использовать вместо указателей индексы/ключи.


  1. ivan_denisov
    27.09.2018 07:07

    «совсем каплю Оберона» любопытно, что вы имели в виду?


    1. andrey_ssh Автор
      27.09.2018 07:19

      Оберон упомянут как источник крайней минималистичности. Например, индексация только целыми числами и от нуля. А «капля» потому, что минималистичность получилась выборочная.


  1. Chupaka
    27.09.2018 08:19

    (for i 5
    ; повторить пять раз i = 0..4
    )
    (for i 1..6
    ; повторить пять раз i = 1..5
    )

    Если первое ещё можно понять, то вот ко второму есть вопрос… Почему до пяти, а не до шести?


    1. andrey_ssh Автор
      27.09.2018 09:17

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

      Есть в Паскале одна особенность

      for i := 0 to Count - 1 do ...

      Вот это "-1" есть почти всегда. В Лисе, соответственно, этот "-1" не появляется.

      Допустим нам надо итерировать по индексам списка начиная с 2
      (var L \(0 1 2 3))
      (for i (range 2 (length L)) тело цикла)

      Функция RANGE в данном случае вернёт 2..4 и итерация выполнится по индексам 2 и 3.


  1. Coriolis
    27.09.2018 08:44

    2. Перезапуски
    Обработчик исключения может внести изменения в состояние программы и послать команду перезапуска коду, сгенерировавшему исключение. Эффект от применения аналогичен использованию оператора GOTO для перехода из функции в функцию.

    Не знаком с лиспом, но. Перезапуск, на мой взгляд, отличная штука для работы откатом транзакции в бд. Представьте, начали вы транзакцию, что-то делаете, затем делаете commit. А commit выясняет что те данные которые вы использовали — кто-то другой поменял пока вы были в рамках транзакции. И вот язык может взять и перезапустить код к месту начала транзакции, с откатом значений всех локальных переменных. Это, понятно, перспективная разработка, но я думаю это было бы круто.


    1. andrey_ssh Автор
      27.09.2018 09:27

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

      Возможность перезапуска кода, находящегося на один уровень ниже, как и во всех языках, конечно есть и в Лисе. Оператор TRY, перехватывающий исключения, предусмотрен.


  1. jMas
    27.09.2018 09:45

    Мне одному показалось, что здесь программы в императивном стиле написаны на функциональном языке (императивщину записывать, имхо, удобней не стало)? Вознакает вопрос: в чем профит?


    1. andrey_ssh Автор
      27.09.2018 09:57

      На самом деле в разделе «Использование» приведена одна императивная программа и две функциональных.

      Императивщину записывать действительно удобнее не стало, но её стало удобнее отлаживать, благодаря решению ПРОБЛЕМЫ-1 (и проблемы-2).


  1. impwx
    27.09.2018 10:51

    Вопрос регистрозависимости было бы логичнее решить на уровне автокомплита и поиска в редакторе кода, а не на уровне языка.


  1. prefrontalCortex
    28.09.2018 12:24

    Сначала можно зайти на Википедию на страницу Лиспа. Осмотреть раздел «Диалекты». Прочитать краткое введение к каждому. И осознать, что на вкус и цвет все фломастеры разные.

    Я бы на вашем месте чуть внимательнее прочитал бы раздел о диалекте Racket. Мне кажется, он бы вам понравился, не в последнюю очередь потому, что в нём есть возможность вволю проявить синдром NIH легко создавать новые диалекты лиспа, и даже использовать в одном проекте несколько диалектов.