Warsztat.GDCompo!ProjektyMediaArtykułyQ&AForumOferty pracyPobieranie

Opisz napotkaną sytuację, a redakcja niezwłocznie znajdzie rozwiązanie!

wyślij anuluj

Własny język skryptowy, część 2

Tekst został importowany z Warsztatowych artykułów. Jego oryginalnym autorem jest Złośliwiec. Jeżeli został importowany poprawnie, usuń ten szablon!

Wstęp

W poprzedniej części tego artykułu stworzyliśmy pierwszą, dość prymitywną wersję naszego języka skryptowego. Można w nim było tylko wydawać proste polecenia z parametrami. Skrypt był parsowany, interpretowany i zapamiętywany w tablicy, którą można było używać wielokrotnie do różnych celów (np. sprawdzenie poprawności skryptu, wyświetlenie, wykonanie). Częściowo poprawność skryptu była sprawdzana już na poziomie parsingu (przede wszystkim były wówczas wykrywane nieznane polecenia). Dodatkowo mieliśmy możliwość umieszczania w tekście skryptu komentarzy.

Teraz rozbudujemy nieco nasz język. Będzie w nim można przede wszystkim deklarować zmienne, których wartość dałoby się ustawiać w dowolnym momencie wykonywania skryptu. Następnie dodamy proste instrukcje warunkowe, procedury, a wreszcie - instrukcję skoku (wyklęte goto ;-)), dającą nam większą kontrolę nad skryptem.

Zmienne

Deklarację i inicjalizację zmiennej w naszym języku skryptowym wyobrażamy sobie tak:

// zmienna
var zm 0

Natomiast przypisanie zmiennej wartości:

// zmiana wartości zmiennej
zm = 666

Przy tak prostej składni zaimplementowanie tego nowego elementu języka nie będzie zbyt ciężkim zadaniem. Przede wszystkim będziemy potrzebować nowej struktury, opisującej zmienną:

struct VARIABLE { string name; int value; };

Jak widać, struktura nie ma pola przechowującego informację o typie zmiennej, a wartość (value) jest typu int. Po prostu idziemy na łatwiznę i zakładamy, że wszystkie zmienne naszego języka będą tego samego typu. Wbrew ewentualnym pozorom nie jest to zbyt drastyczne ograniczenie; zmienne całkowite powinny nam w zupełności wystarczyć do większości zadań, które ma wykonywać skrypt. Od biedy moglibyśmy wykorzystać do przechowywania informacji o zmiennej strukturę COMMAND_INFO, którą zadeklarowaliśmy w pierwszej części artykułu, jednak wkrótce przekonamy się, że nie byłoby to najszczęśliwsze rozwiązanie. A ponieważ struktura jest inna, więc i musimy stworzyć nową, globalną tablicę:

vector<VARIABLE> Variables;

Musimy teraz przerobić nasz parser, a konkretnie - dodać mu obsługę dwóch nowych poleceń: deklaracji/inicjalizacji zmiennej oraz przypisania. Pamiętamy jednak, że na razie funkcja parse jest nastawiona na "zwykłe" polecenia, czyli te, które zapamiętywane są w tablicy Commands. Trzeba jej jakoś powiedzieć, żeby w tej tablicy nic nie zapisywała, jeśli aktualne polecenie dotyczy zmiennej. Tak więc na początku funkcji parse deklarujemy flagę. Na początku zaś pętli do-while ustawiamy tę flagę na domyślną wartość:

bool parse() { bool bNotCommand; //flaga COMMAND_INFO cmd_info = { CMD_UNKNOWN }; VARIABLE var_info; do { bNotCommand = false; ...

Modyfikujemy odpowiednio instrukcję dodania polecenia do wektora Commands:

//dodanie polecenia do tablicy if(!bNotCommand) { Commands.push_back(cmd_info); }

Teraz wreszcie możemy się zabrać do obsługi nowych poleceń (mimo, iż nazwa naszej flagi sugeruje, że nie są one poleceniami - nie miałem pomysłu na inną nazwę ;-)). Najpierw obsługa deklaracji:

else if(cword == "var") { bNotCommand = true; if(!get_word()) { cout << "B£¡D, spodziewałem się nazwy zmiennej..." << endl; return false; } var_info.name = cword; if(!get_param()) { cout << "B£¡D, spodziewałem się wartości..." << endl; return false; } var_info.value = param; Variables.push_back(var_info); }

Proste, nieprawdaż? Warto przy okazji zauważyć, że nie ma tu żadnego sprawdzania poprawności nazwy zmiennej, tak więc możemy w owej nazwie używać dowolnych znaków - oprócz tych, które wchodzą w skład WS_SET. Możemy między innymi używać literek z polskimi ogonkami, liczb, symboli... Jak widać, nasz język zaczyna się robić ciekawy :-).

