Infix kifejezések kiértékelése

Ebben a feladatban olyan függvényeket kell megvalósítani, amelyek segítségével infix formában felírt, egyszerűbb kifejezéseket lehet beolvasni és kiértékeltetni.

A feladatban most egyetlen unáris művelettel, a negálással dolgozunk, valamint bináris műveletek rögzített készletével (összeadás, kivonás, szorzás, osztás, maradékképzés) dolgozunk. Mindezek mellett, mivel infix műveletekről beszélünk, lehetővé tesszük, hogy zárójelezéssel meg lehessen adni a részkifejezések kiértékelésének sorrendjét. Ennek hiányában a műveletekre érvényes kötési erősségéket és kötési irányokat (minden esetben balasszociatív) vesszük alapul a kiértékelés során.

Az operátorok feldolgozás mellett a feladat annyiban egészül még ki, hogy tudnunk kell kezelni különböző számrendszerekben megadott literálokat is. Ezt a számok előtt opcionális megadható, különböző előtagokkal ("0x" és "0o") jelezzük és ettől függően fogunk majd tudni dolgozni nyolcas, tízes és tizenhatos számrendszerben megadott konstansokkal is.

Próbáljuk meg megoldani a feladatot csak a következő segédanyagok felhasználásával: Haskell könyvtárainak dokumentációja, Hoogle, a tárgy honlapja és a BE-AD rendszerbe feltöltött beadandók.

A feladatok egymásra épülnek, ezért érdemes ezeket a megadásuk sorrendjében megoldani, de legalább megérteni az aktuális feladatot megelőző feladatokat! A függvények definíciójában lehet, sőt javasolt is alkalmazni a korábban definiált függvényeket (függetlenül attól, hogy sikerült-e azokat megadni). Beadni viszont a teljes megoldást kell, vagyis az összes függvény definícióját egyszerre!

Tekintve, hogy a tesztesetek, bár odafigyelés mellett íródnak, nem fedik le minden esetben a függvény teljes működését, határozottan javasolt még külön próbálgatni a megoldásokat beadás előtt!

Adott számrendszer számjegye-e?

Készítsünk egy olyan függvényt, amely eldönti a paramétereként megadott karakterről, hogy egy szintén adott számrendszer számjegye-e! Akkor tekintünk egy karaktert valamilyen számrendszer számjegyének, ha benne van a neki megfelelő számjegyek halmazában. Ez a számrendszertől függően 0 és F közötti számjegyeket jelent:

D8 = {0, 1, 2, 3, 4, 5, 6, 7} (oktális)
D10 = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9} (decimális)
D16 = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, F} (hexadecimális)

Ha a bázis nem megfelelő, akkor a függvény kiértékelése álljon meg a "isDigitOfBase: Invalid base." üzenettel. (Ilyet az error függvény segítségével tudunk csinálni.)

isDigitOfBase :: Char -> Int -> Bool

Test>
True :: Bool
Test>
True :: Bool
Test>
True :: Bool
Test>
True :: Bool
Test>
False :: Bool
Test>
True :: Bool
Test>
⊥₁ :: Bool
⊥₁: isDigitOfBase: Invalid base.
CallStack (from HasCallStack):
  error, called at ./InfixExpression.lhs:68:28 in main:InfixExpression

Szöveg egész számmá alakítása

Írjunk egy olyan függvényt, amely adott bázis szerint megpróbál feldolgozni egy szöveget és előállítani a neki megfelelő nemnegatív egész számot! Ha a feldolgozandó szöveg üres, akkor a kiértékelés álljon meg a "parseInteger: Empty string." üzenettel, illetve ha a szövegeben szereplő valamelyik karakter nem az adott bázis szerinti számjegyet takarja, akkor a "parseInteger: Invalid digit: X" üzenettel, ahol X a hibás számjegyet jelenti.

parseInteger :: Int -> String -> Integer

Test>
3405691582 :: Integer
Test>
123456789 :: Integer
Test>
382 :: Integer
Test>
⊥₁ :: Integer
⊥₁: parseInteger: Invalid digit: '9'
CallStack (from HasCallStack):
  error, called at ./InfixExpression.lhs:95:33 in main:InfixExpression
Test>
⊥₁ :: Integer
⊥₁: parseInteger: Empty string.
CallStack (from HasCallStack):
  error, called at ./InfixExpression.lhs:90:23 in main:InfixExpression

Számliterálok felismerése szövegből

