Dacă ar fi să stăm să ne gândim, majoritatea dintre noi ne-am lovit cel puțin o dată în viață de situația în care bucăți similare de cod au fost scrise și rescrise, bucăți care diferă doar prin anumite detalii. Te-ai întrebat vreodată - în timp ce făceai copy-paste dintr-o clasă în alta, și poate apoi în alte 50 de clase - de ce faci acest lucru repetitiv, predispus erorilor și pe deasupra plictisitor când ar putea fi foarte ușor efectuat de o mașină? Mașinile sunt cele mai indicate pentru astfel de task-uri, dar de ce le evităm? În acest articol va fi prezentat un program care generează cod pe baza unui fișier xml de intrare și a unor template-uri flexibile.
Generarea de cod sună mai mult ca o cerință pentru inginerii NASA, decât pentru un grup de programatori din Cluj care lucrează în industria de outsourcing. Mare ne-a fost surpriza când, într-o zi călduroasă și lungă de vară clientul ne anunța bucuros un nou topic: o aplicație care să genereze cod - și nu una oarecare, ci una care să genereze cod pentru mai multe platforme și pentru diferite limbaje de programare, folosind ca input un fișier xml și niște template-uri.
Pentru a înțelege mai bine problema, să luăm un exemplu. Să ne imaginăm că avem de realizat o aplicație care presupune gestionarea unei magazii folosind baze de date care stochează diferite tipuri de aparate electronice și electrocasnice. Aceasta este o aplicație clasică cu baze de date care poate conține mai multe form-uri și o mulțime de clase de tipul acces la date - DAL (Database Access Layer). Dacă această magazie este mică și conține doar câteva tipuri de produse atunci scrierea claselor de acces la date nu pare să fie o mare problemă. Dar cum ar fi să stocăm toate tipurile de aparate de la telefoane mobile până la monitoare, routere sau DVD playere și să creăm pentru fiecare categorie tabele? Scrierea tuturor claselor de acces la date, chiar dacă ele diferă doar puțin una de cealaltă, nu pare a fi totuși o problemă. Doar că în acest moment rezolvarea task-ului ar presupune o alocare mare de timp. O mică modificare sau o nouă cerință adusă aplicației poate genera schimbări în toate clasele, care prin mecanismul de copy-paste dintr-o clasă în alta ar duce la erori neașteptate. Într-un scenariu de acest tip, folosirea un generator de cod ne-ar ușura enorm munca: ar genera toate clasele necesare, în plus, pentru anumite modificări ulterioare ar implica schimbări doar în fișierele template. Astfel, refactorizarea codului ar fi ușoară și rapidă. Un generator de cod bine scris și flexibil, poate fi o unealtă ajutătoare în orice proiect.
Un generator de cod este structurat în două mari componente: partea care citește și analizează fișierul de intrare și partea care analizează fișierele template și generează fișierele de ieșire. Prima componentă pasează datele celei de-a doua urmând un format bine definit. Prin urmare, orice modificare adusă acestui format necesită modificarea ambelor părți implicate.
Fișierul de intrare trebuie să conțină toate informațiile necesare umplerii template-urilor. Acestea trebuie să fie dispuse într-o formă cât mai structurată și ușor de analizat de către o mașină. În acest articol va fi folosit un fișier xml. Vom exemplifica un caz în care codul generat va fi unul de C++, bazat pe librăria STL. Fișierul de intrare va trebui să conțină cât mai multe informații despre categoriile (
<?xml version="1.0"?>
<categories>
<category name="smartphone">
<field name="vendor" type="string" cpp-type="std::string" sql-type="VARCHAR(30)"/>
<field name="os" type="string" cpp-type="std::string" sql-type="VARCHAR(10)"/>
...
</category>
<category name="monitor">
<field name="vendor" type="string" cpp-type="std::string" sql-type="VARCHAR(30)"/>
<field name="diagonal" type="number" cpp-type="int" sql-type="INTEGER"/>
...
</category>
...
</categories>
Acest fișier xmlconține de fapt o listă de categorii, fiecare categorie având la rândul ei o listă de câmpuri. Fiecare câmp are un nume, un tip și un tip specific platformei pentru care urmează a fi generat fișierul de ieșire. Salvarea informației specifică platformei în tag-uri nu este prea convenabilă nici măcar pentru două platforme. Ar fi mai ușor să stocăm doar un tip, independent de platformă, precum "string" sau "number" și să stocăm informația de mapare spre platforma într-o altă parte a xml-ului, dar pentru a ușura exemplificarea, această informație va rămâne stocată în tag-uri. Legat de parsarea unui xml - nimic nou sub soare; există o mulțime de librării sau parsere disponibile care pot fi folosite.
Datele analizate din fișierul de intrare trebuie stocate într-o primă fază într-un format intermediar, dar și în momentul în care acestea sunt pasate către cealaltă componentă a generatorului. Detaliile acestui format nu sunt importante; ele sunt specifice, în funcție de cerințele și problema care trebuie adresată. De asemenea, ele depind și de cât de generic se dorește a fi generatorul de cod, și multe altele. Ar trebui totuși să respecte anumite cerințe:
Aceasta este cea mai delicată parte a generatorului de cod. Este și cea mai importantă, dar și cea mai complicată. Dacă se dorește scrierea unui generator de cod flexibil și reutilizabil, hard-codările anumitor informații, precum platforma sau limbajul pentru care se urmărește a fi generat codul sau chiar bucăți din codul final sunt de evitat. Cea mai bună soluție sunt template-urile.
Fișierele template conțin bucăți asemănătoare cu codul și reprezintă de fapt formatul fișierelor pe care le dorim la ieșire - în acest caz, clase C++ - dar folosesc totodată și un set de instrucțiuni pentru a putea umple informațiile lipsă la generarea fișierelor de output. Părțile care lipsesc sunt exact informațiile extrase din fișierul de intrare: atributele categoriilor și câmpurile acestora. Cel mai evident exemplu este numele clasei. Acesta depinde de numele categoriei. În fișierul template, numele clasei va avea un substituent, care va fi ulterior înlocuit în componenta de procesare a template-ului, cu un nume valid al unei categorii. Un astfel de proces va fi numit "substituirea unei variabile". Dar pentru generarea unei clase complete, substituirea variabilelor nu este suficientă.
De exemplu, este necesară declararea membrilor clasei bazându-ne pe câmpurile din fișierul de intrare. Astfel, avem nevoie de un fel de buclă care iterează prin toate câmpurile. De asemenea poate să apară cazul în care este necesară luarea unei decizii pe baza informațiilor din fișierul de intrare; astfel, vom avea nevoie de un fel de evaluare a unei expresii logice și probabil niște ramificări cu intrucțiuni de cod diferite. Variabile, bucle, instrucțiuni condiționale - sintaxa template-ului începe să semene cu cea a unui limbaj de programare. Scrierea unui parser pentru un astfel de template, care să fie și ușor de modificat în cazul în care apar schimbări în template, nu este ușoară. Dar înainte de a detalia parserul pentru fișierele template, să aruncăm o privire asupra unui posibil fișier de template:
class <$category.name$>
{
public:
<$category.name$>();
~<$category.name$>();
<@foreach <$field$> in <$category.fields$> @>
<$field.cpp-type$> get<$field.name$>();
void set<$field.name$>( const <$field.cpp-type$> &value);
<@endforeach@>
private:
<@foreach <$field$> in <$category.fields$> @>
<$field.cpp-type$> <$field.name$>;
<@endforeach@>
}
Acesta este un model de template pentru o clasă care ar putea reprezenta o categorie. Conține substituiri ale variabilelor și bucle. Variabilele sunt marcate cu simbolurile "<$" si "$>", iar instrucțiunile cu "<@" si "@>". Acești delimitatori permit oarecum citirea fișierului template, deși template-uri mai complexe ar părea indescifrabile. Când fișierul template întră în componenta de parsarea este analizat și completat: toate variabilele vor fi înlocuite cu valori valide și tot ceea ce reprezintă bucla va fi evaluat iterație cu iterație.
Scrierea unui parser pentru fișierul template pare o opțiune plauzibilă, însă nu e o idee chiar așa bună din mai multe motive. În primul rând - deși exemplul ilustrat nu este complicat - în viața reală un template are o sintaxă mai complexă, cu mult mai multe simboluri și cuvinte cheie. Scrierea unui parser pentru un minilimbaj de programare necesită timp și energie, iar rezultatul poate fi imprevizibil din cauza obstacolelor care pot apărea. În al doilea rând, de ce să reinventăm roata de la căruță? Există soluții fiabile care pot genera parsere dintr-un set bine definit de reguli. Cel mai cunoscut generator de parsere este perechea lex - yacc. Lexeste folosit pentru a genera un analizor lexical și yacc este un generator de parsere. Atât lex cât și yacc sunt soluții open source, însă în exemplul din articol au fost folosite versiunile GNU, flex - bison.
Procesarea fișierelor template si generarea fișierelor de ieșire este realizată în trei pași, ilustrați în imaginea de mai jos:
Nu vom prezenta prea multe detalii, doar o scurtă prezentare a fiecărui pas.
În primul pas, fișierul template este analizat și spart în simboluri. Este exact ceea face preprocesorul C (printre multe altele). De fapt, fiecare grup de caractere care este considerat important este notat, dar celelalte sunt ignorate. De exemplu, grupul de caractere "<$" va fi notat cu "VAR_B" și împreună - notația și evaluarea sa - formează un simbol. O linie de cod C++ din fișierul template care nu conține nici un cuvânt cheie va fi notate ca "text". Grupuri de caractere fără importanță sunt de exemplu comentariile sau spațiile libere (în anumite cazuri). Această spargere în simboluri este exact ceea ce face analizorul lexical generat de flex. Flexgenerează acest proces pe baza unui fișier de intrare. Fișierul de intrare al Flex conține un set de reguli care descriu ce fel de simboluri vor fi recunoscute și cum arată acestea. Regulile sunt sub forma unei expresii regulate și bucăți de cod care urmează a fi executate când expresiile regulate sunt îndeplinite pe o anumită parte din fișierul de intrare. Analizorul generat de Flex citește fișierul template de intrare caracter cu caracter și încearcă să mapeze peste regulile descrise. Imediat ce o potrivire a fost găsită, bucata de cod asociată se execută. Flex generează practic funcția yylex() care, la fiecare apel returnează următorul simbol din template.
În acest pas secvența cu simbolurile de intrare este validată împotriva legilor gramaticale care descriu limbajul template. Acest pas e efectuat de parser-ul generat de Bison. Bison generează parser-ul din fișierele de intrare care conțin regulile care descriu limbajul. Regulile gramaticale trebuie să descrie un LALR(1) cu gramatica independentă de context. Această gramatică, deși constrânsă de faptul că nu poate manipula ambiguități, e potrivită pentru cele mai multe limbaje, chiar și mai complexe, cum ar fi Java. Limbajele LALR(1) sunt compuse din simboluri terminale și nonterminale. Simbolurile terminale sunt cele care pot fi cuplate cu un singur token de intrare. De exemplu, în acest limbaj template token-ul <$ e terminal, o componentă atomică a limbajului. Simbolurile nonterminale sunt compuse din mai multe simboluri terminale și nonterminale. Definiția unui simbol nonterminal alcătuiește regula gramaticală. O regulă este o listă cu toate combinațiile posibile de simboluri terminale și nonterminale care alcătuiesc acest nonterminal. De exemplu,dacă am vrea să definim un nonterminal pentru numele unei variabile, acesta ar arăta în felul următor:
variable_name: word OR word.word
Simbolul nonterminal nume_variabilă este o componentă compusă a unui limbaj. Poate fi o parte a unei liste de definiții a unui alt simbol nonterminal pentru a alcătui alte nonterminale mai complexe, cum ar fi o instrucție foreach. Există un nonterminal special care reprezintă întregul limbaj. Acest nonterminal este nivelul cel mai de sus al limbajului. Toate combinațiile valide ale altor componente a limbajului trebuie să fie parte a definiției sale. Pentru că o intrare să fie validă, înseamnă că, gradual, dacă se substituie componentele mai mici cu unele mai mari, acest nonterminal master poate fi substituit la sfârșit cu toate input-urile.
Parser-ul generat este un automat de stări. Parser-ul pune token-urile de intrare recepționate pe stivă. Această operație este numită mutare. Atunci când e cazul, e substituită cu nonterminale. Această operație numită reducere este aceeași procedură ca aceea descrisă mai sus. Dacă totul merge corect, toate input-urile sunt reduse la nonterminalul master și limbajul este valid.
În timpul validării gramaticii arborele de sintaxă abstract este de asemenea construit. Acest pas reprezintă partea crucială a procedurii de parsare. Arborele de sintaxă abstract reprezintă tot template-ul sub forma de arbore, unde părțile importante ale template-ului sunt reprezentate sub forma de noduri de arbore. Pentru a avea o idee cum arată acest arbore, ca exemplu avem arborele de sintaxă din imaginea de mai jos.
În arborele exemplificat avem noduri cu text, foreach cu ceva text și câteva variabile. Dar cum este construit acest arbore? Cu ajutorul lui bison putem specifica cod customizabil care să fie executat de fiecare dată când se întâmplă o reducere. Prin urmare, se poate reacționa la fiecare eveniment apărut în urma parsării template-ului și vor fi create nodurile arborelui. În exemplul nostru, arborele va avea noduri pentru text, foreach și variabile.
Bison generează metoda yyparse(). Aceasta apelează yylex()- metoda generată de flex - pentru a obține datele de întrare. În cazul în care analiza a fost cu succes se returnează valoare 0 și o altă valoare în caz de eroare. Utilizatorul metodei poate să specifice un parametru de intrare - cum ar fi un pointer spre rădăcina arborelui de sintaxă. Prin urmare, când analiza este gata, arborele construit din fișierul template poate fi returnat utilizatorului.
Partea de cod care va folosi arborele mai sus generat trebuie să știe concret ce se va executa pentru fiecare nod,astfel încât output-ul rezultat să fie conform specificațiilor. De exemplu, un nod care conține o variabilă va fi substituit cu o valoare găsita în fișierul xmlde intrare. Fiecare nod trebuie să conțină destule informații astfel încât utilizatorul arborelui se poate genera output-ul dorit. În exemplul menționat, nodul de variabilă trebuie să conțină numele variabilei reprezentate. Pentru a genera întreg output-ul, arborele de sintaxă trebuie parcurs în adâncime.
Un output generat pentru template-ul propus ca input, folosind doar una categoriile descrise de xml, ar fi:
class Smartphone
{
public:
Smartphone();
~ Smartphone ();
std::string getvendor();
void setvendor(const std::string &value);
std::string getos();
void setos(const std::string &value);
private:
std::string vendor;
std::string os;
}
Scrierea unui generator de cod nu este o sarcină ușoară; este nevoie de mult efort, dar de cele mai multe ori se dovedește a fi o investiție bună. Prin acest articol, am încercat să expun principalele provocări care apar în realizarea unei astfel de aplicații într-un mod cât mai ușor de înțeles.De aici și multitudinea de exemple. În cazul nostru, decizia de a investi timp în aprofundarea flex - bison s-a dovedit a fi excelentă. Datorită faptului că există și că am găsit soluții fiabile, noi ne-am concentrat în mare parte pe definirea limbajului. Flex - bison este o soluție care ne-a satisfăcut cerințele, având și o documentație bine pusă la punct. Lucrând cu generatoare de parser-e, un avantaj ar fi de subliniat: în cazul unor schimbări majore sau minore din punct de vedere structural sau al introducerii unor noi cerințe în codul deja existent, prin utilizarea generării de cod vor fi modificate doar câteva fișiere. Acest lucru ar fi în avantajul oricărui programator. Generarea codului folosind generator de cod e într-adevăr deosebită.
de Ovidiu Mățan