Generovanie kódu

Snímky prednášky

Generovanie zdrojového kódu

Podľa knihy Programátor pragmatik môžeme nástroje generujúce zdrojový kód rozdeliť do dvoch skupín:

  1. pasívne generátory, pri ktorých kód vygenerujeme raz a ďalej ho upravujeme ručne;
  2. aktívne generátory, ktoré používame vždy, keď je potrebné zmeniť kód.

Príkladom pasívnych generátorov sú nástroje generujúce kostru projektu. Po tom, čo je kód vygenerovaný, upravujeme ho ručne a generátor už ďalej nepoužívame. Ďalším použitím pasívnych generátorov sú nástroje na jednorazovú konverziu medzi jazykmi alebo formátmi, napríklad konvertor z Javy do Kotlinu v IntelliJ IDEA.

V minulosti sa pasívne generátory často používali na generovanie rozsiahlych častí kódu. Napríklad súčasťou Visual C++ boli sprievodcovia pre vytvorenie projektu, ktoré dokázali vygenerovať aj tisíc riadkov kódu. Výhodou bolo to, že ste tento kód nemuseli písať. Museli ste ho však rozširovať a udržiavať. Pritom išlo o kód, ktorý ste nepísali a vôbec ste mu nemuseli rozumieť. Preto sa takéto riešenie ďalej ukázalo ako nevýhodné. Dnes sa pomocou pasívnych generátorov zvykne generovať iba úplne triviálny kód, s pochopením ktorého by nemal nastať žiaden problém. Problém nutnosti veľkého množstva kódu hneď na začiatku projektu je dnes riešený skôr pomocou vyššej úrovne abstrakcie poskytovanej knižnicami a rámcami.

Príkladom aktívnych generátorov sú prekladače z jedného jazyka do iného. Napríklad prekladač zo SASS do CSS, alebo z TypeScriptu do JavaScriptu. Tieto prekladače spúšťame po každej zmene vstupného kódu.

Pri použití aktívneho generátora platí, že vygenerovaný kód nemá byť upravovaný ručne. Všetky zmeny sa robia iba v zdrojovom kóde a generátor sa spúšťa znovu. Pritom, samozrejme, všetky ručné zmeny výsledného kódu budú prepísané.

Príkladom aktívneho generátora je Google Protocol Buffers (skrátete Protobuf). Protobuf definuje jazyk pre opis údajových štruktúr, ktoré sa budú posielať v správach medzi procesmi. Na základe tohto opisu dokáže vygenerovať kód pre serializáciu a deserializáciu v niekoľkých podporovaných jazykoch.

Klasickým príkladom sú tiež generátory syntaktických analyzátorov ako YACC alebo Antlr, ktoré na základe formálne zapísanej gramatiky jazyka generujú kód v programovacom jazyku, ktorý dokáže text v tomto jazyku spracovať.

Vstupom prekladača je teda program v inom jazyku, ktorý poskytuje bohatšie vyjadrovacie schopnosti ako cieľový jazyk, alebo model opisujúci požadovaný výsledok na vyššej úrovni abstrakcie. Pričom model môže byť vyjadrený rôznymi spôsobmi. Môže to byť nejaký textový doménovo-špecifický jazyk, alebo dokonca grafický jazyk (napríklad diagramy).

Vzťah medzi modelom a generovaným textom
Obr. 1: Vzťah medzi modelom a generovaným textom

Model však môže byť zadaný pomocou používateľského rozhrania, napríklad formulára, či sprievodcu, alebo môže byť odvodený z rôznych vstupov. V každom prípade v generátore bude musieť existovať aspoň vnútorná reprezentácia modelu ako nejakej údajovej štruktúry.

Techniky generovania

Rôzne techniky generovania kódu sú veľmi detailne opísané v knihe Martina Fowlera Domain Specific Languages. Rozlišuje sa predovšetkým

  • transformačné generovanie a
  • generovanie pomocou šablón.