Készítsünk egy függvényt, amely annyival egészíti a nemnegatív számok szöveges beolvasását, hogy egy előtag alapján eldönti, hogy milyen számrendszerben kell értelmezni! Ha a szöveg első két karaktere '0' és ‘x’, akkor a szöveg folytatása egy tizenhatos számrendszerbeli számot takar, ha viszont '0' és 'o', akkor nyolcas számrendszerbelit. Ha a szöveg nem tartalmaz semmilyen előtagot, akkor alapértelmezés szerint tízes számrendszerbeli értéknek tekintjük a teljes szöveget. Az előtagok vizsgálata során nem kell különbséget tennünk a kis- és nagybetűk között.

A paraméterül kapott szöveg üres, akkor a kiértékelést állítsuk meg a "parseLiteral: Empty string." üzenettel!

parseLiteral :: String -> Integer

Test>
16826095 :: Integer
Test>
3402240564 :: Integer
Test>
467 :: Integer
Test>
13402 :: Integer
Test>
727282353 :: Integer
Test>
⊥₁ :: Integer
⊥₁: parseInteger: Invalid digit: 'e'
CallStack (from HasCallStack):
  error, called at ./InfixExpression.lhs:95:33 in main:InfixExpression
Test>
⊥₁ :: Integer
⊥₁: parseInteger: Invalid digit: 'a'
CallStack (from HasCallStack):
  error, called at ./InfixExpression.lhs:95:33 in main:InfixExpression
Test>
⊥₁ :: Integer
⊥₁: parseInteger: Invalid digit: 'O'
CallStack (from HasCallStack):
  error, called at ./InfixExpression.lhs:95:33 in main:InfixExpression
Test>
⊥₁ :: Integer
⊥₁: parseLiteral: Empty string.
CallStack (from HasCallStack):
  error, called at ./InfixExpression.lhs:120:31 in main:InfixExpression

A folytatásban a kifejezésként értelmezendő szöveg többi részének vizsgálatát és feldolgozását írjuk le. Ennek során a szöveg felismert elemeit tokeneknek fogjuk nevezni. Ezt z olvashatóság kedvéért a továbbiakban a String típushoz rendelt szinonimával jelöljük, mivel a tokenek maguk továbbra is szövegek lesznek:

type Token = String

Operátorok felismerése

Adjuk meg azt a függvényt, amely egy tokenről eldönti, hogy műveleti jelet képvisel-e vagy sem! A kifejezésekben felbukkanó műveleti jelek lényegében a korábban említett, támogatott műveletek szöveges jelöléseit takarják:

isOperator :: Token -> Bool

Test>
True :: Bool
Test>
True :: Bool
Test>
True :: Bool
Test>
False :: Bool
Test>
False :: Bool
Test>
False :: Bool

A szöveg tokenekre bontása

Készítsünk egy olyan függvényt, amely segítségével egy komplett kifejezést tartalmazó szöveget tudunk felbontani tokenekre! A tokeneknek alapvetően két fajtája lehet:

Ügyeljünk arra, hogy a feldolgozás során a szövegben elhelyezett szóközök nem befolyásolják a kiszámítandó kifejezés jelentését, ezért azok minden következmény nélkül elhagyhatóak!

tokenize :: String -> [Token]

Test>
[] :: [Token]
Test>
["0x12AA"] :: [Token]
Test>
["1", "+", "2"] :: [Token]
Test>
["(", "1", "+", "2", ")"] :: [Token]
Test>
["(", "1", "+", "2", ")", "*", "3"] :: [Token]
Test>
["(", "1", "+", "2", ")", "*", "3"] :: [Token]
Test>
["3", "*", "(", "1", "+", "2", ")"] :: [Token]
Test>
["0x4D5A", "*", "2", "+", "0o32", "-", "~", "111"] :: [Token]
Test>
["(", "(", "~", "9", ")", "+", "~", "0o711", ")", "/", "(", "0Xcafe", "%", "8", ")"] :: [Token]

A műveletek erőssége

Egy függvény segítségével határozzuk meg az operátorok kötési erősségét! Erre azért lesz szükségünk, hogy el tudjuk hagyni később a zárójeleket a kifejezésekből. A kötési erősséget egy nemnegatív számmal jelezzük, amely annál erősebb kötési erősséget jelöl, minél nagyobb az értéke. A függvénynek úgy kell kiosztania ezeket az értékeket, hogy a következő szintezés megvalósuljon a műveletek között:

Az egy szinten levő műveletek azonos erősségüek lesznek, ezek közül mindig egységesen a balra levőt választjuk. A véletlenül nem műveleti jelet ábrázoló tokent adunk a függvénynek, akkor álljon meg a kiértékelése a "precedence: Invalid operator: 'X'" üzenettel, ahol X jelöli a hibát kiváltó paraméter értékét! A zárójelet itt most nem tekintjük műveletnek, ezért nem tartozik hozzá kötési erősség!

precedence :: Token -> Int

Test>
True :: Bool
Test>
True :: Bool
Test>
True :: Bool
Test>
True :: Bool
Test>
⊥₁ :: Int
⊥₁: precedence: Invalid operator: '?'
CallStack (from HasCallStack):
  error, called at ./InfixExpression.lhs:233:20 in main:InfixExpression
Test>
⊥₁ :: Int
⊥₁: precedence: Invalid operator: ''
CallStack (from HasCallStack):
  error, called at ./InfixExpression.lhs:233:20 in main:InfixExpression
Test>
⊥₁ :: Int
⊥₁: precedence: Invalid operator: '('
CallStack (from HasCallStack):
  error, called at ./InfixExpression.lhs:233:20 in main:InfixExpression
Test>
⊥₁ :: Int
⊥₁: precedence: Invalid operator: '67232'
CallStack (from HasCallStack):
  error, called at ./InfixExpression.lhs:233:20 in main:InfixExpression

Infix formáról postfix formára alakítás

Készítsünk egy olyan függvényt, amely tokenek adott listája alapján átrendezi úgy a sorrendjüket, hogy benne a műveletek infix ábrázolás helyett postfix módon jelennek meg! Vagyis az operandusai között elhelyezkedő elhelyezkedő műveletei jelek az operandusai után kerülnek. Például:

1 + 2  -- infix jelölés
1 2 +  -- postfix jelölés

Az átrendezéshez figyelembe vesszük a műveletek megjelenési sorrendjét, egymás viszonyított erősségét és a zárójeleket. Például:

(1 + 2) * 3  -- infix jelölés
1 2 + 3 *    -- postfix jelölés

Mint látható, a postfix jelölést azért alkalmazzuk, mert ez zárójelek használata nélkül is egyértelmű lesz.

Az átalakítás megvalósításához az E. W. Dijkstra által publikált “vagonrendező” (“shunting yard”) algoritmust lehet használni. Az algoritmus a feldolgozás során használ egy vermet, amelynek tetejére el tud helyezni műveleteket, majd amelyeket később, a megfelelő pillanatban ki tud onnan venni és visszailleszteni a tokenek sorozatába.

Az algoritmus vázlatos működése a következő:

A működés szemléltetéséhez tekintsünk egy konkrét példát, a 3 + 4 * 2 / (1 − 5) kifejezés kiértékelését! A hozzá tartozó tokensorozat a korábbiaknak megfelelően [ "3", "+", "4", "*", "2", "/", "(", "1", "-", "5", ")" ]. A φ( ⋅ ) függvény megadja az operátor kötési erősségét, ld. a precedence függvényt. A “verem ” a verem legfelső elemének kivételét, illetve a “ verem” az elem a verem tetejére elhelyezését jelenti. A “” a lista bővítését jelenti egy másik listával.

