Зубенко, Омельчук - Програмування. Поглиблений курс
.pdf
Розділ IV. АЛГОРИТМИ
if(!*prog) return; /*кінець виразу*/ while(isspace(*prog)) ++prog; /*пропустити пробіли,
символи табуляції й порожнього рядка*/
if(strchr("+-*/%^=()", *prog)){ tokType=DELIMITER;
/*перейти до наступного символу*/ *temp++ = *prog++;
}
else if(isalpha(*prog)) { while(!isdelim(*prog)) *temp++ = *prog++; tokType=VARIABLE;
}
else if(isdigit(*prog)) { while(!isdelim(*prog)) *temp++ = *prog++; tokType=NUMBER;
}
*temp='\0';
}
/*Повернення значення ІСТИНА, якщо символ с є роздільником*/ int CSimpleParser::isdelim(char c)
{
if(strchr("+-/*%^=()", c)||c==9||c=='\r'||c==0) return 1;
return 0;
}
Розглянемо наведені вище методи докладніше. Після кількох ініціалі- зацій метод getToken() перевіряє, чи не досягнутий символ кінця ряд- ка ('\0'), який завершує вираз. Якщо у виразі ще є нерозібрана части- на, то метод getToken() спочатку пропускає пробіли, якщо вони є. Піс- ля цього prog указує на число, змінну, операцію або, якщо вираз заве- ршується пробілами, – то на символ кінця рядка ('\0'). Якщо черговий символ є операцією, то він повертається у вигляді рядка, збереженого в глобальній змінній token, а змінній tokType, що містить тип отрима- ної лексеми, присвоюється значення DELIMITER. Якщо ж наступний си- мвол є літерою, то він вважається іменем змінної й повертається в ря- дковій змінній token. При цьому tokType одержує значення VARIABLE. У випадку, коли черговий символ є цифрою, зчитується все число, при- чому воно міститься в змінній token, а його типом є NUMBER. Нарешті, якщо наступний символ не є жодним із перерахованих вище, то вва-
551
ПРОГРАМУВАННЯ
жається, що досягнутий кінець виразу. У цьому випадку token містить порожній рядок, повернення якого означає кінець виразу.
Як уже було сказано раніше, щоб не ускладнювати код цього мето- ду, були опущені деякі засоби контролю за помилками та зроблені де- які припущення. Наприклад, будь-який нерозпізнаний символ завер- шує вираз. Крім того, у даній версії програми імена змінних можуть мати будь-яку довжину, але значущою є тільки перша літера. Відпо- відно до вимог конкретного завдання можна ускладнити засоби кон- тролю за помилками й додати інші подробиці.
Для кращого розуміння принципу дії методу getToken() нижче на- ведені лексеми, що повертаються ним, і типи лексем для вхідного ви-
разу А * В–(W+10):
Лексема |
Тип лексеми |
|
A |
|
VARIABLE |
* |
|
DELIMITER |
B |
|
VARIABLE |
- |
|
DELIMITER |
( |
|
DELIMITER |
W |
|
VARIABLE |
+ |
|
DELIMITER |
10 |
|
NUMBER |
) |
|
DELIMITER |
0 |
(кінець рядка) |
0(нуль) |
Варто пам'ятати, що атрибут token завжди містить рядок, який за- вершується символом кінця рядка ('\0'), навіть якщо цей рядок скла-
дається тільки з одного символу ■ Після того, як у результаті роботи лексичного аналізу лексеми роз-
пізнано, інформація про них збирається й запам'ятовується в одній із кількох таблиць (таблиці ідентифікаторів). Зміст цієї інформації зале- жить від мови (у даному випадку – мови арифметичних виразів).
Таблиця ідентифікаторів (змінних) забезпечує можливість швидко-
го додавання нових ідентифікаторів, нових відомостей про них і швидкого пошуку інформації для даного ідентифікатора.
У нашому випадку для спрощення вважатимемо, що застосовують- ся лише однолітерні змінні латинського алфавіту без урахування регі- стра. Тому нам потрібна така таблиця ідентифікаторів, де кожна змінна зберігається в одному елементі масиву з 26 компонентами ти- пу double (у складнішому випадку для реалізації таблиці застосовують інші методи – зазвичай це хеш-таблиці). Отже, у вихідний текст ана- лізатора додамо такий фрагмент:
552
Розділ IV. АЛГОРИТМИ
double vars[26]; /*26 змінних, A-Z*/
Ці змінні первісно ініціалізуються 0.0 у конструкторі класу
CSimpleParser.
Крім цього, знадобиться метод для одержання значення заданої змін- ної. Оскільки імена змінних є літерами від А до Z, то їх можна викорис- тати для індексації масиву vars, віднімаючи код ASCII літери А від імені змінної. Нижче описано метод findVar(), що повертає значення змінної:
/*Одержання значення змінної*/
double CSimpleParser::findVar(char *s)
{
if(!isalpha(*s)){
serror(1); return 0.0;
}
return vars[toupper(*token)-'A'];
}
Цей метод приймає імена будь-якої довжини, але тільки перший символ є значущим. Дане обмеження за потреби можна змінити.
Розглянемо метод atom(), що обробляє числа та змінні:
/*Одержання значення числа або змінної*/ void CSimpleParser::atom(double *answer)
{
switch(tokType) { case VARIABLE:
*answer=findVar(token);
getToken();
return; case NUMBER:
*answer=atof(token);
getToken();
return;
default:
serror(0);
}
}
4.5.2. СИНТАКСИЧНИЙ АНАЛІЗ
Синтаксичний аналіз – це процес, в якому досліджується низка ле- ксем і встановлюється, чи відповідає вона граматичним правилам, що описують синтаксис мови. На виході парсер видає синтаксичне дерево й таблиці (ідентифікаторів, типів).
553
ПРОГРАМУВАННЯ
Зазвичай фази лексичного й синтаксичного аналізу взаємодіють між собою в межах одного проходження програми. При цьому основ- ною є фаза синтаксичного аналізу, який, у свою чергу, звертається до лексичного аналізу за черговим термінальним символом.
Теоретичні відомості про КВ- та LL(k)-граматики, побудову множи- ни FIRSTk (w) можна знайти в підрозд. 1.4.4.
Більшість відомих методів аналізу належать до одного з двох кла- сів, один з яких об'єднує низхідні, а інший – висхідні алгоритми. Похо- дження цих термінів пов'язане з тим, як утворюються вузли синтак- сичного дерева: від аксіом граматики до термінальних символів (низ- хідні парсери) чи від термінальних символів до аксіом граматики (ви-
східні парсери).
Знизхідними парсерами пов'язані LL-граматики, які можуть бути проаналізовані достатньо простим для реалізації методом рекурсив- ного спуску.
Звисхідними парсерами пов'язані LR(1)-граматики [7], що задають ширший клас мов (за їхньою допомогою визначається синтаксис більшо- сті мов програмування). Для них розроблені спеціальні програми автома- тичної побудови парсерів. Найвідоміші з них – утиліти Lex та Yacc [144].
Lex – програма для генерації лексичних аналізаторів. Вона є стан- дартним генератором лексичних аналізаторів у ОС Unix. Lex зчитує вхідний потік, що описує лексичний аналізатор, і повертає на виході код аналізатора мовою програмування C.
Yacc (від англ. Yet Another Compiler Compiler – ще один компілятор компіляторів) – програма для генерації парсерів у Unix-системах. Розро- блена С. Джонсоном у компанії AT&T. Вона отримує на вхід LR(1)- граматику, описану в нотації БНФ, і генерує код парсера мовою про- грамування C. Пізніше програма була переписана іншими мовами, та-
кими як Ada, Java, C# тощо. Існує Стандарт IEEE POSIX P1003.2, що визначає як функціональність, так і вимоги до програм типу Lex i Yacc.
4.5.3. ОБЧИСЛЕННЯ ВИРАЗІВ
Розглянемо складні арифметичні вирази, що описуються КВ-граматикою G (див. підрозд. 1.4.4), і на їхньому прикладі покажемо, як будується парсер і обчислюються значення виразів. Вираз потрібно спочатку проаналізувати й отримати синтаксичне дерево, потім шляхом рекурсивного обходу цього дерева обчислюється значення виразу.
Спочатку звернемося до граматики G1, що є частиною граматики G і задає в ній вирази без присвоювань. Граматика G1 є LL(1)-
554
Розділ IV. АЛГОРИТМИ
граматикою. Для синтаксичного аналізу за такою граматикою може бути застосований метод рекурсивного спуску (без повернень).
Побудуємо для граматики G1 парсер і програму для обчислення зна- чень виразів. Розглянемо спочатку загальний алгоритм і окремі мето- ди, застосовані в ньому.
Щоб зрозуміти, як саме програма обчислює вираз, розглянемо вираз 7
– 2 * 3. Припустимо, що покажчик prog указує на початок виразу.
При виклику методу evalExp() – вхідної точки програми – зі вхідно- го рядка вибирається лексема. Якщо лексема є порожнім рядком, то метод друкує повідомлення "Немає виразу" і завершується. Однак у нашому випадку лексемою є число 7. Оскільки це непорожній рядок, то викликається метод evalExp1() (аналіз: <вираз> → <доданок> <опе- рація1> <доданок>). Потім метод evalExp1() викликає evalExp2() (ана-
ліз: <доданок> → <множник> <операція2> <множник>); evalExp2() викликає evalExp3() (аналіз: <множник> → <степінь> <операція3> <множник>); той, у свою чергу, – evalExp4() (аналіз: <степінь> → <операція1> (<вираз>)). Потім метод evalExp4() перевіряє, чи не є лек- сема унарним плюсом або мінусом. У розглядуваному випадку це не так, тому викликається метод evalExp5() (аналіз: <степінь> → (<ви- раз>)| <змінна> | <число>). У цей момент evalExp5() може рекурсивно викликати або evalExp1() (у випадку виразу, вкладеного в дужки), або atom(), щоб визначити значення числа. Оскільки лексема не є ві- дкриваючою дужкою, то виконується метод atom() і змінній *answer присвоюється значення 7. Потім відбувається перехід до наступної лексеми й повернення з ланцюжка викликів методів. Лексемою стає операція –, а керування повертається методу evalExp1().
Те, що відбувається далі, дуже важливо. Оскільки поточною лексе- мою є символ -, то він зберігається у змінній ор. Потім аналізатор ви- бирає наступну лексему, і спуск по ланцюжку починається знов. Як і раніше, викликається метод atom(). Отримане значення 2 поверта- ється змінною *answer, і зчитується лексема *. Це викликає повернен- ня по ланцюжку до evalExp2(), де зчитується остання лексема 3. У цей момент відбувається перша арифметична операція – множення 2 на 3. Отриманий результат повертається методу evalExp1(), де від- бувається віднімання. У результаті віднімання отримуємо 1.
Повернемося тепер до граматики G, що задає складні арифмети- чні вирази.
Побудуємо значення функції FIRST1 для правих частин правил цієї граматики:
555
ПРОГРАМУВАННЯ
FIRST1(<присвоювання>) = {a, …, z, A, …, Z}
FIRST1(<вираз>) = FIRST1(<доданок> [<операція1> <доданок>]) = {+, -, (, a, …, z, A, …, Z, 0, …9}
FIRST1(<множник > [<операція2> <множник>]) = {+, -, (, a, …, z, A, …, Z, 0, …9}
FIRST1((<вираз>)) = { (} FIRST1(<змінна>) = {a, …, z, A, …, Z} FIRST1(<число>) = {0, …, 9}.
Очевидно, що граматика G не є LL(1)-граматикою. Це випливає з того, що перетин множин
FIRST1 (<присвоювання>)∩FIRST1(<вираз>) = {a, …, z, A, …, Z}
непорожній, отже, для неї метод рекурсивного спуску не буде працювати. Існують алгоритми, що перетворюють неLL(1)- на LL(1)-граматики.
Проте вони достатньо складні, тому для синтаксичного аналізу за неLL(1)-граматикою зручніше скористатися методом рекурсивного спуску з поверненням. При цьому лексичний аналізатор матиме дода- тковий метод, який у всіх неоднозначних ситуаціях перед початком аналізу запам'ятовує поточний стан лексичного аналізатора й намага- ється продовжити виведення за першою з можливих альтернатив. Якщо альтернатива невірна, то повертається до запам'ятованого ста- ну й переглядає наступну. Якщо всі варіанти продовження виведення завершилися невдачею, то повертається повідомлення про помилку.
З технічного погляду це все, що потрібно аналізатору для коректної обробки змінних. Однак поки немає способу присвоїти змінним зна- чення. Часто це робиться за межами аналізатора, але в аналізаторі можна розглядати знак рівності як знак операції присвоювання й обробляти його частиною аналізатора. Цього можна досягти кількома способами. Один із них – додати в аналізатор метод evalExp6() (ана-
ліз: <присвоювання>→ <змінна>=<вираз>).
Приклад 4.32. Синтаксичний аналіз:
Лістинг.
/*Обробка присвоювання*/
void CSimpleParser::evalExp6(double *answer)
{
int slot;
char ttokType; char tempToken[80];
if(tokType==VARIABLE) {
/*зберегти стару лексему*/
556
Розділ IV. АЛГОРИТМИ
strcpy(tempToken, token); ttokType=tokType; /*обчислити індекс змінної*/ slot=toupper(*token) – 'A';
getToken();
if(*token != '=') {
putback(); /*повернути поточну лексему*/
/*відновити стару лексему – це не присвоювання*/ strcpy(token, tempToken);
tokType=ttokType;
}
else {
getToken(); /*одержати наступну частину виразу*/ evalExp1(answer);
vars[slot]=*answer;
return;
}
}
evalExp1(answer);
}
У цьому методі доводиться заглядати вперед, щоб визначити, чи є насправді присвоювання. Це пов'язано з тим, що ім'я змінної завжди перебуває перед оператором присвоювання, але наявність імені змін- ної не гарантує, що за нею піде присвоювання. Інакше кажучи, ана- лізатор сприйме вираз А=100 як присвоювання, причому він може визначити, що А/10 ним не є. Для цього метод evalExp6() зчитує зі вхідного потоку наступну лексему. Якщо ця лексема не є знаком рів- ності, то вона за допомогою методу putback() повертається у вхідний потік для наступного використання:
/*Повернення лексеми у вхідний потік*/ void CSimpleParser::putback(void)
{
char *t;
t=token;
for(; *t; t++) prog-і;
}
■
Приклад 4.33. Повний текст програми обчислення складних виразів:
Лістинг.
557
ПРОГРАМУВАННЯ
/*Даний модуль містить рекурсивний спадний парсер, що розпізнає змінні*/
#include <iostream> #include <fstream> #include <string>
#define DELIMITER 1
#define |
VARIABLE |
2 |
#define |
NUMBER |
3 |
using namespace std;
class CSimpleParser { public:
CSimpleParser()
{
prog=new (char[100]);//[100]; if(!prog) {
printf("Помилка при виділенні пам'яті.\n"); exit(1);
};
for(int i=0; i<26; i++) vars[i]=0.0;
};
void evalExp(double *answer);
char *prog; /*містить вираз, що аналізується*/
private:
void setProg(char *pr)
{
prog=pr;
};
void setToken(char tok[80])
{
strcpy(token,tok);
};
void evalExp1(double *answer); void evalExp2(double *answer); void evalExp3(double *answer); void evalExp4(double *answer); void evalExp5(double *answer); void evalExp6(double *result); void atom(double *answer); void getToken();
558
Розділ IV. АЛГОРИТМИ
void putback();
void serror(int error); int isdelim(char c);
/*Одержання значення змінної*/ double findVar(char *s);
char token[80]; char tokType;
double vars[26]; /*26 змінних, A-Z*/
};
/*Обробка присвоювання*/
void CSimpleParser::evalExp6(double *answer)
{
int slot; char ttokType;
char tempToken[80];
if(tokType==VARIABLE) { /*зберегти стару лексему*/ strcpy(tempToken, token); ttokType=tokType; /*обчислити індекс змінної*/ slot=toupper(*token) – 'A'; getToken();
if(*token != '=') {
putback(); /*повернути поточну лексему*/ /*відновити стару лексему – це не присвоювання*/ strcpy(token, tempToken);
tokType=ttokType;
}
else {
getToken(); /*одержати наступну частину виразу*/ evalExp1(answer);
vars[slot]=*answer;
return;
}
}
evalExp1(answer);
}
/*Одержання значення змінної*/
double CSimpleParser::findVar(char *s)
{
if(!isalpha(*s)){
serror(1);
559
ПРОГРАМУВАННЯ
return 0.0;
}
return vars[toupper(*token)-'A'];
}
/*Точка входження аналізатора*/
void CSimpleParser::evalExp(double *answer) /*точка входження аналізатора*/
{
getToken(); if(!*token) { serror(2); return;
}
evalExp6(answer);
if(*token) serror(0); /*остання лексема має бути нулем*/
}
/*Додавання або віднімання двох доданків*/ void CSimpleParser::evalExp1(double *answer)
{
double temp; register char op;
evalExp2(answer); while((op=*token)=='-'||op=='+') {
getToken();
CSimpleParser(token);
evalExp2(&temp); switch(op) {
case '+': *answer=*answer+temp; break;
case '-':
*answer=*answer – temp; break;
}
}
}
/*Множення або ділення двох множників*/ void CSimpleParser::evalExp2(double *answer)
{
double temp; register char op;
evalExp3(answer);
560
