KIV / TI - Teoretická informatika
Cvičení 2 - zpracování čísla konečným automatem

Zpracování čísla konečným automatem

Každý překladač vyššího programovacího jazyka se musí vypořádat s následující úlohou: je-li v překládaném zdrojovém kódu například řádek

promenna = 1234 ;

musí rozpoznat, že

Rozpoznání se tradičně dělí do tří částí. V první, které se říká lexikální analýza, musí překladač ze sekvence znaků rozpoznat tzv. lexikální symboly, v našem případě "proměnná", "symbol přiřazení", "číslo" a "symbol konce příkazu". V druhé, které se říká syntaktická analýza, musí překladač ověřit, že lexikální symboly po sobě jdou v takovém pořadí, které je vyžadováno formálními pravidly daného programovacího jazyka, tzv. gramatikou jazyka. Konečně v části třetí, které se říká sémantická analýza, si musí překladač uvědomit, že zapsané lexikální symboly představují příkaz přiřazení.

My se zdaleka nebudeme zajímat o kompletní činnost překladače, soustředíme se jen na lexikální analýzu. Navíc se pro jednoduchost zaměříme jen na jeden problém lexikální analýzy: jak rozpoznat číslo.

Postupně vyřešme následující dílčí úlohy:

  1. Jak rozpoznat, že sekvence znaků představuje celé číslo?
  2. Jak zjistit hodnotu tohoto čísla?
  3. Jak udělat totéž (rozpoznat a zjistit hodnotu) u desetinného čísla?

Rozpoznání celého čísla

K rozpoznávání řetězců znaků se výtečně hodí konečné automaty; budeme tedy sestavovat rozpoznávací konečný automat. Připomeňme, že k definici rozpoznávacího konečného automatu potřebujeme definovat:

  1. vstupní abecedu,
  2. množinu stavů,
  3. počáteční stav,
  4. koncové (akceptační) stavy a
  5. přechodovou funkci.

Rozpoznáváme-li celé číslo zapsané desítkovou soustavou, může na vstup automatu teoreticky přijít:

  1. Znak + nebo -.
  2. Znak 0, 1, 2, ..., 9.
  3. Libovolný jiný znak, označme si jej symbolicky jako "K" (proto, že takový znak bude ukončovat proces rozpoznávání, je tedy koncový). Mezi znaky typu "K" bychom mohli dále rozlišovat - například těsně za číslem se může vyskytnout mezera, zatímco znak "podtržítko" se v bezprostředním okolí čísla vyskytnout obvykle nemůže. Obdobné rozlišování se ale typicky nechává na starost syntaktické analýze, pro nás tedy budou takové znaky ekvivalentní.

Vstupní abecedou je tedy množina

Σ = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, +, -, K }

Jak uvidíme, z pohledu konečného automatu je celkem jedno, zda přijde na vstup číslice 1 nebo 6; pro automat je podstatné, že šlo o číslici. Označíme-li libovolnou číslici symbolem D, můžeme vstupní abecedu přepsat na

Σ = { D, +, -, K }

Při konstrukci množiny stavů, volbě počátečního a koncového stavu a tvorbě přechodové funkce je vhodné postupovat od speciálních případů k obecným. Takovými šikovnými speciálními případy jsou například řetězce "-3" nebo "+1234", což ve vstupní abecedě představuje sekvence symbolů "-DK" nebo "+DDDDK" Automat, který rozpozná sekvenci skládající se ze znaménka, libovolného nenulového počtu číslic a koncového znaku vypadá třeba takto:

Základ pro automat rozpoznávající celá čísla

V dalším kroku je vhodné zamyslet se, jak ještě mohou vypadat korektně zapsaná celá čísla. Musí začínat znaménkem? Samozřejmě ne. Musí obsahovat alespoň jednu číslici? Samozřejmě ano. Po úpravě:

Téměř kompletní automat rozpoznávající celá čísla

Konečně je vhodné rozhodnout, co má automat udělat, je-li na vstupu jiný než zakreslený znak. Obvykle se proto definuje "chybový stav", do kterého automat přejde, jakmile obdrží nečekaný vstup a v tomto stavu zůstane, resp. ohlásí, že došlo k chybě. Tak dostáváme:

Hotový automat rozpoznávající celá čísla

Všimněme si podstatné skutečnosti: při tvorbě automatu nekreslíme "kolečka propojená šipkami". Každé "kolečko" představuje stav a návrhář automatu musí umět přesně slovně vyjádřit, jaký je smysl daného stavu. V našem případě:

Jakmile se během návrhu dostane návrhář do fáze, že začne nazdařůh doplňovat stavy "aby to začalo fungovat", je na nejlepší cestě se v návrhu ztratit!

Automat můžeme nyní kompletně definovat:

Zjištění hodnoty celého čísla

Překladači samozřejmě nestačí vědět, že řetězec "100" představuje celé číslo; potřebuje vědět, že jde o číslo sto, přičemž předpokládáme, že čísla zapisujeme desítkovou soustavou. Při sestavování algoritmu rozpoznání hodnoty čísla můžeme vyjít z jednoduché úvahy: pokud jsme již zpracovali jistou část vstupu (například znaky "10"), odpovídá tato část hodnotě uložené v proměnné value. Po načtení další číslice se hodnota value musí jistým způsobem upravit.

Pro dokončení úvahy potřebujeme vědět, že znaky jsou typicky kódovány standardem ASCII, případně UTF-8. V obou případech má znak "0" kód 0x30 hexadecimálně, resp. 48 desítkově, znak "1" má kód 0x31, znak "2" kód 0x32 atd. Zjištění hodnoty jednociferného čísla uloženého ve znakové proměnné input je proto triviální:

char input;
int  digit;
...
digit = (int)input - (int)'0';

Automat nyní doplníme tzv. akcemi, které zakreslíme za lomítko. Symbolem e označujeme "nic", tj. buď "žádný vstup", nebo "žádná akce".

Hotový automat rozpoznávající celá čísla a určující jejich hodnotu

Budeme předpokládat, že akce manipulují následujícími proměnnými:

char input;  // input character
int digit;   // value of the digit being processed
int value;   // temporary value of the processed input
int sign;    // sign of the number, either +1 or -1
int result;  // final result

Akce definujeme následovně

  1. Inicializace pracovních proměnných:

    sign = +1;
    value = 0;
  2. Zpracování znaménka:

    sign = +1;
    
  3. Zpracování znaménka:

    sign = -1;
    
  4. Zpracování číslice:

    digit = (int)input - (int)'0';
    value = value * 10 + digit;
    
  5. Interpretace výsledku:

    result = sign * value;
    

Nyní již můžeme pohodlně zapsat program, který implementuje návrh pomocí konečného automatu. Předpokládáme, že vstup je uložen v řetězcové proměnné inputText a rozpoznávat jej budeme počínaje znakem číslo startIndex.


public class fsm_integer {
    
    // state names for better readability
    public static final int stateStart = 0;
    public static final int stateSign = 1;
    public static final int stateNum = 2;
    public static final int stateEnd = 3;
    public static final int stateErr = 4;
    
    // empty actions
    public static final int actionEmpty = -1; // true "empty action" = do nothing
    public static final int actionErr = -2;   // stop the state machine if an error occurs

