Você está na página 1de 8

Como escrever uma calculadora em 70 linhas Python, escrevendo um analisador de Descida

Recursiva

Fev 23/2013, 21:35

Trs meses atrs, eu escrevi um post detalhando o processo de escrever uma calculadora usando
uma biblioteca de anlise. A resposta popular, no entanto, foi que os leitores esto muito mais
curiosos sobre ver uma calculadora escrita a partir do zero, com as baterias includas, mas nada
mais. Eu percebi, por que no?

Escrever uma calculadora simples, se voc usar hacks especficos para expresses aritmticas,
mas o efeito de hacks quase sempre o mesmo: a soluo no elegante, no extensvel e difcil
de entender intuitivamente. Na minha apreciao de um bom desafio, e meu objetivo em um posto
benfico, eu decidi escrev-lo usando um analisador de descendncia recursiva principalmente
genrico. No mesmo esprito da ltima vez, eu queria faz-lo em to poucas linhas como eu
razoavelmente pode, por isso est cheio de hacks e truques, mas eles so superficiais e no
especficos para a tarefa em mos.

Este post uma explicao detalhada e passo a passo da minha implementao. Se voc quiser
pular direto para o cdigo e descobrir por si mesmo, basta ir at o final deste post. Esperemos que
quando voc tiver feito voc ter uma melhor compreenso de como a anlise funciona
internamente, e voc ser inspirado a usar uma biblioteca de anlise adequada para evitar essa
confuso sangrenta.

Para entender este post, voc deve ter uma forte compreenso do Python, e recomendado ter
alguma compreenso do que analisar e para que serve. Se voc no tiver certeza, eu recomendo
que voc leia meu post anterior, no qual eu explico completamente a gramtica que vou usar neste
post.

Passo 1: Tokenize

A primeira etapa do processamento da expresso transform-la em uma lista de smbolos


individuais. Esta a parte mais fcil, e no o ponto deste exerccio, ento eu me permiti a fazer
batota aqui muito.

Primeiro, eu defini os tokens (os nmeros so notavelmente ausentes, eles so o padro) e um tipo
de token:

token_map = {'+':'ADD', '-':'ADD',


'*':'MUL', '/':'MUL',
'(':'LPAR', ')':'RPAR'}
Token = namedtuple('Token', ['name', 'value'])

E aqui est o cdigo que eu usei para tokenize uma expresso `expr`:

split_expr = re.findall('[\d.]+|[%s]' % ''.join(token_map), expr)tokens =


[Token(token_map.get(x, 'NUM'), x) for x in split_expr]

A primeira linha um truque que divide a expresso em tokens bsicos, ento

'1.2 / ( 11+3)' --> ['1.2', '/', '(', '11', '+', '3', ')']
A prxima linha nomeia os tokens, para que o analisador possa reconhec-los por categoria:

['1.2', '/', '(', '11', '+', '3', ')']-->[Token(name='NUM', value='1.2'),


Token(name='MUL', value='/'), Token(name='LPAR', value='('), Token(name='NUM',
value='11'), Token(name='ADD', value='+'), Token(name='NUM', value='3'),
Token(name='RPAR', value=')')]

Qualquer token que no esteja no token_map assumido como um nmero. Nosso tokenizer no
tem uma propriedade chamada validao que impede que os non-numbers sejam aceitos, mas
felizmente o avaliador ir lidar com essa tarefa mais tarde.

isso a. Agora que temos uma lista de tokens, nosso prximo passo analis-lo em um AST.
Etapa 2: Definir a gramtica

O analisador que eu escolhi implementar um analisador de descida recursivo ingnuo, que uma
verso mais simples da anlise de LL. o analisador mais simples de implementar, e na verdade o
meu leva apenas 14 linhas. um tipo de analisador de cima para baixo, o que significa que ele
comea combinando a regra mais alta (como: expresso) e recursivamente tenta combinar suas sub-
regras at que ele corresponda s regras mais baixas (como: nmero). Dito de outra forma, enquanto
um analisador de baixo para cima (LR) gradualmente dobra tokens e regras em outras regras, at
que haja apenas uma regra esquerda, um parser de cima para baixo (LL) como o nosso vai
gradualmente expandir as regras em menos abstrato Regras, at que eles coincidam completamente
com os tokens de entrada.

