Искусство программирования для Unix.

8.3. Проектирование мини-языков.

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

Некоторые родственные идеи можно найти в описании моделей проектирования "Alternate Hard And Soft Layers" <http://www.c2.com/cgi/wiki?Alternate-HardAndSoftLayers> и "Scripted Components" <http://www.doc.ic.ac.uk/~np2/patterns/scripting/scripting.html>.

Интересное обозрение стилей и методик разработки с помощью мини-языков приведено в статье "Notable Design Patterns for Domain-Specific Languages" [77].

8.3.1. Определение соответствующего уровня сложности.

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

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

Макровирусы Microsoft Word показывают, как мини-язык может стать действительно опасной брешью в безопасности системы, простои и потери производительности от которой ежегодно обходятся в миллиарды долларов. Полезно отметить, что несмотря на существование в мире как минимум 20 млн. пользователей[91] Unix, в Unix-среде никогда не было вспышек макровирусов, характерных для Windows. Существует множество причин этому, включая принципиально лучшую с точки зрения безопасности конструкцию Unix; но по крайней мере одной причиной является тот факт, что почтовые агенты в Unix по умолчанию не обрабатывают исполняемое содержимое в документах, просматриваемых пользователем[92].

Если существует какая-либо возможность того, что пользователи приложения могут запускать программы из неблагонадежных источников, рискованные функции мини-языка приложения могут в конце концов привести к необходимости его подавления. Языки, подобные Java и JavaScript, явно изолируются в "песочнице", т.е. они имеют ограниченный доступ к своему окружению. Это сделано не только для того, чтобы упростить их конструкцию, но и для того, чтобы воспрепятствовать потенциально деструктивным операциям со стороны ошибочного или злонамеренного кода.

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

Рискованно разрабатывать мини-языки, которые только отчасти являются языками Тьюринга. Если разработчик решается на это, то высока вероятность того, что когда-нибудь в будущем какой-нибудь сообразительный программист придет к необходимости заставить данный язык выполнять циклы и условные операции. Поскольку это можно будет сделать только "туманным путем", он создаст неясный код, который в краткосрочной перспективе, возможно, будет легко обслуживаемым, но, вероятно, будет кошмаром для тех, кто впоследствии будет с ним работать.

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

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

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

В данном вопросе, как и в других, хороший вкус и инженерное мышление невозможно заменить ничем. Разрабатывая мини-язык, не следует делать это наполовину. Декларативные мини-языки должны иметь очевидный, последовательный языковой синтаксис, облегчающий их чтение. В императивных языках необходимо добавить полный диапазон управляющих структур, который адаптирован из языковых моделей, с которыми, пользователи разрабатываемого мини-языка, вероятно, знакомы. О языке необходимо думать как о языке, спрашивая себя: "будет ли удобно программировать на нем?" и даже "приятно ли будет смотреть на данную конструкцию?". Здесь, как и в других областях разработки программного обеспечения, применим принцип Дэвида Гелентера: красота — основная защита против сложности.

8.3.2. Расширение и встраивание языков.

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

Иногда можно написать императивный язык путем простого кодирования служебных функций в интерпретируемый язык, который далее в целях освещения данной темы называется "базовым" (host) языком. Программы, написанные на таком мини-языке, являются просто сценариями, которые загружают служебную библиотеку и используют управляющие структуры и другие средства базового языка как каркас. Каждое средство, имеющееся в базовом языке, избавляет от необходимости писать собственное аналогичное средство.

Данный способ является простейшим для написания мини-языка. Lisp-программисты старой школы (включая самого автора) предпочитают данный прием и интенсивно его используют. Он лежит в основе конструкции редактора Emacs и открывается заново в языках сценариев новой школы, таких как Tcl, Python и Perl. Однако такой подход имеет свои недостатки.

Базовый язык может оказаться неспособным предоставить интерфейс к необходимой библиотеке кода. Или его онтология типов данных может оказаться неадекватной для необходимого вида вычислений. Или измерения покажут слишком низкий уровень производительности прототипа. В таких ситуациях обычным решением является программирование на С (или С++) и интеграция результатов в создаваемый мини-язык.

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