Transformačné generovanie predstavuje kód, ktorý prechádza vstupný model a na základe neho konštruuje výsledný text. Transformačné generovanie môže byť riadené vstupom alebo výstupom. Rozdiel spočíva v tom, či poradie generovania jednotlivých prvkov je dané ich poradím vo vstupnom modeli, alebo tým poradím, ktoré je vyžadované výstupným formátom.

Napríklad majme jazyk pre špecifikáciu konečno-stavových automatov, ktorý definuje stavy a prechody. Ak generátor prechádza elementy definované na vstupe bez ohľadu na ich typ a generuje príslušný kód v tom istom poradí, potom ide o generovanie riadené vstupom:

for (var element: stm.getElements()) {
    if (element.isState())
        printState(element);
    else if (element.isTransition())
        printTransition(element);
}

Ak však generátor najprv vyberie z modelu všetky stavy a následne vyberie všetky prechody, lebo také usporiadanie je potrebné na výstupe, potom ide o generovanie riadené výstupom:

printStatesHeader();
for (var state: stm.getStates())
    printState(state);

printTransitionsHeader();
for (var trans: stm.getTransitions())
    printTransition(trans);

Generovanie pomocou šablón využíva pripravenú statickú časť výsledného textu, v ktorej sú špeciálne označené dynamické elementy — šablónu. Pritom sa používa šablónový systém, ktorý skombinuje šablónu s premenlivými dátami — kontextom do výsledného textu.

Existuje množstvo hotových šablónových systémov, ktoré môžeme použiť. Hlavným rozdielom medzi nimi je šablónový jazyk — formát ako v texte označujeme premenlivé časti, ktoré budú vyplnené na základe kontextu. Medzi populárne šablónové systémy patria napríklad:

Väčšinou sa transformačné a šablónové generovanie používajú spolu. Napríklad kostra výstupného súboru je generovaná pomocou šablón, ale niektoré zložitejšie časti pomocou transformačného kódu. Alebo naopak — kostra výstupu je generovaná transformačne na základe vstupu, ale jednotlivé elementy sú opísané šablónami.

Na čo si dať pozor

Exploits of a Mom
Obr. 2: Exploits of a Mom

Útok typu SQL Injection je iba konkrétnym príkladom všeobecnejšieho problému, ktorý vzniká pri generovaní kódu.

Pri generovaní kombinujeme dopredu pripravené časti kódu so vstupom, ktorý prichádza z vonku. Zatiaľ čo časti kódu, ktoré sú súčasťou generátora, môžeme pripraviť tak, aby presne zodpovedali syntaxi cieľového jazyka, časti preberané zo vstupu môžu potenciálne obsahovať čokoľvek. Napríklad pri generovaní dopytu v jazyku SQL môžeme použiť jednoduchú šablónu:

String.format("INSERT INTO Students(name) VALUES ('%s');", studentName);

Pritom predpokladáme, že obsah, ktorý vložíme bude celý súčasťou reťazca. Ak však premenná studentName obsahuje reťazec „Robert'); DROP TABLE Students;--“, jej obsah naruší nami zamýšľanú štruktúru a namiesto jedného príkazu INSERT dostaneme INSERT nasledovaný príkazom DROP TABLES:

INSERT INTO Students(name) VALUES ('Robert'); DROP TABLE Students;--');

Takýto problém môže vzniknúť aj neúmyselne. Napríklad ak niekto má priezvisko „O'Reilly“, v našom prípade pridanie jeho mena zlyhá, lebo vygenerovaný kód nebude syntakticky správny. Program by však mal vedieť správne spracovať všetky mená (napríklad aj Jennifer Null, alebo Rachel True).

Dynamickými prvkami, ktoré generujeme na základe vstupu sú zvyčajne reťazce. Pre zápis reťazcov vo väčšine jazykov platí pravidlo, že musia byť obklopené úvodzovkami alebo apostrofmi a vo vnútri nesmú obsahovať samotné úvodzovky a niektoré ďalšie znaky. Takéto pravidlá sú nevyhnutné kvôli tomu, aby bolo možné neskôr daný kód jednoznačne analyzovať. Špeciálne znaky však môžeme reprezentovať pomocou zápisu zvaného escaping. Napríklad priezvisko „O'Reilly“ musíme v rôznych dialektoch SQL reprezentovať ako 'O''Reilly' alebo 'O\'Reilly'.

