2008-07-25

О пользе унификации и вреде предубеждений

Написано для: developers.org.ua (изменен код макро gcase)
Время написания: июль 2007


In fact, let’s not even worry about Java. Let’s not complain about Microsoft. Let’s not worry about them because we know how to program computers, too, and in fact we know how to do it in a meta-way. We can set up an alternative point of view, and we’re not the only ones who do this, as you’re well aware.[1]
–Alan Kay
There is a huge difference working with a language where you have to wait a year to get a new loop statement from the language designer and compiler implementer - or - where you do it yourself in twenty minutes.[2]
–Rainer Joswig


1. Принципы хорошего стиля программирования



На Западе широко известна следующая концепция из классического учебника MIT по программированию “Структура и интерпретация компьютерных программ”:
Сперва мы хотим утвердить идею, что компьютерный язык — это не просто способ добиться от компьютера выполнения операций, но, скорее, что это новое средство для формального выражения идей о методологии. Таким образом, программы должны быть написаны для людей, чтобы они их читали, и только между прочим для машин, чтобы они их исполняли.[3]


Есть и другой подход: программа — как набор кубиков лего, которые нужно любой ценой состыковать вместе, а где совсем не сходиться — допилить напильником.

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

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

К сожалению, современные мейнстрим языки жертвуют целью сделать возможным хороший стиль из коньюнктурных соображений реализации. В результате нам приходится использовать корявые языки, что приводит к полной неэффективности на более высоких уровнях абстракции. Мало того, что программист для того, чтобы получить возможность создания серьезных программ, вынужден вкладывать большое количество времени в изучение эзотерических технологий, которые через пару лет устареют (они устаревают как раз из-за своей ограниченности и привязки к конкретным промежуточным архитектурам), таких как: фабрики классов COM или же MFC на пару с Win32 API, AJAX и пр. Применение этих технологий серьезно затрудняет развитие достигнутых результатов другими людьми, которые не имеют времени, желания или возможности изучить их, не говоря уже о внутренних ограничениях самих технологий.

Также и хорошая парадигма, как и любая другая идеология, дает большой выигрыш на начальном этапе, но становится тормозом развития впоследствии, и языки программирования созданные для реализации концепций одной парадигмы становятся ее заложниками. Человек лучше всего думает на естественном языке, и хороший язык программирования должен давать ему возможность наиболее естественно (т.е. близко к их оригинальной форме) выразить свои мысли о структурах данных и алгоритмах, которые составляют по Вирту все, из чего состоят программы, накладывая при этом как можно меньше ограничений. В языке программирования должны быть равные возможности реализации любой прадигмы, любой концепции, способной зародиться в голове программиста, наиболее простым и элегантным путем. Такой подход лежит в основе Lisp’а, “программируемого языка програмиирования”.

2. Ограниченность языков с жестким синтаксисом



Если говорить по существу, то первая проблема большинства языков — жесткий и часто весьма запутанный и не унифицированный синтаксис, который, если немного перефразировать слоган Perl’а, делает некоторые вещи (которые по мнению создателя языка должны быть простыми) простыми, а остальные — как прийдется. А приходится так, что, большинство концепций, которые не были приняты во внимание при создании языка, требуют огромных трудозатрат для реализацию теми, кто в них нуждается. В качестве примера можно привести такую вездесущую потребность, как передача функций в качестве параметров другим функциям (так называемым, higher-order functions), которую не возможно элегантно реализовать в императивных языках (С или Java). Можно обратиться к функциональным языкам, но и здесь мы видим, что в основу языка заложено множество семантически-нагруженных конструкций, об универсальности которых говорить не приходится.

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

Показательным в этом отношении является шаблон with-..., который используется в Lisp’е в таких задачах, как работа с файлами и потоками, интерфейсами и протоколами обмена информацией. Примером может быть запись данных в файл. Это требует выполнения таких операций, как объявления переменной потока вывода, открытие физического файла на запись, при этом проверки на предмет того, возможно ли это, и выполнения соответсвующих операций в случае ошибок. Также не нужно забывать о необходимости закрыть файл после окончания манипуляций с данными. Все эти операции просты и рутинны, а необходимость их повторного написания много раз приводит к бесполезной трате времени, а также к тому, что файл в конце концов забывают закрыть… В Lisp эта констукция реализуется с помощью макро with-open-file в теле которого выполняются манипуляции с данными. Почему такой подход не применяется в других языках — об этом ниже.

3. Синтаксис Lisp’а



Почему-то распространено мнение, что у Lisp’а сложный синтаксис, впрочем обычно подразумевается другое — зачем все эти скобки??! А вот Lisp-программисты считают, что у Lisp’а вообще нет синтаксиса.

На самом деле, как верно подмечено в комментарии Тима Бредшоу у Lisp’а синтаксис минимальных обязательств. Можно сказать, что он выполняет только лишь функцию необходимой регуляризации английского языка для того, чтобы исключить неоднозначность.

