spi [spáj]

(Simply Pick Information)

Motivace

Kontext:

Spolu s přenosem textových dokumentů je spojen velký problém nekompatibility datových formátů. Přitom mnohdy stačí získat ze souboru textovou informaci v co nejjednodušší podobě a pokud možno bez velké námahy.

Problém:

Jak snadno z velmi různorodých dokumentů extrahovat textovou informaci?

Síly:

Řešení:

Vytvořit prostředí, v němž každý uživatel snadno připraví pro problematické dokumenty daného formátu "konverzní rouru", na jejímž konci bude čistá textová informace z dokumentu. Konverzní roura bude mít podobu skriptu ve speciálním jazyce spi.


Popis jazyka spi

S ohledem na předchozí motivaci jsem stanovil, že příkazy jazyka spi musí dovolit:

Příkazy jazyka spi

Příkaz jazyka spi má tento formát:
klíčové_slovo [případné volby]

Za příkazem může následovat středník a další příkaz, nebo může být další příkaz uveden na následující řádce. Většina příkazů může i svými případnými volbami přecházet i na další řádky, přesto je možné o spojení řádek explicitně požádat znakem "\" na konci řádky. Klíčová slova a později i jména maker a kontextů jsou rozpoznávána bez ohledu na velikost písmen.

Pro další účely je zaveden též tzv. složený příkaz, tj. posloupnost příkazů uzavřená ve složených závorkách.

Ve skriptu je rovněž možné používat komentáře. Za komentář je považován řetězec od znaku "#" do konce řádky. (Je-li znak "#" uveden uvnitř nějakého ohraničeného řetězce, není přirozeně pokládán za začátek komentáře.)

Aby bylo možné v jazyce spi popsat výše uvedené operace, zavedl jsem následující klíčová slova představující jednotlivé typy příkazů jazyka spi. Pro přehlednost jsem je příkazy rozdělil do několika skupin:

Příkazy pro práci s kontexty slouží k omezení platnosti jiných příkazů jen na určitou část vstupního dokumentu.
Příkazy volání představují samostatnou skupinu příkazů, jsou obdobou volání uživatelských procedur.
Jednoduché příkazy slouží k provedení jednoduchých změn v daném úseku dokumentu.
Pomocné příkazy slouží k definování maker, zavedení knihoven připravených operací, předběžné deklaraci maker a kontextů.

Příkazy pro práci s kontexty

Příkazy pro práci s kontexty slouží k omezení platnosti dalších příkazů na určitou část dokumentu a k dalšímu ovlivnění chování interpretu spi v těchto částech. Stručně se část dokumentu nalezená podle určitých pravidel nazývá kontext. Pravidla používaná k nalezení kontextu se stručně nazývají popis kontextu.

Obecně mají příkazy kontextu tuto syntaxi:

[klíčové_slovo] [název_kontextu:] <popis_kontextu> <akce_v_kontextu>

Klíčovým slovem je buď slovo context, nebo slovo in. Rozdíl mezi nimi spočívá v tom, že po slovu context povinně následuje uživatelem volený název kontextu a znak dvojtečka, kdežto po slovu in název ani dvojtečka naopak následovat nesmí. Je-li klíčové slovo úplně vynecháno (a je to jediná povolená možnost vynechání klíčového slova v celém jazyce spi), má příkaz stejný význam, jako s použitým slovem in.

Akcí v kontextu je jeden příkaz jazyka spi nebo složený příkaz.

Popis kontextu tvoří posloupnost regulárních výrazů a oddělovačů čárka a svislá čára. Podle typu popisu kontextu jazyk spi odlišuje dva typy kontextů: tvarový a běžný.

Pro popis tvarového kontextu stačí jediný regulární výraz. Tento výraz udává, jak má vypadat text, který bude považován za tělo kontextu a bude dále zpracováván příkazy akce. V rámci tvarového regulárního výrazu má opodstatnění definovat proměnné, tj. části výrazu ohraničené kulatými závorkami (viz oddíl o regulárních výrazech níže). Části textu dokumentu, jež odpovídají definovaným proměnným se lze v rámci jednoduchého příkazu reshape odvolávat pomocí specielních opisů.

Pro popis běžného kontextu je potřeba dvojice (regulárních) výrazů oddělená čárkou, z nichž první odpovídá levému a druhý pravému hraničnímu řetězci. Část dokumentu mezi hraničními řetězci je označována jako tělo kontextu a je postoupena k dalšímu zpracování akcí kontextu. Hraniční řetězce nejsou do řetězce těla kontextu zařazeny.

Místo koncového regulárního výrazu je možné užít jméno zavedeného kontextu. Tento kontext se označuje jako nahrazující V tomto případě se jako koncové výrazy používají též všechny začáteční nebo tvarové regulární výrazy nahrazujícího kontextu. Nalezený výraz pak není považován za koncový výraz předchozího kontextu, ale za začáteční výraz následujícího kontextu a jemu je též ke zpracování předán.

Symbol svislé čáry slouží k tzv. přetížení výrazů. Dva výrazy oddělené tímto znakem spolu při vyhledávání kontextu "soupeří", který začne dřív a který bude delší (v tomto pořadí). Takto je možné přetížit jak tvarové výrazy, tak začáteční nebo koncové výrazy. Mezi koncové výrazy lze stejně přiřadit i jména nahrazujících kontextů.

Příklad:

context paragraph: /<P>/i | /<DIV>/, /<\/P>/i | header | list omit;
Tento příkaz říká: odstavec smaž. Podrobněji: vyhledej kontext odstavec, tj. řetězec, který začíná příkazem P nebo DIV a končí koncem P nebo začátkem hlavičky nebo seznamu; v tomto kontextu proveď akci omit, tj. smazání. Předpokládá se, že header a list jsou řádně definované kontexty.
Pokud by akcí v kontextu mělo být pouze volání jiného kontextu (viz níže), je nutné toto volání uvést v rámci složeného příkazu. Jinak bude název volaného kontextu považován za další nahrazující kontext.

Uvedením příkazu kontextu do skriptu spi provádíme současně dvě oddělené operace: kontext definujeme a použijeme, voláme zároveň.

Definování kontextu představuje zavedení jména kontextu do prostoru použitelných klíčových slov a přiřazení popisu kontextu a akce v kontextu k tomuto jménu. Od tohoto okamžiku lze použít název kontextu jako příkaz volání (viz níže) nebo jako nahrazující kontext jiného kontextu. (V případě bezejmenného kontextu uvozeného klíčovým in pravda nemá definování praktický užitek.)

Použití (též volání) kontextu představuje pro spi skutečně pokyn k hledání hranic kontextu a k provedení příkazů akce na nalezené tělo kontextu. Je-li kontext již z dřívějška definován, lze pro jeho další použití použít příkazu volání.

Chcete-li dosáhnout pouze definování nějakého kontextu, aniž by byl kontext hned použit, musíte příkaz kontextu zahrnout do těla nějakého makra (viz pomocné příkazy). To se může hodit např. při přípravě knihoven užitečných operací (viz též pomocné příkazy).

Samostatný příkaz pro práci s kontexty uvozuje klíčové slovo or. Příkaz má velmi zvláštní syntaxi:

or <příkaz kontextu>

Příkaz or tedy pouze uvozuje nějaký příkaz kontextu. (Příkazem kontextu může být i volání kontextu definovaného jinde, viz příkazy volání.)

Sémantický význam příkazu or je následující: tento příkaz kontextu souřadně spoj s předchozím příkazem kontextu. Souřadné spojení příkazů kontextu znamená, že interpret bude současně hledat, který z kontextů začne v dokumentu dříve a podle toho se rozhodne, které příkazy má na nalezený text uplatnit. Z toho vyplývá i omezení použitelnosti příkazu or, může být uveden pouze za příkazy kontextu nebo za jiným příkazem or.

Pro ovlivnění chování interpretu nad tělem kontextu lze v akci kontextu použít i příkazy uvozené klíčovými slovy omit a print:

omit
Tělo aktuálního kontextu lze smazat, tj. nevložit do výstupního souboru. Smazaný kontext nemůže být dále zpracováván, omit tedy ukončí zpracovávání aktuálního kontextu, podobně jako exit ukončuje běh procedury. (Tzv. příkazy následující v rámci složeného příkazu příkaz omit jsou ignorovány. Výjimkou je příkaz print.)
print [tied]
Příkaz print není v pravém slova smyslu příkazem. Nevykonává totiž žádnou specifickou operaci nad vstupním dokumentem. Jeho atribut tied pouze udává, aby byl aktuální běžnýkontext po zpracování na výstup odeslán včetně hraničních výrazů.

Příkazy volání

Příkazy volání jsou tvořeny pouze názvem volaného makra nebo kontextu, které hraje v příkazu roli klíčového slova. Předpokladem je, že kontext nebo makro byly předem řádně deklarovány nebo definovány.

Jak již bylo uvedeno výše, příkaz volání kontextu může být použit i za příkazem souřadného spojení kontext, or.

Jednoduché příkazy

Klíčová slova exact a reshape (nebo znak ->) uvozují příkazy pro pozměnění tvaru dokumentu. Příkazy mají tuto syntaxi a pravidla použití:
exact { string1 -> replacement1, ...}
Nahradí všechny výskyty řetězců string1, ... jejich příslušnými náhradami replacement1... Nahrazování je přesné, tzn. děje se s ohledem na velikost i diakritiku písmen. V řetezcích nelze užít znak 0, ostatní opisy jednotlivých znaků jsou povoleny (viz též dodatek).
Řetězec musí být zadán v jednom z povolených oddělovačů, tj. mezi uvozovkami, apostrofy, lomítky nebo vykřičníky. Použití určitého typu oddělovačů nemá vliv na chování příkazu a není ani závazné pro všechny řetězce v rámci jednoho příkazu exact.
Příkaz exact lze přirozeně použít na konverzi znakové sady.
Příkaz exact je možno použít kdekoli, v hlavní úrovni skriptu, v těle makra i v těle libovolného kontextu.
reshape /new-shape/
-> /new-shape/
Příkaz reshape (místo klíčového slova reshape lze užít "klíčový symbol" ->) slouží k změně tvaru právě zpracovávané části dokumentu, tzv. kontextu (viz níže).
new-shape je v tomto případě řetězec ohraničený opět libolným z povolených typů oddělovačů udávající, čím se má zpracovávaná část nahradit. V řetězci lze používat opisy libovolných jednotlivých znaků a náhrady za proměnné, tj. výrazy \1, \2..., jež jsou nahrazeny nalezeným textem.
Příkaz reshape je možné použít pouze jako první z příkazů v nějakém tvarovém kontextu. Toto omezení je zřejmé, pokud si uvědomíme, že jedině hned po vyhledání kontextu je dobře definován obsah proměnných, na něž se příkaz reshape může odvolávat.
Velmi stručně lze příkaz reshape použít hned za popis tvarového kontextu. Dosáhneme tak elegantního řádku, který realizuje vyhledávání a nahrazování podle regulárního výrazu:
/shape/ -> /newshape/

Pomocné příkazy

V předchozím textu jsme se odvolávali nejčastěji na pomocný příkaz definice makra. Tento příkaz je tvořen klíčovým slovem macro a parametry podle této syntaxe:

macro <název_makra>: <akce_makra>;

Příkaz macro provádí pouze definici makra. Pro použití makra je nutné ve skriptu uvést příslušný příkaz volání.

Často může být ve skriptu třeba odkázat se ať již příkazem volání nebo nahrazujícím kontextem na kontext nebo makro, jejichž definice teprve přijdou. Pro tyto situace je zavedeno klíčové slovo forward uvozující příkaz předběžné deklarace makra nebo kontextu, syntaxe je následující:

forward <slovo macro nebo context> <zaváděné_jméno_makra_nebo_kontextu>

Posledním pomocným příkazem je příkaz vložení souboru do skriptu uvozený klíčovým include. Syntaxe příkazu:

include "jméno_vkládaného_souboru"
Jméno vkládaného souboru musí být uvedeno v uvozovkách.

Příkaz vložení souboru není příkazem pro interpret spi, ale pro kompilátor. Vložení souboru do skriptu má tentýž efekt, jako kdybychom všechny příkazy z vkládaného souboru opsali na místo příkazu include, tzn. včetně provádění všech operací.

Při častém použití užitečných soustav příkazů se vyplatí shrnout je v podobě maker do knihovny, jejíž obsah pak můžeme do libovolného skriptu nechat vložit. Abychom se vyvarovali vedlejších efektů, je však nutné dbát na to, aby knihovna obsahovala v hlavní úrovni pouze příkazy macro, tj. pouze definice maker a žádné příkazy provádějící nějaké úpravy vstupních dokumentů.

Zápis regulárních výrazů

Klíčovou operací interpretu spi je práce s regulárními výrazy. Regulární výrazy se používají jak na vyhledávání kontextů, tak i na opravy vstupního textu. Ať už jsou regulární výrazy ve skriptu použity kdekoli, musí být zapsány podle následujících pravidel.

Regulární výraz musí být od okolního textu oddělen párem jednoho typu povolených oddělovačů. Povolené oddělovače jsou lomítko, vykřičník, apostrof nebo uvozovka a použití konkrétního typu nemá na význam výrazu vliv. Bezprostředně za koncovým oddělovačem může následovat jeden nebo více modifikátorů chování výrazu (viz níže).

Klasické regulární výrazy známé z unixových programů jsou prou účely spi mírně upraveny:

Modifikátory regulárních výrazů, jež lze je připojit za konec výrazu, jsou tyto:

i
ignoruj velká a malá písmena
s
spaced - znak mezery se v regulárním výrazu nahrazuje \w+, tj. odpovídá mu posloupnost jednoho a více libovolných bílých znaků

Příklady:

/<{P||H[1-6]}[^>]*>/i
Odpovídá řetězci příkazu jazyka HTML pro odstavec nebo hlavičku i se všemi atributy až po uzavírací >.

/{[^\b]*{60-79}\b}*/
V textu se zalamovanými odstavci (wrap na 79 znaků) vyznačí celý odstavec textu, tj. všechny řádky až do první kratší 60 znaků.
Význam výrazu: Co nejvíc výrazů, které začínají 60 až 79 libovolnými znaky vyjma znaku konce řádku (v libovolné notaci, CR, LF nebo CRLF) a jsou znakem konce řádku následovány.

Popis interpretace skriptu spi

Skript spi jako celek představuje soubor akcí příslušejících k výchozímu kontextu - celému vstupnímu dokumentu. Jednoduchý skript tedy může vypadat třeba jen takto:
/s[yi]sel/ -> /jouda/

Nahrazuje slova sysel a sisel slovem jouda v celém vstupním dokumentu.

Pro strukturální členění dokumentu je potřeba definovat kontexty. Nejprve popíšeme příklad slovně:

jsi-li v textové části dokumentu (kontext 1):
  převeď znakovou sadu z CP1250 do ISO-8859-2
  zalomené odstavce spoj
nebo jsi-li v unicodové části dokumentu (kontext 2):
  smaž přebytečné znaky 0 před každým písmenem
  zase převeď znakovou sadu
  zase spoj zalomené odstavce
zaveď typografické uvozovky
zaveď vlnku
odstavce zalom na 72 znaků

Program probíhá takto: Současně se vyhledává, který z kontextů 1, 2 nebo 3 nastane nejdříve. Jakmile je nalezen takový kontext (je nalezen jeho začáteční výraz nebo tvarový výraz, koncový výraz běžného kontextu je vyhledáván až dodatečně, s ohleden na nalezený vstup do kontextu), vyznačená část textu vstupuje do "podprocedury" k dalšímu zpracování. Po zpracování v rámci procedury se pokračuje v další části vstupního dokumentu ve vyhledávání kontextů 1 nebo 2. Je-li prohledán a podprocedurami upraven celý dokument, provede se na něm náhrada obyčejných uvozovek typografickými a pak zavedení pevné mezery před neslabičné předložky. Úplně nakonec je text zalomen.

