Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
kernigan_paik.doc
Скачиваний:
0
Добавлен:
01.07.2025
Размер:
2.91 Mб
Скачать

9.4. Интерпретаторы, компиляторы и виртуальные машины

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

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

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

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

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

Синтаксические анализаторы часто создаются с помощью специаль­ных автоматических генераторов, называемых также компиляторами-компиляторов (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

Соседние файлы в предмете [НЕСОРТИРОВАННОЕ]