Token Teendő Eredmény Verem Megjegyzések
3 eredmény 3
+ verem 3 +
4 eredmény 3 4 +
* verem 3 4 * + φ( * ) > φ( + )
2 eredmény 3 4 2 * +
/ verem eredmény 3 4 2 * + φ(/) = φ( * )
verem 3 4 2 * / + φ(/) > φ( + )
( verem 3 4 2 * ( / +
1 eredmény 3 4 2 * 1 ( / +
- verem 3 4 2 * 1 - ( / +
5 eredmény 3 4 2 * 1 5 - ( / +
) verem eredmény 3 4 2 * 1 5 − ( / + amíg nincs (
verem  → ∅ 3 4 2 * 1 5 − / + ( eldobása
vége eredmény verem 3 4 2 * 1 5 − / +

Ne feledjük, hogy az algoritmust itt most tisztán funkcionális módon szeretnénk megvalósítani, ezért az első paraméter lesz a műveleteket tároló – kezdetben üres – verem, a második paraméter pedig a feldolgozandó tokensorozat!

shunt :: [Token] -> [Token] -> [Token]

Test>
["3", "4", "2", "*", "1", "5", "-", "/", "+"] :: [Token]
Test>
["3", "4", "2", "*", "1", "5", "-", "(", "/", "+"] :: [[Char]]
Test>
["1"] :: [Token]
Test>
["999"] :: [Token]
Test>
["22"] :: [Token]
Test>
["32"] ++ ⊥₁ :: [Token]
⊥₁: shunt: Mismatching parentheses.
CallStack (from HasCallStack):
  error, called at ./InfixExpression.lhs:350:19 in main:InfixExpression
Test>
["42", "~"] :: [Token]
Test>
["42", "~", "0x29", "*"] :: [Token]
Test>
["42", "~", "89", "~", "+"] :: [Token]
Test>
["3", "17", "89", "*", "+"] :: [Token]
Test>
["777", "0o22", "+", "16", "%"] :: [Token]
Test>
["2", "238", "*", "1956", "77", "+", "/"] :: [Token]
Test>
["19", "2", "%", "~", "472", "3", "/", "~", "*"] :: [Token]

Számolás veremszámológéppel

Valósítsunk meg egy olyan függvényt, amely egy postfix formába rendezett tokensorozatból kiszámítja a neki megfelelő műveletek és literálok által meghatározott kifejezés eredményét! Hasonlóan a infix és postfix forma közti átalakítást végző függvényhez, itt is alkalmazunk egy vermet, ahova ezúttal a számolás során a literálok értékeit helyezzük el. Ezért itt is lesz a függvénynek egy plusz paramétere, amely ennek a veremnek az állapotát adja tovább és kezdetben üres.

Ezt a megoldást veremszámológépnek is nevezik, és a következő algoritmus szerint működik:

Ha a fenti elvárások valamilyen szempontból sérülnének (például egy bináris művelet elvégzéséhez nincs két érték a veremben), akkor szakítsuk meg a függvény futását a "calculate: Invalid state." üzenettel!

A működés szemléltetéséhez folytassuk az előző példánkat, és nézzük meg, miként számolható ki így a 3 4 2 * 1 5 − / + kifejezés eredménye! Az alkalmazott jelöléseink ugyanazok, mint korábban.

Token Teendő Verem Megjegyzések
3 verem 3
4 verem 4 3
2 verem 2 4 3
* verem y 4 3 y = 2
verem x 3 x = 4
(x ⋅ y)→ verem 8 3
1 verem 1 8 3
5 verem 5 1 8 3
- verem y 1 8 3 y = 5
verem x 8 3 x = 1
(x − y)→ verem -4 8 3
/ verem y 8 3 y =  − 4
verem x 3 x = 8
(x/y)→ verem -2 3
+ verem y 3 y =  − 2
verem x x = 3
(x + y)→ verem 1
vége verem eredmény

A függvényünk az előzőekhez hasonlóan tisztán funkcionális stílusú lesz, így a verem annak első paramétere lesz ismét.

calculate :: [Integer] -> [Token] -> Integer

Test>
3 :: Integer
Test>
⊥₁ :: Integer
⊥₁: calculate: Invalid state.
CallStack (from HasCallStack):
  error, called at ./InfixExpression.lhs:464:33 in main:InfixExpression
Test>
⊥₁ :: Integer
⊥₁: parseInteger: Invalid digit: '+'
CallStack (from HasCallStack):
  error, called at ./InfixExpression.lhs:95:33 in main:InfixExpression
Test>
⊥₁ :: Integer
⊥₁: parseInteger: Invalid digit: '+'
CallStack (from HasCallStack):
  error, called at ./InfixExpression.lhs:95:33 in main:InfixExpression
Test>
-11 :: Integer
Test>
0 :: Integer
Test>
-1 :: Integer
Test>
152 :: Integer
Test>
5 :: Integer
Test>
20 :: Integer
Test>
1 :: Integer

Szövegesen megadott infix kifejezés kiértékelése

Az előbbi függvény segítségével építsünk egy olyan függvényt, amely képes szöveges formában megadott infix kifejezések eredményét kiszámolni! Ha a függvény üres karaktersorozatot kap, akkor az eredmény legyen alapértelmezés szerint nulla!

evaluate :: String -> Integer

Test>
0 :: Integer
Test>
42 :: Integer
Test>
33 :: Integer
Test>
-11 :: Integer
Test>
1 :: Integer