Teraz twardszy orzech do zgryzienia. Deklarację rozpoznawaliśmy po słowie kluczowym "var", ale jak rozpoznamy przypisanie, skoro zaczyna się ono od nazwy zmiennej, której w momencie parsowania jeszcze nie znamy? Jeśli myślisz o bloku else, który wyłapuje u nas nieznane polecenia, to dobrze myślisz. Tak, możemy w tym bloku wykonywać test. Gdy parser napotka w skrypcie nieznane sobie polecenie, to przyjmie najpierw, że jest ono nazwą zmiennej. "Przejedzie się" po liście zmiennych, porównując ich nazwy z napotkanym słowem, a dopiero gdy nie znajdzie pasującego, przyjmie, że użytkownik popełnił w skrypcie błąd. Natomiast jeśli znajdzie, doda polecenie przypisania do listy Commands. Zacznijmy od uwzględnienia tego polecenia w typie wyliczeniowym ECmdType:

enum ECmdType { CMD_UNKNOWN, CMD_ASSIGN_VALUE, //nowy typ CMD_CREATE_MONSTER, CMD_CREATE_ITEM, CMD_EQUIP_MONSTER };

Teraz wspomniany blok else w funkcji parse:

else { bool bFound = false; for(int i=0; i<Variables.size(); ++i) { if(Variables[i].name == cword) { if(!get_word() || cword[0] != '=') { cout << "B£¡D, spodziewałem się znaku \'=\'..." << endl; return false; } if(!get_param()) { cout << "B£¡D, spodziewałem się wartości liczbowej..." << endl; return false; } bFound = true; cmd_info.Type = CMD_ASSIGN_VALUE; cmd_info.n1 = param; // przypisywana wartość cmd_info.n2 = i; // numer zmiennej break; } } if(!bFound) { cout << "Nieznane polecenie: " << cword << endl; return false; } }

Jeszcze tylko niezbędne zmiany w funkcji execute_commands. Dodaliśmy wszak nowe polecenie, trzeba nauczyć nasz program wykonywania go:

case CMD_ASSIGN_VALUE: Variables[cmd_array[i].n2].value = cmd_array[i].n1; break;

Nic nadzwyczajnego; indeks modyfikowanej zmiennej mamy w polu n2, przypisywaną wartość - w n1, więc powyższy kod jest chyba jasny. Weźmy teraz przykładowy skrypt:

// zmienna
var zm 0
// zmiana wartości zmiennej
zm = 666

W porządku, mamy już działające zmienne. Cóż jednak z tego, skoro nie mamy z nich żadnego pożytku - nie można sprawdzić ich wartości z poziomu skryptu. Dlatego zajmiemy się wprowadzeniem następnej nowości...

Instrukcja warunkowa

Zaimplementowanie instrukcji warunkowej jako takiej nie jest zbyt skomplikowane, jednakże rozbudowanie jej możliwości do takiego stopnia, jak w wielu językach programowania - to już będzie wymagało nieco zachodu. Na pewno cenna byłaby możliwość tworzenia całych bloków, objętych warunkiem (nie tylko pojedynczych instrukcji), przydałaby się też możliwość zagnieżdżania warunków. Nie byłoby źle, gdyby dało się zrobić także coś w rodzaju bloku else. I to wszystko zrobimy. Odpuścimy sobie natomiast sprawdzanie bardziej złożonych wyrażeń logicznych[1].

Zastanówmy się najpierw (tak, jak to robiliśmy do tej pory z każdą nowością w naszym języku skryptowym), jak będą wyglądały instrukcje warunkowe w skrypcie. Napiszmy sobie coś, co wykorzystywałoby kilka różnych operatorów i w dodatku z zagnieżdżonymi if-ami.

var zm 15
con_out "Zadeklarowano zmienna."
if zm == 15
{
 con_out "Warunek spelniony!"
}
if zm != 15
{
 con_out "Ten tekst sie nie pojawi..."
}
if zm > 2
{
 con_out "A nawet powiem, ze zmienna zm..."
 con_out "...jest wieksza od dwoch!"
 if zm < 666
 {
  con_out "...oraz mniejsza od liczby bestii!"
 }
}

Jak widać, postanowiliśmy urządzić wszystko na wzór i podobieństwo poczciwego if-a, znanego z języka C. Jedyna różnica polega na tym, że wyrażenie warunkowe nie jest umieszczone w nawiasach. Powodem jest to, o czym już wspomnieliśmy - nasz język skryptowy będzie "rozumiał" tylko proste wyrażenia typu zmienna operator wartość. W takiej sytuacji nawiasy tylko by nam utrudniły życie (choć oczywiście nie aż tak bardzo, żebyś sobie ich nie mógł zaimplementować na własną rękę ;-)).

Zacznijmy od czegoś, co przyda nam się później, ale niekoniecznie musi być związane z instrukcjami warunkowymi - wprowadźmy sobie nowe polecenie con_out, które będzie po prostu wypisywało na ekranie podany tekst wraz ze znakiem końca linii. Najpierw uzupełniamy typ wyliczeniowy o nową wartość CMD_CON_OUT, natomiast kasujemy stamtąd polecenia związane z potworkami do naszej fikcyjnej gry - te spełniły już swoją rolę i nie będą nam już dłużej potrzebne:

enum ECmdType { CMD_UNKNOWN, CMD_ASSIGN_VALUE, CMD_CON_OUT // to dodaliśmy };

Tekst, będący parametrem polecenia con_out, może zawierać spacje (jak widać w przykładowym kawałku skryptu powyżej), które przecież są u nas znakami rozdzielającymi poszczególne tokeny. Dlatego nie możemy użyć naszego murzyna get_token do pobrania całego tekstu w cudzysłowie. A ściślej: możemy, ale funkcja ta pobierze tylko fragment tekstu; możemy oczywiście wywoływać ją kilkakrotnie, sklejać sprawdzać, czy wyrażenie kończy się drugim cudzysłowiem, wreszcie obydwa cudzysłowy wyciąć z wynikowego stringa... Dużo roboty. Tym samym nakładem pracy napiszemy samodzielną funkcję get_string, która będzie zarazem nieco wydajniejsza, niż opisany przed chwilą sposób:

bool get_string() { const char* CHAR_QUOTE = "\""; int pos2; pos = bufor.find_first_not_of(WS_SET, pos); if(pos == NOT_FOUND) return false; cword = bufor.substr(pos, 1); if(cword != CHAR_QUOTE) return false; pos = bufor.find_first_not_of(CHAR_QUOTE, pos); if(pos == NOT_FOUND) return false; pos2 = bufor.find_first_of(CHAR_QUOTE, pos+1); if(pos2 == NOT_FOUND) return false; cword = bufor.substr(pos, pos2-pos); pos = bufor.find_first_of(WS_SET, pos2); if(pos == NOT_FOUND) pos = bufor.size()-1; return true; }

Funkcja ta będzie od razu pobierać cały tekst, bez cudzysłowów, zapisywać go w cword, a także aktualizować "wskaźnik" pos, tak aby po pobraniu tekstu ustawiony był on na pierwszy znak po cudzysłowie (albo na sam cudzysłów, jeśli ten znajduje się na samym końcu pliku). Z takim pomocnikiem dodanie rozpoznawiania polecenia con_out do naszego parsera (tj. do funkcji parse) będzie banalne:

else if(cword == "con_out") { cmd_info.Type = CMD_CON_OUT; if(!get_string()) { err_str = "Spodziewałem się łańcucha znaków..."; return false; } cmd_info.s1 = cword; }

Jeszcze prościej będzie dodać polecenie do funkcji execute_commands:

case CMD_CON_OUT: { cout << cmd_array[i].s1 << endl; } break;

Teraz przystąpimy do naszego właściwego zadania, czyli budowania if-a. Z czego w naszym nowym języku składa się taki if? Wymieńmy po kolei jego części:

  • słowo kluczowe "if"
  • nazwa zmiennej
  • symbol operatora
  • wartość (stała)
  • lewy nawias klamrowy
  • instrukcje
  • prawy nawias klamrowy

Całkiem sporo tego, jak na niepozornego if-a, ale podejdziemy do tego zadania z oceanicznym spokojem. Przede wszystkim: co z powyższych rzeczy będziemy trzymać w naszej tablicy poleceń? Otóż do naszych celów w zupełności wystarczy, jeśli wpakujemy tam samą instrukcję if oraz prawy (domykający) nawias klamrowy. Tutaj dygresja. Czy nawias lewy jest w ogóle potrzebny, a jeśli tak, to po co? W języku Basic, przykładowo, nie ma żadnego odpowiednika takiego nawiasu; piszemy po prostu:

If warunek = wartosc Then 'od biedy można uznać, że Then... instrukcja1 ' ...jest odpowiednikiem lewego nawiasu... instrukcja2 instrukcja3 End If If warunek = wartosc Then { ...aczkolwiek w Pascalu jest zarówno Then... } Begin {...jak i Begin, czyli lewy nawias :-) } instrukcja1; instrukcja2; instrukcja3; End;

Czytelność kodu w Basic-u chyba niewiele traci na braku odpowiednika lewego nawiasu, choć trzeba przyznać, że gdyby w języku tym były nawiasy klamrowe, to ten prawy dość głupio by wyglądał samotnie. Mimo to bardzo wielu programistów C++ praktykuje taki oto (obrzydliwy, zdaniem autora artykułu) zwyczaj rozstawiania klamerek:

if(warunek) { instrukcja1; instrukcja2; }

Tak więc jednym lewy nawias się podoba, innym przeszkadza, natomiast od strony naszego problemu warto wiedzieć tylko to, że nie jest ten nieszczęsny nawias do niczego potrzebny parserowi. Wiemy już zatem, co dodać do typu wyliczeniowego ECmdType:

enum ECmdType { CMD_UNKNOWN, CMD_ASSIGN_VALUE, CMD_RBRACKET, // CMD_IF, // te dwie dodaliśmy CMD_CON_OUT, };

Skoro już jesteśmy przy enum-ach, stwórzmy jeszcze jeden w celu identyfikowania operatorów relacyjnych do naszych if-ów:

enum EOpType { OP_UNKNOWN, OP_EQUAL, OP_NOT_EQUAL, OP_GREATER, OP_LESS, OP_GREATER_OR_EQUAL, OP_LESS_OR_EQUAL };

Wspomnieliśmy już, że nasze if-y będzie się dało zagnieżdżać. Aby to było możliwe, program wykonujący polecenia skryptu musi wiedzieć, jaki jest poziom zagnieżdżenia danej pary nawiasów (oraz, co za tym idzie, instrukcji objętych tą parą). Można ten poziom zapamiętywać w strukturze polecenia. Wprowadzimy tam dodatkowe pole, nNestingLevel:

struct COMMAND_INFO { ECmdType Type; int n1, n2, n3, nNestingLevel; string s1, s2, s3; };

Funkcja parse będzie potrzebowała własnej, lokalnej zmiennej, która będzie określała, do jakiego stopnia zagnieżdżenia się aktualnie dokopała; zmienna ta będzie zwiększana, gdy tylko napotkamy instrukcję if oraz zmniejszała, gdy napotkamy pierwszy prawy nawias:

bool parse() { bool bNotCommand; int nCurNestingLevel = 0; //nowe COMMAND_INFO cmd_info = { CMD_UNKNOWN }; VARIABLE var_info; // ...

Drugą zmianą w funkcji parse będzie dodanie obsługi polecenia if. Tutaj niestety sporo klepania - widzieliśmy przecież, z ilu części składa się taki if. Ogólnie jednak działanie poniższego kodu jest bardzo proste. Pobieramy kolejno: indeks zmiennej (o tym zaraz), operator relacji (z tym najwięcej roboty), wartość liczbową i zapisujemy w odpowiednich składowych: n1, n2, n3. Na koniec pomijamy prawy nawias (zgodnie z obietnicami nic z nim nie robimy, ale krzyczymy na usera, jeśli nawiasu nie wstawił) i zwiększamy stopień zagnieżdżenia:

else if(cword == "if") { cmd_info.Type = CMD_IF; if(!get_token()) { cout << "B£¡D, spodziewałem się nazwy zmiennej..." << endl; return false; } cmd_info.n1 = get_var_index(cword); if(cmd_info.n1 < 0) return false; if(!get_token()) { cout << "B£¡D, spodziewałem się operatora..." << endl; return false; } if(cword == "==") cmd_info.n2 = OP_EQUAL; else if(cword == "!=") cmd_info.n2 = OP_NOT_EQUAL; else if(cword == "<") cmd_info.n2 = OP_LESS; else if(cword == ">") cmd_info.n2 = OP_GREATER; else if(cword == "<=") cmd_info.n2 = OP_LESS_OR_EQUAL; else if(cword == ">=") cmd_info.n2 = OP_GREATER_OR_EQUAL; else { cout << "B£¡D, nieznany operator \'" << cword << "\'" << endl; return false; } if(!get_param()) { cout << "B£¡D, spodziewałem się wartości..." << endl; return false; } cmd_info.n3 = param; if(!get_token() || cword != "{") { cout << "B£¡D, spodziewałem się znaku \'{\'..." << endl; return false; } cmd_info.nNestingLevel = ++nCurNestingLevel; }

Funkcja get_var_index, jak sama nazwa wskazuje, pobiera indeks zmiennej o podanej nazwie. Nie jest to nic, czego byśmy do tej pory nie robili, ale od tej pory będziemy tego potrzebować w aż kilku miejscach, więc dobrze te czynności mieć wydzielone w funkcji:

int get_var_index(const string& sVarName) { for(int i=0; i<Variables.size(); ++i) if(Variables[i].name == sVarName) return i; cout << "B£¡D, nie znaleziono zmiennej o nazwie \'" << sVarName << "\'." << endl; return -1; }

Zostaje nam jeszcze jedna zmiana w funkcji parse - obsługa "polecenia", jakim jest prawy nawias. Ktoś kojarzy ideę wartownika z algorytmiki? Ten prawy nawias, wpisywany do tablicy poleceń jest u nas właśnie takim wartownikiem. Kiedy go napotkamy, wiemy od razu, że przechodzimy o jeden poziom zagnieżdżenia wyżej:

else if(cword == "}") { cmd_info.Type = CMD_RBRACKET; cmd_info.nNestingLevel = nCurNestingLevel--; }

Wszystko, co nam pozostaje, to egzekucja... Albo, używając mniej drastycznie kojarzącego się słowa, wykonanie skryptu. To dla if-a najważniejszy moment. W funkcji execute_commands dokonamy dwóch zmian. Po pierwsze, dodamy dwa nowe parametry, start i stop. Dzięki nim będzie można wykonywać tylko wybraną grupę poleceń z tablicy Commands, co otworzy nam drogę do wykonania zagnieżdżonych if-ów. Najprostszą bowiem metodą na wykonywanie zagnieżdżonych poleceń jest zwykła rekurencja. Tak więc wykonanie polecenia if będzie wyglądało tak:

  • znalezienie pasującego prawego nawiasu
  • sprawdzenie prawdziwości wyrażenia warunkowego
  • wykonanie (lub nie) poleceń w nawiasach
  • wyskoczenie poza nawiasy

Poszukiwanie pasującego nawiasu, wbrew ewentualnym pozorom, nie jest trudne. Zaczynając od pozycji, gdzie mamy w tablicy naszego if-a, "zadowolimy się" pierwszym napotkanym poleceniem CMD_RBRACKET, o ile będzie ono miało ten sam poziom zagnieżdżenia (składowa nNestingLevel), co sam if.

Jeśli sprawdzone wyrażenie jest prawdziwe (czyli funkcja is_true, którą zaraz zdefiniujemy, zwraca true), wówczas wywołujemy rekurencyjnie execute_commands, podając następujący zakres: od instrukcji bezpośrednio następującej w tablicy po if-ie (pierwsza instrukcja wewnątrz klamer) do instrukcji bezpośrednio poprzedzającej prawy nawias (ostatnia instrukcja wewnątrz klamer).

Ostatnią czynnością jest przeskoczenie w tablicy do miejsca, gdzie znajduje się prawy nawias. W tym celu modyfikujemy licznik i. Po co to robimy? W sytuacji, gdy warunek jest spełniony, konieczność wykonania tej czynności jest oczywista; gdybyśmy nie przeskoczyli, instrukcje wewnątrz klamerek wykonałyby się niezależnie od spełnienia warunku. Natomiast w przeciwnym wypadku zapominając o tym przeskoku osiągnęlibyśmy taki efekt, że instrukcje w klamrach wykonałyby się dwa razy, co nie zawsze jest pożądane ;-). Wystarczy gadania, oto "nowa" funkcja execute_commands:

void execute_commands(const vector<COMMAND_INFO>& cmd_array, int start, int stop) { for(int i=start; i<=stop; ++i) { switch(cmd_array[i].Type) { case CMD_ASSIGN_VALUE: Variables[cmd_array[i].n2].value = cmd_array[i].n1; break; case CMD_CON_OUT: { cout << cmd_array[i].s1 << endl; } break; case CMD_IF: { int rbracket = -1; for(int j=i+1; j<=stop; ++j) { if(cmd_array[j].Type == CMD_RBRACKET && cmd_array[j].nNestingLevel == cmd_array[i].nNestingLevel) { rbracket = j; break; } } if(rbracket < 0) { cout << "Blad wykonania skryptu - niedomkniety blok if" << endl; return; } if(is_true(cmd_array[i].n1, (EOpType)cmd_array[i].n2, cmd_array[i].n3)) execute_commands(cmd_array, i+1, rbracket-1); i = rbracket; } break; } } }

Mieliśmy zdefiniować funkcję is_true, wartościującą wyrażenia warunkowe - niniejszym to czynimy:

bool is_true(int nVarIndex, EOpType OpType, int nValue) { switch(OpType) { case OP_EQUAL: return (Variables[nVarIndex].value == nValue); case OP_NOT_EQUAL: return (Variables[nVarIndex].value != nValue); case OP_LESS: return (Variables[nVarIndex].value < nValue); case OP_GREATER: return (Variables[nVarIndex].value > nValue); case OP_LESS_OR_EQUAL: return (Variables[nVarIndex].value <= nValue); case OP_GREATER_OR_EQUAL: return (Variables[nVarIndex].value >= nValue); } return false; }

Jak widać, nic nadzwyczajnego. Jest to po prostu wrapper na wbudowane w C++ operatory relacyjne, z tym, że jako lewy operand zawsze wstawia wartość podanej zmiennej, a jako prawy - podaną w nValue wartość "stałą" (dla osoby piszącej skrypt w naszym języku jest to faktycznie stała :-)).

Ostatnim naszym wysiłkiem (na szczęście już niewielkim) będzie zaktualizowanie jednej instrukcji w funkcji main - tej mianowicie, w której wywołujemy execute_commands. Teraz będziemy robić to w takiej postaci:

execute_commands(Commands, 0, Commands.size()-1);

To już wreszcie wszystko - mamy działającego if-a!

Skok bezwarunkowy

O ile instrukcja goto nie jest zbytnio kochana przez programistów[2], to w językach skryptowych na ogół nikomu nie przeszkadza. Można przy pomocy goto realizować koncepcję procedury, a w połączeniu ze zmiennymi i instrukcjami warunkowymi - również pętle. Nawet jeśli mamy w języku skryptowym "normalne" pętle, to łatwiej je kontrolować właśnie za pomocą goto (które między innymi może zastąpić słowa w rodzaju break czy continue). Jeśli mamy "normalne" procedury, goto zastąpi nam instrukcję return. Proste języki skryptowe nie zawierają raczej mechanizmu wyjątków, więc goto zastępuje z powodzeniem również i to. Ogólnie więc jest to całkiem pożyteczny twór i warto by go było mieć.

Po pierwsze, goto nie skacze w ciemność, tylko do określonego miejsca w kodzie. Może być ono identyfikowane przez numer linii (niewygodne) lub przez etykietę. Etykiety przechowywać będziemy razem z poleceniami w tablicy Commands. Tak więc definiujemy nowe stałe:

enum ECmdType { CMD_UNKNOWN, CMD_ASSIGN_VALUE, CMD_RBRACKET, CMD_IF, CMD_LABEL, //nowe CMD_GOTO, //nowe CMD_CON_OUT };

Jak parser ma sobie poradzić z etykietą? Na podobnej zasadzie jak zmienne, czyli wszystkie ciągi znaków nie rozpoznane jako polecenia będą traktowane najpierw jako etykiety. Jeśli jednak nie będą kończyć się dwukropkiem, to przejdziemy do sprawdzania, czy nie są przypadkiem nazwą zmiennej - resztę już znamy. Powinniśmy jeszcze sprawdzić, czy napotkana nazwa etykiety nie była przypadkiem użyta już wcześniej, a także czy nie została już użyta jako nazwa zmiennej, ale takie drobiazgi sobie na razie odpuszczamy. W sumie nasz blok else w funkcji parse przyjmie postać:

else { if(cword[cword.length()-1] == ':') { cmd_info.Type = CMD_LABEL; cmd_info.s1 = cword.substr(0, cword.length()-1); cmd_info.nNestingLevel = nCurNestingLevel; Commands.push_back(cmd_info); continue; } bool bFound = false; for(int i=0; i<Variables.size(); ++i) { if(Variables[i].name == cword) { if(!get_token() || cword[0] != '=') { cout << "B£¡D, spodziewałem się znaku \'=\'..." << endl; return false; } if(!get_param()) { cout << "B£¡D, spodziewałem się wartości liczbowej..." << endl; return false; } bFound = true; cmd_info.Type = CMD_ASSIGN_VALUE; cmd_info.n1 = param; cmd_info.n2 = i; break; } // if(Variables... } // for if(!bFound) { cout << "Nieznane polecenie: " << cword << endl; return false; } } // else

Trzeba tutaj zwrócić uwagę na dwa szczegóły. Po pierwsze, jeśli już rozpoznamy, że ciąg znaków jest etykietą, to oprócz jej nazwy zapamiętujemy również poziom zagnieżdżenia. Dlaczego? Kiedy wykonujemy skok z dowolnego miejsca do etykiety, zmieniamy stopień zagnieżdżenia na taki, jaki ma ta etykieta. Dzięki temu możliwe jest przeskakiwanie np. ze środka bloku if na zewnątrz albo odwrotnie. Druga ważna sprawa: ponieważ obecnie w bloku else mamy rozpoznawanie dwóch rodzajów "poleceń" (etykieta oraz zmienna), to w przypadku rozpoznania etykiety musimy jakoś wydostać się z tego bloku, tak więc używamy continue, a strukturę dodajemy do wektora Commands oddzielnie, nie czekając na opuszczenie bloku else.

Parsing samego polecenia goto będzie wręcz trywialną sprawą:

else if(cword == "goto") { cmd_info.Type = CMD_GOTO; if(!get_token()) { err_str = "Blad - spodziewalem sie etykiety"; return false; } cmd_info.s1 = cword; }

Większym wyzwaniem będzie wykonanie polecenia goto. Powiedzieliśmy już sobie, że daje nam ono możliwość opuszczania bloku o dowolnym poziomie zagnieżdżenia i przejście do innego bloku, również o dowolnym poziomie zagnieżdżenia. Aby naprawdę opuścić jeden lub więcej zagnieżdżonych bloków w momencie wystąpienia instrukcji goto, musimy jakoś powychodzić ze wszystkich poziomów funkcji execute_commands, która, jak wiemy, wywoływana jest w przypadku bloków if rekurencyjnie. Można to oczywiście zrobić instrukcją return, ale potrzebujemy też jakiejś zmiennej, dzięki któremu pozostałe wywołania funkcji execute_commands (znajdujące się aktualnie na stosie programu) będą "wiedziały", że również muszą wykonać powrót. Poza tym musimy gdzieś pamiętać, jaki mamy aktualnie poziom zagnieżdżenia oraz do jakiej pozycji skaczemy. Możemy te wszystkie zmienne zadeklarować jako statyczne w funkcji execute_commands:

void execute_commands(const vector<COMMAND_INFO>& cmd_array, int start, int stop) { static bool bUnwind = false, bError = false; static int nNestingLevel = 0, nNewPos = 0;

Zmienna bUnwind wzięła swoją nazwę od terminu stack unwinding, czyli odwijanie stosu (chodzi oczywiście o stos, na który odkładane są poszczególne wywołania execute_commands). Będziemy ustawiać ją na true podczas wykonywania naszego goto. Druga zmienna typu bool to bError - jak sama nazwa wskazuje, służy ona do sygnalizowania błędu. Obydwie zmienne mają podobną rolę dla przebiegu wykonania funkcji, bowiem oznaczają, że funkcja ma natychmiast wykonać powrót. Różnica polega na tym, że w przypadku bUnwind odwijanie stosu zatrzymuje się na zerowym poziomie zagnieżdżenia (tj. w pierwszej "instancji" funkcji, tej wywołanej z main), podczas gdy w przypadku bError odwijanie trwa aż do opuszczenia execute_commands w ogóle (nie ma sensu wykonywać skryptu dalej, gdy są w nim błędy).

Po co dwie pozostałe statyczne zmienne? Instrukcja goto, którą właśnie implementujemy, ma w zasadzie dwie rzeczy do zrobienia: ustawić licznik i na indeks, pod którym w tablicy Commands znajduje się etykieta, do której skaczemy, oraz ustawić nowy poziom zagnieżdżenia. Nie możemy jednak wykonać tych czynności w tej "instancji" execute_commands, która napotkała goto; przecież właśnie z niej wychodzimy. Dlatego trzeba zapamiętać obie wartości w statycznych zmiennych, a wykorzystać je dopiero w momencie, gdy już dotrzemy do zerowego poziomu zagnieżdżenia. I stąd właśnie wynika mnóstwo komplikacji, które wywracają do góry nogami całą dotychczasową funkcję execute_commands i czynią ją "nieco" dłuższą:

bool execute_commands(const vector<COMMAND_INFO>& cmd_array, int start, int stop) { static bool bUnwind = false, bError = false; static int nNestingLevel = 0, nNewPos = 0; for(int i=start; i<=stop; ++i) { InsideLoop: switch(cmd_array[i].Type) { case CMD_ASSIGN_VALUE: Variables[cmd_array[i].n2].value = cmd_array[i].n1; break; case CMD_CON_OUT: { cout << cmd_array[i].s1 << endl; } break; case CMD_GOTO: { for(int j=0; j<cmd_array.size(); ++j) { if(cmd_array[j].Type == CMD_LABEL) if(cmd_array[j].s1 == cmd_array[i].s1) { nNewPos = j; if(nNestingLevel > 0) { bUnwind = true; --nNestingLevel; return false; } else { bUnwind = false; i = nNewPos; goto InsideLoop; } } } bError = true; cout << "Blad wykonania skryptu - nie znaleziono etykiety \'" << cmd_array[i].s1 << "\'" << endl; return; } break; case CMD_IF: { int rbracket = -1; for(int j=i+1; j<=stop; ++j) { if(cmd_array[j].Type == CMD_RBRACKET && cmd_array[j].nNestingLevel == cmd_array[i].nNestingLevel) { rbracket = j; break; } } if(rbracket < 0) { bError = true; cout << "Blad wykonania skryptu - niedomkniety blok if" << endl; return; } if(is_true(cmd_array[i].n1, (EOpType)cmd_array[i].n2, cmd_array[i].n3)) { ++nNestingLevel; execute_commands(cmd_array, i+1, rbracket-1); if(bError) return; if(bUnwind) { if(nNestingLevel > 0) { --nNestingLevel; return; } else { bUnwind = false; i = nNewPos; goto InsideLoop; } } // if(bUnwind) } // if(is_true... i = rbracket; } break; } // główny switch } // główna pętla for --nNestingLevel; }

Co my tu mamy? Przede wszystkim dodaliśmy nowy case, dotyczący oczywiście nowej instrukcji goto. Zadaniem tego fragmentu kodu jest odnalezienie właściwej etykiety. Gdy już ją mamy, zapamiętujemy jej indeks. Następnie możliwe są dwa scenariusze. Albo mamy wyskoczyć z wnętrza jakiegoś bloku if (nNestingLevel > 0) i wówczas realizujemy odwijanie stosu, o czym już mówiliśmy. Drugi przypadek to skok w obrębie tego samego bloku, w którym znajduje się instrukcja goto i na tym samym poziomie zagnieżdżenia. Wtedy nie możemy już wyjść z funkcji execute_commands, bo to zatrzymałoby wywoływanie skryptu w ogóle. Dlatego też po prostu zmieniamy licznik i, po czym... korzystamy z niesławnej instrukcji goto języka C++, gdyż jest to w naszej sytuacji najprostszy sposób, by jednocześnie wyjść z pętli wewnętrznej (tej z licznikiem j) i kontynuować we właściwy sposób zewnętrzną (licznik i).

Jak widzimy, blok case CMD_IF zyskał podejrzanie dużo treści. Dlaczego? Przecież działania instrukcji if nie zmieniamy... Powody są dwa. Musimy kontrolować wartość nNestingLevel w momencie wchodzenia do bloku if oraz opuszczania go (przy czym chodzi o "przedwczesne" opuszczanie za pomocą goto). Musimy też stworzyć wreszcie mechanizm odpowiedzialny za odwijanie stosu. Właśnie tutaj jest dla niego jedyne odpowiednie miejsce. W końcu to właśnie tutaj wywołujemy rekurencyjnie execute_commands. Tak więc jeśli któreś z tych rekurencyjnych wywołań napotka goto i wróci, ustawiwszy bUnwind na true (albo błąd wykonania, po którym ustawi bError), to sterowanie zostanie przekazane właśnie tutaj. W przypadku błędu po prostu wychodzimy z funkcji, natomiast jeśli odwijamy stos, to są dwa scenariusze, dokładnie takie same, jak w przypadku omówionym w poprzednim akapicie.

Ostatnia zmiana w funkcji execute_commands to instrukcja --nNestingLevel, znajdująca się na samym końcu. Jest to uwzględnienie przypadku, gdy wychodzimy z dowolnego bloku "po Bożemu", tj. nie przez goto.

Zwróćmy jeszcze uwagę (wracając jeszcze do problemu poszukiwania właściwej etykiety), że pomyłki w nazwie etykiety (np. literówki) wykrywane są tutaj dopiero w fazie wykonywania skryptu, w funkcji execute_commands. Nie jest to dobra praktyka - powinniśmy wykrywać takie nieprawidłowości już podczas parsingu i/lub interpretacji. Pamiętajmy, by stosować się do tej zasady, jeśli tylko będziemy tworzyć język skryptowy "do używania", a nie tylko w celach dydaktycznych, jak tutaj :-).

Aby przekonać się, czy nasze goto działa prawidłowo, podsuńmy naszemu interpreterowi do wykonania następujący skrypt:

var zm 0
zm = 666

Sprawdz:
if zm == 666
{
 con_out "Spelniony."
 goto Bla
}
if zm == 444
{
 con_out "444."
 goto Koniec
}
con_out "Ten tekst sie nie wyswietli..."
Bla:
con_out "A ten owszem."
zm = 444
goto Sprawdz

Koniec:

Co tu gadać, po prostu wszystko gra. Możemy sobie pogratulować: nasz język skryptowy zyskał właśnie ogromne możliwości. Nie tylko możemy korzystać ze zmiennych i sprawdzać ich wartości, ale też dowolnie kontrolujemy wykonanie za pomocą goto. Możemy imitować deklaracje procedur, możemy po dodaniu prostych poleceń (do zwiększania wartości zmiennej o 1) imitować dowolne pętle. Programowanie w naszym nowym języku nie jest może tak wygodne, jak choćby w C++, ale przecież nie o to nam chodziło. Natomiast jako język na wysokim poziomie abstrakcji do zastosowania w tworzeniu gameplay'a gier nasze dzieło sprawdza się doskonale.

Wszystko, co pozostaje do powiedzenia w następnej części tego artykułu, to tworzenie procedur i pętli "z prawdziwego zdarzenia", tak aby nie trzeba było stosować w tym celu sztuczek z goto (które nie są złe same w sobie, ale zbyt duże ich nagromadzenie w kodzie powoduje zwykle gigantyczne kłopoty). Wprowadzimy też blok else do naszych instrukcji warunkowych. A na koniec sprawdzimy, czy nasz język rzeczywiście jest już kompletnym i uniwersalnym narzędziem.

------------------
[1] Choćby po to, by nie powtarzać tego, co napisał już DarkJarek w swoim artykule "Parser wyrażeń matematycznych".
Zakładając, że wyrażenia o wartości zerowej traktujemy jako "fałsz", a niezerowe - jako "prawdę", możemy z powodzeniem wykorzystać parser DarkJarka do sprawdzania nawet najbardziej złożonych warunków.
[2] Dlaczego i czy słusznie - rozważa o tym między innymi Bruce Eckel w swojej znanej książce "Thinking In Java", gdzie nawet poświęcił tej dyskusji osobny podrozdział.

[[Kategoria:c++]]

Tekst dodał:
Złośliwiec
19.08.2006 10:59

Ostatnia edycja:
Złośliwiec
19.08.2006 10:59

Kategorie:

Aby edytować tekst, musisz się zalogować.

# Edytuj Porównaj Czas Autor Rozmiar
#1 edytuj 19.08.2006 10:59 Złośliwiec 38.66 KB
Zwykły
Do sprawdzenia
Do akceptacji
  • Napisz komentarz:
    Aby dodać swój komentarz, musisz się zalogować.
Licencja Creative Commons

Warsztat używa plików cookies. | Copyright © 2006-2017 Warsztat · Kontakt · Regulamin i polityka prywatności
build #ff080b4740 (Tue Mar 25 11:39:28 CET 2014)