Je vidět, že někdy jsou příkazy psané pod sebou chápány jako současně probíhající vyhledávání a někdy jako posloupnost příkazů, která se má postpně provést na aktuální část dokumentu. Pravidlo jsou jednoduchá: pokud je příkaz kontextu následován příkazem or (s udáním dalšího kontextu), jsou oba tyto příkazy spojeny do pomyslného příkazu větvení kontextu. Příkaz větvení kontextu je pak už prováděn běžným způsobem, tak, že dále zpracovává výstup příkazu, který je uveden nad ním, a jeho výstup je převeden na vstup příkazu následujícího.

Jak tedy vypadá popsaný příklad ve spi?

forward macro translit # makro translit bude definováno až později
context text: /(\p*{10-})/
{ # text je to, co obsahuje alespoň deset tisknutelných znaků
  # převedeme znakovou sadu, převodní tabulku máme v makru níže
  translit;
  # odstraníme zalomení řádek odstavců
  context unwrap: /({[^\b]*{60-79}\b}*)/
  { # zalomený odstavec jsou řádky dlouhé 60 až 79 znaků
    / *\b */ -> / /;
    # v zalomeném odstavci zruš konce řádků a dej mezi slova jen mezeru
  };
};

or context unicode: /({\#00\p}*{10-})/
{ # unicode je to, co obsahuje tisknutelné znaky proložené nulami
  # zahodíme zbytečné nuly
  context zapzeros: "\#00" -> "";
  # převedeme znakovou sadu, převodní tabulku máme v makru níže
  translit;
  # odstraníme zalomení řádek odstavců, makro už bylo popsáno výše
  unwrap;
};
# dalším příkazem není příkaz or, takže se napřed celý dokument zpracuje
# předchozími kontexty a pak teprve je předán následujícím příkazům

exact {
  ' "' -> ' ``';
  '" ' -> "'' ";
};

/(\w+[szkv])\w+/i -> /\1~/;
# spoj neslabišné předložky znakem ~ s dalším textem

/\b+/ -> /\n\n/; # odstavce zdvoj

/([^\b]*{-72})\w+/ -> /\1\n/;
# za nejvýš 72 znaků kromě konce řádku dej místo bílého místa,
# typicky mezery mezi slovy, konec řádku

# dále následuje jen definice makra, makro se samo se nespustí, ale je
# spouštěno příkazem volání uvedeným v jednotlivých kontextech
macro translit: exact { Š -> ; ... };
  # obsahuje převodní tabulku z CP 1250 do ISO-8859-2

Postatnou informací je, že v rámci kteréhokoli příkazu, včetně příkazu kontextu, volání kontextu nebo větvení kontextu, jsou data, která nevyhovují podmínkám příkazu rovněž postoupena k dalšímu zpracování. Výjimkou jsou hraniční výrazy běžných kontextů, není-li o jejich vytištění explicitně požádáno příkazem print tied, a těla kontextů, které v rámci své akce obsahují příkaz omit.


Programátorská dokumentace spi

Struktura programu

Vstupem programu spi je soubor se skriptem a vstupní soubory, na nichž se má skript vykonat. Na výstupu jsou výstupní soubory odpovídající zpracovaným vstupním. Struktura programu spi se od tohoto základního úkolu odvíjí.

Základní bloky programu spi jsou:

Důvody tohoto členění blíže vysvětluje další text.

Důvody vyčlenění samostatného modulu pro práci s regulárnímí výrazy jsou zjevné. Jednak je prohledávání textu podle regulárního výrazu z hlediska programátorské náročnosti úkol plně srovnatelný se samotnou kompilací zbytku skriptu a jednak je takový samostatný modul velmi dobře použitelný v dalších programech. Rozhodl jsem se celý tento modul sám implementovat, za prvé z cvičných důvodů a za druhé, protože spi podporuje v regulárních výrazech atypické prvky.

Modul pro vyhledávání podle souboru vzorků hraje významnou roli při rozpoznávání klíčových slov během kompilace skriptu a rovněž při interpretaci příkazu exact. Je to modul, který jsem vyrobil již dříve pro účely zápočtového příkladu z jazyka C a z tohoto důvodu se mu nebudu v této dokumentaci blíže věnovat.

Vykonávaný skript ze vstupního souboru nelze číst pouze lineárně, jednak z důvodu souřadného spojení kontextů, jež však ve skriptu musí být přirozeně popsány pod sebou, jednak z důvodu maker a dalších odkazů na kontexty. Abych se vyhnul nutnosti listovat souborem skriptu, rozhodl jsem se soubor skriptu číst jen jedenkrát a skript uchovat v operační paměti v kompilovaném tvaru. Toto rozhodnutí neprotiřečí ani požadavku maximálních úspor paměti pro samotné zpracovávání vstupních souborů, budeme-li předpokládat, že prováděné skripty jsou mnohem kratší než zpracovávané soubory. Příjemným vedlejším efektem je pak odhalení všech syntaktických chyb ve skriptu hned na začátku a rovněž méně nákladné provádění jednoho skriptu na větší množství vstupních souborů.

Posledním blokem programu spi je interpret kompilovaného skriptu nad vstupním souborem, tj. de facto celý úkol programu spi. Tento interpret může být díky osamostatnění předchozích částí implementován srozumitelnějším kódem, protože řada podružných úkolů (čtení skriptu a jeho syntaktická a semantická analýza ad.) byla již vyřešena.

Popis modulu pro práci s regulárními výrazy

Modul nFA je pojmenován podle metody, která je v něm pro vyhledávání regulárních výrazů použita, totiž nedeterministický konečný automat (nondeterministic finite automata).

Modul svému uživateli poskytuje třídu nFA. V rámci konstruktoru je řetězec regulárního výrazu zkompilován do vyhledávacího stroje a uložen v datech objektu. Další metody pak slouží k prohledávání textu na regulární výraz a k předávání nasbíraných proměnných (řetězců vyznačených závorkami a typicky odkazovaných jako \1, \2...). Informaci o v "uživatelském rozhraní" regulárních výrazů lze najít v odpovídající sekci výše.

nFA_chunk

Při kompilaci výrazu je použit Objekt stavitele (viz vzorec v dodatku 2), nFA_chunk, v němž se rekurzí podle struktury výraz zpracuje. Regulární výraz (k němuž lze stavět odpovídající automat) je:

Priority jednotlivých operací odpovídají uvedenému pořadí, např.: a*b|cd je výraz odpovídající slovům začínajícím libovolným počtem písmen a (včetně úplného vynechání) následovaných jedním písmenem b, nebo slovu cd.

Jako klíčovou proceduru rekurzivní konstrukce jsem zvolil následující úkol, implementovaný v konstruktoru objektů nFA_chunk: "Postav ve svých datových strukturách automat pro regulární výraz, který začíná na dané adrese (označované jako výhled, lookahead) a tento odkaz přitom posuň za řetězec regulárního výrazu." Je zřejmé, že zavolání této procedury na celý výraz uzavřený ve složených závorkách vrátí požadovaný automat. Podstatnou skutečností při implementaci této procedury je, že typ regulárního výrazu lze rozeznat již z jediného znaku ve výhledu.

Postup při čtení nějakého složeného regulárního výrazu (mezi něž můžeme zařadit i celý žádaný regulární výraz) je tedy následující:

Opakuj, dokud nenarazíš na konec výrazu:

  1. rozhodni další postup podle výhledu a načti si přitom do pomocné struktury následující regulární výraz typu:
    {, (
    složenina (příp. chápána jako proměnná)
    |
    následující výraz budeme připojovat souřadně, načti si složeninu až do ukončení výrazu (do "}" nebo ")")
    [
    množina
    }, )
    označuje konec výrazu
    \
    opis, podle dalšího znaku se může jednat o jednoduchý výraz, množinu nebo složeninu
    ostatní
    jednoduchý reg. výraz (odpovídá jednomu konkrétnímu písmenu)
  2. (výhled se posunul za načtený výraz)
  3. pokud jsme nenačítali výraz k souřadnému připojení a pokud je ve výhledu znak "*", "+" nebo "?", uprav právě načtený automat tak, aby dovoloval iteraci (s případnými omezeními podle dalších instrukcí); posuň výhled za instrukci iterace
  4. připoj právě získaný automat k dosavadní konstrukci, podle předchozího buď souřadně nebo za sebe

Po nalezení konce výrazu překroč výhledem i koncový znak a odevzdej výsledek.

Datové struktury pro uchování automatu

Struktura nedeterministického konečného automatu je ve své abstraktní podobě dobře známa. Pro její uchování je třeba nějakým způsobem reprezentovat množinu stavů a množinu přechodů mezi jednotlivými stavy, přičemž u jednotlivých přechodů jsou uvedeny dodatečné informace o tom, kdy je možné přechod učinit. (Přechod se děje přes určitý znak nebo jeden znak z určité množiny.) Pro naše potřeby je rovněž nutné ve struktuře nějak reprezentovat informaci o začátku a konci sledování proměnných a o počtu opakování, kolikrát je povoleno přechod provádět.

Je patrné, že těžiště informací je v jednotlivých přechodech. Z tohoto důvodu jsem nejprve zavedl strukturu nFA_transition, která reprezentuje jeden přechod mezi stavy automatu. Mezi její data patří např. informace o tom, jestli je přechod podmíněn výskytem určitého pevně daného znaku (proměnná c) nebo výskytem znaku z určené množiny (proměnná set).

Jedinou informací, kterou je třeba udržovat o nějakém stavu automatu, je soubor přechodů, které lze z tohoto stavu realizovat. Nejprve jsem chtěl soubor odkazů na příslušné struktury nFA_transition realizovat pomocí rostoucího pole pointrů, po problémech s implementací tohoto pole jsem se ale rozhodl zvolit jednodušší strukturu spojového seznamu. Do struktury nFA_transition jsem proto přidal odkaz next, ukazující na dalšího člena řetízku možných přechodů, nebo na NULL. Odkaz na strukturu stavu a na strukturu jediného přechodu se tedy typově neliší, v obou případech jde o nFA_transition*, rozdíl je jen v tom, zda vnímáme jediný objekt přechodu, nebo celý řetízek těchto objektů spojených přes proměnnou next.

Informace o cílovém stavu je v každém přechodu uskladněna v proměnné dest_state typu ukazatel na strukturu stavu, tedy vlastně nFA_transition*. Ve speciálním případě přechodů, jež vedou do koncového stavu automatu (tzn. výraz bude po absolvování tohoto přechodu přijat), je již odkaz dest_state směrován na NULL.

Dosud tedy struktura automatu vypadá jako kanonický binární strom z prvků nFA_transition, jak ukazuje následující text, není ovšem ve skutečnosti tak jednoduchá.

V metodách třídy nFA_chunk, která si drží odkaz na kořen svého automatu, je potřeba realizovat takové operace s automatem, jak popisuje předchozí kapitola.

Jednoduchým problémem je spojování automatů za sebe. Pro tyto účely stačí umět najít v prvním automatu koncové přechody (tj. přechody vedoucí do koncových stavů, tj. přechody s nulovým dest_state). Aby toto vyhledání proběhlo co nejrychleji a nebylo nutné procházet všechny přechody v automatu, rozhodl jsem se mezi pomocné informace o automatu uskladněné v každé instanci objektu nFA_chunk přidat proměnnou finishers, obsahující rostoucí pole ukazatelů na všechny stavy automatu, z nichž vedou přechody s nulovým odkazem dest_state. U jednoduchého automatu je v tomto poli pouze odkaz na kořenový stav, z něhož vede jediný a zároveň koncový přechod automatu, a při dalších operacích s automaty je snadné toto pole aktualizovat (např. při spojování automatů za sebe může být pole finisherů prvního automatu po spojení úplně zapomenuto, až na případ lambda, viz níže).

Při pokládání automatů vedle sebe je situace ještě jednodušší, stačí prostě spojit řetězy přechodů odpovídající počátečním stavům obou automatů.

Problém i pro ostatní operace představuje zavedení iterace automatu. Tato operace vyjadřuje, že lze automatem opakovaně projít několikrát stejnou cestou. Nejjednodušší způsob, jak tuto možnost v popisu automau postihnout, je zdvojit koncové přechody tak, že jeden jeden zůstane koncovým přechodem, a druhý bude přesměrován svým odkazem dest_state znovu na kořen automatu. Rozhodl jsem se implementovat tento názornů způsob i přesto, že bylo nutné vyřešit další problém: při spojování automatů vedle sebe je nutné opravit všechny odkazy na kořen automatu tak, aby i po spojení řetízků kořenových stavů ukazovaly na počátek tohoto řetízku (a ne na počátek nově připojené části). Tento problém vyřešilo přidání další pomocné struktury restarters, totiž rostoucího pole odkazů na přechody, jejichž dest_state je směrován na kořenový stav. Při spojování automatů tak stačí opravit uvedené přechody a pole restarters se rovněž snadno aktualizuje - stačí spojit pole restarterů obou automatů do nového pole restarterů souřadné složeniny těchto automatů.

Samostatnou otázkou bylo vyřešení prázdného slova. Například regulární výraz a* odpovídá všem slovům sestaveným pouze z písmene a, přičemž prázdné slovo se do této množiny počítá. Dosud popsaná struktura pro uchování automatu nenabízí žádnou možnost, jak nulový přechod rozumně zapsat. Rozhodl jsem se proto tento speciální případ vyřešit samostatným příznakem has_lambda, který je považován za pomocnou proměnnou při stavbě automatu a jako takový je uchován v objektu nFA_chunk. (Tento příznak je v závěrečné fázi skutečně zapomenut, protože vyhledávání prázdných slov v textu nemá praktický užitek.) Význam tohoto příznaku se projeví především při spojování automatů za sebe. Například máme-li spojit automaty odpovídající výrazům a* a b, musí výsledný automat umožňovat hned z kořenového stavu přechod i přes písmeno b.

Postup pro správné vyřešení tohoto problému je velmi názorný. Pokud má první z automatů příznak lambda, je ve výsledném automatu soubor přechodů z kořenového stavu souhrnem počátečních přechodů obou automatů. Pokud má druhý z automatů příznak lambda, jsou koncové přechody prvního automatu zdvojeny, tak, aby jeden z dvojice ukazoval na počáteční stav druhého automatu a druhý zůstal koncový (tj. druhý automat byl zcela přeskočen). Výsledný automat má příznak lambda, pokud bylo možné přeskočit oba z dvojice spojovaných automatů. Upravit řádně i pomocné struktury finishers a restarters je snadné.

Dosud jsem se vůbec nevěnoval vyřešení problému kontroly počtu opakování a sběru proměnných. Tato otázka rovněž souvicí s realizací práce automatu, jak je popsáno níže. Stručně řečeno, výpočet automatu je souborem všech možných výpočtů nad automatem, tzv. vláken. Vlákno realizuje přechody mezi stavy přes určitý znak. Vlákno může z výpočtu vypadnout, pokud přechod není možný.

Jedním z důvodů zakázaného přechodu může být skutečnost, že vlákno neprošlo předchozím cyklem dostatečněkrát, aby vyhovovalo omezením, která klade uživatel. Během přechodů přes cyklické části automatu je tedy nutné zajistit, aby vlákno počítalo, kolikrát cyklus vykonalo, a před opuštěním této cyklící se části musí být vlákno prověřeno, zda jeho počet opakování splňuje požadované limity.

Pro tyto účely struktura nFA_transition obsahuje navíc souhrn tzv. iterátorů. Jsou to identifikátory "počitadel", uchované jako celé číslo. Pokud je mezi iterátory daného přechodu např. číslo +3, znamená to že vlákno musí ke svému třetímu počitadlu při přechodu přičíst jedničku. Pokud je mezi iterátory naopak např. číslo -3, znamená to, že vlákno musí před svým přechodem zkontrolovat, jestli jeho třetí počitadlo vyhovuje limitům na toto počitadlo, jak stanovil uživatel.

Uskladnění iterátorů u přechodů je jednoduché, postačí rostoucí pole pointrů ukončené číslem 0. Limity jednotlivých iterátorů uživatel ve výrazu zadává hned za znakem iterace, např. jako a*{5-29}, o jejich načtení se tedy musí postarat nějaká metoda třídy nFA_chunk, kontrétně metoda iterate_self(). Limity je však třeba uskladnit globálně, pro celý automat. Limity jsou proto uchovány ve struktuře nFA_chunk_globals a po zkonstruování celého automatu jsou přesunuty do struktury nFA.

Metoda iterate_self() kromě načtení limitů přidá i příslušné iterátory k potřebným přechodům v automatu, všechny přechody z kořenového stavu dostanou přičítací iterátor a všechny přechody, které se vracejí z konce na začátek automatu, dostanou testovací iterátor.

Sbírání proměnných je řešeno podobným způsobem. Proměnné jsou ve výrazu číslovány od 1 výše. Je-li právě načítaný výraz uzavřen v kulatých závorkách, má "plnit" proměnnou. Proto jsou k jeho počátečním a výstupním přechodům přidány pokyny pro přecházející vlákna: kladné číslo říká, že tímto přechodem začíná řetězec proměnné tohoto čísla, záporné číslo říká, že příslušná proměnná v tuto chvíli končí. Když vlákno přechod realizuje, zapamatuje si ve svých strukturách hodnotu globálního čítače kroků jako informaci o začátku a jako informaci o konci proměnné.

Třída nFA

Objekt stavitele, nFA_chunk, je běžnému uživateli modulu skryt za třídu nFA. Konstruktor této třídy má jediný argument - ukazatel na regulární výraz, který bude třeba vyhledávat. Výraz se tentokrát očekává již ve standardním tvaru, tj. mezi párem povolených oddělovačů (/, ', " nebo !) a s případnými volbami na konci. Pro případnou potřebu uživatele pracuje i konstruktor nFA tak, že výraz zadaným ukazatelem překročí (ukazatel je v argumentu předáván odkazem).

Problém, který při tomto návrhu bylo třeba vyřešit, představuje otázka přístupu k proměnné výhledu a k proměnným vyjadřujícím nastavení voleb výrazu a limitům počtu opakování. Souhrnně lze říci, že všechna volání metod třídy nFA_chunk, ať už jsou v rekurzi vnořeny libovolně hluboko, musejí mít přístup nejen k hodnotě proměnné lookahead, aby měly možnost začít číst výraz od správného místa, ale k samotnému místu uložení této proměnné, aby metody po přečtení svého úseku v této proměnné vrátily ukazatel na následující text. Raději, než zavádět společné globální proměnné pro celý modul nFA (které by rovněž zbytečně ubíraly volnou paměť zásobníku, i když už by se žádný výraz nekonstruoval), rozhodl jsem se zavést strukturu nFA_chunk_globals.

Struktura nFA_chunk_globals ve svých datech obsahuje jednak samotnou proměnnou lookahead typu char*, jednak další potřebné volby společné pro celý právě načítaný výraz (např. přepínače spaced a insens, viz uživatelský popis výše). Struktura je vytvořena a řádně inicializována (aby lookahead ukazoval na začátek výrazu ap.) během přípravné fáze konstruktoru nFA. Odkaz na tuto strukturu je pak předáván jako parametr všem volaným konstruktorům nFA_chunk. Každá instance nFA_chunk si pro potřeby svých dalších metod tento ukazatel uchová.

Ošetření chyb

Pro případ syntaktických chyb v regulárním výrazu jsem navrhl následující schéma zachytávání: Na chybu narazí nějaká metoda třídy nFA_chunk. (Např. metoda construct_from_set dostane za úkol načíst množinu povolených znaků, avšak v zápisu této množiny bude chybět ukončovací hranatá závorka.) Metoda proto nevytvoří žádná data. Všechny tyto metody jsou ale volány hned konstruktorem třídy nFA_chunk, který nemá možnost neúspěch při konstrukci ohlásit. Z tohoto důvodu jsem ve třídě zavedl pomocnou stavovou proměnnou error, jejíž hodnota udává, proběhla-li konstrukce řádně, nebo k jaké došlo chybě. V rámci rekurzivních volání konstruktorů nFA_chunk je po každém pokusu o konstrukci regulárního výrazu kontrolován obsah této proměnné a v případě problému volající nastaví svoji proměnnou error na stejnou hodnotu a neprodleně ukončí běh.

Úplně na závěr testuje proměnnou error konstruktor nFA. Ten hlásí případné selhání odlišným způsobem, nastavením statické proměnné nFA::nFA_result. Pokud konstrukce automatu po všech stránkách uspěje, je statická proměnná nFA::nFA_result nastavena na 0 a lze používat metody hledání.

Výpočet automatu, hledání

Uživatel třídy nFA má k dispozici tyto metody hledání:

Jak již bylo naznačeno výše, nedeterministický výpočet automatu nad vstupním slovem jsem se rozhodl realizovat pomocí tzv. vláken. Vlákno je struktura nFA_thread shrnující potřebné informace o jednom z možných postupů výpočtu.

Na počátku výpočtu, po funkci reset(), je v automatu zavedeno jediné běžící vlákno v počátečním stavu.

V každém kroku automatu je studován a upraven soubor běžících vláken. Každé vlákno má informaci o tom, v kterém stavu automatu se právě nachází (a to v podobě ukazatele na tento stav). Během tohoto kroku se vlákno rozdělí na tolik vláken, kolik je možných přechodů z daného stavu, a každý klon vlákna se pokusí přechod uskutečnit. Pokud se mu to nezdaří (načítaný znak neodpovídá povoleným znakům přechodu nebo čítače vlákna, jak je popsáno výše, neodpovídají limitům na počet opakování), je vyřezen z dalšího výpočtu, pokud naopak uspěje, změní se jeho ukazatel na aktuální stav v automatu a v příštím kroku výpočtu bude moci pokračovat.

Samostým případem jsou vlákna, jimž se zdařil přechod do koncového stavy, tj. přešli přes přechod, jehož ukazatel na cílový stav měl hodnotu NULL. Tato vlákna jsou rovněž vyřazena z výpočtu, jako výsledky hledání jsou ovšem uskladněna ve spojovém seznamu finished. Vlákno si ve svých strukturách rovněž uchová hodnotu globálního čítače age v okamžiku, kdy byl jeho běh ukončen.

V každém kroku je rovněž mezi běžící vlákna přidáno nové vlákno do počátečního stavu, pro případ, že ve vstupním řetězci dosud nebyl vůbec nalezen začátek řetězce odpovídajícího regulárnímu výrazu. Každé nově založené vlákno si také zapamatuje hodnotu globálního čítače age v době svého vzniku, aby bylo možné po skončení hledání vyznačit ve vstupním řetězci řetězec odpovídající regulárnímu výrazu.

Informaci o sbírání hodnot proměnných během výpočtu lze nalézt výše.

Pokud je spojový seznam skončených vláken neprázdný, je naděje, že některé vlákno zvítězilo. Seznam skončených vláken je po celou dobu probírán a zůstávají v něm jen vlákna, která začala ve vstupním řetězci co nejdříve a skončila co nejpozději (v tomto pořadí). Než může metoda scan() ohlásit definitivní nalezení regulárního výrazu, musí být všechna běžící vlákna mladší než než vlákna skončená (tj. musí mít vyšší hodnotu kopie globálního čítače age v době svého vzniku). Teprve tomto případě je jasné, že žádné z běžících vláken se nemůže později stát skončeným vláknem, které by bylo lepší (starší a delší), než vlákna dosud úspěšná.

Metoda scan() tedy na výstupu vrací hodnotu nFA_running nebo nFA_finished, podle toho, zda jsou nalezená vlákna definitně nejlepší výrazy odpovídající regulárnímu zadání.

Podrobné informace o výsledcích i průběhu hledání lze získat z proměnných running a finished, které odkazují na řetízky vláken automatu. Metody a proměnné jednotlivých objektů vláken pak umožňují získat informaci o tom, kde ve vstupním řetězci běh vlákna začal, hde byl ukončen, a které části tohoto úseku odpovídají jednotlivým proměnným výrazu.

Popis kompilátoru spi

Struktura spi_program

Struktura spi_program slouží k uskladnění načteného skriptu spi v interní paměti, je proto přímou analogií struktury skriptu spi, tak jak ji definuje jazyk. Základní informací je posloupnost příkazů spi s příslušnými argumenty. Rozhodl jsem se proto strukturu spi_program realizovat jako spojový seznam objektů typu spi_cmd (příkaz spi), tzv. interních příkazů spi. Interní příkazy spi jsou několika různých typů, ve struktuře spi_cmd odlišených pomocí výčtové proměnné. Podle typu příkazu k příkazu patří i dodatečné informace odlišných charakterů:
CMD_CONTEXT_CALL
Představuje příkaz volání jednoho kontextu, jeho dodatečnou informací je odkaz na strukturu context_info, která obsahuje jednak popis kontextu a jednak odkaz na spi_program příslušné akce v kontextu.
CMD_CONTEXT_FORK
Představuje složeninu více souřadně spojených kontextů, tedy blok kontextových příkazů pospojovaných užitím příkazu or. Dodatečnou informací je přehled odkazů na struktury context_info všech jednotlivých kontextů.
CMD_OR_CONTEXT_CALL
Doslova odpovídá příkazu or zdrojového skriptu, dodatečnou informací je odkaz na příslušnou strukturu context_info volaného kontextu.
Nejedná se o příkaz pro interpret, ale pro kompilátor. Při spojování interních příkazů ve skriptu je totiž potřeba tento příkaz spojit s předcházejícím příkazem CONTEXT_CALL nebo CONTEXT_FORK a vytvořit složeninu CONTEXT_FORK.
CMD_OMIT
Představuje příkaz omit, nemá žádnou dodatečnou informaci.
CMD_EXACT
Představuje příkaz exact, tj. pokyn k nahrazování řetězců jejich ekvivalenty. Jeho dodatečnou informací je odkaz na strukturu exact_info, v níž je uskladněn jednak vyhledávací stroj na jednotlivé řetězce a jednak souhrn řetězců náhrad.
CMD_RESHAPE
Představuje pokyn k přetvarování právě nalezeného tvarového kontextu. Jeho dodatečnou informací je odkaz na výraz náhrady.
CMD_MACRO_CALL
Představuje příkaz volání makra, jako dodatečná informace v něm slouží odkaz na strukturu spi_program s příkazy těla makra.
CMD_PRINT
Představuje záznam příkazu print v interní podobě zápisu skriptu. Jeho dodatečnou informací je příznak with_boundaries, který má stejnou hodnotu jako příznak tied v původním zápisu.
V podstatě se ani nejedná o příkaz pro interpret, ale o pokyn pro kompilátor, že je třeba právě načítanému kontextu nastavit příznak tied, aby jej interpret tiskl včetně hraničních výrazů. Z tohoto důvodu je příkaz print při generování skriptu rovněž řazen atypickým způsobem, jak popisuje další text.
CMD_INLINE_BLOCK
Tento interní příkaz je generován po načtení příkazu include nebo po načtení složeného příkazu. Jeho dodatečnou informací je odkaz na spi_program s právě načtenými příkazy.
Při řazení je jeho tělo rovnou včleněno do sestavovaného programu, tento příkaz tedy v definitivní podobě zkompilovaného skriptu nefiguruje.
CMD_BLOCK_END
Funkce kompilátoru, která načítá jednotlivé příkazy, tímto příkazem hlásí nalezení ukončovací složené závorky. Pro kompilátor je to pokyn zastavit spojování příkazů do programu a celý načtený blok odevzdat.
CMD_DUMMY
Toto je falešný interní příkaz, je generován v případě, když je načtena jen definice makra nebo příkaz forward nebo je nalezena syntaktická chyba. Příkaz není řazen do výsledného programu, protože nemá vykonávat žádnou operaci.
NO_CMD
Toto je prázdný příkaz, který je jedinou součástí řetízku příkazů, pokud dosud nebyl načten žádný jiný příkaz. Důvod jeho zavedení tkví k možnosti odkazovat se na tělo programu makra ještě dříve, než bylo makro doopravdy načteno.
CMD_DELETED
Tato speciální hodnota typu příkazu dává na vědomí, že struktura spi_cmd je teď už prázdnou obálkou a neobsahuje odkazy na žádná další data, která by bylo nutné rušit.

Popis kompilátoru spi

Úkolem kompilátoru spi je načíst soubor skriptu do interní paměti. Kompilátor spi je realizovaný objektem compiler a je tak v podstatě další aplikací vzorce Objekt stavitele (viz dodatek 2), neboť vyrábí požadovanou strukturu spi_program, ale pro účely kompilace zavádí i pomocné datové struktury.

Mezi pomocné datové struktury se řadí především objekt reader, který je zodpovědný za načítání vstupního kompilovaného skriptu do paměti a dále ukazatel lookahead (tj. výhled) na aktuální polohu v načteném bufferu příkazů. Pro účely rozpoznávání klíčových slov a jmen definovaných maker a kontextů je zaveden automat key_finder. Tento automat je implementován v samostatném modulu machine.c. Na počátku kompilace je naplněn pouze klíčovými slovy jazyka spi, při definování dalšího makra nebo kontextu je o nové slovo rozšířen.

Jinou pomocnou strukturou je přehled definovaných maker a kontextů, který se užívá při řešení příkazů volání. (viz níže)

Kompilace skriptu spi musí být navržena tak, aby umožňovala rekurzivní načíst zápis skriptu, tj. že např. součástí vstupního souboru je pokyn načíst ještě další vstupní soubor, součástí akce v kontextu může být úkol vyhledat další kontext a provést v něm nějakou akci, součástí definice makra může být volání jiného makra ap. Rozhodl jsem se pro nejjednodušší implementaci, totiž tak, že funkce načítající jednotlivé příkazy se budou rekurzivně volat stejným způsobem, jako je rekurzivně zapsán kompilovaný skript.

Do značné míry jde o analogii rekurzivního načítání regulárního výrazu, jak bylo popsáno výše. I zde se vyskytují určité informace, jež je nutné sdílet přes všechna volání načítajících procedur. Proto i zde zavádím objekt compiler_globals, který tyto informace shrnuje a který je odkazem předáván všem volaným metodám. Mezi sdílené informace patří všechny pomocné datové struktury jak bylo popsáno výše, tj. zatím key_finder a přehled maker.

Klíčovou metodou rekurzivního načítání je "řídicí" procedura read_cmd(). Jejím úkolem je z aktuální pozice v bufferu načíst a vrátit jeden příkaz. Procedura read_cmd nejprve zkontroluje zda ve výhledu není speciální symbol. Mezi speciální symboly se řadí složené závorky pro začátek a konec složeného příkazu, povolené oddělovače regulárních výrazů, které dávají okamžitý pokyn k načtení bezejmenné definice a volání kontextu (příkaz in), znak konce řádku nebo znak komentáře, které přikazují načtení další řádky ze vstupního souboru a rovnež znak ->, který funguje jako zkratka za příkaz reshape.

Pokud na vstupu není žádný speciální symbol, je celé slovo ve výhledu prozkoumáno automatem key_finder. Neodpovídá-li žádnému klíčovému slovu ani příkazu volání makra nebo kontextu, jde o syntaktickou chybu. V opačném případě je předáno řízení funkci, která se stará o načtení dodatečných informací příkazu. (Tato funkce může rekurzivně volat opět příkaz read_cmd ap.) Po úspěšném načtení příkazu je tento příkaz vrácen.

Ze všech načítacích funkcí se zmíním především o funkci read_block(), která se stará o načítání jednak celého skriptu, jednak obsahu všech složených příkazů. Řízení je jí předáno ve chvíli, kdy byla načtena otevírací složená nebo byla zahájena kompilace nějakého souboru. Úkolem této funkce je opakovaně volat funkci read_cmd() a příkazy, které vrací spojovat do podoby celého programu spi, dokud neobdrží příkaz CMD_BLOCK_END.

Protože toto spojování s sebou přináší celou řadu speciálních pravidel, je shrnuto do funkce add_command, rozumí se přidej tento příkaz k tomuto programu. Mezi nejvýznamnější pravidla spojování jsem zařadil následující:

Relativně zajímavý je rovněž úkol načíst vložený soubor - příkaz include. Díky rozhodnutí programovat celý kompilátor v podobě jednoho objektu, který rekurzivně volá své funkce, může být tento příkaz realizován velmi snadno. Funkce read_include() především zjistí jméno požadovaného souboru. Pak vyrobí vlastní instanci třídy compiler, která ovšem bude s aktuálním kompilátorem sdílet globální informace, tj. např. definice maker a kontextů (jen díky tomu je možné používat vkládané soubory jako knihovny) a tomuto kompilátoru předá úkol načíst soubor skriptu. Výsledný program je pak vrácen v podobě příkazu CMD_INLINE_BLOCK a později zařazen přímo do právě načítaného skriptu.

Kromě celé řady dílčích problémů, jak správně realizovat načítání jednotlivých příkazů jsem musel vyřešit problém deklarování, definování a volání maker a kontextů. Deklarace kontextů a maker jsou informací, která musí být přístupná všech funkcím v kompilátoru, a proto je zařazena do zmínené struktury compiler_globals. O provedení předběžných deklarací příkazem forward se stará příslušná funkce read_forward(). Ta načte plánované jméno makra nebo kontextu, přidělí mu unikátní identifikační číslo, rozšíří o něj automat key_finder a nastaví jej tak, aby při vyhledání tohoto slova vracel zvolené identifikační číslo. Zároveň rozšíří sdílenou strukturu přehledu maker nebo kontextů o dosud nenaplněnou položku makra nebo kontextu příslušného identifikačního čísla.

Při načítání definice makra nebo kontextu je nejprve zkontrolováno, jestli už dříve nebylo makro nebo kontext deklarováno. V tomto případě načítající funkce načtené dodatečné informace uskladní u připravených prázných obálkách, jinak nejprve samy provedou deklaraci makra nebo kontextu.

Ošetření chyb

Odlišil jsem dva druhy chyb, které mohou v průběhu kompilace nastat. Jednak jde o chyby zásadní, které zabraňují možnosti pokračovat v kompilaci, a pak o chyby syntaktické.

Reakce kompilátoru na chyby zásadní, mezi něž se řadí nedostatek operační paměti a nemožnost číst vstupní soubor skriptu, je jednoduchá. Bez ohledu na to, v které funkci chyba nastane, funkce chybu oznámí nastavením statické proměnné compiler::fatal,neprodleně ukončí běh a vrací speciální hodnotu vyjadřující zásadní chybu. Volající funkce na oznámení zásadní chyby reaguje obdobně, hlášenou chybu už nehlásí a pouze ukončí běh. Nakonec celý kompilátor oznámí volajícímu, k jaké chybě došlo.

Chyby syntaktické jsou ošetřeny odlišným způsobem. Jejich viníkem je uživatel, který nedodržel předepsanou syntaxi někde uvnitř skriptu a úkolem programu je co nejlépe místo chyby i chybu označit. Pro tyto účely např. čtečka vstupního souboru, objekt reader, při načítání řádků zároveň počítá, kolikátý řádek právě načetl. Standardizovaná funkce syntax(), jejímž argumentem je kód syntaktické chyby, uživateli chybu oznámí výpisem na obrazovku a dodá všechny dostupné informace: v kterém souboru a na které řádce byla chyba nalezena, na kterém místě je právě výhled, tzn. kde přesně nejspíše chyba je, a dále kód a popisek chyby.

Čtecí funkce, která chybu nalezla a ohlásila voláním funkce syntax(), se pak pokusí nastavit výhled na nějaké definované místo a vrátit takovou hodnotu, která umožní pokračovat v kompilaci zbývajících řádek skriptu, aby bylo syntaktických chyb najednou oznámeno co nejvíc.

Na konci kompilace je oznámeno, kolik řádek kompilátorem prošlo a koik na nich bylo chyb. Pouze v případě, že nebyla nalezena žádná chyba, je výstupem z kompilátoru korektní skript, který lze předložit interpretu.

Popis interpretu spi

Jazyk spi popisuje dokumenty velmi "rekurzivně". Častým příkazem spi je vyhledání určitého podbloku (vnořeného kontextu) v dokumentu a realizace příslušného podprogramu na tomto podbloku, přičemž podblok může být dále členěn. Zpracovaný podblok je pak opět vložen do bloku nadřazeného a společně s ním může být upravován dále.

Přirozená koncepce interpretu by se držela popsané struktury: klíčovou funkcí by byla parse_block(block, program), jejímž výstupem by byl blok po zpracování příkazy programu. Tato funkce by podle potřeby blok rozdělila, na každý podblok zavolala rekurzivně sama sebe s příslušným podprogramem, blok opět spojila a po dalších úpravách odevzdala jako svůj výstup. Úskalí tohoto přístupu jsou následující:

S ohledem uvedená úskalí jinak velmi slibného "rekurzivního" přístupu a vědom si skutečnosti, že všechny příkazy spi včetně členění dokumentu na bloky mohou být s určitou vyrovnávací pamětí realizovány v režimu "roury", ryze sekvenčně, tj. bez možnosti vidět najednou všechna data ke zpracování, rozhodl jsem se pro zcela odlišný "kaskádní" nebo "překapávaný" přístup.

Kaskádní interpret skriptů spi pracuje takto: z příkazů je sestavena kaskáda, na jejímž vrcholu je čtečka vstupního souboru. Část souboru je načtena a postoupena ke zpracování prvnímu příkazu, výstup z tohoto příkazu "stéká" po kaskádě dál a je pokaždé upraven. Mezi jednotlivými příkazy jsou zařízeny vyrovnávací paměti, protože částečný výstup jednoho příkazu nemusí následujícímu článku ještě stačit nebo může být naopak delší, než je třeba. Výstup na spodním konci kaskády (tj. data zpracovaná všemi příkazy až do posledního) je pravidelně ukládán do výstupního souboru. Celý soubor je zpracován v okamžiku, kdy všechny články kaskády hlásí konečný stav. V tuto chvíli je výstupní soubor uzavřen a běh spi končí nebo se věnuje novému vstupnímu souboru.

Nespornou výhodou kaskádního zpracování je ryze sekvenční, lineární postup s souboru, nevyžadující načtení celého souboru do paměti. Práce s úseky dokumentu je výhodou i při samotném zpracování - manipulace s menšími bloky dat v paměti je rychlejší.

Kaskádní přístup má však ještě jeden zajímavý rys: dovoluje plynule regulovat, zda program šetří paměť nebo čas. Pokud budeme data do kaskády tlačit shora, ušetříme čas za režii, ale vyrovnávací paměti budou plnější (v extrémním případě celý dokument projde v jednom kroku jedním příkazem). Pokud budeme naopak data "tahat" z dolního konce kaskády, ušetříme v souhrnu na vyrovnávací paměti (extrémem je malý kousek dokumentu, který ze vstupu rovnou zpracujeme až do definitivní podoby a uložíme na výstup). Zvolená implementace nepředstavuje ani jeden vyhrocený přístup, rozhodně však upřednostňuje úsporu paměti.

Kaskáda z příkazů je obsluhována takto: nejspodnější (tj. poslední) příkaz je požádán o provedení své operace. Pravděpodobně zjistí, že v jeho vstupní vyrovnávací paměti není dost dat (vlastně dosud žádná data), a toto oznámí. Řídicí cyklus v příštím kroku požádá proto příkaz o stupeň výše. Je-li ve vyrovnávací paměti příkazu dostatek vstupních dat, jsou příkazem zpracována a postoupena do výstupní paměti, příkaz pak oznámí, buď že je připraven pracovat dále, nebo, že právě zpracovaná data jsou úplně poslední a že už nebude, co zpracovávat. Řídicí cyklus udělí v následujícím kroku slovo příkazu o stupeň níže.

Jednotlivé stupně odpovídající jednotlivým příkazů spi jsem se rozhodl implementovat pomocí objektů odvozených ze společného předka iprt (interpret), protože objektový přístup usnadní zápis hlavního cyklu programu, jak byl popsán výše. Hlavní cyklus programu bude pracovat pouze s objekty typu iprt a bude jim postupně podle popsaného schématu předávat řízení, aby vykonaly svůj kousek práce. Použití virtuální metody perform_job() přitom zajistí, že interprety jednotlivých odlišných příkazů budou provádět odlišné operace.

Tento předek obsahuje společné rysy všech interpretů rozličných příkazů. Jedná se především o odkazy na vyrovnávací paměti unifikovaného formátu, typu block*, přičemž block je specialní datová struktura pro uchování bloku dat společně s informací o délce tohoto bloku. Dále jsou zde pro zjednodušení psaní jednotlivých interpretů shrnuty pomocné metody, např. jednoduché operace s vyrovnávacími paměťmi jako je metoda flush_source(), která celý aktuální obsah vstupní vyrovnávací paměti interpretu předá do výstupní vyrovnávací paměti, nebo metoda flush_part(), která takto předá jen vymezenou část.

Z předka iprt odvozuji následující třídy:

reader_iprt
Tento interpret není do kaskády řazen na základě požadavku ve skriptu, ale je nedílnou součástí všech kaskád. Stojí vždy úplně na vrcholu kaskády a stará se o načítání vstupního souboru do své vyrovnávací paměti. Odtud jsou data souboru podle potřeby ostatních interpretů stahována níž.
exact_iprt
Je v kaskádě zařazen na místo příkazu CMD_EXACT a stará se tedy o provádění nahrazování řetězců jejich ekvivalenty ve vyrovnávací paměti. Jeho základní dodatečnou informací je odkaz na strukturu exact_info, která potřebné informace obsahuje.
reshape_iprt
Je v kaskádě řazen na místo příkazu CMD_RESHAPE, tedy zásadně za příkaz těla kontextu. Toho reshape_iprt využívá a z dat svého dárce čerpá dodatečné informace o vítězném vláknu a proměnných regulárního výrazu, které právě načetlo.
Úkolem tohoto interpretu je do svého výstupu zkopírovat řetězec náhrady a zaměnit v něm opisy \1, \2... za řetězce odpovídající nalezeným proměnným. Zdrojem jejich textů je mu jeho vstupní paměť, informace o poloze těchto textu v paměti je, jak bylo zmíněno, čerpána z nadřazeného příkazu kontextu.
Po vytištění celého řetězce náhrady reshape_iprt smaže svoji vstupní paměť a ohlásí skončený stav.
omit_iprt
Interpret příkazu CMD_OMIT je ze všech nejjednodušší. Jeho jediným úkolem je mazat příchozí data. Omit_iprt hlásí takový stav průběhu zpracování, jako jeho dárce. Pokud tedy dárce má ještě údaje, které hodlá zpracovat, počká omit_prt, na jejich dokončení, aby je mohl smazat. Pokud dárce už svoje zpracovávání skončil, ví omit_iprt, že už nebude co mazat a hlásí rovněž skončený stav.
context_starter_iprt
context_stopper_iprt
Těmito dvěma interprety jsem zajímavě vyřešil příkaz vyhledání vnořeného kontextu (příp. volba z více vnořených kontextů) a provedení podprogramu na tomto podbloku. Příkaz je realizován pomocí dvou kaskádních stupňů - horní context_starter a dolní context_stopper - které spolu spolupracují.
Vyhledání začátku kontextu provádí horní díl, než je nějaký kontext nalezen, jsou data z definice odesílána dolnímu dílu, který je obratem předává níže. Jakmile se context_starteru podaří najít začáteční řetězec, je mezi horní a dolní částí příkazu rozvinuta kaskáda odpovídajícího podprogramu, dané akce v kontextu. Tato kaskáda "vnořených" (ovšem nikoli z hlediska hlavního řídícího cyklu) se stručně nazývá roura a jejím úkolem je požadovanými příkazy zpracovat tělo nalezeného.
Až horní vyhledávač kontextu narazí na koncový řetězec, přestane data do roury odesílat a hlásí stav ukončení (třebaže má nad sebou ještě data k dispozici). Řídicí cyklus se bude proto postupně věnovat příkazům níže, než hladina "hotového" klesne ke spodní části kontextového příkazu. Ten všechna zpracovaná data odešle a rouru podprogramu z kaskády odstraní. Oznámí nedostatek vstupních dat a horní článek bude opět vyhledávat nějaký vnořený kontext.
V případě tvarového kontextu je tento postup ještě zkrácen: context_starter nalezne řetězec, který odpovídá tvarovému regulárnímu výrazu, požádá o rozvinutí roury, předá tento řetězec do roury a rovnou hlásí skončený stav, protože tělem tvarového kontextu je právě text odpovídající tvarovému regulárnímu výrazu.
Problém, který musí context_starter řešit, je, pokud ve vstupní paměti narazí na zahájení jednoho z možných kontextů, ale některý z používaných automatů vyhledávající začátky konkurujících kontextů dosud obsahuje běžící vlákna, která jsou starší, než vlákno dosud úspěšné. V tomto případě by se totiž mohlo stát, že po dodání dalších dat zvítězí starší vlákno, které však dosud není dokončeno, a context_starter by se dopusil chyby, kdyby zahájil zpracování odlišného kontextu. V tomto případě context_starter ohlásí nedostatek vstupních dat a počká, až se rozhodování vyjasní. Pokud vstupní data úplně dojdou (tj. dárce context_starteru hlásí skončený stav), musí starter opravdu zahájit práci s dosavadním vítězem.
Vedlejší efekt tohoto chování se může projevit vyčerpáním operační paměti, pokud je požadováno vyhledání příliš obecného regulárního výrazu ve velmi dlouhém vstupním souboru a context_starter musí stále žádat o další vstupní data.


Dodatky

Dodatek 1: Opisy znaků

V rámci regulárních výrazů omezujících kontexty a v rámci řetězců v příkazech exact a reshape lze užívat následující opisné sekvence.

V příkazu exact je možné použít pouze opisy jednotlivých a řídících znaků, příkazu reshape navíc i víceznačný znak nahrazující X-tý uzávorkovaný výraz vyhledaný tvarovým kontextem.

Opisy jednotlivých znaků
\t
tabulátor, znak číslo 9
\r
Carriage Return, znak číslo 13
\n
Line Feed (mnemotech. New Line), znak číslo 10
\b
CR, LF nebo CRLF (Line Break)
\#XXX
Znak číslo XXX, dekadicky.
\xXX
Znak číslo XX, hexadecimálně.
Víceznačné znaky
\w
bílé místo (whitespace), tj. mezera, tabulátor nebo konec řádku
\a
malé písmeno z dolní poloviny ASCII
\A
velké písmeno z dolní poloviny ASCII
\d
číslice
\<
symboly z dolní poloviny ASCII
\>
znaky horní poloviny ASCII (nad 127)
\p
tisknutelné znaky, tj. [\w\a\A\d\$\>]
\X
X-tý uzávorkovaný výraz vyhledaný tvarovým kontextem
Opisy řídících znaků
\.
tečka
\*
hvězdička
\?
otazník
\!
vykřičník
\/
lomítko
\"
uvozovka
\'
apostrof
\\
zpětné lomítko
\{
složená závorka
\}
složená závorka
\(
závorka
\)
závorka

Dodatek 2: Vzorec Objekt stavitele

Alternativní názvy: Třída stavitele

Kontext:

Konstrukce nějakého objektu je náročná a vyžaduje řadu pomocných proměnných, které však později nebudou k užitku.

Problém:

Jak co nejsrozumitelněji a s maximální úsporou paměti konstrukci objektu implementovat?

Síly:

Řešení

Vytvořte Objekt stavitele, speciální třídu, v níž shrnete pracovní proměnné a datový obsah plánovaného objektu. Operace konstrukce kýžené struktury implementujte v metodách Objektu stavitele.

V konstruktoru kýženého objektu vyrobte instanci Třídy stavitele a nechte ji provést všechny potřebné stavební operace. Pak si výsledky její práce prostě přivlastněte a stavitele jako takového zrušte.

Odůvodnění:

V metodách Objektu stavitele budete mít k pracovním proměnným i konstruované struktuře přímý přístup. Objekt stavitele bude zabírat operační paměť jen během konstrukce a již nikoli během používání kýžené struktury.

Možná úskalí:

Při "vykrádání" a rušení objektu stavitele dobře ošetřete, aby vám stavitel v rámci svého destruktoru nezbořil i hotovou strukturu. Není však vhodné, aby stavitel strukturu nechával vždy "opuštěnou" v paměti, mohlo by snadno dojít ke ztrátě pointru (tj. strukturu by nikdo nejen nepoužil, ale ani neodalokoval). Raději při vykrádání úplně zrušte stavitelův odkaz na hotové dílo.


Ondřej Bojar
obo@cuni.cz
http://www.cuni.cz/~obo
16.9.1999