Обе методики также зависят от способности перемещать данные между онтологией типов С и онтологией типов используемого языка сценариев. Некоторые языки сценариев, в целях поддержки такой возможности разработаны снизу вверх. Одним из них является Tcl, который рассматривается в главе 14, другим — Guile, диалект с открытым исходным кодом Lisp-варианта Scheme. Guile поставляется в виде библиотеки и специально предназначен для внедрения в C-программы.

Вполне возможно (хотя до сих пор довольно трудно) расширять или встраивать язык Perl. Очень просто расширять Python и немного сложнее внедрять его; C-расширения в мире Python используются особенно интенсивно. Язык Java имеет интерфейс для вызова "собственных методов" в С, хотя практические результаты этого бесперспективны, поскольку нарушается переносимость. Большинство версий командного интерпретатора Unix (shell) не предназначены для внедрения или расширения, однако оболочка Korn (ksh93 и боле поздние версии) является заметным исключением.

Существует множество причин не надстраивать создаваемый императивный мини-язык над существующим языком сценариев. Одна из весомых причин заключается в необходимости реализовать собственный, специальный грамматический аппарат для проверки ошибок. В таком случае следует рассмотреть приведенные ниже рекомендации по применению утилит yacc и lex.

8.3.3. Написание специальной грамматики.

Для декларативных мини-языков основной вопрос состоит в том, следует ли использовать XML в качестве основного синтаксиса и определять грамматику как тип XML-документа. Вполне возможно, что такой подход верен для сложно структурированных декларативных мини-языков, однако здесь также актуальны предостережения, изложенные в главе 5, о конструкции форматов файлов данных — XML может быть излишним. Если XML не используется, следует соблюдать правило наименьшей неожиданности, поддерживая описанные Unix-соглашения для файлов данных (простой синтаксис на основе лексем, поддержка C-соглашений об использовании обратной косой черты и т.д.).

Если специальная грамматика действительно требуется, то утилиты yacc и lex (или их локальный эквивалент в используемом языке), вероятно, будут наилучшими помощниками, кроме тех случаев, когда грамматика используемого языка настолько проста, что ручное кодирование рекурсивного нисходящего синтаксического анализатора представляет собой тривиальную задачу. Даже тогда утилита yacc может предоставить более надежное устранение ошибок, а модифицировать yacc-спецификацию по мере развития синтаксиса языка будет проще. В главе 9 рассматриваются производные от yacc и lex инструменты, доступные в языках различной реализации.

Даже в случае принятия решения о реализации собственного синтаксиса рекомендуется рассмотреть возможную выгоду от повторного использования имеющихся инструментальных средств. Если требуются макросредства, следует учесть, что предобработка средствами m4(1) может быть правильным решением. Однако, прежде всего, необходимо учесть предостережения, приведенные в следующем разделе.

8.3.4. Проблемы макросов.

Средства макрорасширения были излюбленной тактикой разработчиков языков в ранней Unix. Язык С, несомненно, имеет такое средство. Кроме того, они обнаруживаются в некоторых более сложных мини-языках специального назначения, таких как pic(1). Препроцессор m4 предоставляет общее средство для реализации макрорасширяющих препроцессоров.

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

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

Для языка С классическим примером такой проблемы является макрос, подобный следующему.

#define max(x, у) х > у ? x : у.

Данный макрос создает как минимум две проблемы. Одна из них заключается в том, что он может вызвать непредсказуемые результаты, в случае если один из аргументов является выражением, включающим в себя оператор меньшего приоритета, чем > или ?:. Рассмотрим выражение max(а = b, ++с). Если программист забыл, что max является макросом, то он будет ожидать, что присваивание a = b и преинкрементная операция с с будут выполнены до того, как результирующие значения будут переданы max в качестве аргументов.