Antes de chegarmos ao analisador real, vamos falar sobre a gramtica. No meu post anterior, eu usei
um analisador LR, e eu defini a gramtica da calculadora como este (caps so tokens):

add: add ADD mul | mul;mul:

mul MUL atom | atom;atom:

NUM | '(' add ')' | neg;

neg: '-' atom;

(Se voc no entender esta gramtica, voc deve ler meu post anterior)

Desta vez eu estou usando um analisador de LL, em vez de LR, e aqui est como eu defini a
gramtica:

rule_map = {

'add':['mul ADD add', 'mul'],

'mul':['atom MUL mul', 'atom'],

'atom':['NUM', 'LPAR add RPAR', 'neg'],

'neg' : ['ADD atom'],}


H uma mudana sutil aqui. As definies recursivas de add e mul
so invertidas. Este um detalhe muito importante, e eu preciso
explic-lo.

A verso LR desta gramtica usa algo chamado esquerda-recurso.


Quando analisadores LL ver recurso, eles apenas mergulham l em
uma tentativa de igualar a regra. Assim, quando confrontados com a
recursividade esquerda, eles entram em recurso infinita. Mesmo
os analisadores LL inteligentes, como a ANTLR, sofrem com esta
questo, embora provavelmente escreva um erro amigvel ao invs de
fazer um loop infinitamente como o nosso analisador de brinquedos.

A recurso esquerda facilmente resolvida mudando-a para


recurso direita, e foi isso que eu fiz. Mas como nada fcil
com os analisadores, ele criou outro problema: Enquanto a recurso
esquerda analisa 3-2-1 corretamente como (3-2) -1, a
recursividade direita a analisa
Incorretamente como 3- (2-1). Eu no sei de uma soluo fcil para
este problema, de modo a manter as coisas curtas e simples para
voc e eu tanto, eu decidi manter o formulrio incorreto e lidar
com ele em ps-processamento (consulte o passo 4).
Passo 3: Analisar um AST

O algoritmo simples. Vamos definir uma funo recursiva que


recebe dois parmetros: O primeiro o nome da regra que estamos
tentando combinar, eo segundo a lista de tokens que nos resta.
Vamos comear com add (que a regra mais alta) e com toda a lista
de tokens, e ter as chamadas recursivas cada vez mais especficas.
A funo retorna uma tupla: A correspondncia atual e uma lista
dos tokens que ficam para corresponder. Para efeitos de cdigo
curto, vamos torn-lo capaz de tambm correspondncia tokens (so
ambas as seqncias de caracteres, um UPPER-CASE e os outros
minsculas).

RuleMatch = namedtuple('RuleMatch', ['name', 'matched'])

def match(rule_name, tokens):


if tokens and rule_name == tokens[0].name: # Match a token?
return RuleMatch(tokens[0], tokens[1:])
for expansion in rule_map.get(rule_name, ()): # Match a rule?
remaining_tokens = tokens
matched_subrules = []
for subrule in expansion.split():
matched, remaining_tokens = match(subrule, remaining_tokens)
if not matched:
break # no such luck. next expansion!
matched_subrules.append(matched)
else:
return RuleMatch(rule_name, matched_subrules), remaining_tokens
return None, None # match not found
Linhas 4-5 verifique se rule_name realmente um token e se ele corresponde ao token atual. Se o
fizer, ele retornar a partida, e quais tokens ainda so deixados para consumir.

A linha 6 itera sobre as sub-regras de rule_name, ento cada uma pode ser correspondida
recursivamente. Se rule_name for um token, a chamada get () retornar uma tupla vazia eo fluxo
cair para o retorno vazio (linha 16).

As linhas 9-15 iteram sobre cada elemento da sub-regra atual e tentam combin-las
sequencialmente. Cada iterao tenta consumir tantos tokens correspondentes quanto possvel. Se
um elemento no corresponder, descartamos a sub-regra inteira. No entanto, se todos os elementos
corresponderem, alcanaremos a clusula else e retornaremos nossa correspondncia para
rule_name, com os tokens restantes para corresponder.

