Стили и методы программирования

         

Автоматизированное преобразование таблиц переходов


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

_1 char symbol; // Чтение потока символов неявное int cnt; . . . // Инициализация _4 St1 _5 _1 St1 _2 'a'<=symbol && symbol <= 'z' _3 printf ("%c", symbol); cnt = 1; _4 St2 _5 _1 _2 //symbol <'a' && 'z'< symbol && symbol != '\n' _3 //Так как нужно печатать только слова, //действий нет _4 St1 _5 _1 _2 failure _3 // symbol == '\n' не нужно читать _4 St3 _5 _1 St2 _2 'a'<=symbol && symbol <= 'z' _3 printf ("%c", symbol); cnt++; _4 St2 _5 _1 _2 //(symbol <'a'||'z'< symbol)&&*/ symbol!='\n' _3 printf (" - %i\n", Cnt); _4 St1 _5 _1 _2 failure _3 printf (" - %i\n", Cnt); _4 St3 _5 _1 St3 _2 'a'<=symbol && symbol <= 'z' _3 printf ("%c", symbol); cnt = 1; _4 St2 _5 _1 _2 //symbol <'a' && 'z'< symbol && symbol != '\n' _3 //Так как нужно печатать только слова, //действий нет _4 St1 _5 _1 _2 failure _3 // symbol == '\n' не нужно читать _4 exit_5 _1 exit_2 // условие истина, но без неявного чтения потока _3 return 0; _4 _5

Листинг 10.3.1. Программа в виде размеченного текста.

Здесь <_i>, i = 1, . . . , 5 обозначают позиции таблицы. Эти сочетания символов, нигде в обычной программе не встречающиеся, легко могут распознаваться препроцессором. Размещаемые между ними последовательности символов разносятся по соответствующим полям нужной структуры, которая, в зависимости от выбранной стратегии, интерпретируется либо транслируется. С помощью этих сочетаний осуществляется разметка текста, которая дает возможность распознавать и осмысливать его фрагменты.
Стоит обратить внимание на то, что за счет специального взаимного расположения символов в данном тексте представляемая им таблица автомата вполне обозрима. Если нет более подходящего представления, то данную структуру можно рекомендовать для ввода.

Однако непосредственная интерпретация универсального текстового размеченного представления довольно затруднительна. Предпочтительнее, чтобы единицами интерпретации были бы сами поля таблицы. Вообще, этому противоречит наличие в таблице полей, значения которых - фрагменты исполняемого кода на языке программирования (такая запись удобна для человека, так как для заполнения таблицы не приходится прибегать ни к каким дополнительным соглашениям). На самом деле противоречие возникает только для поля действий, поскольку последовательности символов между <_2> и <_3> имеют ясно выраженную семантику: это проверка условия. Если всегда рассматривать условия как проверку того, чему равен входной символ, то вполне понятны, легко задаются, распознаются и интерпретируются специальные обозначения: перечисления значений, их диапазоны и т.д. Трактовка этих обозначений не вызывает осложнений, а потому подобные приемы кодирования применяются довольно часто.

Если говорить о полях действий, то их представление для большинства языков программирования плохо разработано. Данное обстоятельство препятствует использованию автоматного программирования: кодирование действий усложняет обработку. Но если в языке можно задавать подпрограммы как данные, то описание структуры таблицы, которое будет согласовано с дальнейшей трансляцией или интерпретацией, возможно, в частности, в языках функционального программирования (семейство LISP и ML) и в языке Prolog. Но функциональное и сентенциальное программирование хорошо интерпретируют лишь частные случаи таблиц.

Пусть тип T_StructTableLine описывает структуру одной строки таблицы, а вся таблица представлена в виде массива Table таких структур. Пусть далее iSt - переменная, используемая для индексирования массива Table, некоторые из ее значений представляют состояния конечного автомата (но не все, а только те, к которым определен переход!).


Наконец, пусть

int handler ( int );

-обработчик строки (ее интерпретатор или транслятор), <понимающий> структуру T_StructTableLine, специфицируемый как функция, при выполнении которой должно быть определено очередное значение iSt. Значение iSt, не указывающее ни на какую строку таблицы, служит сигналом для завершения обработки (при желании можно заняться кодированием того, как завершилась обработка с помощью задания различных таких значений).

Тогда схема программы, которая оперирует с таблицей с помощью функции handler, может быть задана следующим циклом:

iSt = 0; do { iSt = handler ( iSt ); } while ( OUTSIDE ( iSt ) );

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

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

  1. Извлечь значение Table[iSt];
  2. Активизировать вычисление условия обрабатываемой строки таблицы;
  3. Если условие истинно, то

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


  4. Если условие ложно, то

    • iSt++;
    • Перейти к 1;




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