Podobné problémy vznikajú ak dynamicky generujeme identifikátory. Identifikátory môžu obsahovať iba obmedzenú množinu znakov a nesmú sa zhodovať s kľúčovými slovami jazyka. Napríklad môžeme vygenerovať databázovú tabuľku reprezentujúcu premenovanie, ktorá sa bude volať „RENAME“ a bude mať stĺpce „FROM“ a „TO“. Tento príkaz však zjavne nie je validným v SQL:

SELECT FROM, TO FROM RENAME;

Jedným z riešení je escaping, podobne ako pri reťazcoch. Avšak málo ktoré jazyky ho podporujú pri identifikátoroch. Ďalším riešením je quoting — obklopenie špeciálnym druhom úvodzoviek, ktoré sú určené pre identifikátory. Túto možnosť podporuje SQL, kde identifikátory môžeme uvádzať v úvodzovkách:

SELECT "FROM", "TO" FROM "RENAME";

Ďalšou možnosťou je prepis mien, pri ktorom identifikátory nespĺňajúce podmienky jazyka modifikujeme:

SELECT FROM_, TO_ FROM RENAME_;

Musíme však dávať pozor na to, aby modifikované identifikátory neboli v konflikte s inými identifikátormi.

Literatúra

Makrá v C

Makrá sú jedným zo spôsobov generovania kódu. Makro definuje vzor, ktorý bude vyhľadávaný v kóde programu a pravidlo čim bude tento kód nahradený. Typickým prípadom sú makrá v jazyku C, ktoré sú definované pomocou #define.

Zoberme si ako príklad makro umožňujúce výmenu hodnôt dvoch premenných:

#define SWAP(a, b, type) type t = a; a = b; b = t

Makro môžeme použiť v kóde podobne ako voláme funkcie:

int x = 1, y = 2;
SWAP(x, y, int);
printf("x=%d, y=%d\n", x, y);

Na rozdiel od volania funkcie, ktoré sa uskutoční počas behu programu, makro sa vykoná už počas kompilácie. Výsledkom vykonania makra bude nahradenie jeho použitia pomocou tela makra:

int x = 1, y = 2;
int t = x; x = y; y = t;
printf("x=%d, y=%d\n", x, y);

To znamená, že napriek podobnosti použitia, makrá fungujú úplne inak ako funkcie. Jednou z výhod makra oproti funkcií môže byť väčšia efektivita, keďže počas vykonania odpadá potreba vytvárať nový aktivačný záznam na zásobníku volaní a prepínať riadenie do inej funkcie a naspäť. To sa však v súčastnosti dá riešiť aj pomocou inline funkcií.

Podstatnejšou výhodou je to, že makro môže obchádzať niektoré obmedzenia jazyka — jeho syntaxe a štandardného spôsobu vyhodnocovania. Napríklad makro SWAP má typ premennej ako parameter, čo by sa pri funkcii nedalo realizovať. Makrá tiež umožňujú implementovať niečo, čo vyzerá ako nové syntaktické konštrukcie. Napríklad rámec pre testovanie Googletest poskytuje takúto konštrukciu pre definíciu testu, kde za slovom TEST nasleduje názov sady testov, názov testu a telo testu:

TEST(FactorialTest, HandlesZeroInput) {
  EXPECT_EQ(Factorial(0), 1);
}

Poznámka

Iné jazyky môžu poskytovať bohatšie vyjadrovacie schopnosti, ktoré umožnia realizovať podobné syntaktické konštrukcie bez použitia makier. Napríklad toto je príklad definície testov v jazyku Kotlin pomocou knižnice Kotest:

class MyTests : StringSpec({
  "length should return size of string" {
    "hello".length shouldBe 5
  }
  "startsWith should test for a prefix" {
    "world" should startWith("wor")
  }
})

Problémy textových makier

