WWW.DISSERS.RU

БЕСПЛАТНАЯ ЭЛЕКТРОННАЯ БИБЛИОТЕКА

   Добро пожаловать!

Pages:     | 1 |   ...   | 3 | 4 || 6 |

«Практика программирования Б. Керниган, Р. Пайк Книга: ...»

-- [ Страница 5 ] --

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

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

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

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

Следующий небольшой фрагмент пытается справиться с системой, в которой по некоторым причинам нет стандартного заголовочного файлa stdlib. h:

? #if defined (STDC_HEADERS) || defined (_LIBC) ? #include ? #else ? extern void *malloc(unsigned int);

? extern void *realloc(void *, unsigned int);

? #endif Защитный стиль приемлем, если он применяется время от времени,;

а не всегда.

Возникает резонный вопрос: а для скольких еще функций из stdlib. h придется писать аналогичный код? В частности, если вы собираетесь использовать mall ос и real loc, то явно потребуется еще и free. А что, если тип unsigned int нетождественен size_t — правильному типу аргумента для malloc и realloc? Более того, откуда мы знаем, что STOC_HEADERS и _LIBC определены, и определены корректно? Можем ли мы быть уверенными в том, что не существует другого имени, которое потребует замены для другой среды? Любой условный код вроде этого неполон, а значит — непереносим, поскольку рано или поздно встретится система, не удовлетворяющая его условию, и тогда придется редактировать #ifdef. Если нам удастся решить задачу без помощи условной компиляции, мы избавимся и от проблем, связанных с дальнейшим поддержанием этого кода.

Итак, проблема, которая решается в рассмотренном примере, существует в реальности. Так как же нам решить ее раз и навсегда? На самом деле нам просто надо предположить, что стандартные заголовочные файлы присутствуют во всех системах всегда;

если одного из них нет, то это уже не наши проблемы. Но мы можем решить и их;

для данного случая достаточно вместе с программой поставить и заголовочный файл, который определяет malloc, realloc и free в точности так, как этого требует стандарт ANSI С. Такой файл всегда может быть включен полностью вместо "заплаток", и мы будем уверены, что нужный интерфейс обеспечен.

Избегайте условной компиляции. Условной компиляцией с помощью ttifdef и подобных ей директив препроцессора трудно управлять, поскольку информация оказывается рассеянной по всему коду:

? #ifdef NATIVE ? char *astring = "convert ASCII to native character set";

? #else ? #ifdef MAC ? char *astring = "convert to Mac textfile format";

? #else ? #ifdef DOS ? char *astring = "convert to DOS textfile format";

? #else *„ ? char *astring = "convert^ to Unix textfile format";

? #endif /*:?DOS */ ? #endif /* ?MAC */ ? #endif /* ?NATIVE */ В этом фрагменте, вообще говоря, лучше было бы использовать tfelif после каждого определения — тогда бы не было такого ненужного скопления tfendif в конце. Однако главная проблема вовсе не в этом, а в том, что, несмотря на все старания, код плохо переносим, потому что он ведет себя по-разному в разных системах, а для работы в каждой новой системе должен быть дополнен новым Sifdef. Одна-единственная строка с унифицированным текстом (но на самом деле столь же информативным) была бы гораздо удобнее, проще и переносимее:

char *astring = "convert to local text format";

Для этой строки никаких условий не нужно, она будет выглядеть одинаково во всех системах.

Смешивание управляющей логики времени компиляции (определяемой выражениями «if def) и времени исполнения приводит к еще более трудно воспринимаемому коду:

? #ifndef DISKSYS ? for (i = 1;

i <= msg->dbgmsg.msg_total;

i++) ? #endif ? #ifdef DISKSYS ? i = dbgmsgno;

? if (i <= msg->dbgmsg.msg_total) ? #endif ? { ?

