Simplu calculator în Python - partea 1

De mai multă vreme tot mă bate gândul să fac ceva in­ter­pre­ta­tor pentru un limbaj mai simplu. Ca o primă aventurare în lumea in­ter­pretării șirurilor de caractere ca in­strucți­u­ni pentru calculator, m-am decis să fac un simplu „cal­cu­la­tor” în Python, care să aibă și suport pentru câteva funcții matematice și pentru variabile.

Basically, ce urmăresc eu e să am un mic progrămel care știe să facă ur­mă­toarele:

7+10*sin(pi/2)/2-3^2  # să dea 3
x = 127-30*ln(e)      # x să aibă valoarea 97
y = x^2 - 60/30*(6+x) # y să aibă 9203

Definiția riguroasă a sintaxei pe care o acceptă programul este următoarea:

<Number> = [0-9]+.?[0-9]* <Identifier> = [a-zA-Z][a-zA-Z0-9_]* <Operator> = [+*/%()^-]

<Function> = <Identifier> (<Identifier> | <Term>)
<Term> = <Number> | <Identfier> | LPAREN <Sum> RPAREN |
<Function>
<PowProduct> = <Term> (^ <Term>)?
<Product> = <PowProduct> ((*|/|%) <PowProduct>)*
<Sum> = <Product> ((+|-) <Product>)*
<Expression> = <Sum> | <Identifier> = <Expression>

Programul va avea 3 părți: un tokenizer, care va împărți stringul în bucăți elementare, un parser, care verifică corec­ti­tudinea datelor de intrare și apoi con­stru­iește un arbore de parsare, și în final un in­ter­pre­ta­tor, care parcurge arborele și evaluează expresia.

În acest post mă voi ocupa de prima parte, de tokenizer.

Vom avea ur­mă­toarele tipuri de tokene:

  • numere întregi (10) sau cu zecimale (15.34)
  • operatori (+,-,/,*,^,=,(,) )
  • iden­ti­fi­ca­tori de variabile (x,y,rezultat) sau funcții (sin,ln,cos)

În primul rând avem nevoie de o clasă care să reprezinte tokenele, pe care o punem într-un fișier tokenizer.py:

    class token:

    def __init__(self,type,value = None):
        self.type = type
        self.value = value

    def __str__(self):
        return self.type + " " + self.value

    def __eq__(self, other):
        if isinstance(other,token):
            return self.type == other.type and self.value == other.value
        elif isinstance(other,str):
            return str(self.value) == other
        else:
            return False

Nu e prea complicată această clasă. Primește ca parametrii tipul și valoarea tokenului. Egalitatea între doi tokeni înseamnă că ambii au același tip și aceași valoare. Pentru a ne scurta un pic codul, permite și testarea cu un string, în acest caz având egalitate dacă valoare tokenului este egală cu stringul. Funcția __str__ o să o folosim doar la afișare.

Pentru că suntem dez­volta­tori moderni și grijulii, vom aplica Test Driven De­vel­op­ment, așa că mai întâi vom scrie câteva teste pentru cum ar trebui să funcționeze to­k­enizerul (la clasa token nu avem ce să testăm). Testele le punem în alt fișier (ideal ar fi în alt modul, nu ne complicăm cu asta acuma), tokenizerTest.py:

    import tokenizer
    t = tokenizer.Tokenizer()
    assert (t.tokenize("1+2") == ['1',"+",'2'])
    assert (t.tokenize("(x+y)\*2/4") ==
    ["(","x","+","y",")","\*","2","/","4"])
    assert(t.tokenize("(x+(y\*z+2))-3\*((5+x)/2-4)") ==
    ["(","x","+","(","y","\*","z","+","2",")",")","-","3","\*","(","(","5","+","x",")","/","2","-","4",")"
    ])
    assert(t.tokenize(" x + y ") == ['x','+','y'])
    try:
        t.tokenize("_&amp;amp;amp;x+y") \# test for invalid symbols
        assert(False)
    except tokenizer.TokenizerError:
        assert(True)

To­k­enizerul va parcurge pe rând fiecare caracter din șirul de caractere și la fiecare pas va încerca să găsească ce tip de token se potrivește. Dacă la un punct nu găsește un token potrivit, înseamnă că șirul dat nu este o expresie validă. Așa că în fișierul tokenizer.py adăugăm clasa Tokenizer. Folosim o clasă doar ca să încapsulăm funcțiile să nu poluăm name­spaceul general:

    class TokenizerError(Exception): # exceptie speciala pentru cazul in care nu se recunoaste un caracter
        pass

    class Tokenizer:

        def tokenize(self,string):
            self.string = string
            self.tokens = []

            self.index = 0
            match = False # am gasit un token iteratia asta
            while self.index < len(self.string):
                token = self.operator()
                if token:
                    self.tokens.append(token)
                    match = True

                token = self.identifier()
                if token:
                    self.tokens.append(token)
                    match = True

                token = self.number()
                if token:
                    self.tokens.append(token)
                    match = True

                if self.skipWhite(): # sarim peste spatiile libere
                    match = True
                if not match: # daca nu am gasit token iteratia aceasta inseamna ca
                              # avem simboluri incorecte
                    raise TokenizerError('Invalid simbol found:'+self.string[self.index])
                    match = False
            return self.tokens

Ca să detectăm un operator pur și simplu comparăm caracterul cu operatorii posibili:

def operator(self):
    if self.index < len(self.string) and self.string[self.index] in '+-/\*()%^=':
        t = token('operator',self.string[self.index])
        self.index +=1
        return t
    else:
        return None

Detectare unui iden­ti­fi­ca­tor e un pic mai complicată, deoarece poate avea lungime variabilă și poate fi formată din tot felul de caractere: începe doar cu litere, dar apoi pot fi și cifre sau liniuțe de subliniere, în afara literelor. Vom folosi un regex pentru aceasta (acuma nu o să explic ce este un regex, doar pe scurt ce face fiecare componentă: ^[a-zA-Z][a-zA-Z0-9_]*$. ^ face ca potrivirea să se facă la începutul stringului, [a-zA-Z] corespunde unei litere, [a-zA-Z0-9_] corespunde unei litere, cifre sau unui underscore, iar * face repetitivă și opțională ultima chestie. Iden­ti­fi­ca­torul, dacă există, va fi dat de match.group():

def identifier(self):
    pattern = '^[a-zA-Z][a-zA-Z0-9_]\*'
    match = re.match(pattern,self.string[self.index:])
    if match:
        t = token('identifier',match.group())
        self.index = self.index + match.end()
        return t
    return None

Pentru a identifica un număr, facem aceași chestie, doar că schimbăm regexul: [0-9]+.?[0-9]. + face ca termenul anterior să se repete, dar să fie cel puțin o dată, . face "escaping" la ., care este caracter special în regexuri, iar ? face ca termenul anterior să fie opțional.

def number(self):
    pattern = "^[0-9]+\\.?[0-9]\*"
    match = re.match(pattern,self.string[self.index:])
    if match:
        t = token('number',match.group())
        self.index = self.index + match.end()
        return t
    return None

Funcția skipWhite face un singur lucru: sare peste spații și returnează True dacă a sărit peste ceva.

def skipWhite(self):
    match = False
    while self.index < len(self.string) and self.string[self.index] == " ":
        self.index+=1
        match = True
    return match

Punând toate acestea împreună, avem un simplu tokenizer pentru expresii matematice. Stay tuned for part 2, the parser.

Codul sursă se poate descărca de aici.