Okrem užitočných vlastností majú makrá aj viacero problémov. Textové makrá, ktoré sa používajú v jazyku C, pracujú s kódom programu ako s jednoduchým textom a preto po ich nahradení sa môže stať, že výsledok nebude syntakticky alebo sémanticky správny.

Vráťme sa k príkladu makra SWAP. Ak ho použijeme vo vnútri iného príkazu, môže nastať problém.

int x = 1, y = 2;
if (x > y)
    SWAP(x, y, int);
printf("x=%d, y=%d\n", x, y);

Po nahradení makra totiž iba prvý jeho príkaz bude súčasťou podmienky a ostatné zostanú mimo nej (upravil som formátovanie pre lepšiu čitateľnosť):

int x = 1, y = 2;
if (x > y)
    int t = x;
x = y; y = t;
printf("x=%d, y=%d\n", x, y);

Ďalší problém nastane v prípade, že v kóde, ktorý používa makro sa už používa premenná s názvom t. Obidva problémy sa dajú vyriešiť tým, že telo makra sa zabalí do príkazu do-while takto:

#define SWAP(a, b, type) do { type t = (a); (a) = (b); (b) = t; } while (0)

Syntaktické makrá

Mnohé problémy textových makier riešia syntaktické makrá, ktoré pracujú na úrovni syntaktického stromu a nie textu. Syntaktické makrá nenahradzujú fragment textu iným fragmentom textu, ale fragment syntaktického stromu iným fragmentom syntaktického stromu. Takéto makrá sa používajú napríklad v dialektoch jazyka LISP.

Na ilustráciu použijeme jazyk Racket, ktorý vychádza z jazyka Scheme. Tak ako všetky dialekty LISPu, aj Racket má nezvyčajnú syntax používajúcu veľké množstvo zátvoriek. Neprajníci dokonca tvrdia, že názov LISP znamená „lost in stupid parentheses“. Ako príklad si pozrime definíciu funkcie na výpočet n-tého člena Fibonacciho postupnosti:

(define (fib n)
  (cond
    ((= n 0) 0)
    ((= n 1) 1)
    (else (+ (fib (- n 1))
             (fib (- n 2))))))

Volanie tejto funkcie by vyzeralo jednoducho tak, že v zátvorkách uvedieme názov funkcie a jej argument (fib 5). Celý kód programu je vlastne zapísaný pomocou vnorených zoznamov uzavretých v zátvorkách. Týka sa to nielen volaní funkcií, ale aj operátorov a zabudovaných konštrukcií jazyka. Prvý prvok zoznamu označuje operáciu, ktorá sa má vykonať a zvyšok — jej argumenty. Tým pádom je kód v LISPe vlastne len serializovaný syntaktický strom s minimom doplňujúcej syntaxe.

Definícia makra pre výmenu premenných v Racket vyzerá následovne:

(define-syntax-rule (swap x y)
  (let ([tmp x])
    (set! x y)
    (set! y tmp)))

Definuje sa vzor a to, ako bude tento vzor nahradený, podobne ako je to v C. Rozdiel je však v tom, že tu sa vzor aj jeho náhrada spracovávajú ako zoznamy — fragmenty syntaktického stromu, čim odpadá značná časť problémov syntaktických makier, čo je opísané aj v dokumentácii makier založených na vzoroch.

Makrá môžu byť implementované nielen ako jednoduché náhrady, ale aj všeobecnejšie — ako funkcie manipulujúce fragmentom syntaktického stromu počas kompilácie. Dokumentácia Racket uvádza príklad makra swap ako transformačnej funkcie, ktorá najprv kontroluje, či zadané argumenty sú identifikátormi:

(define-syntax (swap stx)
  (syntax-case stx ()
    [(swap x y)
     (if (and (identifier? #'x)
              (identifier? #'y))
         #'(let ([tmp x])
             (set! x y)
             (set! y tmp))
         (raise-syntax-error #f
                             "not an identifier"
                             stx
                             (if (identifier? #'x)
                                 #'y
                                 #'x)))]))

Príklad definície makier a vysvetlenie významu ich použitia nájdete aj v článku Educational Pearl: Automata via Macros.