? if (msg->dbgmsg.msg_"total == i) ?#ifndef DISKSYS ? break;

/* больше ожидаемых сообщений нет */ ? еще около 30 строк с условной компиляцией ? #endif ? } Даже будучи явно безопасной, условная компиляция может быть заменена более простым кодом. Например, tfifdef часто используют для управления отладочным кодом:

? ftifdef DEBUG ? printf(...);

? tfendif однако обычное выражение if с константой в условии может делать то же самое:

enum { DEBUG = 0 };

if (DEBUG) { printf(...);

} Если DEBUG есть ноль, то большинство компиляторов не сгенерируют для приведенного фрагмента никакого кода, но при этом они еще и проверят синтаксис.

Секция с #if def, наоборот, может содержать синтаксические ошибки, которые сорвут компиляцию, как только соответствующее условие #if def окажется выполнено.

Иногда условия компиляции содержат большие блоки кода:

tfifdef notdef /* неопределенный символ */ ttendif или #if О tfendif В таком случае этот код лучше вынести в отдельные файлы, которые будут подключаться при компиляции при соблюдении определенных условий. К этой теме мы еще вернемся в следующем разделе.

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

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

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

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

Каждое выражение tfif def разделяет всю программу на две по отдельности компилируемые программы, и определить, все ли возможные варианты программ были скомпилированы и проверены, очень сложно. Если в один блок tfifdef было внесено изменение, то может статься, что изменить надо и другие блоки, но проверить эти изменения можно будет только в той среде, которая вызовет эти tfifdef к исполнению. Точно так же, если мы добавляем блок #if def, то трудно изолировать это изменение, то есть определить, какие еще условия должны быть учтены и в каких еще местах должен быть изменен код. Наконец, если некий блок кода должен быть опущен в соответствии с условием, то компилятор его просто не видит, и проверить этот блок можно только в соответствующей конфигурации. Вот небольшой пример подобной проблемы — программа компилируется, если _МАС определено, и отказывается это делать в противном случае:

flifdef _MAC printf("This is Macintosh\r");

#else This will give a syntax error on other systems #endif Итак, мы предпочитаем использовать только те возможности, которые присутствуют во всех средах, где будет исполняться программа. Мы всегда можем скомпилировать и протестировать весь код. Если что-то вызывает проблемы с переносимостью, мы переписываем этот кусок, а не добавляем условно компилируемый код;

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

Некоторые большие системы распространяются с конфигурационными скриптами, которые помогают приспособить код к локальной среде. Во время компиляции скрипт проверяет возможности среды: расположение заголовочных файлов и библиотек, порядок байтов внутри слов, размер типов, уже известные неверные реализации функций (таких на удивление много) и т. п. — и генерирует параметры настройки или make-файлы (makefile), которые описывают нужные настройки для данной ситуации.

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

Упражнение 8- Выясните, как ваш компилятор обрабатывает код, содержащийся внутри условного блока типа const int DEBUG = 0;

/* или enum { DEBUG = 0 };

*/ /* или final boolean DEBUG = false;

*/ if (DEBUG) { } При каких обстоятельствах компилятор проверяет синтаксис? Когда он генерирует код?

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

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

Выносите системные различия в отдельные файлы. Когда для разных систем требуется разный код, его лучше выносить в отдельные файлы — один файл на каждую систему. Например, текстовый редактор Sam работает под Unix, Windows и в ряде других операционных систем. Интерфейсы с системой меняются от среды к среде очень широко, но большая часть кода Sam везде идентична. Все различия, связанные с конкретной системой, вынесены в отдельные файлы: unix. с содержит код интерфейса с системами Unix, a windows, с — со средой Windows. В этих файлах реализуются переносимые интерфейсы с операционной системой, различия же получаются скрытыми. Таким образом, можно сказать, что Sam написан для своей собственной виртуальной операционной системы, которая переносится на различные реальные системы с помощью пары сот строк кода на С, реализующих несколько небольших, но непереносимых операций, вызывающих функции конкретной системы.

Самое интересное, что графические оболочки различных операционных систем не играют почти никакого значения: Sam имеет собственную переносимую библиотеку для своей графики. Несмотря на то что написать такую библиотеку, конечно же, гораздо труднее, чем просто адаптировать код под данную систему (код интерфейса с системой X Window, например, по своим размерам приближается к половине всего остального ко,ш Sam), суммарные затраты для нескольких систем получаются все равно меньше. При этом не надо забывать, что графическая библиотека ценна сама по себе и использовалась для создания и других переносимых программ.

Sam — это довольно старая программа;

в наши дни переносимые графические оболочки, такие как OpenGL, Tcl/Tk и Java, доступны для большого числа платформ.

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

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

Реализация редактора Sam представляет собой пример абстракции другого рода.

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

Подход к переносимости, реализованный в системе Java, — пример того, насколько далеко можно здесь продвинуться. Программа на Java транслируется вЪперации "виртуальной машины", модели компьютера, которую можно реализовать на любой настоящей машине. Библиотеки Java предоставляют унифицированный доступ к возможностям системы: графике, пользовательскому интерфейсу, сети и т. п.;

библиотеки же отображают этот доступ в возможности локальной системы.

Теоретически должно быть возможно исполнить Java-программу (даже после трансляции) где угодно без каких-либо изменений.

Обмен данными Текстовые данные передаются между системами в неизменном виде, | так что это самый простой и универсальный способ для передачи произ вольной информации между системами.

Для обмена данными используйте текст. С текстом легко оперировать посредством множества программ;

его можно обрабатывать разными, самыми неожиданными способами. Например, если вывод одной программы нельзя использовать напрямую для ввода в другую, то скрипт на Awk или Perl позволяет легко его преобразовать в нужный вид. С помощью g rep можно производить отбор строк, а ваш любимый редактор поможет произвести более сложные преобразования. Кроме всего прочего, I текстовые файлы легко документировать, да и пояснений к ним требуется гораздо меньше, потому что их всегда можно прочитать. Комментарий в текстовом файле может указывать, какая версия программы необ- \ ходима для обработки данных:

например, первая строка файла PostScript определяет версию языка (и, возможно, тип документа):

%!PS-Adobe-2. В противоположность текстовым двоичные файлы требуют для своей j обработки специализированные средства, и их редко удается использовать совместно даже на одной машине. Существует множество известных программ, преобразующих произвольные двоичные данные в текст. Среди них стоит назвать binhex для Macintosh, uuencode и uudecode для Unix и различные инструменты, использующие кодировку MIME для преобразования двоичных данных в почтовые сообщения. В главе 9 мы расскажем о ряде средств паковки и распаковки двоичных данных для их передачи с сохранением переносимости. Кстати, уже само обилие таких инструментов подчеркивает наличие серьезных проблем, связанных с двоичными форматами.

С передачей текста связана одна давняя проблема: PC-системы (операционные системы D'OS и Windows) используют для обозначения конца строки символ возврата каретки '\r' к символ перевода строки ' \п', а системы Unix — только символ перевода строки. Возврат каретки — это артефакт, дошедший до нас от древнего устройства, называемого телетайпом, который имел операцию возврата каретки (CR) для возврата печатающего механизма в начало строки и отдельный оператор протяж-'ки на строку (LF — от Line Feed) для перевода этого механизма на следующую строку.

Несмотря на то что в современных компьютерах уже нет кареток, которые бы надо было возвращать, программное обеспечение для PC, по большей своей части, продолжает ожидать этой комбинации (известной также как CRLF, произносится "curliff") в конце каждой строки. Если в файле отсутствуют возвраты каретки, то он может быть проинтерпретирован как одна гигантская строка, при этом счетчики строк и символов могут вести себя непредсказуемым образом. Некоторые программы умеют изящно справляться с этой проблемой, но таких - меньшинство. Надо сказать, что PC не единственный виновник подобного безобразия: благодаря последовательному внедрению требований совместимости некоторые современные сетевые стандарты, такие как HTTP, также используют CRLF для разделения строк.

Мы можем посоветовать использовать стандартные интерфейсы, которые воспринимают CRLF в зависимости от конкретной системы -либо (для PC) удаляя символ \r при вводе и добавляя его обратно на выходе, либо (для Unix), никогда даже не создавая его. Для файлов, которые должны будут передаваться туда и обратно, необходимо написать программу, преобразующую их форматы.

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

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

По крайней мере один вопрос решен: все современные машины имеют 8-битовые байты. Однако все объекты, большие байта, представляются на разных машинах по разному, поэтому полагаться на какие-то определенные свойства было бы ошибкой.

Короткие целые числа (обычно 16 битов, или 2 байта) могут иметь младший байт, расположенный как по меньшему адресу (little-endian, младшеконечное расположение), чем старший, так и по большему (big-endian, старшеконечное)i.

Выбор варианта произволен, а некоторые машины вообще поддерживают обе!

модели.

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

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

/* byteorder: отображает байты длинного целого */ int main(void) { unsigned long x;

unsigned char *p;

int i;

/* 11 22 33 44 => big-endian */ /* 44 33 22 11 => little-endian */ /* x = Ox1122334455667788UL;

для 64-битового long */ x = Ox11223344UL;

p = (unsigned char *) &x;

for (i = 0;

i < sizeof(long);

i++) pnntf("%x ", *p++);

printf("\n");

return 0;

} На 32-битовом старшеконечнике на экран будет выведено 11 22 33 на младшеконечнике — 44 33 22 а на PDP-11 (16-битовая машина, все еще встречающаяся во встроенных системах) результатом будет 22 11 44 На машинах с 64-битовым типом long мы можем рассмотреть константу большей длины и увидеть те же результаты.

Наша программа выглядит довольно глупо, но если нам надо послать целое число через побайтовый интерфейс, например по сети, то надо решить, какой байт посылать первым, а какой вторым, и в этом выборе суть проблемы со старше- и младшеконечниками. Другими словами, наша программа неявнр выполняет то, что выражение fwrite(&x, sizeof(x), 1, stdout);

делает явным образом. Небезопасно писать (отправлять) int (или short, или long) на одном компьютере и читать это число как int на другом.

Например, если компьютер-передатчик пишет с помощью unsigned short x;

fwrite(&x, sizeof(x), 1, stdout);

а компьютер-приемник производит чтение так:

unsigned short x;

fread(&x, sizeof(x), 1, stdin);

то, если эти компьютеры имеют разный порядок байтов, значение х будет воспроизведено неправильно. Например, если отправлено было число 0x1000, то прочитано оно будет как 0x0010.

Эта проблема часто решается посредством условной компиляции и перестановки байтов, то есть примерно так:

? short x;

? fread(&x, sizeof(x), 1, stdin);

? flifdef BIG_ENDIAN ? /* осуществляем перестановку байтов */ ? x = ((x&OxFF) « 8) | ((x»8) & OxFF);

? #endif При пересылке большого количества двух- и четырехбайтовых целых такой подход получается слишком громоздким. На практике получает- ;

ся, что байты при пересылке не один раз подвергаются перестановке.

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

Используйте при обмене данными фиксированный порядок байтов.

Решение проблемы все же существует. Записывайте байты в каноническом порядке, используя переносимый код:

unsigned short x;

putchar(x » 8);

/* пишем старший байт */ putchar(x & OxFF);

/* пишем младший байт */ и считывайте их обратно побайтово, собирая первоначальные значения unsigned short x;

x = getchar() « 8;

/* читаем старший байт */ x |= getchar() & OxFF;

/* читаем младший байт */ Этот подход можно перенести и на структуры: записывайте значе-'' ния членов структур в определенной последовательности, побайтово, без выравнивания.

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

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

Система X Window воспринимает данные от клиента с любым порядком байтов:

считается, что это забота сервера. А система Plan 9, наоборот, сама задает порядок байтов для отправки сообщений файл-серверу (или графическому серверу), а данные запаковываются и распаковываются неким универсальным кодом вроде приведенного выше. На практике определить временные затраты во время исполнения оказывается практически невозможно;

можно только сказать, что на фоне ввода-вывода упаковка данных оказывается малозаметной.

Java — язык более высокого уровня, чем С и C++, в нем порядок байтов скрыт совсем. Библиотеки представляют интерфейс Serializable, который определяет, как элементы данных пакуются для передачи.

Однако, если вы пишете на С или C++, всю работу придется выполнять самостоятельно. Главное, что можно сказать про побайтовую обработку: она решает имеющуюся проблему для всех машин с 8-битовыми байтами, причем решает без участия flifdef. Мы еще вернемся к этой теме в следующей главе.

Итак, лучшим решением нередко оказывается преобразование информации в текстовый формат, который (не считая проблемы CRLF) является абсолютно переносимым: не существует никаких неопределенностей с его представлением.

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

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

Существует один тонкий момент, связанный с использованием стан-| дартных функций для обработки двоичных файлов, — эти файлы необходимо открывать в двоичном режиме:

FILE *fin;

fin = fopen(binary_file, "rb");

с = getc(fin);

Если ' b' опущено, то в большинстве систем Unix это ни на что не повлиЯ яет, но в системах Windows первый встретившийся во вводе байт 1 Control-Z (восьмеричный 032, шестнадцатеричный 1А) прервет чтение! (такое происходило у нас с программой st rings из главы 5). В то же вре- j мя при использовании двоичного режима для чтения текстовых файлов;

вам придется вставлять символы \ r во ввод и убирать их из вывода.

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

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

При изменении спецификации изменяйте и имя. Наш любимый (если) можно так выразиться) пример — изменение свойств команды Unix: echo, которая в изначальном виде предназначалась для простого вывода аргументов:

% echo hello, world hello, world % Однако со временем эта команда стала ключевой частью многих тов оболочки, и перед ней встала необходимость генерировать формате рованный вывод. Теперь echo стала некоторым образом интерпретирс вать аргументы, то есть стала неким аналогом printf:

% echo 'hello\nworld hello world % Новые возможности, конечно, полезны, но из-за них у всех скриптов, использующих echo в изначальном варианте, возникли проблемы с совместимостью. Поведение % echo $PATH стало зависеть от того, какая из версий echo используется. Если переменная случайно содержит обратную косую черту (что вполне может произойти в DOS или Windows), то echo попытается интерпретировать ее. Это похоже на разницу в выводе через printf(str) и printf ("%s", str) в случае, если переменная str содержит знак процента.

Мы привели только часть истории про echo, но уже то, что сказано, иллюстрирует основную проблему: изменения в системе приводят к появлению версий программ с преднамеренно различным поведением, что создает непреднамеренные проблемы с переносимостью. И исправить эти ошибки зачастую оказывается непросто. Проблем было бы гораздо меньше, если бы новая версия echo получила и новое имя.

Приведем еще один пример. В Unix существует команда sum, которая вводит размер файла и его контрольную сумму. Предназначена эта команда для проверки правильности передачи данных:

% sum file 52313 2 file % % копируем file на другую машину % % telnet othermachine $ $ sum file 52313 2 file $ После передачи контрольная сумма не изменилась, так что с хорошей вероятностью можно считать, что передача прошла успешно.

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

% sum file 52313 2 file % % копируем file на машину % копируем file на машину % telnet machine $ Ф $ sum file eaaOd468 713 file $ telnet machines > > sum file 62992 1 file > Непонятно, произошел сбой в передаче или просто мы столкнулися разными версиями sum. Может быть и то, и другое.

Таким образом, sum являет собой яркий пример препятствия на пути переносимости:

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

Для выполнения изначально поставленной задачи первая версия sum] юлнелодходила: алгоритм, заложенный в ней, был не самым эффективным, но приемлемым. Ее "улучшение", может, и сделало собственно команду лучше, но зато использовать ее по назначению стало нельзя. И делят, надо сказать, не в том, что получилось несколько разных по существу! зманд, а в том, что все эти команды имеют одно и то же имя. Как видите! юблема несовместимости версий может оказаться весьма серьезной.

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

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

Интернационализация Если вы живете в Соединенных Штатах, то вы, может быть, забыли, что английский — не единственный язык на свете, ASCII — не единственный набор символов, $ — не единственный символ валюты, что даты могут записываться с указанием сначала дня, а потом уже месяца, что время может записываться в формате с 24-мя часами и т. п. Так вот, еще один аспект переносимости в общем виде связан с созданием программ, переносимых между разными языками и культурными границами. Это на самом деле весьма обширная тема для разговора, и мы будем вынуждены ограничиться освещением лишь нескольких основных концепций.

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

Не рассчитывайте на ASCII. В большинстве стран мира наборы символов богаче, чем ASCII. Стандартная функция проверки символов из ctype. h, в общем, успешно справляется с этими различиями:

if (isalpha(c))...

Такое выражение не зависит от конкретной кодировки символов, а главное — если программу скомпилировать в локальной среде, то она будет работать корректно и в тех случаях, когда букв больше или меньше, чем от а до г. Правда, имя isalpha ("это буква?") говорит само за себя, а ведь существуют языки, в которых алфавита нет вообще.

В большинстве европейских стран кодировка ASCII, определяющая только значения до 0x7F (7 битов), расширяется дополнительными символами, которые представляют собой буквы национальных языков.

Кодировка Latin-1, широко распространенная в Западной Европе, является расширением ASCII, определяющим значения байтов от 80 до FF я небуквенных символов и акцентированных букв — так, значение Е7 представляет букву д.

Английское слово boy представляется в ASCII ли Latin-1) тремя байтами с шестнадцатеричными значениями 62 6F 79, эранцузское слово да гсоп представляется в Latin-1 байтами 67 61 72 Е7 6Е. В других языках определяются, соответственно, другие символы, но они не могут уложиться в 128 значений, не используемых в ASCII, к что существует множество конфликтующих стандартов для символов, привязанных к байтам от 80 до FF.

Некоторым языкам вообще не хватает 8 битов: в большинстве азиат-их языков существуют тысячи символов. В кодировках, используемых в Китае, Японии и Корее, на символ отводится 16 битов. В результате возникает глобальная проблема переносимости: как прочитать кумент на некотором языке на компьютере, настроенном на другой язык. Даже если все символы передадутся без ошибок, для прочтения на американском компьютере документа на китайском языке должны как шимум стоять специальные шрифты и соответствующее программное обеспечение.

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

Набор символов Unicode — попытка улучшить описанную ситуацию, предоставив единую кодировку для всех языков мира. Unicode совместима с 16-битовым подмножеством стандарта ISO 10646;

в ней используется 16 битов на символ.