    public static void main(String[] args) {
        String inputText = "-1234";  // text to process
        int startIndex = 0;          // begin processing at the string start
        
        // internal state machine variables
        int index;  // position in the inputText to process
        char input; // input character from inputText                 
        int state;  // current state of the state machine
        int action; // action to perform
        
        // internal variables of the actions
        int sign;   // sign of the result
        int value;  // current value of the processed input
        int digit;  // current digit being read
        int result; // final result
        
        // START
        System.out.println("Input text '" + inputText +"', start automaton at position " + startIndex);
        
        // init finite state machine
        index = startIndex;
        state = stateStart;
        action = actionEmpty;
        result = 0;
        
        // action 0
        sign = 1;
        value = 0;
        
        loop:
        while (true) {
            if (index < inputText.length())
                // regular input characters
                // security risk: no check if index >= 0
                input = inputText.charAt(index);
            else if (index == inputText.length())
                // put any character after the end of the inputText
                input = ' ';
            else 
                // nothing to read!
                break loop;
            
            // finite state machine
            switch (state) {
            case stateStart:
                if (input == '+')                      { state = stateSign; action = 1; }
                else if (input == '-')                 { state = stateSign; action = 2; }
                else if (input >= '0' || input <= '9') { state = stateNum;  action = 3; }
                else                                   { state = stateErr; action = actionErr; }
                break;
            case stateSign:
                if (input >= '0' && input <= '9')      { state = stateNum; action = 3; }
                else                                   { state = stateErr; action = actionErr; }
                break;
            case stateNum:
                if (input >= '0' && input <= '9')      { state = stateNum; action = 3; }
                else                                   { state = stateEnd; action = 4; }
                break;
            case stateEnd:
                // should not get here, see action 4
                break loop;
            case stateErr:
                // should not get here, see actionErr
                break loop;
            }
            
            // do actions
            switch (action) {
            case 1: 
                sign = +1;
                break;
            case 2:
                sign = -1;
                break;
            case 3:
                digit = (int)input - (int)'0'; 
                value = value * 10 + digit;
                break;
            case 4:
                result = sign * value;
                break loop;
            case actionErr:
                break loop;
            case actionEmpty:
                // should not get here
                break;
            }
            
            // move to the next character
            index++;
        }
        
        // notify the result
        if (state == stateEnd)
            System.out.println("OK, read integer value: " + result + ", input may continue at position " + index);
        else
            System.out.println("Error at position " + index);
    }
}
Code Formatted by ToGoTutor

Všimněme si, že kód v příkazu switch reprezentujícím přechodovou funkci (za komentářem finite state machine) má jednoduchou strukturu: vždy se jen na základě vstupu a stavu rozhodne, jaký bude následující stav a prováděná akce. Zejména u složitějších automatů je proto výhodné vytvořit dvojrozměrnou tabulku, kde řádky odpovídají stavům a sloupce vstupům, a přechodovou funkci zapsat v ní. Místo obrovského přepínače switch bychom pak jednoduše psali něco jako

action = actionTable[state][input];
state = stateTable[state][input]; 

Zejména v případě, je-li množina vstupů velká (např. všechny znaky ASCII tabulky), lze zvážit využití rozptýlené tabulky (hash table), případně jiné datové struktury.

Rozpoznání desetinného čísla

Použijme stejné principy návrhu při rozpoznání řetězce reprezentujícího desetinné číslo, resp. číslo, které lze uložit do proměnné typu double, případně float. Připomeňme, že ve většině programovacích jazyků implementujících čísla s pohyblivou řádovou čárkou podle IEEE 754 lze desetinné číslo zapsat mnoha způsoby, například:

a tak dále. Jelikož zatím nemáme k dispozici rozumný aparát, který by dokázal popsat všemožné nuance zápisu, zůstaňme u slovního popisu: desetinné číslo

Jelikož je pravidel až hanba, zkusme se do popisu konečným automatem pustit stejně jako v případě celých čísel: vytvořme napřed automat, který rozpoznává nějaký speciální, co nejsložitější případ. Takovým případem nechť je číslo

+123.321e+123

Postupně tak musíme přečíst:

Základ pro automat rozpoznávající desetinná čísla

Ve schématu jsme před stavem end použili speciální symbol ∀, který značí "jakýkoliv jiný znak", v našem případě "jakýkoliv jiný znak než číslice". To proto, že podrobné vypisování, o jaké znaky jde, by bylo nepřehledné.

Budeme-li uvažovat, že zapisované číslo má exponenciální část, ale nemusí mít část celou, případně desetinnou (ale alespoň jednu ano), a přítomnost znamének je volitelná, dostaneme obecnější verzi. Všimněme si, že jsme museli přidat nový stav, abychom vyloučili zápis typu ".E+10", tj. vynechnou jak celou, tak desetinnou část.

Doplněná verze automatu, která předpokládá přítomnost exponenciální části

Konečně uvažujme, že exponenciální část může zcela chybět. Doplňme proto přechody, které umožní přejít do koncového stavu dříve, ale tak, aby vstup zpracovaný automatem odpovídal korektnímu zápisu čísla.

Téměř hotový automat