Vamos execut-lo e ver o que temos para 1.2 / (11 + 3).

>>> tokens = [Token(name='NUM', value='1.2'), Token(name='MUL', value='/'),


Token(name='LPAR', value='('), Token (name='NUM', value='11'), Token(name='ADD',
value='+'), Token(name='NUM', value='3'), Token(name='RPAR', value=')')]

>>> match('add', tokens)


(RuleMatch(name='add', matched=[RuleMatch(name='mul',
matched=[RuleMatch(name='atom', matched=[Token(name='NUM', value='1.2')]),
Token(name='MUL', value='/'), RuleMatch(name='mul',
matched=[RuleMatch(name='atom', matched=[Token(name='LPAR', value='('),
RuleMatch(name='add', matched=[RuleMatch(name='mul',
matched=[RuleMatch(name='atom', matched=[Token(name='NUM', value='11')])]),
Token(name='ADD', value='+'), RuleMatch(name='add',
matched=[RuleMatch(name='mul', matched=[RuleMatch(name='atom',
matched=[Token(name='NUM', value='3')])])])]), Token(name='RPAR',
value=')')])])])]), [])

O resultado uma tupla, claro, e podemos ver que no existem tokens restantes. A partida real no
fcil de ler, ento deixe-me desenh-lo para voc

atom

NUM '1.2'
MUL '/'
mul
atom
LPAR '('
add
mul
atom
NUM '11'
ADD '+'
add
mul
atom

Isto o que a AST parece, no conceito. uma boa prtica imaginar


o parser executado em sua mente, ou em um pedao de papel. Eu ouso
dizer que necessrio faz-lo se voc quiser grok-lo. Voc pode
usar este AST como uma referncia para se certificar de que voc
tem direito.

At agora ns escrevemos um analisador capaz de analisar


corretamente operaes binrias, operaes unrias, colchetes e
precedncia.

H apenas uma coisa que faz incorretamente, e vamos corrigi-lo na


prxima etapa.
Etapa 4: Ps-processamento

Meu analisador no perfeito em muitos aspectos. O importante


que ele no consegue lidar com a recurso esquerda, o que me
obrigou a escrever a gramtica como recursiva correta. Como
resultado, analisar 8/4/2 resulta na seguinte AST:

mul

atom
NUM 8
MUL '/'
mul
atom
NUM 4
MUL '/'
mul
atom
NUM 2

Se tentarmos resolver a expresso usando este AST, teremos que


calcular 4/2 primeiro, o que errado. Alguns LL-parsers optam por
corrigir a associatividade na rvore. Isso leva muitas linhas;).
Em vez disso, vamos aplain-lo. O algoritmo simples: para cada
regra na AST que 1) precisa de fixao, e 2) uma operao
binria (tem trs sub-regras), e 3) o seu operando direita a
mesma regra: aplainar o ltimo na antigo. Por "aplainar", quero
dizer substituir um n com seus filhos, no contexto de seu pai.
Uma vez que o nosso percurso DFS post-order, ou seja, comea a
partir da borda da rvore e funciona o seu caminho para a raiz, o
efeito se acumula. Aqui est o cdigo:

fix_assoc_rules = 'add', 'mul'

def _recurse_tree(tree, func):


return map(func, tree.matched) if tree.name in rule_map else tree[1]
def flatten_right_associativity(tree):
new = _recurse_tree(tree, flatten_right_associativity)
if tree.name in fix_assoc_rules and len(new)==3 and
new[2].name==tree.name:
new[-1:] = new[-1].matched
return RuleMatch(tree.name, new)

Este cdigo ir transformar qualquer sequncia estrutural de


adies ou multiplicaes em uma lista plana (sem misturar uns aos
outros). Parnteses quebrar a seqncia, claro, para que eles
no sero afetados.

A partir deste ponto eu poderia reconstruir a estrutura como


associativa esquerda, usando cdigo como
def build_left_associativity(tree):

new_nodes = _recurse_tree(tree, build_left_associativity)

if tree.name in fix_assoc_rules:

while len(new_nodes)>3:

new_nodes[:3] = [RuleMatch(tree.name, new_nodes[:3])]


return RuleMatch(tree.name, new_nodes)