Значения от OOFF и ниже относятся к Latin-1,то есть слово gargon будет представлено 16-битовыми значениями 0067 61 0072 ООЕ7 006F 006Е. Кириллица занимает значения от 0401 до 04FF,а идеографическим языкам отведен большой блок, начинающийся ЮОО. Все известные и некоторые почти неизвестные языки мира представлены в Unicode, так что именно этой кодировкой и стоит пользовать для передачи документов между странами или для хранения текста,.писанного на разных языках. Unicode стала весьма популярна в Интер-;

те, и некоторые языки программирования даже поддерживают ее как стандартный формат: например, Java использует Unicode как родной набоp символов для строк. Операционные системы Plan 9 и Inferno испольч ют Unicode более широко — даже для имен файлов и пользователей, Microsoft Windows поддерживает набор символов Unicode, но не считает о стандартом;

большинство приложений Windows до сих пор лучше;

.ботает с ASCII, хотя соотношение стремительно меняется в пользу: nicode.

Надо сказать, что и у Unicode есть недостатки: символы в ней уже не гещаются в один байт, поэтому текст в Unicode страдает от проблемы порядка байтов. Для преодоления этой напасти документы в Unicod перед передачей между программами или по сети обычно преобразуются в кодировку потока байтов, называемую UTF-8. В ней каждый 16-битовый символ кодируется для передачи как последовательность из 1, 2 или 3 байтов. Набор символов ASCII использует значения от 00 до 7F, все они умещаются в один байт при использовании UTF-8. Таким образом, получается, что UTF-8 односторонне совместима с ASCII. Значения между 80 и 7FF представляются двумя байтами, а значения от 800 и выше — тремя.LВ UTF-8 слово gargon представляется байтами 67 61 72 СЗ А7 6F 6Е;

значение Unicode E7 — символ g — представляется в UTF-8 двумя байтами — СЗ А7.

Совместимость UTF-8 с ASCII весьма полезн-а, поскольку благодаря ей программы, рассматривающие текст как непрерывный поток байтов, могут работать с текстом Unicode на любом языке. Мы опробовали программу markov из третьей главы с текстом в UTF-8 на русском, греческом, японском и китайском языках, и она работала без каких-либо проблем. Для европейских языков, слова в которых разделяются ASCII-символами пробелов, табуляции или перевода строки, программа выдавала вполне сносный текст. При использовании других языков для того, чтобы получить что-то приемлемое на выходе, пришлось бы изменять правила разбиения текста на слова.

С и C++ поддерживают "широкие символы" (wide characters), которые представляются 16-битовыми или еще большими целыми. Суще-CTByiw и соответствующие функции, которые могут быть использованы для обработки символов в Unicode или в другом расширенном наборе символов. Строковые константы из широких символов записываются как L"..,". Однако и здесь возникает большая проблема с переносимостью: программа с константами из широких символов может быть воспроизведена только на дисплее, использующем тот же набор символов. Поскольку символы должны быть конвертированы в поток байтов вроде UTF-8 для передачи между машинами, язык С предоставляет функции для преобразования широких символов в байты и обратно. Однако какое преобразование использовать? Интерпретация набора символов и описания кодировки потока байтов таятся в недрах библиотек, и вытащить их оттуда достаточно сложно;

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

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

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

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

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

Существует множество местных культурных особенностей, например формат дат mm/dd/yy используется только в Северной Америке. Если существует вероятность того, что ваша программа будет использоваться в другой стране, от таких особенностей надо по возможности избавиться. Иконки в графическом интерфейсе очень часто зависят от традиций;

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

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

Мы рассмотрели два подхода к обеспечению переносимости — объединение и пересечение. Объединение предусматривает создание ;

версий, которые работают в каждой конкретной среде;

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

трудно изменять версии, очень трудно тестировать.

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

Дополнительная литература Есть много описаний языков программирования, но немногие из них достаточно точны, чтобы служить полноценным справочным руководством по языку. Авторы данной книги имеют личные причины, чтобы предпочитать книгу "Язык программирования С" Брайана Кернигана и Денниса Ритчи (Brian Kernighan, Dennis Ritchie. The С Programming Language. Prentice Hall, 1988), но она не заменяет стандарт. В книге "С: Справочное руководство" Сэма Харбисона и Гая Стила (Sam Harbison, Guy Steele. C: A Reference Manual. Prentice Hall, 1994), которая дожила уже до четвертого издания, даны хорошие советы по переносимости. Официальные стандарты языков С и C++ доступны в ISO (The International Organization for Standardization). Книга, наиболее близкая к официальному стандарту языка Java, "Спецификация языка Java" Джеймса Гослинга, Билла Джоя и Гая Стила (James Gosling, Bill Joy and Guy Steele. The Java Language. Specification. Addison-Wesley, 1996).

Книга Ричарда Стивенса "Программирование в системе Unix" (Richard Stevens.

Advanced Programming in the Unix Environment. Addison-Wesley, 1992) является отличным пособием для программистов под Unix;

в частности, там дан подробный обзор вопросов переносимости между различными Unix-системами.

POSIX (the Portable Operating System Interface) — международный стандарт команд и библиотек, основанный на Unix-системах. Он описывает стандартную среду, переносимость исходного кода, а также унифицированный интерфейс для ввода вывода, файловых систем и процессов. Этот стандарт описан в нескольких книгах, опубликованных IEEE.

Термин "big-endian" был введен Джонатаном Свифтом в 1726 г.1 Статья Денни Коэна "О святых войнах и мольбе о мире" (Danny Cohen. On holy wars and a plea for peace.

IEEE Computer, October 1981) является замечательной басней о порядке байтов, в которой термин "endian" был впервые применен в компьютерной области.

В операционной системе Plan 9, разработанной в Bell Labs, переноносимость является главным приоритетом. Система компилируется из одного и того же исходного кода (без директив условной компиляции!) наЦИ ожестве разных процессоров и повсеместно использует символы icode. Последние версии редактора Sam, впервые описанного в "The Text Editor sam" (Software — Practice and Experience, 17, 11, p. 813-845, 1987), используют Unicode, но тем не менее работают на большом количестве систем. Проблемы работы с 16-битовыми наборами символов вроде Unicode описаны в статье Роба Пайка и Кена Томпсона "Hello, World ? (Документы зимней конференции USENIX'1993. Сан-Диего, 1993. С. 43-50). Впервые кодировка ГР-8 была представлена именно в этой статье. Данный документ, как тоследняя версия редактора Sam, также доступен на Web-сайте, посвященном системе Plan 9 в Bell Labs.

Система Inferno основывается на опыте Plan 9 и в чем-то похожа на j va, поскольку она определяет виртуальную машину, которая может ггь реализована на любой реальной машине, предоставляет язык (Limbo), который может быть скомпилирован в инструкции для этой фтуальной машины, и использует Unicode в качестве основного набор символов. Она также включает виртуальную операционную систему,которая предоставляет переносимый интерфейс ко множеству коммер-;

ских систем. Она описана в статье "Операционная система Inferno" Шона Дорварда, Роба Пайка, Дэвида Л. Презотто, Денниса Ритчи, Говарда Трики и Филиппа Винтерботтома (Sean Dorward, Rob Pike, David eo Presotto, Dennis M. Ritchie, Howard W. Trickey и Philip Winter-ottom. The Inferno Operating System. Bell Labs Technical Journal, 2, 1, /inter, 1997).

Нотация • Форматирование данных • Регулярные выражения • Программируемые инструменты • Интерпретаторы, компиляторы и виртуальные машины • Программы, которые пишут программы • Использование макросов для генерации кода • Компиляция "на лету" • Дополнительная литература Из всех творений человека самым удивительным является язык.

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

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

Регулярные выражения позволяют использовать компактные (из-за этого подчас превращающиеся в тайнопись) описания классов строк. Язык HTML позволяет определять внешний вид интерактивных документов, нередко используя встроенные программы на других языках, вроде JavaScript. PostScript рассматривает целый документ — например эту книгу — как стилизованную программу. Электронные таблицы и текстовые процессоры часто содержат в себе языки программирования типа Visual Basic, они используются для вычисления выражений, доступа к информации, управления размещением данных в документе.

Если вы ловите себя на том, что приходится писать слишком много кода' для выполнения рутинных операций, или если у вас возникают проблемы с тем, чтобы в удобной форме описать весь процесс, знайте — скорее всего, вы выбрали неправильный язык. Отсутствие правильного языка можно считать хорошим поводом написать его самостоятельно. Придумать свой язык вовсе не означает создать преемника Java: просто нередко самые запутанные проблемы проясняются при выборе должной нотации. В связи с этим вспомните форматные строки семейства printf, которые дают нам компактный и выразительный способ для управления выводом.

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

Форматирование данных Между тем, что мы хотим сказать компьютеру ("реши мою проблему"), и тем, что нам приходится ему говорить для достижения нужного результата, всегда существует некоторый разрыв. Очевидно, что чем этот разрыв меньше, тем лучше. Хорошая нотация поможет нам сказать лменно то, что мы хотели, и препятствует ошибкам.

Иногда хороший ;

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

Малые языки (little languages) — это нотации для узких областей применения. Эти языки не только предоставляют удобный интерфейс, но| л помогают организовать программу, в которой они реализуются. Хоро-лим примером является управляющая последовательность printf:

printf("%d %6.2f %-10.10s\n", f, s);

Здесь каждый знак процента обозначает место вставки значения сле-гующего аргумента printf ;

за ним следуют необязательные флаги и раз- \ меры поля и, наконец, буква, которая указывает тип параметра. Такая нотация компактна, интуитивно понятна и легка в использовании;

ее реализация достаточно проста и прямолинейна. Альтернативные возможности в C++ (lost ream) и Java ( j ava. io) выглядят гораздо менее привле-сательно, поскольку они не предоставляют специальной нотации, хотя могут расшириться типами, определяемыми пользователем, и обеспечив 5ают проверку типов.

Некоторые нестандартные реализации printf позволяют добавлять] ;

свои приведения типов к встроенным. Это удобно, когда вы работаете] : другими типами данных, нуждающимися в преобразованиях при вы- р| зоде. Например, компилятор может использовать знак %L_ для обозначения номера строки и имени файла;

графическая система — использовать Р для точки, a %R — для прямоугольника. Строка шифра из букв и номеров — сведения о биржевых котировках, которая рассматривалась нами главе 4, относится к тому же типу: это компактный способ записи таких котировок.

Схожие примеры можно придумать и для С и C++. Представим себе, что нам нужно пересылать пакеты, содержащие различные комбинации типов данных, из одной системы в другую. Как мы видели в главе 8, самым чистым решением была бы передача данных в текстовом виде. Однако для стандартного сетевого протокола лучше иметь двоичный формат по причинам эффективности и размера. Как же нам написать код для обработки пакетов, чтобы он был переносим, эффективен и прост в эксплуатации?

Для того чтобы дальнейшее обсуждение было конкретным, представим себе, что нам надо пересылать пакеты из 8-битовых, 16-битовых и 32-битовых элементов данных из одной системы в другую. В стандарте ANSI С оговорено, что в char может храниться как минимум 8 битов, 16 битов может храниться в sho rt и 32 бита — в long, так что мы, не мудрствуя лукаво, будем использовать именно эти типы для представления наших данных. Типов пакетов может быть много: пакет первого типа содержит однобайтовый спецификатор типа, двухбайтовый счетчик, однобайтовое значение и четырехбайтовый элемент данных:

Пакет второго типа может состоять из одного короткого и двух длинных слов данных:

Один из способов — написать отдельные функции упаковки и распаковки для каждого типа пакета:

int pack_type1(unsigned char *buf, unsigned short count, unsigned char val, unsigned long data) { unsigned char *bp;

bp = buf;

*bp++ = 0x01;

*bp++ = count » 8;

*bp++ = count;

*bp++ = val;

*bp++ = data » 24;

*bp++ = data » 16;

*bp++ = data » 8;

*bp++ = data;

return bp - but;

} Для настоящего протокола потребовалось бы написать не один десяток сих функций — на все возможные варианты. Можно было бы несколь-упростить процесс, используя макросы или функции для обработки ювых типов данных (short, long и т.

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

Именно повторяемость кода и является его основной чертой, и здесь-то и может помочь грамотно подобранный способ записи. Позаимствовав идею у printf, мы можем определить свой маленький язык спецификации, в котором каждый пакет будет описываться краткой строкой, дающей информацию о размещении данных внутри него. Элементы пакета даруются последовательно: с обозначает 8-битовый символ, s — 16-битовoe короткое целое, а 1 — 32-битовое длинное целое.Таким образом, на-зимер, пакет первого типа (включая первый байт определения типа) моет быть представлен форматной строкой cscl. Теперь мы в состоянии :пользовать одну-единственную функцию pack для создания пакетов обых типов;

описанный только что пакет будет создан вызовом pack(buf, "cscl", 0x01, count, val, data);

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

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

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

Ниже приведена реализация pack, которая заполняет буфер buf кодированными в соответствии с форматом значениями аргументов. Мы сделали значения беззнаковыми, в том числе байты буфера пакета, чтобы избежать проблемы переноса знакового бита. Чтобы укоротить описания, мы использовали некоторые привычные определения типов:

typedef unsigned char uchar;

typedef unsigned short ushort;

typedef unsigned long ulong;

Точно так же, как sprinth, strcopy и им подобные, наша функция предполагает, что буфер имеет достаточный размер, чтобы вместить результат;

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

#include /* pack: запаковывает двоичные элементы в буфер, */ /* возвращает длину */ int pack(uchar *buf, char *fmt,..'.) { va_list args;

char *p;

uchar *bp;

ushort s;

ulong 1;

bp = buf;

va_start(args, fmt);

for (p = fmt;

*p != '\0'i P++) ( switch (*p) { case 'c': /* char */ *bp++ = va_arg(args, int);

break;

, case 's'.: /* short */ s = va_arg(args, int);

*bp++ = s » 8;

*bp++ = s;

break;

case '!': /* long */ 1 = va_arg(args, ulong);

*bp++ = 1 » 24;

*bp++ = 1 » 16;

*bp++ = 1 » 8;

*bp++ = 1;

break;

default: /* непредусмотренный тип */ va_end(args);

return -1;

} } va_end(args);

return bp - buf;

} Функция pack использует заголовочный файл stda rg. h более активно, чем функция eprintf в главе 4. Аргументы последовательно извлекаются с помощью макроса va_arg, первым операндом которого является переменная типа va_list, инициализированная вызовом va_start;

а в качестве второго операнда выступает тип аргумента (вот почему va_arg — то именно макрос, а не функция). По окончании обработки должен быть осуществлен вызов va_end. Несмотря на то что аргументы для ' с ' ;

' s ' представлены значениями char и short соответственно, они должны извлекаться как int, поскольку, когда аргументы представлены многоточием, С переводит char и short в int.

Теперь функции pack_type будут состоять у нас всего из одной строки, которой их аргументы будут просто заноситься в вызов pack:

/* pack_type1: пакует пакет типа 1 */ int pack_type1(uchar *buf, ushort count, uchar val, ulong data) { return pack(buf, "cscl", 0x01, count, val, data);

} Для распаковки мы делаем то же самое и вместо того, чтобы писать тдельный код для обработки каждого типа пакетов, вызываем общую функцию unpack с соответствующей форматной строкой. Это централизирует преобразования типов:

/* unpack: распаковывает элементы из buf, возвращает длину */ int unpack(uchar *buf, char *fmt,...) { va_list args;

char *p;

uchar *bp, *pc;

ushort *ps;

ulong *pl;

bp = buf;

va_start(args, fmt);

for (p = fmt;

*p !='\О';

р++) { switch (*p) { case 'c': /* char */ pc = va_arg(args, uchar*);

*pc = *bp++;

break;

case 's': /* short */ ps = va_arg(args, ushort*);

*ps = *bp++ « 8;

*ps |= *bp++;

break;

case '!' /* long */ pi = va_arg(args, ulong*);

*pl = *bp++ « 24;

*pl |= *bp++ « 16;

*pl |= *bp++ « 8;

*pl |= *bp++;

break;

default: /* непредусмотренный тип */ va_end(args);

return -1;

} } va_end(args);

return bp - buf;

} Так же как, например, scanf, функция unpack должна возвращать вызвавшему ее коду множество значений, поэтому ее аргументы являются указателями на переменные, где и будут храниться требуемые результаты. Значением функции является количество байтов в пакете, его можно использовать для контроля.

Все значения у нас беззнаковые. Мы придерживались размеров, которые ANSI С определяет для типов данных, и поэтому наш код можно переносить даже между машинами, имеющими разные размеры для типов short и long. Если только программа, использующая pack, не будет пытаться переслать как long (к примеру) значение, которое не может быть представлено 32 битами, то значение будет передано корректно;

на самом деле мы передаем младшие 32 бита числа. Если же потребуется передавать более длинные значения, то нужно придумать другой формат.

Благодаря использованию unpack, функции для распаковки пакетов в зависимости от их типа стали выглядеть гораздо проще:

/* unpacktype2: распаковывает и обрабатывает пакет типа 2 */ int tinpack_type2(int n, uchar *buf) { uchar с;

ushort count;

ulong dw1, dw2;

if (unpack(buf, "call"', &c, Scount, &dw1, &dw2) != n) return -1;

assert(c == 0x02);

return process_type2(count, dw1, dw2);

} Перед тем как вызывать unpack_type2, мы должны сначала убедиться,что имеется пакет именно 2-го типа;

распознаванием типа пакетов занимается цикл получателя, примерно такой:

while ((n = readpacket(network, buf, BUFSIZ)) > 0) { switch (buf[OJ) { default:

eprintf("неправильный тип пакета Ох%х", buf[0]);

break;

case 1:

unpack_type1(n, buf);

break;

case 2:

unpack_type2(n, buf);

break;

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

int (*unpackfn[])(int, uchar *) = { unpack_typeO, unpack_type1, unpack_type2, };

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

/* receive: читает пакеты из сети, обрабатывает их */ void receive(int network) { uchar type, but[BUFSIZ];

int n;

while ((n = readpacket(network, buf, BUFSIZ)) > 0) { type = buf[0];

if (type >= NELEMS(unpackfn)) eprintf("неправильный тип пакета Ох%х", type);

if ((*unpackfn[type])(n, buf) < 0) eprintf("ошибка протокола, тип %х длина %d", type, n);

} } Итак, теперь код для обработки каждого пакета стал компактен;

основная часть всей обработки происходит в одной функции и потому поддерживать такой код нетрудно.

Код приемника теперь мало зависит от самого протокола;

код его также прост и однозначен.

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

Упражнение 9- Измените pack и unpack так, чтобы можно было передавать и значения со знаком, причем даже между машинами, имеющими разные размеры short и long. Как вы измените форматную строку для обозначения элемента данных со знаком? Как можно протестировать код, чтобы убедиться, что он корректно передает, например, число -1 с компьютера с 32-битовым long на компьютер с 64-битовым long?

Упражнение 9- Добавьте в pack и unpack возможности обработки строк. (Есть вариант включать длину строки в форматную строку.) Добавьте возможность обработки повторяющихся значений с помощью счетчика. Как это соотносится с кодировкой строк?

Упражнение 9- Вспомните таблицу указателей на функции, которую мы применили олько что в программе на С. Такой же принцип лежит в основе механизмa виртуальных функций C++. Перепишите pack, unpack и receive на ;

++, чтобы прочувствовать все удобство этого способа.

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

Упражнение 9- Напишите функцию, реализующую спецификации.формата,.исполь-/емого в какой нибудь программе работы с электронными таблицами ли в Java-классе Decimal Format, где числа отображаются в соответствии некоторым заданным шаблоном, указывающим количество обязательных и возможных символов, положение десятичной точки и тысячных шятых и т. п. Для иллюстрации рассмотрим строку ##,##0. Эта строка задает число с двумя знаками после десятичной точки, э крайней мере одним знаком перед десятичной точкой, запятой в качестве разделителя тысяч и заполняющими пробелами до позиции 10 000. аким образом, число 12345.67 будет представлено как 12, 345. 67, а.4 — ж **,**0.40 (для наглядности вместо пробелов мы вставили звездоч-i). Для получения полной спецификации можете обратиться к описанию DecimalFormat или программам работы с электронными таблицами.

Регулярные выражения Спецификаторы формата для pack и unpack — достаточно простой юсоб записи, описывающий компоновку пакетов. Следующая тема iniero обсуждения — несколько более сложный, но гораздо более фазительный способ записи -- регулярные выражения (regular pressions), определяющие шаблоны текста (patterns of text). В этой книге мы время от времени использовали регулярные выражения, не вая им точного описания;

они достаточно знакомы, чтобы понять их без особых пояснений.

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

Существует несколько разновидностей регулярных выражений, но суть у них у всех одинакова — это способ описания шаблона буквенных символов, включающего в себя повторения, альтернативы и сокращения для отдельных классов символов вроде цифр или букв. Примером такого шаблона могут служить всем знакомые символы замещения (wildcards), используемые в процессорах командной строки или оболочках для задания образцов поиска имен файлов. Как правило, символ * используется для обозначения "любой строки символов", так что команда С:\> del *.exe использует шаблон, которому соответствуют все имена файлов, оканчивающиеся на ".-. ехе". Как это часто бывает, детали меняются от системы к системе и даже от программы к программе.

Причуды отдельных программ могут навести на мысль, что регулярные выражения являются механизмом? создаваемым ad hoc, для каждого случая отдельно, но на самом деле регулярные выражения — это язык с формальной грамматикой и точным значением каждого выражения. Более того, при должной реализации конструкция работает очень быстро: благодаря соединению теории и практического опыта;

вот пример в пользу специализированных алгоритмов, упоминавшихся нами в главе 2.

Регулярное выражение есть последовательность символов, которая определяет множество образцов поиска. Большинство символов просто-напросто соответствуют сами себе, так что регулярное выражение abc будет соответствовать именно этой строке символов, где бы она ни появлялась. Но, кроме того, существует несколько метасимволов, которые обозначают повторение, группировку или местоположение. В принятых в Unix регулярных выражениях символ ~ обозначает начало строки, а $ — конец строки, так что шаблон ~х соответствует только символу х в начале строки, х$ — только х в конце строки. Шаблону ~х$ соответствует только строка, содержащая единственный символ х, и, наконец, ~$ соответствует пустая строка.

Символ. (точка) обозначает любой символ, так что выражению х. у будут соответствовать хау, х2у и т. п., но не ху или xaby;

выражению ~. $ соответствует строка из одного произвольного символа.

Набору символов внутри квадратных скобок соответствует любой ия этих символов.

Таким образом, [0123456789] соответствует одной цифре, это выражение можно записать и в сокращенном виде: [0-9]1.

Эти строительные блоки комбинируются с помощью круглых скобок для группировки, символа — для обозначения альтернатив, * — для обозначения нуля или более совпадений, + — для обозначения одного или более совпадений и ? — для обозначения нуля или одного совпадения. Наконец, символ \ применяется в качестве префикса перед метасимволом для исключения его специального использования, то есть \* обозначает просто символ *, а \\ — собственно символ обратной косой черты \.

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

различие обусловлено разным назначением выражений.

Какой исходный файл использует класс Regexp?

% grep Regexp *.java В каком файле этот класс реализован?

% grep 'class.*Regexp' *.java Куда я подевал это письмо от Боба?

% grep '"From:.* bob@' mail/* Сколько непустых строк кода в этой программе?

grep *. с++ 1 we Благодаря наличию флагов, позволяющих выводить номера соответствующих выражению строк, подсчитывать количество соответствий, осуществлять сравнение без учета регистра, инвертировать смысл выражения (то есть отбирать строки, не соответствующие шаблону) и т. п., программа grep используется сейчас настолько широко, что стала уже классическим примером программирования с помощью специальных инструментов.

К сожалению, не во всех системах имеется grep или ее аналог. Некоторые системы включают в себя библиотеку регулярных выражений, которая называется, как правило, regex или regexp, и ее можно использовать для создания собственной версии g rep. Если же нет ни того, ни другого, то на самом деле не так трудно реализовать какое-то скромное подмножество языка регулярных выражений. Здесь мы представляем реализацию регулярных выражений и grep, чтобы работать с ними;

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

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

/* match: ищет regexp в text */ int match(char *regexp, char *text) { if (regexp[0] == '",'') return matchhere(regexp+1, text);

do { /* должны попробовать, даже если строка пуста */ if (matchhere(regexp, text)) return 1;

} while (*text++ != '\0');

return 0;

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

Обратите внимание на использование do-while: выражениям может соответствовать пустая строка (например, шаблону $ соответствует пустая строка, а шаблону. * — любое количество символов, включая и ноль), поэтому вызвать matchhere мы должны даже в том случае, если строка текста пуста.

Большая часть работы выполняется в рекурсивной функции matchhere:

/* matchhere: ищет regexp в начале text */ int matchhere(char *regexp, char *text) { if (regexp[0] == "\0') return 1;

if (regexp[1] == '*') return matchstar(regexp[0], regexp+2, text);

if (regexp[0] == '$' && regexp[1] == 'ДО') return *text == '\О';

if (*text!='\0' && (regexp[0]==',' || regexp[0]==*text)) return matchhere(regexp+1, text+1);

return 0;

} Если регулярное выражение пусто, это означает, что мы дошли до его конца и, следовательно, нашли соответствие. Если выражение оканчивается символом $, оно имеет соответствие только в том случае, если текст также расположен в конце. Если выражение начинается с точки, то первый символ соответствующего текста может быть любым. В противном случае выражение начинается с какого-то обычного символа, который) в тексте соответствует только самому себе. Символы ~ и $, встречающиеся в середине регулярного выражения, рассматриваются как простые литеральные, а не метасимволы.

Отметьте, что matchhe re, убедившись в совпадении одного символа из;

шаблона и подстроки, вызывает себя самое, таким образом, глубина ре] курсии может быть такой же, как и длина шаблона (при полном соответветствии).

Единственный сложный случай представляет собой выражение, начинающееся с символа и звездочки, например х*. В этом случае мы осуществляем вызов matchstar, где первым аргументом является операнд звездочки (то есть х), а следующими аргументами — шаблон после звездочки и сам текст.

/* matchstar: ищет oregexp в начале text */ int matchstar(int с, char *regexp, char *text) { do { /* знак * означает ноль или более вхождений */ if (matchhere(regexp, text)) return 1;

" ' ---- i- -\n- ss fi,toYt++ c || c ')) return 0;

} Здесь мы опять используем do-while — из-за того, что регулярному выражению х* может соответствовать и ноль символов. Цикл проверяет, совпадает ли текст с остатком регулярного выражения, пытаясь сопоставить их, пока первый символ текста совпадает с операндом звездочки.

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

Скоро мы представим некоторые соображения по расширению возможностей нашего кода, а пока напишем свою версию g rep, использующую match. Вот как выглядит основная часть:

/* grep main: ищет regexp в файлах */ int main(int argc, char *argv[]) { int i, nmatch;

FILE *f;

setprogname("grep");

if (argc < 2) eprintf("usage: grep regexp [file...]");

nmatch = 0;

if (argc == 2) { if (grep(argv[1], stdin, NULL)) nmatch++;

} else { for (i = 2;

i < argc;

i++) { f = fopen(argv[i], "r">;

if (f == NULL) { weprintf("can't open %s:", argv[i]);

continue;

} if (grep(argv[1], f, argc>3 ? argv[i] : NULL) > 0) nmatch++;

fclose(f);

} } return nmatch == 0;

} По соглашению программы на С возвращают 0 при успешном заверше-и и ненулевое значение — при различных сбоях. Наша программа Ф, так же как и Unix версия, считает выполнение успешным, если ла найдена строка, соответствующая шаблону. Поэтому она возвращает 0, если было найдено хотя бы одно соответствие;

1 — если соответствий найдено не было, и 2 (посредством eprintf) — если произошла шбка. Эти значения можно протестировать, использовав в качестве олочки какую-то другую программу.

Функция grep сканирует один файл, вызывая match для каждой его строки:

/* grep: ищет regexp в файле */ int grep(char *regexp, FILE *f, char *name) { int n, nmatch;

char buf[BUFSIZ];

nmatch = 0;

while (fgets(buf, sizeof but, f) != NULL) { n = strlen(buf);

if (n > 0 && buf[n-1] == '\n') buf[n-1] = '\0';

if (match(regexp, buf)) { nmatch++;

if (name != NULL) printf ("%s:", name);

printf("%s\n", buf);

} } return nmatch;

i / Если открыть файл не удается, то программа не прекращает работу. Такое поведение было выбрано для того, чтобы при вызове % grep herpolhode *.* даже если какой-то файл в каталоге не может быть открыт, g rep уведомляла об этом и продолжала работу;

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

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

Сравните % strings markov.exe | grep 'DOS mode' и % grep grammer chapter*.txt Продуманность нюансов — одна из тех черт, что сделали grep столь популярным инструментом;

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

В нашей реализации match завершает работу сразу же, как только найдено соответствие. Для g rep это вполне подходит в качестве поведения по умолчанию, но для реализации оператора подстановки (найти и заменить) в текстовом редакторе больше подошел поиск крайне левого самого длинного совпадения (leftmost longest).

Например, если нам задан текст "ааааа", то шаблону а* соответствует, в частности, и пустая строка в начале текста, однако более естественным было бы включить в совпадение все пять а. Для того чтобы match искала крайне левую и самую длинную строку, matchstar надо переписать так, чтобы добиться жадного (greedy) поведения алгоритма: вместо того чтобы просматривать каждый символ текста слева направо, он должен каждый раз пытаться пройти до конца самой длинной строки, соответствующей оператору-звездочке, и откатываться назад, если оставшаяся часть обрабатываемого текста не соответствует оставшейся части шаблона.

Другими словами, она должна выполняться справа налево. Вот как выглядит версия matchstar, осуществляющая поиск крайне левого максимального совпадения:

/* matchstar: поиск в начале теста с*гедехр */ int matchstar (int с, char *regexp, char *text) { char *t;

for (t = text;

*t != ДО' && (*t == с || с == '.');

t++) do { /* знак * соответствует нулю или более вхождений */ if (matchhere(regexp, t)) return 1;

} while (t-- > text);

return 0;

X ;

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

Наша версия g rep вполне сравнима с версиями, поставляемыми с различными системами (неважно, что синтаксис наших регулярных выражений несколько скуднее). Существуют некоторые патологические выражения, которые приводят к экспоненциальному возрастанию трудоемкости поиска, — например, выражение а*а*а*а*а*Ь при введенном тексте ааааааааас, однако экспоненциальный поиск присутствует и в некоторых коммерческих реализациях. Вариант g rep, применяемый в Unix (он называется eg rep), использует более сложный алгоритм поиска соответствий;

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

А как насчет того, чтобы match умела обрабатывать полноценные регулярные выражения? Для этого надо добавить возможность сопоставления классов символов типа [a-zA-Z] конкретному символу алфавита, возможность исключать значение метасимволов (например, для поиска точки нужно обозначить этот символ как литеральный), предусмотреть скобки для группировки, а также ввести альтернативы (аос или clef). Первый шаг здесь — облегчить match работу, скомпилировав шаблон в некое новое представление, которое было бы проще сканировать: слишком накладно производить разбор класса символов для каждого нового сравнения его с одним символом;

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

облегчить написание помогут некоторые средства, о которых мы поговорим далее в этой главе.

Упражнение 9- Как соотносится быстродействие match и strstr при поиске простого текста (без метасимволов)?

Упражнение 9- Напишите версию matchhere без рекурсии и сравните ее быстродействие с рекурсивной версией.

Упражнение 9- Добавьте в g rep несколько ключей командной строки. К наиболее популярным ключам относятся -у для инвертирования смысла шаблона, -i для поиска без учета регистра и ~n для вывода номеров строк. Как должны выводиться номера строк?

Должны ли они печататься на той же строке, что и совпавший текст?

Упражнение 9- Добавьте в match операторы + (один или более) и ? (ноль или один). Шаблону a+bb?

соответствует строка из одного или более а, за которыми следует одно или два b.

Упражнение 9- В нашей реализации match специальное значение символов " и $ отключается, если они не стоят, соответственно, в начале или конце выражения, а звездочка * рассматривается как обычный символ, если она не стоит непосредственно после литерального символа или точки. Однако более стандартным поведением является~отключение значения метасимвола постановкой перед ним символа обратной косой черты (\). Измените match так, чтобы она обрабатывала этот символ именно таким образом.

Упражнение 9- Добавьте в match классы символов. Класс символов определяет соответствие любому из символов, заключенных в квадратные скобки. Для Удобства лучше ввести диапазоны, например [a-z] соответствует любой строчной букве (английского алфавита!), а также определить способ инвертирования смысла, — например, [~0-9] соответствует любому символу, кроме цифры.

Упражнение 9- Измените match так, чтобы в ней использовалась версия matchstar, ( которой ищется крайне левое максимальное соответствие. Кроме того, :ледует добиться возвращения позиции символов начала и конца текста, ;

оответствующего шаблону.

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

grep 'homoiousian' 'homoousian' mission.stmt Упражнение 9- Измените match и дгер так, чтобы они-работали со строками символов Unicode формата UTF-8. Поскольку UTF-8 и Unicode являются расширением набора 7 битового ASCII, такое изменение будет совместимо с предыдущей версией.

Регулярные выражения, так же как и текст, в котором происходит поиск, должны корректно работать с UTF-8. Как в этом случае должны быть реализованы классы символов?

Упражнение 9- Напишите программу для автоматического тестирования регулярных выражений:

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

Программируемые инструменты Множество инструментов группируется вокруг языков специального.'! назначения.

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

Одним из первых подобных инструментов стал командный интерпретатор, или язык управления заданиями. Достаточно быстро стало понятно, что последовательность команд можно поместить в отдельный файл, а потом запустить экземпляр интерпретатора команд, или оболочки /(shell), указав этот файл в качестве источника ввода. Следующим шагомстало уже добавление параметров, условных выражений, циклов, переменных и т. п., то есть всего того, из чего в нашем представлении состоит нормальный язык программирования. Единственное отличие заключалось в том, что существовал только один тип данных — строки, а операторами в программах-оболочках являлись различные программы, осуществлявшие порой достаточно интересные вычисления. Несмотря на то что программирование под конкретные оболочки сейчас уже уходит в прошлое, уступая место работе с инструментами типа Perl в командных средах или щелканью по кнопкам в графических средах, этот "старинный" подход по-прежнему остается простым и достаточно эффективным способом создания каких-то сложных операций из простых частей.

Еще один программируемый инструмент, Awk, представляет собой небольшой специализированный язык, который предназначен для отбора и преобразования элементов входного потока;

он ищет в этом потоке соответствия заданным шаблонам, а когда находит, то выполняет связанные с шаблонами действия. Как мы уже видели в главе 3, Awk автоматически читает входные файлы и разделяет каждую прочитанную строку на поля, обозначаемые от $1 до $NF, где NF есть количество полей в строке. Его поведение "по умолчанию" удобно для выполнения большого числа типовых действий, поэтому многие полезные программы пишутся на Awk в одну строчку. Так, например, программа # split.awk: расщепляет текст на отдельные слова { for (i = 1;

i <= NF;

i+3-) print $i } выводит "слова" по одному в строке. И напротив, вот программа f mt, которая заполняет каждую выводимую строку словами (до 60 символов);

пустая строка означает конец параграфа:

# fmt.awk: форматирует в 60-символьные строки /./ { for (i = 1;

i <= NF;

i++) addword($i) } # непустая строка /~$/ { printlineO;

print "" } # пустая строка END { printlineO } function addword(w) { if (length(line) + 1 + length(w) > 60) printlineO if (length(line) == 0) line = w else line = line " " w } function printline() { if (len'gth(line) > 0) { print line line = "." } } Мы часто используем f mt для того, чтобы заново разбить на абзацы почтовые сообщения и другие короткие документы;

ее же мы использовали главе 3 для форматирования вывода программы markov.

Программируемые инструменты часто берут свое начало от малых ыков программирования, созданных для более простого выражения шений проблем в какой-то узкой предметной области. Прелестным шмером является инструмент Unix под названием eqn, который обра-тывает математические формулы. Язык, применяемый в нем для вво-., очень похож на обычный: например, выражение f мы прочитали бы лух как "пи на два" (pi over two), и в этом языке оно так и записывается - pi over 2. Тот же подход применяется и в ТЕХ, в нем это выраже-ie было бы записано как \pi \over 2. Если для проблемы, которую вы пытаетесь разрешить, уже существует естественный или привычный :особ записи, используйте его или, на худой конец, попробуйте его дотировать: не пытайтесь писать с нуля.

Awk развился из программы, которая использовала регулярные выражения для выявления аномалий в записях телефонного трафика;

теперь же] ffk содержит в себе переменные, выражения, циклы и т. п., — и это делает о полноценным языком программирования. Perl и Tel первоначально проектировались с целью совместить удобство и выразительность малых языков с помощью полноценных языков программирования. В результате получились действительно удобные языки программирования общего назначения, хотя, конечно, чаще всего их используют для обработки текста.

Для подобных инструментов применяют общий термин — языки скриптов (scripting languages). Такое название объясняется их происхождением ранних командных интерпретаторов, вся "программируемость" которых ограничивалась исполнением заранее записанных "сценариев" cript) программ. Языки скриптов позволяют использовать регулярные выражения более творчески: не только для поиска соответствий шаблону — простого обнаружения соответствия, но и для определения участ-iB текста, которые должны быть изменены. Именно это осуществляется в двух командах regsub (от regular expression substitution — замена с помощью регулярных выражений), реализованных в приводимой ниже программе на языке Tel. Программа эта является несколько более общей формулой программы из главы 4, получающей биржевые котировки;

новая версия выполняет это, получая данные из URL, передаваемого ей в качестве первого аргумента. Первая замена удаляет строку http://, если она присутствует;

вторая — удаляет символ / и заменяет его пробелом, разбивая, таким образом, аргумент на два поля. Команда lindex получает поля из строки (начиная с индекса 0). Текст, заключенный в квадратные скобки, выполняется как команда Tel и заменяется результирующим текстом;

последовательность $х заменяется значением переменной х.

# geturl.tcl: получает документ из URL # ввод имеет вид [http://Jabc.def.com[/whatever...] # если присутствует, удалить http:// regsub "http://" $argv ''" argv ;

# в начале строки заменить символ / пробелом regsub ','/" largv " " argv ;

# выполнить сетевое соединение set so [socket [lindex $argv 0] 80] ;

set q "/[lindex $argv 1]" puts $so "GET $q HTTP/1.0\n\n" ;

# послать запрос flush $so ft пропустить заголовок while {[gets $so line] >= 0 && $line != ""} {} ;

# прочесть и вывести весь ответ puts [read $so] ;

Этот скрипт, как правило, производит весьма объемистый вывод, большую часть которого составляют тэги HTML, заключенные между < и >. Perl удобен для текстовых подстановок, так что нашим следующим инструментом станет скрипт на Perl, который использует регулярные выражения и подстановки для удаления тэгов:

# unhtmi.pl: delete HTML tags while (<>) { tt собирает весь ввод в одну строку $st г.'=$_;

# накапливая вводимые строки } $str =~ s/

]*>//g;

# удалить <...> $str =- s/

/ /g;

n заменить

пробелом $str =~ s/\s+/\n/g;

it сжать свободное место print $str:

Для тех, кто не знаком с Perl, этот код будет загадкой. Конструкция $str =" s/regexp/repl/g строке str подставляет строку repl вместо текста, соответствующего регулярному выражению readexp (берется крайне левое максимальное соответствие);

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

Последовательность метасимволов \s является сокращенным обозначением символа пустого места (пробел, знак табуляции, символ перевода строки и т. п.);

Это означает перевод строки. Строка "

" — это символ HTML, как и те, о которых мы упоминали I главе 2, он означает non-breakable space — неразрывный пробел.

Собрав все написанное воедино, мы получим совершенно идиотский, но функционирующий web-браузер, реализованный как скрипт командного интерпретатора, состоящий из одной строки:

# web: получает web-страницу и форматирует ее текст, # игнорируя HTML geturl.tcl $1 | unhtml.pl | fmt.awk Такой вызов получит web-страницу, отбросит все управление и форма-гирование и отформатирует текст по своим собственным правилам. Получился быстрый способ достать страницу текста из web.

Отметьте, что мы неспроста использовали сразу несколько Жыков (Tel, Perl, Awk) и в каждом из них — регулярные выражения. Собственно говоря, мощь различных нотаций и состоит как раз в подборе наилучшего варианта для каждой проблемы.

Teм особенно хорош для получения текста из сети, Perl и Awk прекрасно редактируют и форматируют текст, а регулярные выражения — отличный способ определения фрагментов текста, которые должны быть найдены и изменены.

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

Интерпретаторы, компиляторы и виртуальные машины Какой путь проходит программа от исходного кода до исполнения? Если язык достаточно прост, как в printf или в наших простейших регулярных выражениях, то исполняться может сам исходный код. Это несложно;

таким образом можно запускать программу сразу же по написании.

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

Программы, в которых преобразование и исполнение объединены в один процесс, читающий исходный текст, преобразующий его и его исполняющий, называются интерпретаторами (interpreter). Awk и Perl, как и большая часть других языков скриптов и языков специального назначения, являются именно интерпретаторами.

Третья возможность — генерировать инструкции для конкретного типа компьютера, на котором должна исполняться программа;

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

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

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

— провести синтаксический разбор исходного текста для определения структуры становится труднее.

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

Представление, создаваемое анализатором, — это обычно дерево, в котором внутренние вершины содержат операции, а листья — операнды. Выражение а = max(b, с/2);

может быть преобразовано в такое синтаксическое дерево:

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

После того как дерево построено, обрабатывать его можно множеством способов.

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

typedef struct Symbol Symbol;

typedef struct Tree Tree;

\j struct Symbol { int value;

char *name;

};

struct Tree { int op;

/* код операции */ int value;

' /* значение, если это число */ Symbol «symbol;

/* имя, если это переменная */ Tree *left;

Tree * right;

};

/* eval: версия 1: вычисляет выражение, заданное деревом */ int eval(Tree *t) { int left, right;

switch (t->op) { case NUMBER:

return t->value;

case VARIABLE:

return t->symbol->value;

case ADD:

return eval(t»left) + eval(t->right);

case DIVIDE:

left = eval(t->left);

right = eval(t->right);

if (right == 0).

eprintf("divide %d by zero", left);

return left / right;

case MAX:

left = eval(t->left);

right = eval(t->right);

return left>right ? left : right;

case ASSIGN:

t->left->symbol->value = eval(t->right);

return t->left->symbol->value;

/*...*/ } } Первые несколько выражений case вычисляют простые выражения вроде констант или значений;

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

Подобно тому, как мы делали в pack и unpack, здесь можно заменить явный переключатель таблицей указателей на функции. Отдельные операции при этом будут выглядеть практически так же, как и в варианте с переключателем:

/* addop: суммирует два выражения, заданных деревьями */ int addop(Tree *t) { return eval(t->left) + eval(t->right);

} Таблица указателей сопоставляет операции и функции, выполняющие операции:

enum { /* коды операций, Tree.op */ NUMBER, VARIABLE, ADD, DIVIDE, /*... */ };

/* optab: таблица функций для операций */ int (*optab[])(Tree *) = { pushop, /* NUMBER */ pushsymop, /* VARIABLE */ addop, /* ADD */ divop, /* DIVIDE */ /*... */ };

Вычисление использует операции для индексирования в таблице указанной на функции для вызова этих функций;

в этой версии другие функции вызываются рекурсивно.

/* eval: версия 2: вычисляет выражение */ /* по таблице операций */ int eval(Tree *t) { return (*optab[t->op])(t);

} Обе наши версии eval применяют рекурсию. Существуют способы устранить ее — в частности, весьма хитрый способ, называемый шитым кодом ireaded code), который практически не использует стек вызовов. Самым ящным способом избавления от рекурсии будет сохранение функций в массиве, с последующим выполнением этих функций в записанном порядке. Таким образом, этот массив становится просто последовательностью инструкций, исполняемых некоторой специальной машиной.

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

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

typedef union Code Code;

union Code { void (*op)(void);

/* если операция - то функция */ int value;

/* если число - его значение */ Symbol *symbol;

/* если переменная - то символ */ };

Ниже приведен блок кода, генерирующий указатели на функции и помещающий их в специальный массив code. Возвращаемым значением функции generate является не значение выражения — оно будет подсчитано только после выполнения сгенерированного кода, — а индекс следующей операции в массиве code, которая должна быть сгенерирована:

/* generate: обходя дерево, генерирует команды */ int generate(int codep, Tree *t) { switch (t->op) { case NUMBER:

code[codep++].op = pushop;

code[codep++].value = t->value;

return codep;

case VARIABLE:

code[codep++]. op = pushsymop;

code[codep++].symbol = t->symbol;

return codep;

case ADD:

codep = generate(codep, t->left);

codep = generate(codep, t->right);

code[codep++].op = addop;

return codep;

case DIVIDE:

codep = generate(codep, t->left);

codep = generate(codep, t->right);

code[codep++].op = divop;

return codep;

case MAX:

#... */ } } Для выражения а - max( b, с/2 ) сгенерированный код будет выглядеть так:

pushsymop b pushsymop с pushop divop maxop storesymop a Функции-операции управляют стеком, извлекая из него операнды и загружая результаты.

Код интерпретатора организован в виде цикла, в котором командный :cчетчик рс обходит массив указателей на фрикции:

Code code[NCODE];

int stack[NSTACK];

int stackp;

int pc;

/* счетчик программы */ /* eval: версия З: вычисляет выражение из */ /* сгенерированного кода */ int eval(Tree *t) { pc = generate(0, t);

code[pc].op = NULL;

stackp = 0;

pc = 0;

while (code[pc].op != NULL) (*code[pc++].op)();

return stack[0]: } Этот цикл моделирует в программном виде на изобретенной нами стековой машине то, что происходит на самом деле в настоящем компьютере:

/* pushop:

записывает число в стек;

*/ /* значение - следующее слово в потоке code */ void pushop(void) { stack[stackp++] = code[pc++].value;

} /* divop: частное двух выражений */ void divop(void) { int left, right;

right = stack[--stackp];

left = stack[--stackp];

, if (right == 0) eprintf("divide %d by zero\n", left);

stack[stackp++] = left / right;

} Обратите внимание на то, что проверка делимого на ноль осуществляется в divop, а не в generate.

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

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

Если внимательно посмотреть на файл, который создает эта процеду-, можно понять, что он выглядит как поток команд виртуальной машины. Эти команды реализуют базовые операции нашего рабочего языка, рункция generate — это компилятор, транслирующий язык на виртуальную машину. Виртуальные машины — стародавняя идея, обретшая госледнее время новую популярность благодаря Java и виртуальной шгане Java (Java Virtual Machine, JVM);

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

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

Один обычный пример дает динамическая генерация HTML для web-раниц. HTML — это язык, хоть и достаточно ограниченный;

кроме то, в себе он может содержать и код JavaScript. Web-страницы часто нерируются "на лету" программами на Perl или С, содержание таких раниц (например, результаты поиска или реклама, нацеленная на определенную аудиторию) зависит от приходящих запросов. Мы использовали специализированные языки для графиков, картинок, таблиц, математических выражений и индекса этой книги. Еще одним примером эжет служить PostScript — язык программирования, тексты на котором создаются текстовыми процессорами, программами рисования и множеством других источников;

на финальном этапе обработки книга, которую вы держите в руках, представлена как программа на PostScript, держащая 57 000 строк.

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

Наиболее распространенные программы, создающие программы, — это компиляторы, которые переводят программу с языка высокого уровня в машинный код. Однако нередко оказывается удобным переводить код программы сначала на один из широко известных языков высокого уровня. В предыдущем параграфе мы упоминали о том, что генератор синтаксического анализатора преобразует определение грамматики языка в программу на С, которая и занимается синтаксическим разбором языка. Язык С достаточно часто используется подобным образом -в качестве "языка ассемблера высокого уровня". Modula-З и C++ относятся к тем языкам общего назначения, для которых первые компиляторы создавали код на С, обрабатывавшийся затем уже стандартным компилятором. У такого подхода есть ряд преимуществ — одним из главных является эффективность, поскольку получается, что в принципе программа может выполняться так же быстро, как и программы на С. Еще один плюс — переносимость: такие компиляторы могут быть перенесены на любую систему, имеющую компилятор С. Подобный подход сильно помог этим языкам на ранних стадиях их внедрения.

В качестве еще одного примера возьмем графический интерфейс Visual Basic. Он генерирует набор операторов присваивания Visual Basic для инициализации объектов. Этот набор пользователь выбирает из меню и располагает на экране с по'мощью мыши. Во множестве других языков есть "визуальная" среда разработки и "мастера" (wizard), которые синтезируют код пользовательского интерфейса по щелчку мыши.

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

Операционная система Plan 9 генерирует сообщения об ошибках по заголовочному файлу, содержащему программные имена ошибок и комментарии к ним;

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

В приведенном ниже фрагменте показана структура такого заголовочного файла:

/* errors.h: стандартные сообщения об ошибках еnum { Eperm, /* Доступ запрещен */ Eio, /* Ошибка ввода/вывода */ Efile, /* Файл не существует */ Emem, /* Переполнение памяти */ Espace, /* Нет места для файла */ Egreg /* Гришина ошибка */ };

Имея такой фрагмент на входе, несложная программа сможет произнес* и следующий набор деклараций для сообщений об ошибках:

/* machine-generated;

do not edit. */ char *errs[] = { "Доступ запрещен", /* Eperm */ "Ошибка ввода/вывода", /* Eio */ "Файл не существует", /* Efile */ "Переполнение памяти", /* Emem */ "Нет места для файла", /* Espace */ "Гришина ошибка", /* Egreg */ };

У такого подхода есть несколько достоинств. Во-первых, соотношение лежду значениями enum и строками, которые они представляют, получается самодокументированным, и его нетрудно сделать независимым от роднoro языка пользователя. Во-вторых, информация хранится только в однтом месте, в одном "истинном месте", из которого генерируется весь эстальной код, так что и всё обновление информации выполняется лишь в одном месте. В случае, когда таких мест несколько, после ряда обновлений они неизбежно начнут друг другу противоречить. И наконец, нетрудно сделать так, чтобы файлы программ на С создавались заново и перекомпилировались при каждом изменении заголовочного файла. Когда потребуется изменить сообщение об ошибке, все, что надо будет сделать, — это изменить заголовочный файл и компилировать таким способом операционную систему, и тогда сообщения автоматически обновятся.

Программа-генератор может быть написана на любом языке. Особенно просто это сделать на языке, специально предназначенном для обработки строк, таком как Perl:

# enum.pl: генерирует строки сообщений no enurn + комментарии print "/* machine-generated;

do not edit.

*/\n\n";

print "char *errs[] = {\n";

while (<>) I # удалить перевод строки if (/~\s*(E[a-zO-9]+),?/) { » первое слово - Е... $name = $1;

* сохранить имя s/ *\\* *//;

УДалить до /* s/ *\*///;

удалить print "\t\"$_\", /'•* $name */\n";

} } print "};

\n";

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

Среди прочих способов для тестирования компилятора Энди Кёниг (Andy Koenig) разработал метод написания кода C++, позволяющий проверить, нашел ли компилятор ошибки в программе. Фрагменты кода, которые должны вызвать реакцию компилятора, снабжаются специальными комментариями, в которых описываются ожидаемые сообщения. Каждая строка такого комментария начинается с /// (чтобы их можно было отличить от обычных комментариев) и регулярного выражения, которое должно соответствовать диагностике компилятора, выдаваемой для этой строки. Таким образом, например, следующие два фрагмента кода должны вызвать реакцию компилятора:

int f() {} /// warning.* non-void function.* should return a value void g() {return 1;

} /// error.* void function may not return a value Если мы пропустим второй тест через компилятор C++, то он напечатает ожидаемое сообщение, вполне соответствующее регулярному выражению:

% СС х.с "х.с", line 1: error(321): void function may not return a value Каждый такой фрагмент кода пропускается через компилятор, и вывод сравнивается с прогнозируемой диагностикой, этим процессом управляет схемная оболочка и программа на Awk. Сбоем считается ситуация, гда вывод компилятора не совпадает с ожидаемым. Поскольку комментарии представлены регулярными выражениями, у них получается которая свобода в оценке вывода: ее можно делать более или менее эогой к отклонениям в зависимости от надобности.

Идея использования семантических комментариев не нова. Такие комментарии используются в языке PostScript, где они начинаются с символа %. Комментарии, начинающиеся с %%, могут содержать дополнительную информацию о номерах страниц, окаймляющем прямоугольнике ounding Box), именах шрифтов и т. п.:

%%PageBoundingBox:

126 307 492 768 %%Pages: %%DocumentFonts:

Helvetica Times-Italic Times-Roman LucidaSans-Typewriter В языке Java комментарии, которые начинаются с /** и заканчиваются */, пользуются для создания документации для следующего за ними опи-НЕИЯ класса. Глобальным вариантом самодокументации кода является к называемое грамотное программирование (literate programming), и котором программа и ее документация интегрируются в один документ, и при одной обработке документ готовится для чтения, а при другой программа готовится к компиляции.

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

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

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