Таким образом, нужно научиться описывать структуру строки таблицы. На языке C++/С# эта задача может решаться довольно просто:



struct T_StructTableLine { // поле для имени состояния избыточно int (*condition)(); // поле условия void (*action)(); // поле действия int ref; // поле перехода: индекс строки таблицы, // которая будет обрабатываться следующей, // или признак завершения процесса }



Сама таблица описывается следующим образом:

T_StructTableLine Table[]

или

T_StructTableLine Table [Размер таблицы]

если значения строк задаются вне этого описания.

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

int Cond_St1_1() - условие в Table[1]

и

void Act_St1_1() - действие в Table[1] (строка 1 состояния St1);

int Cond_St1_2() - условие в Table[2]

и

void Act_St1_2() - действие в Table[2] (строка 2 состояния St1);

и т. д.

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

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

Table[]= { {{return true;}, /* инициализация */, 1}, /*St1*/{{return 'a'<=symbol && symbol <= 'z';}, {printf ("%c", symbol); cnt = 1;}, 4}, {{return symbol != '\n';}, /* Так как нужно печатать только слова, действия не заполняются */, 1}, {{return true}, /* Переход не требует чтения, symbol == '\n' не нужно читать */, 0}, /*St2*/{{return 'a'<=symbol && symbol <= 'z';}, {printf ("%c", symbol); cnt++;}, 4}, {{return symbol!='\n';}, {printf ("-%i\n",cnt);}, 1}, {{return true }, {printf ("- %i\n",cnt);}, 0} };



Листинг 10.3.2.

Но для С/С++/C#, а также для других языков, не ориентированных на работу с таблицами, говорить о естественности представления не приходится. По-видимому, наиболее разумно строить автоматический преобразователь (типа препроцессора, но не путать этот термин с С!), который составляет текст программы по таблице. В этом случае снимается много проблем:

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


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

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

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

  • Когда читать? Поскольку прочитанный символ используется после его распознавания, естественно "не терять" его, пока выполняется действие.


    Следовательно, читать очередной символ целесообразно после отработки действия.
  • Как поступать в начале процесса: должен ли быть прочитан символ перед первым вызовом функции handler? Понятно, что чтение символов является частью функциональности этой функции. Если в ней реализовать чтение до вызовов условия и действия, то это противоречит предыдущему соглашению, а противоположное решение делает нестандартным начало процесса. В предлагаемой программе принято решение об особой обработке нулевой строки. Поскольку, являясь инициализацией, она действительно особая, это соответствует сделанному соглашению.
  • Как поступать в конце процесса? Чтение заключительного символа ('\n') должно прекращать возможные попытки последующего обращения к чтению. Следовательно, необходимо позаботиться о завершающих переходах. В предлагаемой программе принято решение о завершающем переходе к нулевой строке, которая, согласно предыдущему, является нестандартной.


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

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

#include <stdio.h> char symbol; // переменная для чтения потока символов int cnt; // переменная для счета длин слов

int c0(), c1(), c2(); // функции-условия void a0(), a1(), a2(), a3(); // функции-действия int handler ( int i ); // обработчик строк таблицы struct T_StructTableLine { // поле для имени состояния избыточно int (*condition)(); // поле условия void (*action)(); // поле действия int ref; // поле перехода: индекс строки таблицы, // которая будет обрабатываться следующей, // или признак завершения процесса } T_StructTableLine Table[]={ {c0,a0,1}, // таблица инициализируется статически, {c1,a1,2}, // альтернативное решение - специальная {c2,a0,1}, // функция задания начальных значений. {c0,a0,3}, // Оно более гибкое, но менее {c1,a2,2}, // эффективно. {c2,a3,1}, // О наглядности см.комментарий {c0,a3,3}; // в тексте. {c1,a2,2}, {c2,a3,1}, {c0,a3,0}}; void main() { int iSt=0; do { iSt = handler ( iSt ); } while ( iSt); }

int handler ( int i ) { if (Table[i].condition()) { Table[i].action(); if (Table[i].ref) { symbol = getchar (); return Table[i].ref; } } else return ++i; }

// Описания используемых функций: int c0(){return symbol == '\n';} int c1(){return 'a'<=symbol && symbol <= 'z';} int c2(){return 'a'>symbol ||symbol > 'z';}

void a0(){} void a1(){printf ("%c", symbol);cnt = 1;} void a2(){printf ("%c", symbol);cnt++;} void a3(){printf ("- %i\n", cnt);}

Листинг 10.3.3. Длины слов: интерпретатор конечного автомата.


Содержание раздела