Mas eu no vou. Estou pressionado por linhas de cdigo e alterar o cdigo de avaliao para lidar
com listas leva muito menos linhas do que reconstruir a rvore.
Etapa 5: Avaliar

Avaliar a rvore muito simples. Tudo o que necessrio percorrer a rvore de forma semelhante
ao cdigo de ps-processamento (ou seja, ps-ordem DFS) e avaliar cada regra nele. No ponto de
avaliao, porque recurse primeiro, cada regra deve ser feita de nada mais do que nmeros e
operaes. Aqui est o cdigo:

bin_calc_map = {'*':mul, '/':div, '+':add, '-':sub} def calc_binary(x):


while len(x) > 1: x[:3] = [ bin_calc_map[x[1]](x[0], x[2]) ]
return x[0]

calc_map = {
'NUM' : float,
'atom': lambda x: x[len(x)!=1],
'neg' : lambda (op,num): (num,-num)[op=='-'],
'mul' : calc_binary,
'add' : calc_binary,
}
def evaluate(tree):
solutions = _recurse_tree(tree, evaluate)
return calc_map.get(tree.name, lambda x:x)(solutions)

Eu escrevi calc_binary para avaliar tanto adio e multiplicao (e suas contrapartes). Ele avalia
listas de qualquer um, de uma forma associativa esquerda, trazendo assim o nosso pequeno
aborrecimento LL-gramtica concluso.
Passo 6: A REPL

O mais simples REPL possvel:

if __name__ == '__main__':

while True:

print( calc(raw_input('> ')) )


Por favor, no me faa explicar isso :)
Apndice: unindo tudo: uma calculadora em 70 linhas

import re, collections


from operator import add,sub,mul,div

Token = collections.namedtuple('Token', ['name', 'value'])


RuleMatch = collections.namedtuple('RuleMatch', ['name', 'matched'])

token_map = {'+':'ADD', '-':'ADD', '*':'MUL', '/':'MUL', '(':'LPAR',


')':'RPAR'}
rule_map = {
'add' : ['mul ADD add', 'mul'],
'mul' : ['atom MUL mul', 'atom'],
'atom': ['NUM', 'LPAR add RPAR', 'neg'],
'neg' : ['ADD atom'],
}
fix_assoc_rules = 'add', 'mul'

bin_calc_map = {'*':mul, '/':div, '+':add, '-':sub}


def calc_binary(x):
while len(x) > 1:
x[:3] = [ bin_calc_map[x[1]](x[0], x[2]) ]
return x[0]

calc_map = {
'NUM' : float,
'atom': lambda x: x[len(x)!=1],
'neg' : lambda (op,num): (num,-num)[op=='-'],
'mul' : calc_binary,
'add' : calc_binary,
}

def match(rule_name, tokens):


if tokens and rule_name == tokens[0].name: # Match a token?
return tokens[0], tokens[1:]
for expansion in rule_map.get(rule_name, ()): # Match a rule?
remaining_tokens = tokens
matched_subrules = []
for subrule in expansion.split():
matched, remaining_tokens = match(subrule, remaining_tokens)
if not matched:
break # no such luck. next expansion!
matched_subrules.append(matched)
else:
return RuleMatch(rule_name, matched_subrules), remaining_tokens
return None, None # match not found

def _recurse_tree(tree, func):


return map(func, tree.matched) if tree.name in rule_map else tree[1]

def flatten_right_associativity(tree):
new = _recurse_tree(tree, flatten_right_associativity)
if tree.name in fix_assoc_rules and len(new)==3 and
new[2].name==tree.name:
new[-1:] = new[-1].matched
return RuleMatch(tree.name, new)

def evaluate(tree):
solutions = _recurse_tree(tree, evaluate)
return calc_map.get(tree.name, lambda x:x)(solutions)

def calc(expr):
split_expr = re.findall('[\d.]+|[%s]' % ''.join(token_map), expr)
tokens = [Token(token_map.get(x, 'NUM'), x) for x in split_expr]
tree = match('add', tokens)[0]
tree = flatten_right_associativity( tree )
return evaluate(tree)

if __name__ == '__main__':
while True:
print( calc(raw_input('> ')) )

Você também pode gostar