Программы Lisp’а — это текст: есть атомы — символьные идентификаторы — это слова, есть список — атомы, разделенные пробелами и взятые в скобки — это предложение. Lisp Reader4 (можно легко представить себя на его месте) читает этот текст и интерпретирует его в качестве так называемых s-выражений, т.е. подходит к нему с таким набором предпосылок:
  • в любом s-выражении первым идет имя операции, а остальные элементы могут быть s-выражениями либо атомами

  • любой атом является либо именем либо означает сам себя

таким образом, s-выражение имеет следующий вид: (имя элемент элемент ...), где элемент — список либо атом.

Так зачем все-таки все эти скобки??! Риску начать издалека. Когда люди впервые занялись символьной математикой, они начали с использования простых операций — = + - * > < — которые имели 2 аргумента, т.е. выражения записывались в виде c = а + b. Им сразу же пришлось столкнуться с проблемой порядка операций (что будет, если записать d = a + b * c, и появились скобки, которые вернули в математику однозначность :). Чуть позднее появилась концепция функции, т.е. обощенной операции, которая имеет аргументы и значения. Поскольку многие функции также могут иметь несколько аргументов и даже несколько значений, их пришлось записывать, например, так: f(x, y, z) := (x, y) + (y, z). Здесь уже теряется единообразие: для базовых операций используется инфиксная нотация (т.е. операция записывается между своими аргументами), а для самой функции — по сути дела префиксная (или польская нотация). Что если попытаться свести все к единой нотации: := f x y z + (x y (y z. Не все понятно и однозначно. Убрать неоднозначность можно, добавив скобок: (define f (x y z) (+ (x y) (y z))). Выглядит не совсем привычно, но и изначальная конструкция тоже не была первым, что мы изучили в школе на математике.

4. Метапрограммирование



Что дает нам префиксная запись операций по сравнению, например, с С-подобным синтаксисом? Думаю, кому-то интересно будет узнать, что во внутренних структурах компилятора программа С выглядит так же. Я, конечно, не имею в виду, что там у них тоже скобки :). Но после прохождения сложного синтаксического разбора С-программа преобразуется компилятором в абстрактное синтаксическое дерево (AST). Код Lisp’а — это и есть AST, подавляющая часть которого скомпрессирована в виде MACRO’в (или, как находчиво придумал Conrad Barski — SPEL’ов), записанный прямо у нас перед глазами. И, поскольку мы избавлены от необходимости трансляции кода программы из ее обычного синтаксиса в AST и обратно, открывается возможность для широкого использования MACRO’в: если мы видим повторяющиеся части в дереве, их легко абстрагировать.

упрощенный вариант преобразования AST, выполняемое с помощью MACRO with-open-file

На это можно посмотреть и с другой стороны:
Lisp — это исполняемый XML с более дружественным синтаксисом.[4]


Ведь XML-файл — это тоже по сути дерево данных и метаданных. Только, чтобы избежать круглых скобок, создатели SGML/HTML/XML придумали использовать угловые и записывать название каждого тэга дважды! HTML элементарно преобразуется в подмножество Lisp’a, как показывают многочисленные реализации Lisp’овых библиотек для HTML-генерации (например, CL-WHO или ParenScript, который также реализует JavaScript и CSS). Интересно то, что в Lisp’е HTML-генерация намного естественней заточенного специально под задачу создания динамических веб-страниц PHP, в котором все в конце концов сводится к банальному вызову функции echo.

За счет использования MACRO’в, Lisp позволяет выражать свои мысли в наиболее лаконичной и адекватной форме, а каждый программист имеет способы самостоятельно сформировать набор примитивных конструкций языка, с которыми он будет работать в рамках конкретной проблемы, программы или платформы. Вы можете сказать, как и в большинстве других языков if ... then ... else ..., но также вы можете сказать when … then …, unless ... then ...; кроме того, вы можете сказать condition 1 -> expression 1; ... condition n -> expression n, case ... variant 1: expression1 ... variant n: expression n. И это далеко не полный список, на самом деле, вы можете легко реализовать любую логическую конструкцию и по возможностям использования в программах она ни чем не будет отличаться от обычного if-then-else.

Конкретный пример: в стандарте Lisp есть 3 вида case конструкций, которые имеют такую обобщенную форму:

case-конструкция тестовый элемент
(значение1 форма1)
...
(значениеN формаN)
[(otherwise формаN+1)]


Они отличаются тем, что обычная case-конструкция имеет default clause, а 2 другие: ecase и ccase, — выдают ошибки в случае ненахождения значения тестового элемента в списке возможных. Ограничением этих конструкций является использование для сравнения функции eql (опять же одной из множетсва операций, которые используются для проверки на равенство). Эта функция не работает для строк, поэтому я решил написать собственный вариант case, который бы давал возможность использовать любую функцию сравнения. Например, эта может быть функция, которая проверяет нахождение числа в рамках заданного интервала. Хочу обратить внимание на то, что конструкция gcase объявлена мной совершенно таким же образом, как и оригинальные case-конструкции — а возможность узнать их реализацию, не залезая глубоко в код или описание стандартных библиотек (доступа к которым может и не быть), как это делается в других языках, можно, как правило, просто запустив macroexpand.

(defmacro gcase ((keyform &key (test #'eql)) &body clauses)
"GENERALIZED-CASE -- the difference from simple CASE is that it can
use any given TEST-function. TYPE-ERRORs signaled by TEST-functions are ignored"
(unless (listp clauses) (error "~a -- bad clause in CASE" clauses))
(let ((t-clause? nil))
(when (eql (caar (last clauses)) 'otherwise)
(setf t-clause? t))
`(let ((it ,keyform))
(cond
,@(mapcar #'(lambda (clause)
(if (and t-clause? (eql (car clause) 'otherwise))
`(t ,@(cdr clause))
(w/uniqs (c)
`((handler-case (funcall ,test it ,(car clause))
(type-error (,c) (warn "The value ~a is not of type ~a"
(type-error-datum ,c)
(type-error-expected-type ,c))))
,@(cdr clause)))))
clauses))))) [5]


То же самое касается любых других абстракций. Людям, привыкшим работать в жестко ограниченных языках, не понятно, зачем иметь столько конструкций для циклов: do, dotimes, dolist, loop, …,— к которым могут быть дописаны еще какие-нибудь dotree, domap и т.п. (Хотя многие, наверно, вспомнят, что были рады увидеть, наконец, в C# конструкцию foreach, которой так не хватало в С++). В действии все тот же принцип хорошего стиля — писать все только один раз: во-первых, не нужно копи-пейстить одни и те же шаблоны снова и снова, во-вторых, сразу понятно, какого типа цикл перед нами (а не так, как в С: for (;;)), в-третьих, легко можно уточнить и расширить эти конструкции с помощью инструментов такого же типа (все эти операции циклов — тоже MACRO)...

Почему макро-систем нет в других языках? На самом деле они были и есть: Dylan — это Lisp с С-синтаксисом, metalua и пр. Очевидно, что для поддержки макров язык должен иметь возможность задействовать позднее связывание, но главная проблема остается в другом: разнородности и запутанности синтаксиса — в трудности учета самим программистом всех возможных побочных эффектов “скрытия” каких-то частей кода. Не то, чтобы такая система была не возможна — она, просто, не так практична, потому что появляется необходимость дополнительной трансляции кода между разными представлениями в воображении. А для полноценного использования макров, как показывает даже простой пример вверху, часто не удается ограничиться только одним уровнем вложения макрокода.

Но и без макро-системы трудно говорить о полноценном языке программирования. Мне кажется, это остановка на полпути для программиста — главная задача которого автоматизировать выполнение операций людьми с помощью компьютера — не иметь возможности или не научиться автоматизировать собственные операции.

5. Обещание Lisp’а



Большинство книг и введений в Lisp обещает, что изучив его, программист станет другим, лучшим. Вряд ли это так, потому что никого нельзя изменить при отсутствии собственного желания измениться (и всегда можно будет найти объяснение отсутствию желания, будь то “наличие/отсутствие скобок, переменных, объектов, …”, отсутствие библиотек, необходимость кормить семью…). На самом деле, главное обещание Lisp’а в другом — в том, что человек сможет выражать свои мысли при программировании свободно, в любой принятой им парадигме или вне парадигм без необходимости руководствоваться искуственными ограничениями. Идея, которая стоит за Lisp’ом сегодня — ясность. Но, как и за все остальное, за нее приходится чем-то расплачиваться. И, как по мне, пусть это что-то будет парой лишних скобок... :-)

Сноски:
[1] На самом деле, давайте не будем беспокоиться о Javе. Давайте не жаловаться на Microsoft. Давайте не беспокоиться о них, потому что мы тоже знаем, как программировать компьютеры, и, на самом деле, мы знаем, как делать это мета путем. Мы можем установить альтернативную точку зрения и мы не единственные, кто делает так, как вы хорошо знаете
[2] Есть большая разница между работой с языком, в котором вы должны ждать год, чтобы получить новое выражение для цикла от дизайнера языка и реализатора компилятора — или, когда вы можете сделать это сами за 20 минут
[3] First, we want to establish the idea that a computer language is not just a way of getting a computer to perform operations but rather that it is a novel formal medium for expressing ideas about methodology. Thus, programs must be written for people to read, and only incidentally for machines to execute.
[4] http://www.defmacro.org/ramblings/lisp.html
[5] В этой реализации, конечно, есть не только скобки, но и специфические функции, а также несколько синтаксических элементов (называемых синтаксическим сахаром): ' ` , #' ,@. На самом деле, это практически исчерпывающий список всех подобных элементов в Common Lisp, более того все они имеют свои аналоги в форме (имя ...), т.е., в конце концов, можно ограничиться только скобками и именами.

3 comments:

html said...

отличная статья, спасибо, исправьте мелкую ошибку пожалуйста

>> Риску начать издалека. Когда люди впервые занялись

alexyakushev said...

Замечательная статья, буду рекоммендовать ее для прочтения людям, которые еще не знакомы с Лиспом и функциональной и декларативной парадигмой вообще.

anton0xf said...

опечатка:
> Например, этО может быть функция, которая проверяет
> нахождение числа в рамках заданного интервала.