Publicado por: hugolt | janeiro 17, 2010

Criando uma Calculadora Usando o Pyparsing

Criando uma Calculadora Usando o Pyparsing

Não é de hoje que eu venho me interessando pela implementação de parsers, mas nunca estudei o suficiente pra fazer um BOM trabalho.
Começei a folhear o livro Programming Languages: Application and Interpretation (PLAI), que o Luciano Ramalho tinha me indicado na PythonBrasil[5] em Setembro de 2009. O livro é bem intessante, mas eu estava lendo no meu PSP e é um pouco desconfortável, por isso estou pensando em procurar saber se está à venda ou acabar imprimindo mesmo (está sob Creative Commons). O livro começa dizendo que nada melhor pra entender uma determinada linguagem do que escrevendo um interpretador para a mesma, assim, começa escrevendo um interpretador pra uma linguagem (inicialmente aritmética) em Scheme.

Achei bem interessante esta abordagem e acabei por tentar fazer algo parecido em Ruby, mas como em Ruby não há o mesmo suporte que Scheme pra expressões do tipo: (+ 1 2) e a linguagem já perceber que isto é um bloco e separar pra você o +, o 1 e o 2, acabei indo pro Python brincar com o Pyparsing – o qual eu já estou pra aprender de verdade há uns poucos meses e não estudei direito. Foi uma ótima oportunidade pra aprender um pouco do Pyparsing e botar alguns conhecimentos em prática.

Uma coisa que eu acho interessante quando se trata de interpretadores/parsers é a gramática – coisa que só começei a me interessar nos últimos meses. O Cucumber por exemplo, usa o TreeTop pra montar seu parser, dado uma gramática definida. Eu não poderia deixar de citar o famoso Yacc, que criar parsers em C (muito usado em conjunto com o Lex na criação de compiladores), mas eu nunca usei nenhuma dessas ferramentas e acabei por pegar o Pyparsing mesmo, o qual eu tenho que aprender a usar.

O objetivo desse tutorial é mostrar o uso do Pyparsing pra criar uma calculadora com notação prefixada e parênteses.
O que significa isso? Significa que as expressões matemáticas não serão do tipo: 1 + 2, mas sim (+ 1 2) [notação comum pra quem programa em LISP].

Caso eu fosse definir uma gramática BNF eu começaria assim:

<expr> ::= <num>
         | (+ <num> <num>)
         | (- <num> <num>)

O que isso significa? Significa que nossas expressões podem ser do tipo:

1
(+ 1 2)
(- 3 4)

E nada além disso!

Depois te instalar o pyparsing, vamos começar nossa implementação (pra instalar basta um easy_install pyparsing):

from pyparsing import Word, White, nums, Literal, OneOrMore, Group

numero = Word(nums)
abre_parenteses = Literal('(').suppress()
fecha_parenteses = Literal(')').suppress()
espacos = OneOrMore(White()).suppress()
mais = Literal('+')
menos = Literal('-')
expr = Group(numero |
             (abre_parenteses +
                 (mais|menos) + espacos + numero + espacos + numero +
              fecha_parenteses))

OPERACOES = {
             '+': lambda x, y: x + y,
             '-': lambda x, y: x - y,
            }

def calcular(e):
    expr_parseada = expr.parseString(e)[0]
    if len(expr_parseada) == 1:
        return int(expr_parseada[0])
    operacao = OPERACOES[expr_parseada[0]]
    return operacao(int(expr_parseada[1]), int(expr_parseada[2]))

Sempre que for necessário dizer que queremos um certo conjunto de caracteres que representam uma coisa só (diz-se que é uma palavra), usamos o Word. Nesse caso, nums é uma string com os caracteres de 0 a 9. Assim, temos um objeto que pode conter os dígitos de 0 a 9 em qualquer quantidade – uma “palavra” em que os caracteres possíveis são 0, 1, 2, 3, 4, 5, 6, 7, 8 ou 9 em qualquer ordem e quantidade.

O Literal é usado sempre que queremos dizer EXATAMENTE o que queremos – nada mais, nada menos! O método suppress apenas indica pro pyparsing que pra nós esse valor não importa, apesar de ter que estar no texto, nós o ignoramos como resultado.

Quando precisamos que algo apareça pelo menos uma vez, podemos usar o OneOrMore. No nosso caso deve haver pelo menos um espaço em branco, que está representado pelo White() (que diz respeito aos caracteres ' ', '\t', '\n', '\r') e também ignoraremos esse resultado.

O mais e menos são também expressões literais. Mas vejam que não foi usado o suppress neles, pois nesse caso é necessário obte-los como resultado.

Por fim, nossa expressão será um grupo (Group) de “subexpressões”. Estamos dizendo que nossa expressão expr será: um número OU um parêntese pra abrir, sinal de + ou de -, espaços, um número, espaços, outro número e um parêntese pra fechar. O pipe (|) significa OU de expressões.

Quando parsearmos alguma expressão válida com o pyparsing através do método parseString, ele retornará um objeto do tipo ParseResults, que pode ser usado com a mesma interface de uma lista. No caso acima o método parseString foi chamado passando como segundo argumento o valor True, indicando que nossa expressão não pode conter nada além do que foi definido.
Assim, dado o caso acima, quando fizermos:

>>> expr.parseString("1")
([(['1'], {})], {})

>>> print expr.parseString("1")
[['1']]

>>> print expr.parseString("1")[0]
['1']

O que nos interessa saber aqui é que no caso acima o resultado será uma lista-like, e o primeiro objeto é o nosso Group. O primeiro e único elemento nesse caso é o número 1 (como string).

O segundo caso (um bem simples), seria fazer:

>>> print expr.parseString("(+ 1 2)")[0]
['+', '1', '2']

Agora já é possível entender a implementação: se o resultado tiver apenas um elemento, é um número único. Caso contrário é uma expressão, tendo o operador como primeiro elemento e os dois próximos elementos são os operandos.

Bem, nossa calculadora está MUITO simples até agora. Só aceitamos números inteiros, podemos somar ou subtrair e subexpressões não funcionam. O interessante agora é implementar subexpressões.

Vamos começar trocando a nossa gramática BNF:

<expr> ::= <num>
         | (+ <expr> <expr>)
         | (- <expr> <expr>)

E agora, o que estamos dizendo?! Estamos dizendo que uma expressão pode ser um número sozinho, como em “1”, ou pode ser algo como “(+ (- 2 1) 3)”, que seria uma expressão com subexpressões. Oh, confuso? É a recursividade!
Parando pra analisar não fica difícil de entender. Atentente-se pra que no fim das contas vai existir uma subexpressão que será do tipo (+ <num> <num>), tornando possível fazer subexpressões em qualquer quantidade!

Vamos à implementação:

from pyparsing import Word, White, nums, Literal, OneOrMore, Group, Forward

numero = Word(nums)
abre_parenteses = Literal('(').suppress()
fecha_parenteses = Literal(')').suppress()
espacos = OneOrMore(White()).suppress()
mais = Literal('+')
menos = Literal('-')
expr = Forward()
atomo = numero | expr
expr << Group(numero |
              (abre_parenteses +
                 (mais|menos) + espacos + atomo + espacos + atomo +
               fecha_parenteses))

OPERACOES = {
             '+': lambda x, y: x + y,
             '-': lambda x, y: x - y,
            }

def avaliar(e):
    if len(e) == 1:
        return int(e[0])
    operacao = OPERACOES[e[0]]
    return operacao(avaliar(e[1]), avaliar(e[2]))

def calcular(e):
    expr_parseada = expr.parseString(e, True)[0]
    return avaliar(expr_parseada)

A maior diferença desse código pro anterior está na definição da expressão expr. Aquele Forward significa que definiremos depois o valor pra expr, mas ela já pode ser usada, mesmo sem valor definido! Mas pra que diabos seria isso necessário? Bem, é a única maneira de fazermos nossa expressão “recursiva”. Continuemos com a definição pra que clarifique o raciocínio… Foi definido atomo que pode ser um número ou uma expressão. Nesse ponto está a mágica. Cada “subexpressão” será um número ou uma expressão (que pode ser um número ou uma expressão e assim recursivamente, até chegar num momento que a subexpressão será um simples número). Se não pudessemos definir uma expressão sem valor previamente definido não seria possível fazer isso! Depois dizemos que expr vai receber (<<) a expressão desejada, que será: um número OU um parêntese pra abrir, sinal de + ou de -, espaços, uma expressão, espaços, outra expressão e um parêntese pra fechar. Repetindo: vai chegar numa hora que cada subexpressão será basicamente um número!

O método calcular agora passou a responsabilidade de avaliar cada subexpressão pro método avaliar, porque tem que ser feito recursivamente.
O método avaliar verifica se a expressão e é um número sozinho (ou seja, tem tamanho um), e, caso seja, retorna a string como inteiro. Caso contrário a expressão será do tipo “(+ <expr> <expr>)”, e teremos que ir avaliando essas subexpressões até chegar no ponto em que <expr> será um <num> (caso que retorna a conversão pra inteiro).

Simples, não?!

Agora implementar multiplicação, divisão e exponenciação é simples e ficará como exercício. A idéia de fazer uma calculadora com parênteses e prefixada é pra facilitar mesmo, pois não é necessário importar-se com precedências e etc.

Os códigos acima estão no github (e estão com testes!) como gists:
primeira calculadora em http://gist.github.com/279361 e a segunda calculadora em http://gist.github.com/279362

Depois que o leitor tiver feito o exercício, pode dar uma olhada numa outra calculadora um pouco mais “potente” que eu fiz usando a mesma idéia: http://gist.github.com/279357

Achei o Pyparsing realmente muito útil. O problema é que a documentação livre dele é muito fraca, mas pelo menos há vários códigos de exemplo no site. Há um livro sobre o Pyparsing, Geting Started with Pyparsing mas não está disponível livremente na rede. Não sei nem se existe algum material em português, mas fica aí a minha contribuição!

update: corrigida a confusão com os nomes infix e prefix


Deixe um comentário

Categorias