Например, программа из главы 7, оценивающая скорость конструкций простейшего языка, использует препроцессор С для компоновки тестов, составляя из них последовательность стереотипных кодов. Суть теста — заключить фрагмент кода в цикл, который запускает таймер, выполняет этот фрагмент много раз, останавливает таймер и сообщает о результатах. Весь повторяющийся код заключен в пару макросов, а измеряемый код передается в качестве аргумента. Первичный макрос имеет такой вид:

#define LOOP(CODE) { \ tO = clock();

\ for (i=0;

i < n;

i++) { CODE;

} \ printf("%7d ", ciock() - tO);

\ } Обратная косая черта (\) позволяет записывать тело макроса в нескольких строках.

Этот макрос используется в "операторах", которые имеют такой вид:

LOOP(f1 = f2) LOOP(f1 = f2 + f3) LOOP(f1 = f2 - f3) Для инициализации иногда применяются и другие операторы, но основная часть, производящая замеры, представлена в этих одноаргументных фрагментах, которые преобразуются в значительный объем кода.

Иногда макросы могут использоваться и для генерации нормального коммерческого кода. Барт Локанти (Bart Locanthi) однажды написал эффективную версию оператора двумерной графики. Этот оператор, называемый bitblt, или rasterop, трудно было сделать быстрым, поскольку он использовал большое количество аргументов, которые могли комбинироваться самыми хитрыми способами. Проведя тщательный разбор вариантов, Локанти уменьшил комбинации до независимых циклов, которые можно было оптимизировать по отдельности. Затем каждый случай был воссоздан с помощью макроподстановки, аналогичной той, что показана в примере на тестирование производительности, и все варианты были перебраны в одном большом выражении switch. Оригинальный исходный код представлял две-три сотни строк, после выполнения макроподстановок он разрастался до многих тысяч строк.

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

Упражнение 9- В упражнении 7-7 вам предлагалось написать программу, оценивающую траты на различные операции в C++. Используя идеи, изложенные в последнем параграфе, попробуйте написать новую версию этой программы.

