- •1. Стиль 10
- •3. Проектирование и реализация 63
- •4. Интерфейсы 85
- •5. Отладка 115
- •6. Тестирование 134
- •7. Производительность 157
- •8. Переносимость 180
- •9. Нотация 203
- •Введение
- •Брайан в. Керниган
- •1.1. Имена
- •1.2. Выражения
- •Упражнение 1 -6
- •1.3. Стилевое единство и идиомы
- •1.4. Макрофункции
- •1.5. Загадочные числа
- •1.6. Комментарии
- •1.7. Стоит ли так беспокоиться?
- •Дополнительная литература
- •2.1. Поиск
- •2.2. Сортировка
- •2.3. Библиотеки
- •2.4. Быстрая сортировка на языке Java
- •2.5. "О большое"
- •2.6. Динамически расширяемые массивы
- •2.7. Списки
- •Упражнение 2-8
- •2.8. Деревья
- •Упражнение 2-15
- •2.10. Заключение
- •Дополнительная литература
- •Проектирование и реализация
- •3.1. Алгоритм цепей Маркова
- •3.2. Варианты структуры данных
- •3.3. Создание структуры данных в языке с
- •3.4. Генерация вывода
- •3.5.Java
- •Into the air. When water goes into the air it
- •3.7. Awk и Perl
- •3.8. Производительность
- •3.9. Уроки
- •Дополнительная литература
- •4. Интерфейсы
- •4.1. Значения, разделенные запятой
- •4.2. Прототип библиотеки
- •4.3. Библиотека для распространения
- •Упражнение 4-4
- •4.5 Принципы интерфейса
- •4.6. Управление ресурсами
- •4.7. Abort, Retry, Fail?
- •4.8. Пользовательские интерфейсы
- •Дополнительная литература
- •5. Отладка
- •5.1. Отладчики
- •5.2. Хорошие подсказки, простые ошибки
- •5.3, Трудные ошибки, нет зацепок
- •5.4. Последняя надежда
- •5.5. Невоспроизводимые ошибки
- •5.6. Средства отладки
- •5.7. Чужие ошибки
- •5.8. Заключение
- •Дополнительная литература
- •6. Тестирование
- •6.1. Тестируйте при написании кода
- •6.2. Систематическое тестирование
- •6.3. Автоматизация тестирования
- •6.4. Тестовые оснастки
- •6.5. Стрессовое тестирование
- •6.6. Полезные советы
- •6.7. Кто осуществляет тестирование?
- •6.8. Тестирование программы markov
- •6.9. Заключение
- •Дополнительная литература
- •7.Производительность
- •7.1. Узкое место
- •7.2. Замеры времени и профилирование
- •7.3. Стратегии ускорения
- •7.4. Настройка кода
- •7.5. Эффективное использование памяти
- •7.6. Предварительная оценка
- •7.7. Заключение
- •Дополнительная литература
- •8. Переносимость
- •8.1. Язык
- •8.2. Заголовочные файлы и библиотеки
- •8.3. Организация программы
- •8.4. Изоляция
- •8.5. Обмен данными
- •8.6. Порядок байтов
- •8.7. Переносимость и внесение усовершенствований
- •8.8. Интернационализация
- •8.9. Заключение
- •Дополнительная литература
- •9.1. Форматирование данных
- •9.2. Регулярные выражения
- •Упражнение 9-12
- •9.3. Программируемые инструменты
- •9.4. Интерпретаторы, компиляторы и виртуальные машины
- •9.5. Программы, которые пишут программы
- •9.6. Использование макросов для генерации кода
- •9.7. Компиляция "налету"
- •Дополнительная литература
- •Интерфейсы
- •Отладка
- •Тестирование
- •Производительность
- •Переносимость
9.4. Интерпретаторы, компиляторы и виртуальные машины
Какой путь проходит программа от исходного кода до исполнения? Если язык достаточно прост, как в printf или в наших простейших регулярных выражениях, то исполняться может сам исходный код. Это несложно; таким образом, можно запускать программу сразу же по написании.
Нужно искать компромисс между временем на подготовку к запуску и скоростью исполнения. Если язык не слишком прост, то для исполнения желательно преобразовать исходный код в некое приемлемое и эффективное внутреннее представление. На это начальное преобразование исходного кода тратится определенное время, но оно вполне окупается более быстрым исполнением. Программы, в которых преобразование и исполнение объединены в один процесс, читающий исходный текст, преобразующий его и его исполняющий, называются интерпретаторами (interpreter). Awk и Perl, как и большая часть других языков скриптов и языков специального назначения, являются именно интерпретаторами.
Третья возможность — генерировать инструкции для конкретного типа компьютера, на котором должна исполняться программа; этим занимаются компиляторы. Такой подход требует наибольших затрат времени на подготовку, однако в результате ведет и к наиболее быстрому последующему исполнению.
Существуют и другие комбинации. Одна из них — мы рассмотрим ее подробно в данном разделе — это компиляция программы в инструкции для воображаемого компьютера (виртуальной машины — virtual machine), который можно имитировать на любом реальном компьютере. Виртуальная машина сочетает в себе многие преимущества обычной интерпретации и компиляции.
Если язык прост, то какой-то особо большой обработки для определения структуры программы и преобразования ее во внутреннюю форму не требуется. Однако при наличии в языке каких-то сложных элементов — определений, вложенных структур, рекурсивно определяемых выражений, операторов с различным приоритетом и т. п. — провести синтаксический разбор исходного текста для определения структуры становится труднее.
Синтаксические анализаторы часто создаются с помощью специальных автоматических генераторов, называемых также компиляторами-компиляторов (compiler-compiler), таких как уасс или bison. Подобные программы переводят описание языка, называемое его грамматикой, как правило, в программу на С или C++, которая, будучи однажды скомпилирована, переводит выражения языка во внутреннее представление. Конечно же, генерация синтаксического анализатора непосредственно из грамматики языка является еще одной впечатляющей демонстрацией мощи хорошей нотации.
Представление, создаваемое анализатором, — это обычно дерево, в котором внутренние вершины содержат операции, а листья — операнды. Выражение
а = max(b, с/2);
может быть преобразовано в такое синтаксическое дерево:
Многие из алгоритмов работы с деревьями, описанных в главе 2, вполне могут быть применимы для создания и обработки синтаксических деревьев.
После того как дерево построено, обрабатывать его можно множеством способов. Наиболее прямолинейный метод, применяемый, кстати, в Awk, — это прямой обход дерева с попутным вычислением узлов. Упрощенная версия алгоритма такого вычисления для языка, основанного на целочисленных выражениях, может включать в себя восходящий обход типа такого:
typedef struct Symbol Symbol;
typedef struct Tree Tree;
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 { /* коды операций, Тгее.ор */
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 применяют рекурсию. Существуют способы устранить ее — в частности, весьма хитрый способ, называемый шитым кодом (threaded 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;
rade[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
2
divop
maxop
storesymop
a
Функции-операции управляют стеком, извлекая из него операнды и загружая результаты.
Код интерпретатора организован в виде цикла, в котором командный счетчик рс обходит массив указателей на функции:
Code code[NCODE];
int stack[NSTACK];
int stackp;
int рс; /* счетчик программы */
/* eval: версия 3: вычисляет выражение из */
/* сгенерированного кода */
int eval(Tree *t)
{
рс = 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, 1001 для pushop и т. д., и переводить их обратно в указатели на функции при чтении программы для интерпретации.
Если внимательно посмотреть на файл, который создает эта процедура, можно понять, что он выглядит как поток команд виртуальной машины. Эти команды реализуют базовые операции нашего рабочего языка, а функция generate — это компилятор, транслирующий язык на виртуальную машину. Виртуальные машины — стародавняя идея, обретшая в последнее время новую популярность благодаря Java и виртуальной машине Java (Java Virtual Machine, JVM); виртуальные машины предоставляют простой способ создавать переносимые и эффективные реализации программ, написанных на языках высокого уровня.18