Однако это не так. Вместо этого препроцессор преобразует данное выражение в a = b > ++c ? a = b : ++c, которое правила приоритета компилятора С заставляют интерпретировать как a = (b > ++c ? a = b : ++c). Результат будет присвоен а.

Подобное неверное взаимодействие можно предотвратить, кодируя определение макроса более безопасно.

#define max(x, у) ((x) > (у) ? (х) : (y)).

С таким определением выражение будет развернуто как ((a = b) > (++с) ? (а = b) : (++c)), что решает одну проблему, однако следует заметить, что переменная с может быть инкрементирована дважды. Существуют менее очевидные варианты данной проблемы, такие как передача макросу вызова функции с побочными эффектами.

Как правило, взаимодействие между макросами и выражениями с побочными эффектами может привести к неудачным результатам, которые трудно диагностировать. Макропроцессор С умышленно создан легковесным и простым. Более мощные макропроцессоры способны действительно вызвать более серьезные проблемы.

Язык форматирования TEX (см. главу 18) хорошо иллюстрирует общую проблему. ТеХ — умышленно разрабатывался как язык Тьюринга (в нем имеются условные операции, циклы и рекурсия), однако, несмотря на то, что его можно заставить делать поразительные вещи, TEX-код часто нечитабельный и трудный в отладке. Исходные коды для LATEX, наиболее широко используемого TEX-макропакета, являются поучительным примером: они созданы в очень хорошем TEX-стиле, но даже несмотря на это их крайне трудно понять.

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

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

Каскады макрорасширений, встроенные в язык, могут выполнять самокоррекцию, обрабатывая номера строк так, чтобы создать ссылки на текст до расширения. Так работает, например, макросредство утилиты pic(1). Данную проблему гораздо труднее решить, когда макрорасширение выполняется препроцессором.

В препроцессоре С проблема решается путем создания директив #line каждый раз, когда выполняется включение или многострочное расширение. Ожидается, что C-компилятор интерпретирует их и соответственно скорректирует номера строк в отчетах об ошибках. К сожалению, в макропроцессоре m4 подобное средство отсутствует.

Выше были описаны причины для крайне осторожного использования макрорасширений. Один из долгосрочных уроков опыта Unix заключается в том, что макросы склонны создавать больше проблем, чем решают. Современные конструкции языков и мини-языков отходят от их использования.

8.3.5. Язык или протокол прикладного уровня.

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

Основное отличие заключается в том, насколько тщательным является обозначение границ транзакций. Люди хорошо определяют, где заканчивается диалоговый вывод CLI-интерфейса и где находится приглашения для следующего ввода. Человек может использовать контекст, для того чтобы определить, что значительно, а что следует проигнорировать. Компьютерным программам гораздо сложнее справиться с этой задачей. Без однозначных маркеров окончания вывода или указания его точной длины они не способны определить, когда необходимо прекратить чтение.

Еще хуже, когда ввод программы буферизируется (часто непреднамеренно, как в stdio). Программа, которая очевидно прекращает чтение в верной позиции, может, несмотря на это, впоследствии принять ввод. Дуг Макилрой.

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

Существуют обходные пути для управления мини-языками, разработанными не так тщательно. Прототипом для большинства из них является Tcl-пакет expect. Данный пакет облегчает диалог с CLI-интерфейсами. Он создан на основе следующей операции: чтение данных подчиненного процесса до тех пор, пока либо не будет найдено соответствие заданному регулярному выражению, либо пока не истечет определенное время. Используя это (и, конечно, операцию отправки данных подчиненному процессу), нередко можно сконструировать главные программы, осуществляющие надежные диалоги с подчиненными процессами, даже когда последние к этому не приспособлены.

В других языках доступны эквиваленты пакета expect. Полезную информацию можно найти в Web, используя в качестве поисковой фразы название языка с дополнительными ключевыми словами "Tcl expect". Однако для разработчика мини-языка было бы недальновидно предполагать, что все пользователи являются специалистами по использованию пакета expect. Даже если они являются таковыми, данный подход является дополнительным связующим уровнем и местом возникновения ошибок.

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