Na závěr už si jen uvědomíme, že v některých případech se automatu nepodařilo načíst nic, co by se korektnímu zápisu čísla podobalo, např. řetězec "e25" (nezačíná ani znaménkem, ani číslicí, ani desetinnou tečkou), řetězec "..." (začíná-li zápis desetinnou tečkou, musí za ním následovat číslice) atd. Doplníme proto chybový stav, do kterého automat přejde ve všech dosud nedefinovaných případech.

Hotový automat rozpoznávající desetinná čísla

Doplněním akcí už snadno záskáme automat, který současně s rozpoznáním vrátí i hodnotu desetinného čísla. Můžeme postupovat třeba následovně: v akcích si do proměnných signAll a signExp zaznamenáme celkové znaménko a znaménko exponentu, v proměnných intPart, fltPart a expValue si vyčíslíme hodnoty celé části, desetinné části a exponentu

a výsledek vyčíslíme výrazem
result = signAll * (intPart + fltPart) * pow(10, signExp * expValue);

Automat rozpoznávající desetinná čísla a vyčíslující jejich hodnotu

Akce mohou vypadat následovně:

  1. Inicializace pracovních proměnných:

    signAll = +1;        // sign of the result
    signExp = +1;        // sign of the exponent
    intPart = 0;         // value of the part before the decimal point
    fltPart = 0;         // value of the part after the decimal point
    fltMultiplier = 1;   // weight of the digit after decimal point
    expValue = 0;        // value of the exponent
    
  2. Zpracování celkového znaménka:

    signAll = +1;
  3. Zpracování celkového znaménka:

    signAll = -1;
  4. Zpracování číslic celé části

    digit = (int)input - (int)'0';
    intPart = intPart * 10 + digit;
    
  5. Zpracování číslic desetinné části

    digit = (int)input - (int)'0';
    fltMultiplier = fltMultiplier * 0.1;
    fltPart = fltPart + digit * fltMultiplier;
    
  6. Zpracování znaménka exponentu:

    signExp = +1;
  7. Zpracování znaménka exponentu:

    signExp = -1;
  8. Zpracování číslic exponenciální části

    digit = (int)input - (int)'0';
    expPart = expPart * 10 + digit;
    
  9. Tvorba výsledku:

    result = signAll * (intPart + fltPart) * pow(10, signExp * expValue);
    

Samotný přepis do kódu je již programátorskou rutinou. Ačkoliv by to mělo být zřejmé, pro jistotu připomeňme: struktura kódu bude úplně stejná jako u kódu automatu rozpoznávajícího celá čísla (viz výše). Jediným rozdílem je větší počet stavů a akcí.

Závěrečné poznámky

Technika rozpoznávání, kterou jsme se naučili, má poměrně omezené využití. Obvykle potřebujeme automatem rozpoznávat něco, o čem předem nevíme, jak vypadá: při zpracování řádky zdrojového kódu překládaného programu nevíme, zda bude prvním lexikálním symbolem název proměnné, klíčové slovo jazyka, číslo nebo jiná entita. Tehdy bychom jakoby potřebovali spustit několik rozpoznávacích automatů současně a sledovat, který z nich "se chytí". I to je možné, ale elegantně řešitelné pomocí tzv. nedeterministického konečného automatu. Těmi se budeme zabývat později.

K algoritmu pro vyčíslení hodnoty desetinného čísla je na místě dodat, že implementace je jednoduchá, ale ne příliš dobrá. Za prvé používá pro vyhodnocení výsledku obecnou mocninnou funkci pow, což je kanón na vrabce; uvědomme si, že interně se mocninná funkce vyčísluje vztahem

pow(a, x) = exp(log(a) * x)

čili vyžaduje vyčíslení dvou netriviálních transcendentních funkcí. Za druhé si uvědomme, že k vyčíslení desetinné části je třeba násobení hodnotou 0.1, kterou nelze ve dvojkové soustavě vyjádřit přesně; v každém kroku převodu se tak dopouštíme drobné nepřesnosti a ty se kumulují. Pro mnoho účelů bude takový převod postačující, ale v algoritmech citlivých na numerické chyby bychom se mu měli vyhnout. V praxi se proto číslo zapsané v textové podobě převádí na binární vyjádření jinak a dodejme, že jde o matematicky velice vybroušené postupy.