A paraméteres kifejezések ismételt kiértékelése a számológépben
A szintaxis fákkal foglalkozó korábbi írásokban (Szintaxis fák, Változók használata) felépítettünk egy számológép programot, ami a beolvasott matematikai kifejezéseket egy szintaxisfává alakítja, majd kiértékeli.
A program a második írás végére már képessé vált változók kezelésére és ehhez
kapcsolódóan belekerült az értékadó operátor, amely nagyon hasonlóan működött a
C nyelvben megtalálhatóhoz. Az egyetlen különbség az volt, hogy nem volt
láncolható, tehát az a = b = c = 5
alakú kifejezéseket nem tudta
kiértékelni.
Egy másik hiányossága volt a bemutatott számológépnek, hogy nem tudta visszahívni a beírt paraméteres kifejezéseket. Pedig nagyon hasznos lehet egy összetett kifejezés újraszámolása megváltozott változóértékekkel. Ehhez mindössze annyit kell tenni, hogy el kell menteni a szintaxisfa gyökerének a címét egy tárolóba.
Jelen írásban a fenti két problémára keresünk megoldást és közben áttekintünk egy-két újabb nyelvészeti érdekességet.
1 A láncolható értékadó operátor
A számológépünk legutóbbi verziója által felismert nyelvtan a következőképpen néz ki:
értékadó_kifejezés ::= ( változónév '=' )? összeg összeg ::= szorzat (('+' | '-') szorzat)* szorzat ::= tényező (('*' | '/') tényező)* tényező ::= szám | változónév | zárójeles zárójeles ::= '(' értékadó_kifejezés ')'
A láncolás itt azért nem tud működni, mert az értékadás (változónév
'='
) egy opcionális elem, tehát vagy egyszer, vagy egyszer sem fordul
elő, utána pedig egy összeg
következik. Az összeg
leegyszerűsödhet a szorzat
on és tényező
n keresztül
egy zárójeles kifejezéssé, ami pedig lehet egy újabb értékadás, de ekkor már
zárójelek között.
Tehát a fenti nyelvtan az értékadó operátor láncolását az következő alakban
tudta értelmezni: a = (b = (c = 5))
.
Először tehát azt kell kitalálnunk, hogy miként tudnánk a nyelvtanunkban megfogalmazni azt, hogy tetszőleges számú értékadás állhat egymás után.
Erre két megoldás is létezik. Az első – a nyelvtan szintjén egyszerűbb – az, hogy az értékadás opcionalitását lecseréljük tetszőleges számú ismétlődésre. Ez azt jelenti, hogy a kérdőjelet csillagra cseréljük:
értékadó_kifejezés ::= ( változónév '=' )* összeg összeg ::= szorzat (('+' | '-') szorzat)* szorzat ::= tényező (('*' | '/') tényező)* tényező ::= szám | változónév | zárójeles zárójeles ::= '(' értékadó_kifejezés ')'
Így továbbra is meg lehet értékadás nélküli kifejezéseket adni, hiszen a
*
operátor megengedi a 0-szor való előfordulást is – ahogy
ez az összeg
és a szorzat
esetén is történik.
Van ettől elvben is különböző megoldás erre a problémára: azt mondjuk, hogy az értékadó_kifejezés egy opcionális értékadásból és egy összegből áll, az összeg pedig egy értékadó kifejezés VAGY két szorzat összege:
értékadó_kifejezés ::= ( változónév '=' )? összeg összeg ::= értékadó_kifejezés | ( szorzat (('+' | '-') szorzat)* ) szorzat ::= tényező (('*' | '/') tényező)* tényező ::= szám | változónév | zárójeles zárójeles ::= '(' értékadó_kifejezés ')'
Így tehát az értékadó kifejezés tartalmazhat egy értkadást, majd egy összeg következik, amely újból lehet egy értékadás plusz egy összeg. Így tulajdonképpen rekurzív módon tetszőleges számú ismétlődést tudtunk megadni.
Amikor végeszakad a láncolásnak, tehát mondjuk az a = b = c = 5
kifejezésben eljutunk az 5
-ig, ott az összeg szabályban az
értékadó_kifejezés már nem fog illeszkedni és ekkor megpróbálja a
szorzat
ot illeszteni, ami sikerül is, hiszen az a
tényező
n keresztül le tud szám
má egyszerűsödni.
Az eredeti jelölésrendszerben, amit formális nyelvtanok leírására használtak
(BNF) nem szerepeltek a ?
, *
és +
operátorok. Mindent a fent látott, rekurzív módon adtak meg.
Vegyük észre, hogy a kétféle módszer tulajdonképpen ugyannak a problémának az iteratív (cikluson alapuló) illetve rekurziv megoldása. Általánosságban is azt mondtuk, hogy a problémáinkra léteznek iteratív és rekurzív megoldások és mindig az aktuális problémához jobban illő, arra hatékonyabb megoldást adó változatot érdemes használnunk.
Mi most az utóbbit fogjuk választani, mert a programkódunk egyszerűbb lesz, ugyanakkor szembetaláljuk magunkat egy problémával, ami pedig gyakorta előkerül komplexebb nyelvtanokban, ezért érdemes néhány szót szólni róla.
2 A láncolható értékadó operátor kódja
A megvalósítandó nyelvtanunkban csak az összeg
szabály
módosult, tehát csak a neki megfelelő kódot kell megváltoztatnunk.
A kódolás előtt még bele kell gondolni abba, hogy a szintaxisfa hogyan fog
felépülni. Ha egy pillantást vetünk az értékadó_kifejezés
kódjára,
láthatjuk, hogy az értékadó szimbólum "gyermeke" az a kifejezésfa, amit az
összeg
szabály épít fel és ad át az
értékadó_kifejezés
nek:
... if (valtozonev(&munka, nev) && karakter(&munka, "=", &kar)) { van_ertekadas = 1; } else munka = *szoveg; if (osszeg(&munka, &kifejezes)) { if (van_ertekadas) { *ast = uj_ertekadas(nev, kifejezes); } ...
Ha tehát az összeg
szabály egy értékadó_kifejezés
által alkotott fával is vissza tud majd térni, akkor az a = b = 5
alak úgy fog kiértékelődni, hogy az értékadó_kifejezés
feldolgozza
az a =
szövegrészletet, majd a további részeket átadja az
összeg
nek, ami ismét megpróbálja értékadásként értelmezni. A
másodjára meghívott ertekado_kifejezes
előállítja a b =
5
kifejezésnek megfelelő részfát, amit az elsőnek meghívott
ertekado_kifejezes
el fog menteni, mint a létrehozott
ertekadas
szimbólum kifejezése. Így jön létre az ábrán látható
szintaxisfa.
A kiértékelés helyesen fog működni, hiszen az a =
szimbólum
lekérdezi az általa tárolt kifejezés értékét. Ez a kifejezés a b =
5
, ami a kiértékelés során egyfelől eltárolja a b
változóban az 5-ös értéket, másfelől vissza is adja ezt, tehát az a
=
kifejezés is 5-öt kap értékül.
Valósítsuk meg a nyelvtanunk által előírt működést az osszeg
függvényben! Tulajdonképpen mindössze annyit kell tennünk, hogy megpróbáljuk a
szöveget értékadó kifejezésként értelmezni. Ha sikerült, akkor a visszaadott
részfa lesz a visszatérési értékünk, hiszen a kapott értékadó kifejezést így
tudjuk hozzáláncolni a korábbihoz. Ha pedig nem sikerült, akkor két szorzat
összegét próbáljuk illeszteni – ahogy azt tettük eddig is:
static int osszeg(char **szoveg, szimbolum **ast) { ... szokoz(&munka); if (ertekado_kifejezes(&munka, ast)) { *szoveg = munka; return 1; } if (szorzat(&munka, &op1)) { while (karakter(&munka, "+-", &kar)) { if (szorzat(&munka, &op2)) { uj = uj_muvelet(kar); uj->op1 = op1; uj->op2 = op2; op1 = uj; } else return 0; } if (uj == NULL) *ast = op1; else *ast = uj; *szoveg = munka; return 1; } else { return 0; } }
Ha megpróbáljuk így futtatni a programunkat, akkor végtelen rekurzióba kerül és a stack beteltekor az operációs rendszer leállítja. Próbáljuk meg megfejteni a hiba okát!
Ehhez a nyelvtanhoz kell visszanyúlnunk.
értékadó_kifejezés ::= ( változónév '=' )? összeg összeg ::= értékadó_kifejezés | ( szorzat (('+' | '-') szorzat)* ) szorzat ::= tényező (('*' | '/') tényező)* tényező ::= szám | változónév | zárójeles zárójeles ::= '(' értékadó_kifejezés ')'
Próbáljuk meg végigkövetni, hogy mi történik az „a = b = 5
”
szövegrészlet elemzésekor! Az elemző meghívja az
ertekado_kifejezes()
függvényt, ami ráilleszt az „a =
”
szövegrészletre, majd meghívja az osszeg()
függvényt. Ő ismét
meghívja az ertekado
függvényt, ami megtalálja az újabb értékadást
– a „b =
” részletet és a maradékra meghívja az
osszeg()
-et, ami természetesen azzal kezd, hogy meghívja az
ertekado_kifejezes()
-t. Ez utóbbi már nem talál tovább
értékadást, így rögtön meghívja az osszeg()
-et, ami az
ertekado_kifejezes()
-t és így tovább, amíg csak be nem telik a
függvényhívási stack.
A problémát a programkód szintjén tulajdonképpen az okozza, hogy nincs báziskritériuma a rekurziónak, tehát nincs olyan feltétel, ami leállítaná a két függvény kölcsönös hívását.
A megoldást az jelentené, ha az osszeg
csak akkor hívná meg az
ertekado_kifejezes()
-t, ha tényleg szükség van rá, tehát tényleg egy
következő értékadás szerepel a kifejezésünkben és nem egy összeg.
Az első ötletünk az lehet, hogy cseréljük fel a két vizsgálatot, először próbáljunk összeget találni és ha az nem sikerült, akkor nézzük meg, hogy nem egy értékadással van-e dolgunk. Itt észrevehetjük azt, hogy bár a VAGY-kapcsolatban elvben felcserélhetőek az operandusai, hiszen a kifejezés értéke a sorrendtől teljesen független, de a nyelvtanok esetében (és a C nyelvben a logikai rövidzár miatt) ez mégsincs így.
Az elgondolásunk azonban nem jó eredményre vezet, az „a = b = 5
”
kifejezésünk „a = b
”-ként lesz értelmezve a végén lévő „=
5
”-tel pedig nem tud mit kezdeni az értelmező. Ennek az az oka, hogy
ebben az esetben a ertekado_kifejezes()
az „a =
”-ig
jut el, majd a további részt az osszeg()
-re bízza, ami pedig
először szorzat
ként próbálja meg értelmezni a fennmaradó szöveget.
A szorzat
a tényező
n keresztül le tud egyszerűsödni
változónév
vé, ami sikeresen illeszkedik a „b
”-re és
ezen a ponton félrecsúszik az értelmezik, hiszen az értelmező a fennmaradó
részben először *
-ot vagy /
-t, majd +
-t
vagy -
-t keres és amikor egyiket sem találja, akkor úgy dönt, hogy
egy egytagú összeggel volt dolga és itt befejeződik a kifejezés.
A nyelvtanunk tehát helyes, az értelmezőnk azonban nem tudja feldolgozni a
kifejezést. Más irányban kell elindulnunk. Azt mondtuk, hogy csak akkor
szabadna meghívni az osszeg()
-ből a
ertekado_kifejezes()
-t, ha biztosak vagyunk benne, hogy egy
értékadás következik. Tegyünk így!
Tipikus technika az értelmezők írásakor, hogy "csalunk" és előreolvasunk a szövegben. Ha biztosan értékadás következik, akkor megpróbálhatunk értékadást értelmezni!
Azt már láttuk, hogy az értelmező függvényeket, amik rekurzív hívásokat hajtanak végre és módosítják a szintaxisfát, csak óvatosan szabad meghívni, hiszen olyan módosításokat vihetnek végbe, amik vakvágányra vihetik az elemzést.
Ugyanakkor vannak egyszerű szövegelemző függvényeink is, amelyek nem hívnak további elemző függvényeket, tulajdonképpen nem a szimbólumok szintjén dolgoznak, hanem egy adott szimbólumot próbálnak felismerni a karakterek szintjén. Az ilyen funkcionalitást szoktuk átfogóan lexernek hívni szemben az értelmező, parser függvényekkel.
Lexer függvényeket tehát bátran hívhatunk, hiszen azok csupán a munka pointert állítják el, ami könnyedén vissza tudunk állítani, ha nem volt illeszkedés.
Az egy értékadás (változónév és '=' karakter) előreolvasó osszeg()
tehát a következőképpen néz ki:
static int osszeg(char **szoveg, szimbolum **ast) { char *munka = *szoveg, *eloreolvaso; char kar, vnev[51]; szimbolum *op1, *op2, *uj = NULL; szokoz(&munka); eloreolvaso = munka; if (valtozonev(&eloreolvaso, vnev) && karakter(&eloreolvaso, "=", &kar)) { if (ertekado_kifejezes(&munka, ast)) { *szoveg = munka; return 1; } } if (szorzat(&munka, &op1)) { while (karakter(&munka, "+-", &kar)) { if (szorzat(&munka, &op2)) { uj = uj_muvelet(kar); uj->op1 = op1; uj->op2 = op2; op1 = uj; } else return 0; } if (uj == NULL) *ast = op1; else *ast = uj; *szoveg = munka; return 1; } else { return 0; } }
Látható, hogy mivel az előreolvasás során elállított pointer értéke minket nem
érdekel, ezért látrehozunk egy ideiglenes változót (eloreolvaso
),
amit a két lexer függvénynek (valtozonev
és karakter
)
adunk át, de az értékét sehol másutt nem használjuk fel. Ez azért lehetséges,
mert ha sikeres volt az előreolvasás, akkor a stringet a kezdeti pozíciótól
kezdve adjuk át az ertekado_kifejezes()
-nek, ha pedig nem akkor a
szorzat()
kapja meg, szintén az eredeti pozíciótól.
3 A paraméteres kifejezések ismételt végrehajtása
Gyakori számítási feladatokban, hogy ugyanazt a kifejezést (pl. másodfokú egyenlet megoldóképlete) szeretnénk kiértékeli különböző változóértékekkel. Hasznos lenne tehát, ha erre képes lenne a programunk.
Igazából programozói szempontból ez a feladat nem jelent kihívást. Minden
kiértékelés során előáll a szintaxisfa, ami változóhivatkozásokat tárol és nem
konkrét változóértékeket, így ha egy későbbi időpontban meghívjuk a fát
kiértékelő függvényt (ast_kiertekel()
), akkor az a változók
aktuális értékét fogja használni.
Mindössze annyit kell tehát tennünk, hogy eltároljuk a szintaxisfákat és visszahívhatóvá tesszük őket.
Ehhez két dolgot kell még biztosítanunk. Az első, hogy valahogyan hivatkozhatóvá kell tennünk a paraméteres kifejezéseket. Ennek a legegyszerűbb módja az, hogy megszámozzuk őket. Itt csak arra kell vigyáznunk, hogy egy kifejezést csak akkor tároljunk el és ezzel együtt a számlálót csak akkor növeljük, ha sikeres volt a kiértékelés. Tehát csak helyes fákat mentünk el, így az újbóli kiértékeléskor hibakezeléssel már nem kell törődnünk.
A másik dolog egy kényelmi szolgáltatás. A visszahívott kifejezés szövegét illik kiírni, hogy könnyebb legyen a számításokat áttekinteni. Ez nem triviális feladat, ugyanis a fában a felépítés tárolja a kiértékelési sorrendet, tehát a zárójelek egészen absztrakt és nehezen visszafejthető módon vannak jelen. Az eredetileg beírt szöveget tehát meglehetősen nehéz lenne visszanyerni a szintaxifából. Éppen ezért ezzel meg sem próbálkozunk. Ehelyett, vállalva, hogy egy kicsit terheljük a memóriát, eltároljuk a kifejezéseink szövegét is, bár azokat többé nem értékeljük ki, mindössze arra használjuk őket, hogy megjelenítsük az kifejezést, amikor a hozzá tartozó szintaxisfát újból kiértékeljük.
Így az adatszerkezetünk a következképpen néz ki:
typedef struct kifejezesek { char szoveg[255]; szimbolum *ast; struct kifejezesek *kovetkezo; } kifejezesek;
A tárolás pedig egy láncolt listában történik. A visszahíváskor a ismetel
utasítás után beírt sorszámnak megfelelő listaelemet keressük meg.
A teljes kód letölthető innen.