Упражнение 9- В упражнении 7-8 надо было построить модель оценки затрат для tva, а в этом языке нет макросов. Попробуйте решить эту проблему, написав другую программу — на любом другом языке (или языках), которая создавала бы Java-версию и автоматизировала бы запуск тестов на эоизводительность.

Компиляция "на лету" В предыдущем разделе мы говорили о программах, которые пишут программы. В каждом примере программы генерировались в виде исходного ада и, стало быть, для запуска должны были быть скомпилированы или нтерпретированы. Однако возможно сгенерировать код, который можно шускать сразу, создавая машинные инструкции, а не исходный текст, акой процесс известен как компиляция "налету" (on the fly) или "как раз :вовремя" (just in time);

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

Очевидно, что скомпилированный код по определению получается епереносимым — его можно запустить только на конкретном типе процессора, зато он может получиться весьма скоростным. Рассмотрим такое выражение:

max (b, c/2) Здесь нужно вычислить с, поделить его на 2, сравнить результат с b и вы-рать большее из значений. Если мы будем вычислять это выражение, используя виртуальную машину, которую мы в общих чертах описали в начале этой главы, то хотелось бы избежать проверки деления на ноль в divop: поскольку 2 никогда не.

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

Вот здесь-то нам и может помочь динамическая генерация кода. Если мы будем создавать непосредственно код для выражения, а не использовать предопределенные операции, мы сможем исключить проверку деления на ноль для делителей, которые заведомо отличны от нуля. На самом деле мы можем пойти еще дальше: если все выражение является константой, как, например, тах (3*3, 2/2), мы можем вычислить его единожды, при генерации кода, и заменять константой значением, в данном случае числом 9. Если такое выражение используется в цикле, то мы экономим время на его вычисление при каждом проходе цикла. При достаточно большом числе повторений цикла мы с лихвой окупим время, потраченное на дополнительный разбор выражения при генерации кода.

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

int matchchar(int literal, char *text) { return *text == literal;

} Однако, когда мы генерируем код для конкретного шаблона, значение этого literal фиксировано, например ' х ', так что мы можем вместо показанного выше сравнения использовать оператор вроде следующего:

int matchx(cbar *text) { return *text == 'x';

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

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

Кен Томпсон (Ken Thompson) именно это и сделал в 1967 году для реализации регулярных выражений на машине IBM 7094. Его версия генерировала в двоичном коде небольшие блоки команд этой машины для разных операторов выражения, сшивала их вместе и затем запускала шучившуюся программу, просто вызвав ее, совсем как обычную функцию. Схожие технологии можно применить для создания специфических юледовательностей команд для обновлений экрана в графических системах, где может быть так много различных случаев, что гораздо более эффективно создавать динамический код для каждого из них, чем расписать с все заранее или включить сложные проверки в более общем коде.

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

Итак, вспомним, в каком виде мы оставили нашу виртуальную машину, — структура ее выглядела примерно так:

Code code[NCODE];

int stack[NSTACK];

int stackp;

int pc;

/* программный счетчик */ Tree *t;

t = parse();

pc = generate(0, t);

code[pc].op = NULL;

stackp = 0;

pc = 0;

while (code[pc].op != NULL) (*code[pc++].op)();

return stack[0];

Для того чтобы адаптировать этот код для JIT-компиляции, в него [адо внести некоторые изменения. Во-первых, массив code будет теперь» re массивом указателей на функции, а массивом исполняемых команд.

Будут ли эти команды иметь тип char, int или long — зависит только от того процессора, под который мы компилируем;

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

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

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

typedef int Code;

Code code[NCODE];

int codep;

int stack[NSTACK];

int stackp;

...

Tree *t;

void (*fn)(void);

int pc;

* t = parse();

pc = generate(0, t);

genreturn(pc);

/^генерация последовательности */ /* команд для возврата из функции */ staokp = 0;

flushcaches();

/* синхронизация памяти с процессором */ fn = (void(*)(void)) code;

/* преобразование массива */ /* в указатель на функцию */ (*fn)();

/* вызов полученной функции */ return stack[0];

После того как generate завершит работу, gen return вставит команды, которые обусловят передачу управления от сгенерированного кода к eval.

Функция f lushcaches отвечает за шаги, необходимые для подготовки процессора к запуску свежесозданного кода. Современные машины работают быстро, в частности благодаря наличию кэшей для команд и данных, а также конвейеров (pipeline), которые отвечают за выполнение сразу нескольких подряд идущих команд. Эти кэши и конвейеры исходят из предположения, что код не изменяется;

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

Замечательное выражение (void (*)(void)) code преобразует адрес :ассива, содержащего сгенерированные команды, в указатель на функцию, который можно было бы использовать для вызова нашего кода.

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

Начнем с некоторых строительных блоков. Как и раньше, [ассив code и индекс внутри него заполняются во время компиляции. Для [ростоты мы повторим свой старый прием — сделаем их оба глобальными. Затем мы можем написать функцию для записи команд:

/* emit: добавляет команду к потоку кода */ void emit(Code inst) { code[codep++] = inst;

} Сами команды могут определяться макросами, зависящими от процессора, или небольшими функциями, которые собирали бы код, заполняя поля в командном слове инструкции. Гипотетически мы могли бы завести функцию pop reg, которая бы генерировала код для выталкивания значения из стека и сохраняла его в регистре процессора, и функцию push reg, которая бы генерировала код для получения значения, хранящегося в регистре процессора, и заталкивания его в стек. Наша обновленная функция addop будет использовать некие их аналоги, применяя некоторые предопределенные константы, описывающие команды (вроде ADDINST) и их расположение (различные позиции сдвигов SHIFT, которые определяют формат командного слова):

/* addop: генерирует команду ADD */ void addop(void) { Code inst;

popreg(2);

/* выборка из стека в регистр 2 */ popreg(l);

/* выборка из стека в регистр 1 */ inst = ADDINST « INSTSHIFT;

inst |= (R1) « OP1SHIFT;

inst = (R2) « OP2SHIFT;

»«m+/i.'ne4-.V /* выпопнить ADD R1, R2 */ pushreg(2);

/* загрузить значение R2 в стек */ } Это, однако, только самое начало. Если бы мы писали настоящий JIT-ком-пилятор, нам бы пришлось заняться оптимизацией. При прибавлении константы нам нет нужды грузить ее в стек, вынимать оттуда и после этого прибавлять: мы можем прибавить ее сразу. Должное внимание к подобным случаям помогает избавиться от множества излишеств. Однако даже в теперешнем своем виде функция addop будет выполняться гораздо быстрее, чем в наших более ранних версиях, поскольку различные операторы уже не сшиты воедино вызовами функций. Вместо этого код, исполняющий их, располагается теперь в памяти в виде единого блока команд, и для нас все сшивается непосредственно счетчиком команд процессора.

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

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

Между регулярным выражением и программой на C++ есть, конечно, немалая разница, но суть у них одна — это всего лишь нотации для решения проблем. При правильной нотации многие проблемы становятся гораздо более простыми. А проектирование и реализация выбранной нотации может дать массу удовольствия.

Упражнение 9- JIT-компилятор сгенерирует более быстрый код, если сможет заменить выражения, содержащие только константы, такие как тах(3*3, 4/2), их значением. Опознав такое выражение, как он должен вычислять его значение?

Упражнение 9- Как бы вы тестировали JIT-компилятор?

Дополнительная литература В книге Брайана Кернигана и Роба Пайка "The Unix Programming nvironment" (Brian Kernighan, Rob Pike. The Unix Programming nvironment. Prentice Hall, 1984) широко обсуждается инструментальный подход к программированию, который так хорошо поддерживается! Unix. В восьмой главе ее содержится полная реализация — от грамматики уасс до выполнимого кода — простого языка программирования.

Дон Кнут в своей книге "ТЕХ: The Program" (Don Knuth. TEX: The\ rogram. Addison Wesley, 1986) описывает этот сложнейший текстовый эоцессор, приводя всю программу целиком — около 13 000 строк на ascal — в стиле "грамотного программирования", при котором пояснения комбинируются с текстом программы, а для форматирования документами и выделения соответствующего кода используются соответствующие эограммы. То же самое для компилятора ANSI С проделано у Криса Фрейзера и Дэвида Хэнсона в книге "A Retargetable С Compiler" (Chris raser, David Hanson. Л Retargetable С Compiler. Addison-Wesley, 1995).

Виртуальная машина Java описана в книге Тима Линдхольма и Франк Еллина "Спецификация виртуальной Java-машины", 2-е издание fim Lindholm, Frank Yellin.

The Java Virtual Machine Specification. 2"d ed. ddison-Wesley, 1999).

Кен Томпсон (Ken Thompson) описал свой алгоритм (это один из самых первых патентов в области программного обеспечения) в статье legular Expression Search Algorithm" в журнале Communications of the CM (11, 6, p. 419-422, 1968). Работа с регулярными выражениями весьма подробно освещена в книге Джеффри Фридла "Mastering Regular xpressions" (Jeffrey Friedl. Mastering Regular Expressions. O'Reilly, 1997).

JIT-компилятор для операций двумерной графики описан в статье Эба Пайка, Барта Локанти (Bart Locanthi) и Джона Рейзера CJonn eiser) "Hardware/Software Tradeoffs for Bitmap Graphics on the Blit", опубликованной в Software — Practice and Experience (15, 2, p. 131-152, ;

bruary 1985).

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

Сэмюэль Тейлор Колридж. Воспоминания Компьютерный мир постоянно изменяется, причем во все возрастающем темпе.

Программисты вынуждены овладевать новым: новыми языками, новыми инструментами и новыми системами и, конечно, изменениями, не совместимыми со старым. Программы становятся все объемнее, интерфейсы все сложнее, а сроки все жестче.

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

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

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

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

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

Зачастую это является и хорошим подспорьем для обеспечения переносимости:

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

Далее стоит назвать эволюцию. Невозможно написать совершенную трограмму с первой попытки. Глубокое и всестороннее осознание проН элемы рождается только из сочетания размышлений и опыта;

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

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

достаточным образом;

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

Интерфейсы являются одним из камней преткновения в программировании, и аспекты, связанные с ними, проявляются во многих местах. Наиболее очевидный случай — библиотеки, но существуют еще интерфейсы между программами и между пользователями и программами. В проектировании интерфейсов идеи простоты и универсальности имеют особое значение. Делайте интерфейсы как можно более последовательными, чтобы их можно было без труда выучить и использовать;

к этому надо подойти очень тщательно. Эффективным способом является абстрагирование: представьте себе идеальный компонент, библиотеку или программу;

постарайтесь, чтобы ваш интерфейс как можно более полно соответствовал такому идеалу;

спрячьте детали реализации — от греха подальше.

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

Нотация также зачастую недооценивается;

Pages:     | 1 |   ...   | 3 | 4 || 6 |



© 2011 www.dissers.ru - «Бесплатная электронная библиотека»

Материалы этого сайта размещены для ознакомления, все права принадлежат их авторам.
Если Вы не согласны с тем, что Ваш материал размещён на этом сайте, пожалуйста, напишите нам, мы в течении 1-2 рабочих дней удалим его.