Издательский дом ООО "Гейм Лэнд"ЖУРНАЛ ХАКЕР #133, ЯНВАРЬ 2010 г.

][-holywar: lisp vs. java. Common Lisp: простота и мощь промышленного стандарта

LinkFly, http://linkfly.ru


В рамках этой статьи мы попробуем сравнить приемы программирования на языке Common Lisp и языке Java. На конкретных примерах мы продемонстрируем концептуальные ограничения языка Java и приведем аргументы, говорящие о необходимости применять более совершенный, легко расширяемый и проверенный временем язык программирования.

Почему все-таки Лисп?

Первый вопрос, который бы появился у меня, будь я на месте читателя: «Чем эта статья отличается от очередного пиара j2ee/.net/python/jsf/asp/ и тому подобных?». Отлично, попробую на него ответить:

  • Тем, что она о языке программирования, который не было бы ошибкой назвать «бессмертным» на фоне рождающихся и умирающих языков и технологий.
  • И тем, что в ней не будут приводиться абстрактные (и сомнительные) доводы вроде: «Ты посмотри, все на нем пишут» или «Все пользуются MS Office, а ведь он написан на С++», или «Видишь, какая большая корпорация Microsoft, а они говорят, что надо писать на .NET».
  • А еще, наверное, тем, что здесь будут приводиться конкретные факты, подтверждающие не сопоставимые ни с чем преимущества.

В современном мире даже начинающий программист догадывается, что предоставляемые корпорациями технологии в первую очередь нужны им самим для укрепления позиций на рынке, и только в последнюю очередь для того, чтобы ты быстро и качественно разрабатывал программный продукт. Для них важно другое: насколько конкретно ты влипнешь в эти самые технологии. Итак, пока одни чертыхаются в мире Microsoft и надеются, что лучший из миров — мир IBM, а в мире IBM думают, что у соседа «трава зеленее» и посматривают в сторону Microsoft, мы займемся настоящим программированием. Конечно, вряд ли нам удастся в рамках одной короткой статьи привести все аргументы и рассказать обо всех сокровищах Лисп-культуры. Тем не менее, основополагающие концепции показать вполне реально.
Lisp - это сокращение от LISt Processing, что в переводе значит: «обработка списков». Следовательно, можно предположить, что язык предназначен лишь для обработки списков. Но есть один, казалось бы, незначительный (для непосвященных) нюанс: программы на Лиспе также представляются в виде списков. Итак, что же мы получаем? Мы получаем язык, который предназначен для обработки списков (в момент своего рождения) с помощью программ, представляемых в виде списков. Звучит сумбурно, но это, собственно, и есть главный секрет успеха.

Если ты решил замутить свой язык программирования и захотел позаимствовать это решение — ты получишь еще один диалект Лиспа. Если же ты пожелаешь, чтобы в твоем языке была поддержка всех существующих на данный момент технологий программирования, то получишь что-то подобное диалекту Common Lisp. Может быть, какие-то возможности твоего диалекта и не будут входить в этот стандарт, но вряд ли для тех, кто программирует на Лиспе это будет серьезный аргумент — макрос, расширяющий язык дополнительной возможностью, пишется за 1-2 часа (а то и за пару минут).

Что и не снилось разработчикам на Java

Здесь я опишу возможности языка Lisp, которые отсутствуют в языке Java или имеют крайне урезанные варианты, не вписывающиеся в модель описания вычислений языка и имеющие проблемы интеграции друг с другом. Приступим!

- Единообразный и древовидный синтаксис, позволяющий максимально просто генерировать программный код.

Главная и нерешаемая проблема языков с так называемым «синтаксическим сахаром» заключается в этом самом сахаре. В принципе, в любом языке можно создать функцию (метод), получающую на вход строку с программой на этом языке, и, разбив ее на лексемы, действовать подобно интерпретатору. Или, сохранив строку в файл, вызвать компилятор, и откомпилированный байт-код (к примеру, файл *.class) загрузить в систему (в нашем случае в jvm). Да вот только делается так крайне редко. Как думаешь, почему? А все очень просто — никакой здравомыслящий разработчик не станет усложнять свой проект (может быть, и без того сложный) парсером исходного кода, тем более, это очень круто может ударить по производительности. Как в случае парсинга входной строки, так и сохранения на диск, и при вызове внешнего компилятора (да еще, блин, загрузке в систему получившегося байт-кода).

Никакие костыли вроде «Аннотаций» не решают проблему — возиться с генерацией синтаксических конструкций языка довольно долго и чревато ошибками. Об отладке говорить вообще не приходится. Тем не менее, жизнь заставляет генерировать массу кода перед его компиляцией, например, для работы с таблицами базы данных. Таблицы, типы полей и нюансы sql-запросов, зависящих от базы данных — все это требует генерации специфичного программного кода. Так что, мнения скептиков и консерваторов из серии «да зачем это надо» перетекли (в создаваемый, видимо, ими же) так называемый «деплой-код» (deploy code). Конечно же, все реализовано не очень хорошо по той простой причине, что язык имеет множество синтаксических конструкций (хотя программы на Яве генерировать, наверное, в разы проще, чем программы на C++ или Perl).

В Лиспе же все происходит с точностью до наоборот: программы на Лиспе генерировать не просто легко — это является частью практики программирования на Лиспе. Можешь убедиться в этом сам, посмотрев какие-нибудь Лисп-библиотеки на http://cliki.net. Например, библиотеку CLOCC. Да и знаменитый лиспер Пол Грэхэм так пишет о своей Viaweb: «Исходный текст редактора Viaweb на 20-25% состоял из макросов». В общем, в процессе программирования на Лиспе писать программы, которые пишут другие программы - совершенно тривиальный процесс. Жаль, что всякие разные «изобретатели деплой-кода» не в курсе. И насколько мы продвинулись в развитии программирования, если, как пишет Пол Грэхэм: «Макросы, очень похожие на современное представление о них, были предложены Тимоти Хартом (Timothy Hart) в 1964 году, через два года после того как Lisp 1.5 был выпущен». Может быть, монстров в нашей жизни было бы гораздо меньше, если бы современные горе-изобретатели знали об этом?

Итак, синтаксическтий сахар мешает появлению в языке возможностей простой и понятной кодогенерации. Отсюда следует идея: нельзя ли нам этот недостаток как-нибудь обойти? В конце концов, можно же создать собственный препроцессор! Например, если бы мы писали на языке Java в синтаксисе Лиспа, то код:

private String myMethod(int x){
ArrayList<?> array = new ArrayList<?>(10);
for(int i = 0; i < x; i++)
array[i] = myfunc(i);
}

;; выглядел бы как-нибудь так:

(private String myMethod (int x)
(ArrayList () array (new ArrayList () 10)
)
(for ((i 0) (i < x) (1+ i))
(array i (myfunc i))
)
)

Не считая статической типизации (которая во многих случаях проигрывает типизации динамической) и отхода от префиксной нотации в описании цикла, в списке - (i < x), этот код не по семантике, но по синтаксису вполне соответствует символьному выражению в Лиспе (s-expression). Ну что, раздуем философский спор о том, что удобнее и читабельнее? Так или иначе, второй вариант представляет собой связный список — вот его-то как раз и чрезвычайно просто генерировать и обрабатывать, особенно с помощью языка, в котором десятки лет совершенствовались и оттачивались механизмы работы со списками! Это было бы, пожалуй, моим гениальным открытием, я не первый, кто к этому пришел — одно из изобретений корпорации Franz (http://franz.com/) - язык JIL (http://www.franz.com/support/documentation/6.2/doc/jil.htm). Вот что пишут сами разработчики: «Java в Lisp (jil) - это язык для записи программ, выполняющихся на виртуальной машине Java (jvm)».

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

Эффективная генерация кода во время выполнения и его оптимизация

Любой более-менее приличный язык, поддерживающий функциональную парадигму (Lisp, Python, Haskell), имеет конструкцию «Lambda», позволяющую создать функцию во время выполнения (в run-time). В Лиспе это происходит так:

(setf add5 (lambda (x) (+ x 5)))

Теперь переменная add5 содержит объект-функцию. Мы ее можем передавать в качестве параметра и вызывать, например, с помощью funcall:

(funcall add5 4)

Особенно интересна возможность ее передачи в качестве параметра. Функции, получающие такой аргумент, называются «функционалами». Отличным примером является «отображающий функционал» MAPCAR. Но об этом чуть позже.

В Лиспе у нас есть возможность скомпилировать функцию, сгенерированную в run-time. Допустим, во время выполнения мы составили список, представляющий подпрограмму, которую нам нужно добавить в систему (между прочим, без какой-либо записи на жесткий диск и вызова внешнего компилятора). Пусть наша программа для простоты будет такой:

(setq program
'(defun func (x y)
(list x (* y y))
)) ;end defun, setq

Теперь мы можем добавить ее в текущую лисп-систему:

(eval program)

Воспользуемся стандартной функцией TIME для получении времени выполнения:

(time (loop repeat 10000
(do (func 3 4))
)) ;end loop, time

И если мы произведем компиляцию добавленной в run-time функции:

(compile 'func)

…то, еще раз выполнив тестовый прогон (time (loop …), получим значительный выигрыш в скорости выполнения (на Lispworks у меня получилось ~2.5, что, конечно же, не предел).

Функционалы

Это функции, которые получают в качестве аргументов (или одного из аргументов) другую функцию. В статье я расскажу только об одном — MAPCAR. Его еще называют «Отображающим функционалом». Несмотря на страшное название, работает он очень просто: принимает в качестве аргумента какую-либо функцию и список элементов, с которыми ее надо вызвать, и затем строит новый список из результатов:

(mapcar (lambda (x) (+ x 5))
(list 1 2 3 4 5))
> (6 7 8 9 10)

Или:

(mapcar 'print
'(«here data:» 4 4.0 (:email lisp@lisp.ru)))
>
"here data:"
4
4.0
(:EMAIL LISP@LISP.RU)

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

Попробуем сделать на языке Java последний пример и начнем мы с определения функции во время выполнения. Правда, в Java нет функций, а есть лишь методы классов... к счастью, имеется очередной «костыль» - анонимный класс:

Object func = new Object() {
public Object run(x){
return x + 5;
}
}

Он, кстати, лишь создает иллюзию определения в run-time — на самом деле, происходит запись программы на диск, и ее компиляция в файл .class. Ты можешь сам в этом убедиться — сохрани приведенную ниже программу в файл My.java и скомпилируй (javac My.java):

class My {
static class Object2 {
public void run(){}
}
public static void main(String[] args){
System.out.println("Hello!\n");
//на случай, если компилятор
//попробует с'оптимизировать
boolean b = !(args.length == 0);
Object2 obj;
if(b){
obj = new Object2(){
public void run(){
System.out.println("First variant");
}
};
} else {
obj = new Object2(){
public void run(){
System.out.println("Second variant");
}
};
}
obj.run();
}
}

Теперь зайди в каталог с классом и полюбуйся на два файла: My$1.class и My$2.class. Так что, если не хочешь тормозов, подумай о том, чтобы разместить соответствующий каталог в оперативной памяти. Да, и самое ужасное в подобных костылях это то, что создается набор некоторых правил, совершенно непредсказуемых и ниоткуда не следующих. В примере выше, если ты избавишься от вложенного класса Object2 и попробуешь использовать Object, то файл просто не скомпилится. А все потому, что вдруг появилось новое правило: анонимный класс должен переопределять существующий метод суперкласса, а не определять новый.

Далее об отображающем функционале. В стандарте Java нет ничего подобного mapcar, поэтому реализуем ее сами:

import java.util.ArrayList;

class Functionals {
/**
* Этот класс не обязан быть вложенным.
*/
abstract static class Function {
public abstract Object call(Object obj);
}

public static ArrayList<Object> mapcar(
Function fn, ArrayList<Object> array) {
ArrayList<Object> result =
new ArrayList<Object>(array.size());
for(Object obj : array)
result.add(fn.call(obj));
}
}

Дело за малым - применить это чудо инженерной мысли:

// В каком-то классе //
public static Integer num = 5;

public static void main(String[] args) {
ArrayList<Object> arList = new ArrayList<Object>();
arList.add(4);
arList.add(5);
arList.add(6);

num += num; //какие-то изменения контекста

arList =
Functionals.mapcar(new Function() {
public Object call(Object obj){
return (Integer)obj + num;
}},
arList);
System.out.println(arList.toString());
}

И всю эту фигню я должен писать вместо (mapcar (lambda (x) (+ num 5)) '(4 5 6))!? Между прочим, тут обнаружилось новое правило. Если захочешь использовать в определяемом анонимном классе локальную переменную, то тебя ждет жестокий облом — этого делать нельзя, если только не объявить ее как final.

А теперь посчитаем количество сущностей, которые мы наплодили для реализации этой идеи.

Лисп:

  1. Стандартная функция mapcar.
  2. Определение функции, которую мы передаем в качестве параметра с помощью стандартной Lambda.
  3. Список элементов (любого типа).

Java:

  1. Новый класс Functions, который мы ввели в систему, чтобы определить нужный метод.
  2. Этот класс объявляет абстрактный метод call, который должны будут определять анонимные классы.
  3. Собственно определение нужного нам метода mapcar.
  4. Объявление и создание ArrayList'a, в который мы будем собирать значения.
  5. Последовательные добавления в ArrayList значений.
  6. Вызов статического метода mapcar класса Functionals.
  7. В качестве первого аргумента передаем экземпляр анонимного класса, наследника Function.
  8. Функциональность сосредоточена в переопределяемом методе call.
  9. Вторым аргументом передаем ArrayList c элементами типа Object.
  10. Присваиваем результат другой или этой же переменной типа ArrayList<Object>.
  11. Выводим на консоль строку, описывающую массив результатов.
  12. А чтобы такая строка получилась, нам надо вызвать метод toString() объекта типа ArrayList<Object>.

Не правда ли, слишком громоздко? Программирование на таком низком (по сравнению с Лиспом) уровне чревато ошибками, трудностью сопровождения кода и - далее - созданием разного инструментария, исправляющего недостатки языка и … в общем, разработчикам всевозможных eclips'ов работы хватит надолго.

Удобное средство инициализации сложных структур данных

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

Очень часто нужно без лишних хлопот быстро его создать и/или инициализировать. Причем на практике нам еще может понадобиться древовидная структура РАЗНОСОРТНЫХ данных. Сравним удобство реализации.

Вот такую древовидную структуру нужно использовать в программе (смотри картинку «Древовидная структура»).

Реализация на Lisp:

(setq tree '(symbol («string»(:number 555)) 5.5))

;;; Или в более читабельном виде:
(setq tree
'(symbol
(«string»
(:number 555))
5.5)
) ;end setq

На Java это можно было бы сделать так:

List<Object> list = new ArrayList<Object>();
// Примитивного типа данных «Символ» в Java не
// существует, так что, допустим, у нас
// есть такой класс:
list.add(new Symbol("symbol"));

List<Object> nested = new ArrayList<Object>();
nested.add("string");

List<Object> nested2 = new ArrayList<Object>();
nested2.add(new Symbol(":number"));
nested2.add(555);

nested.add(nested2);

list.add(nested);
list.add(5.5);

Количество порождаемых сущностей сравни сам :). Только здесь простое добавление нового класса Symbol нас уже не спасет. Тип Symbol – это часть архитектуры Lisp’a и центральный элемент символьных вычислений. Реализация в Java в любом случае будет иметь ограничения.

А если каких-то элементов данных у меня в настоящий момент нет? Тогда я могу создать список, блокируя (с помощью функции quote или сокращения «'») элементы, которые мне надо включить в список как есть, то есть, не вычисляя их:

(list 4 «leaf»
(list
(car '(«beginNestedList» «otherData» 34 43))
'(+ 5 myvar)
(+ 4 5)
'(list (make-instance 'class)
(make-instance 'standard-class))
«endNestedList»)
«endMainList»)

Пусть этот код тебя не смущает, так как, по большей части, тут используются только три функции:

  • «list» — строит список из любых (!) элементов данных (вычисляя свои аргументы, как любая функция лиспа).
  • «+-» - догадайся сам :).
  • «'» - сокращенное применение функции quote. На самом деле, выражение '(+ 4 4) превращается в (quote (+ 4 4)), что позволяет «процитировать» выражение (+ 4 4), а не вычислять его. Писать (quote (+ 4 4)) идеологически правильно, но «цитировать» надо так часто, что даже самые закоренелые идеологи Лиспа вовсе не прочь использовать знак «'». Может показаться, что здесь имеет место «сделка» со строгими правилами языка. Ничего подобного не происходит — символ «'» связывается с так называемым «Макросом чтения», которые делают возможности языка просто безграничными! Все остальное в приведенном выше вызове - это данные.

Но у Лиспа (как обычно) найдется еще кое-что. Последователи любой религии согласятся с тем, что повторения в коде - почти всегда плохо. Если у нас имеет место внушительный объем цитируемых данных, но есть несколько вычисляемых элементов, то мы можем воспользоваться обратным цитированием, пометив с помощью «,» те элементы которые все же надо вычислить. Знаки «`» и «,» являются лишь сокращением и также связаны с макросами чтения:

`(4 «leaf»
,(car '(«beginNestedList» «otherData» 34 43))
(+ 5 myvar)
,(+ 4 5)
(list (make-instance 'class)
(make-instance 'standard-class))
«endNestedList»)
«endMainList»)

Вызов: `((1+ 3) ,(+ 4 5)) превращается во что-нибудь вроде: (SYSTEM::BACKQ-LIST (QUOTE (1+ 3)) (+ 4 5)). Обратное цитирование особенно интенсивно применяется в макросах, потому что позволяет сделать процесс генерации кода более читабельным. Судите сами — без макросов:

(list (get-program-def-name)
(list (get-arg-name pos-arg1) 'arg2)
(list call-list1 call-list2)
)

;;С применением обратного цитирования:

`(,(get-program-def-name)
(,(get-param pos-param1) arg2)
call-list1
call-list2
)

То есть, просто в двух местах нужно произвести вычисление и подставить результаты в общий шаблон кода (а шаблон кода, между прочим, можно передать в качестве параметра).

Возможно, когда это увидят сторонники традиционного подхода, они начнут говорить что-то вроде: «да зачем, да это все в жизни неприменимо ...» и так далее. Вот что в жизни точно ПРИМЕНИМО, так это фантазия! Мы могли бы эту возможность использовать для генерации xml на стороне сервера, и выглядело бы подобное примерно так:

`(table
(tbody
(tr (td name)
(td ,name))
(tr (td address)
(td ,(get-address-by-name name)))
)) ;end tbody, table

Остались сомнения? Попробуй написать аналогичную программу на Java, и они исчезнут.

Заключение

К сожалению, очень многое осталось за бортом. Например, такие крайне интересные возможности, как:

  • Уникальное средство разбора сложных структур данных.
  • Средства определения других языков.
  • Декларативное описание циклических процессов.

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

DVD

Исходный код на Лисп и Java бери на нашем диске.

WWW

  • lisp.ru — главный отечественный ресурс, где ты можешь найти отличные статьи.
  • lisper.ru —кроме всего прочего, здесь есть движок самого ресурса на Лиспе.
  • clisp.linkfly.ru — а этот ресурс когда-нибудь станет главным :).
Содержание