hoc1: calculadora de quatro operações
Esta página descreve o programa do diretório etapa1/.
Exemplo de uso
As linhas indentadas são a saída do programa:
$ ./hoc1
1 + 2
3
(100 - 32) * 5 / 9
37.777778
37.8 * 9 / 5 + 32
100.04
37.777778 * 9 / 5 + 32
100
Construção do programa
Passo 1: gerar o parser
Use yacc (na verdade, bison), para gerar o código do programa em C.
Resultado em um Ubuntu 18.04.1 LTS:
$ yacc --version
bison (GNU Bison) 3.0.4
(etc...)
$ yacc hoc1.y
$ ls
hoc1.y README.md y.tab.c
Isso gera o arquivo y.tab.c, com todo o código da calculadora.
Passo 2: compilar o executável
Use o compilador cc para gerar o executável hoc1:
$ cc --version
cc (Ubuntu 7.3.0-27ubuntu1~18.04) 7.3.0
$ cc y.tab.c -o hoc1
$ ls
hoc1 hoc1.y README.md y.tab.c
Passo 3: testar
O arquivo testes.hoc tem alguns casos de testes básicos.
2 + 2
(100 - 32) * 5 / 9
32 + 38 * 9 / 5
Uma expressão bem simples, uma expressão com parêntesis para converter 100 °F em °C, e uma expressão para converter 38 °C para °F. Nessa última, a precedência das operações importa: a soma deve ser o último passo para chegar ao resultado correto.
Forneça testes.hoc como arquivo de entrada para hoc1 assim:
$ ./hoc1 < testes.hoc
4
37.777778
100.4
Assim conferimos que 2 + 2 é 4, 100 °F é 37.777776 °C, e 38 °C é 100.4 °F. Faz sentido.
Explicação do programa
O programa hoc1 é um interpretador de expressões aritméticas interativo. O código-fonte está em hoc1.y (link).
Termos técnicos
Análise léxica é a transformação do código-fonte em uma série de palavras, números, símbolos, etc., chamados genericamente de tokens (ver a seguir).
Análise sintática é a montagem de tokens para formar expressões e declarações válidas conforme a gramática da linguagem.
Token é o menor elemento sintático significativo. Por exemplo, a expressão…
peso*2 <= 6.5
…contém 5 tokens:
peso*2<=6.5
Estrutura de hoc1.y
Um arquivo-fonte para yacc/bison tem a seguinte estrutura:
%{
Prólogo: declarações em C
%}
Declarações yacc
%%
Regras da gramática yacc
%%
Epílogo: mais código em C
Visão geral do código
Neste exemplo, as três funções mais importantes são yyparse, main e yylex. O código de yyparse é gerado pelo yacc, a partir das declarações regras da gramática. As funções main e yylex são definidas no epílogo.
Vejamos o que fazem essas três funções:
int main(int argc, char* argv[])
É onde tudo começa. A main é muito simples neste exemplo. Sua missão principal é invocar yyparse.
int yyparse(void)
Essa função faz a análise sintática. Seu código aparece no arquivo y.tab.c. Ela implementa a lógica do parser, usando a regras especificadas em Definição da gramática, como veremos. Para ler o código-fonte, yyparse invoca repetidamente a função yylex, que precisamos implementar. Neste exemplo, yyparse realiza os cálculos imediatamente, assim que uma estrutura sintática casa com uma regra da gramática. Em um interpretador mais sofisticado, como veremos a partir da etapa 4, yyparse produz uma representação interna do programa, que é passada para um evaluator (avaliador), que vai executar as instruções.
int yylex(void)
Essa função faz a análise léxica. Toda vez que invocada por yyparse, yylex avança na leitura do código-fonte, e devolve um código tipo int que identifica a categoria do token que acabou de ser lido. Exemplos de categorias: número, identificador, operador aritmético como '+', delimitador como '(' ou '{', etc. Dependendo da categoria do token, yyparse coloca informações adicionais na variável global yylval, que yyparse também pode acessar. Para símbolos e delimitadores com apenas um caractere, o código devolvido por yylex normalmente é o código ASCII do caractere. Códigos acima de 127 são usados para outras categorias de tokens definidas na gramática. Chegando ao fim do código-fonte, yylex devolve o código 0.
Nesse exemplo, quando yylex lê o texto 6.49, ela devolve o código da categoria NUMERO e coloca o valor 6.49 em yylval.
🗒 Na prática, é como se
yylexdevolvesse dois resultados: a categoria e o valor do token. Mas funções em C não podem devolver dois resultados — como em Python ou Go — então a variável globalyylvalguarda a segunda parte da informação sobre o token que acabou de ser lido parayyparsepoder acessar.
Declarações iniciais
A seguir vamos estudar as 59 linhas de hoc1.y (código-fonte). Esse arquivo é uma mistura de linhas em C com linhas na sintaxe especial de yacc.
A primeira seção do código, entre os marcadores %{ e %}, é código em C (tirei os marcadores para que a colorização sintática funcione nesta página):
#include <stdio.h>
#include <ctype.h>
#define YYSTYPE double /* tipo da pilha de yacc */
int yylex(void);
void yyerror(char *);
Aqui temos:
- Inclusão de dois arquivos da biblioteca-padrão de C.
- Definição de uma macro em C que define o tipo
YYSTYPEcomodouble. Esse tipo é usado pelo código gerado por yacc para representar os valores. Por enquanto, temos um simples tipo numérico de ponto flutuante. - Declaração da assinatura de duas funções que serão definidas no final do arquivo, e serão invocadas por código gerado pelo yacc. Sem essas declarações, o compilador gera avisos de “implicit declaration” (declaração implícita).
Definição da gramática
Após o marcador %}, temos três linhas de código yacc com declarações token e left:
%token NUMERO
%left '+' '-' /* associatividade esquerda */
%left '*' '/' /* associatividade esquerda, maior precedência */
- A declaração
token NUMEROdefine uma categoria de token que estamos chamando deNUMERO. Emhoc1, umNUMEROé um valor de ponto flutuante, como 1.618. - As declarações dos operadores
+e-, com associatividade esquerda — ver definição a seguir. - As declarações dos operadores
*e/, também com associatividade esquerda, porém maior precedência, porque estão declarados depois de+e-.
Mais termos técnicos
Precedência é a ordem de execução dos diferentes operadores. Por exemplo, queremos que as multiplicações e divisões sejam feitas antes das somas e subtrações. Ou seja, o resultado de 4 + 3 * 2 é o mesmo que 4 + 6 (=10) e não 7 * 2 (=14).
Associatividade é a ordem de execução de uma sequência com o mesmo operador. Por exemplo, a associatividade esquerda do operador - significa que 4 - 3 - 2 é calculado da esquerda para direita, assim: (4 - 3) - 2 (=-1). Um exemplo do caso contrário, associatividade direita, é o operador de exponenciação ^ que expressa 2³ como 2 ^ 3 (=8). Conforme a convenção da matemática, queremos que o valor de 4 ^ 3 ^ 2, seja calculado a partir da direita, assim: 4 ^ (3 ^ 2), o mesmo que 4 ^ 9 (=262144). Neste caso seria errado fazer (4 ^ 3) ^ 2, que seria 64 ^ 2 (=4096).
Regras sintáticas
O próximo trecho delimitado por %% define as regras sintáticas que definirão toda a lógica da função yyparse que será gerada pela ferramenta yacc.
Neste primeiro exemplo, há duas regras — lista e expr:
%%
lista: /* nada */
| lista '\n'
| lista expr '\n' { printf("\t%.8g\n", $2); }
;
expr: NUMERO { $$ = $1; }
| expr '+' expr { $$ = $1 + $3; }
| expr '-' expr { $$ = $1 - $3; }
| expr '*' expr { $$ = $1 * $3; }
| expr '/' expr { $$ = $1 / $3; }
| '(' expr ')' { $$ = $2; }
;
%%
/* fim da gramática */
A primeira regra diz que uma lista pode ter 3 formas:
- nada (texto vazio);
- uma
listaseguida de'\n'(caractere de quebra de linha); - uma
listaseguida deexprseguida de'\n'.
Na prática, essa definição recursiva diz que uma lista pode ser formada por 0 ou mais expr terminadas por '\n'.
A terceira forma de lista contém um bloco de código {…} à direita. Quando o parser casa um trecho do código-fonte com essa forma, temos uma expr seguida de '\n', então usamos printf para exibir o valor da expressão, que estará em $2.
A regra sobre expr é mais interessante. São 6 formas, cada uma com um bloco {…} à direita para computar seu valor:
- um
NUMERO, como 1.23 — seu valor é o valor da própria expressão,$1; - duas expressões com o caractere
'+'no meio — seu valor é a soma das duas expressões; - duas expressões com o caractere
'-'no meio — seu valor é a subtração da primeira pela segunda expressão; - duas expressões com o caractere
'*'no meio — seu valor é a multiplicação das duas expressões; - duas expressões com o caractere
'/'no meio — seu valor é a divisão da primeira pela segunda expressão; - um caractere
'(', uma expressão, e um caractere')'— seu valor é o valor da expressão no meio.
✋ A gramática definida aqui não aceita o sinal de numero negativo: se você digitar
-1emhoc1, o programa vai reclamar de um erro de sintaxe. Isso será resolvido na Etapa 1b.
Função principal
Depois do comentário /* fim da gramática */, o que temos é só código em linguagem C.
Aqui são definidas duas variáveis globais, e declarada a função main:
char *nome_prog; /* para mensagens de erro */
int num_linha = 1;
int main(int argc, char* argv[]) /* hoc1 */
{
nome_prog = argv[0];
yyparse();
}
A função main faz apenas duas coisas:
- Atribui o valor do primeiro argumento da linha de comando à variável
nome_prog. Esse valor será"hoc1"neste exemplo. - Invoca a função
yyparseque será gerada pelo yacc/bison quando você executar o comandoyacc hoc1.yno terminal.
Se você inspecionar o arquivo gerado, y.tab.c, verá que a função yyparse para este exemplo simples tem cerca de 500 linhas de código (da linha 1061 à 1562 no meu caso, mas pode ser diferente para você).
Analisador léxico
Como já vimos, yyparse depende de uma função chamada yylex para fazer a análise léxica e devolver o próximo token a cada chamada. O resultado de yylex é um código numérico que identifica a categoria do token, e quando o token tem um valor — como um NUMERO neste exemplo — o valor é colocado na variável global yylval, declarada em y.tab.c como sendo do tipo YYSTYPE (como vimos em Declarações iniciais).
Por exemplo, se o token for "3.1416", yylex devolve o código NUMERO, e coloca o valor 3.1416 em yylval. Outro exemplo: se o token é "*", o número '*' é devolvido (esse é o número 42, o código ASCII do sinal *). Neste caso, nenhum valor é colocado em yylval.
Após a declaração int c, o código de yylex pode ser divido em 5 partes:
int yylex(void) /* hoc1 */
{
int c;
while ((c=getchar()) == ' ' || c == '\t')
;
if (c == EOF)
return 0;
if (c == '.' || isdigit(c)) { /* número */
ungetc(c, stdin);
scanf("%lf", &yylval);
return NUMERO;
}
if (c == '\n')
num_linha++;
return c;
}
- Laço
whileque consome caracteres brancos (espaços e tabs), deixando na variávelco primeiro caractere não-branco. - Se
cé EOF, devolva o código 0, sinalizando parayyparseque não há mais nada a ser lido. - Se
cé um ponto ou um dígito, coloque ele de volta no buffer de entrada (ungetc), use a funçãoscanfpara ler um número de ponto flutuante para dentro da variável globalyylval, e devolva o códigoNUMERO. - Se
cé uma quebra de linha, incremente o contador de linhas. - Do contrário, devolva o código ASCII do caractere lido.
Na etapa 3, Kernighan e Pike mostram rapidamente o uso de lex para gerar o analisador léxico a partir de regras com expressões regulares.
Tratamento de erros
O tratamento de erros nessa versão hoc1 é tosco. Isso será melhorado nas próximas etapas.
O código de yyparse gerado por yacc/bison precisa que você forneça uma função chamada yyerror, que será invocada para reportar situações de erro. Neste exemplo, yyerror apenas exibe a mensagem de erro recebida de yyparse, junto com o número da linha.
void yyerror(char* s) /* erro de sintaxe */
{
fprintf(stderr, "%s: %s near line %d\n",
nome_prog, s, num_linha);
}
Um detalhe é que a função yyerror emite uma mensagem de erro em inglês, porque parte do texto vem do código gerado pelo yacc. Por isso, mantive o texto “near line” em inglês na chamada de fprintf. Do contrário o programa iria gerar mensagens com idiomas misturados, como “syntax error perto da linha 1”.
Voltar para o índice de páginas.