Nesta aula, vamos colocar em prática tudo o que aprendemos ao longo do curso, construindo uma calculadora completa em C. Este projeto integra conceitos de parsing, manipulação de strings, estruturas de dados, funções e tratamento de erros. Ao final, você terá uma calculadora funcional que aceita expressões como 2 + 3 * 4, resolve com a precedência correta e trata entradas inválidas com elegância.

O objetivo é criar um programa modular, legível e extensível. Começaremos pelo parsing da entrada, depois implementaremos as operações, o tratamento de erros e, por fim, organizaremos tudo em uma estrutura limpa. Vamos lá!

Parsing de entrada

O parsing é o processo de analisar uma string de entrada e convertê-la em uma estrutura que o computador possa processar. Para nossa calculadora, precisamos interpretar números, operadores e parênteses. Uma abordagem comum é usar uma pilha para avaliar expressões no formato pós-fixo (notação polonesa reversa), mas aqui adotaremos uma técnica mais simples: converter a expressão infixa (como a escrevemos normalmente) para pós-fixa usando o algoritmo shunting-yard de Edsger Dijkstra.

O algoritmo shunting-yard usa duas pilhas: uma para operadores e outra para valores (ou saída). Percorremos a entrada caractere por caractere, ignorando espaços. Quando encontramos um número, o empurramos para a pilha de valores. Quando encontramos um operador, comparamos sua precedência com o topo da pilha de operadores e, se a precedência for menor ou igual, desempilhamos operadores para a pilha de valores até que a condição seja satisfeita. Parênteses são tratados separadamente: ( é empurrado, e ) desempilha até encontrar o (.

Vejamos um exemplo simples: 3 + 4 * 2. O algoritmo produzirá a sequência 3 4 2 * +, que pode ser avaliada facilmente com uma pilha.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>

#define MAX_EXPR 100
#define MAX_STACK 100

typedef struct {
    double valores[MAX_STACK];
    int topo;
} PilhaValores;

typedef struct {
    char operadores[MAX_STACK];
    int topo;
} PilhaOperadores;

void empilhaValor(PilhaValores *p, double v) {
    if (p->topo < MAX_STACK) {
        p->valores[p->topo++] = v;
    } else {
        fprintf(stderr, "Erro: pilha de valores cheia\n");
        exit(1);
    }
}

double desempilhaValor(PilhaValores *p) {
    if (p->topo > 0) {
        return p->valores[--p->topo];
    } else {
        fprintf(stderr, "Erro: pilha de valores vazia\n");
        exit(1);
    }
}

void empilhaOperador(PilhaOperadores *p, char op) {
    if (p->topo < MAX_STACK) {
        p->operadores[p->topo++] = op;
    } else {
        fprintf(stderr, "Erro: pilha de operadores cheia\n");
        exit(1);
    }
}

char desempilhaOperador(PilhaOperadores *p) {
    if (p->topo > 0) {
        return p->operadores[--p->topo];
    } else {
        fprintf(stderr, "Erro: pilha de operadores vazia\n");
        exit(1);
    }
}

int precedencia(char op) {
    switch (op) {
        case '+': case '-': return 1;
        case '*': case '/': return 2;
        default: return 0;
    }
}

// Função que converte infixa para pós-fixa (RPN) e retorna a expressão em string
void infixaParaPosfixa(const char *infixa, char *posfixa) {
    PilhaOperadores ops = { .topo = 0 };
    int i, j = 0;
    for (i = 0; infixa[i] != '\0'; i++) {
        char c = infixa[i];
        if (isspace(c)) continue;
        if (isdigit(c) || c == '.') {
            while (isdigit(infixa[i]) || infixa[i] == '.') {
                posfixa[j++] = infixa[i++];
            }
            posfixa[j++] = ' ';
            i--; // ajusta para o for incrementar
        } else if (c == '(') {
            empilhaOperador(&ops, c);
        } else if (c == ')') {
            while (ops.topo > 0 && ops.operadores[ops.topo-1] != '(') {
                posfixa[j++] = desempilhaOperador(&ops);
                posfixa[j++] = ' ';
            }
            if (ops.topo > 0) desempilhaOperador(&ops); // remove '(' 
        } else if (strchr("+-*/", c)) {
            while (ops.topo > 0 && precedencia(ops.operadores[ops.topo-1]) >= precedencia(c)) {
                posfixa[j++] = desempilhaOperador(&ops);
                posfixa[j++] = ' ';
            }
            empilhaOperador(&ops, c);
        } else {
            fprintf(stderr, "Caractere inválido: %c\n", c);
            exit(1);
        }
    }
    while (ops.topo > 0) {
        posfixa[j++] = desempilhaOperador(&ops);
        posfixa[j++] = ' ';
    }
    posfixa[j] = '\0';
}

Operações

Com a expressão em pós-fixa, a avaliação é direta: percorremos os tokens e, quando encontramos um número, empilhamos; quando encontramos um operador, desempilhamos dois valores, aplicamos a operação e empilhamos o resultado. Ao final, o único valor na pilha é o resultado.

Implementaremos funções para cada operação básica (soma, subtração, multiplicação, divisão) e também suporte a potência e raiz quadrada como extras, mostrando como estender o projeto.

double avaliarPosfixa(const char *posfixa) {
    PilhaValores vals = { .topo = 0 };
    const char *p = posfixa;
    while (*p) {
        if (isspace(*p)) { p++; continue; }
        if (isdigit(*p) || *p == '.') {
            char *end;
            double num = strtod(p, &end);
            empilhaValor(&vals, num);
            p = end;
        } else {
            double b = desempilhaValor(&vals);
            double a = desempilhaValor(&vals);
            switch (*p) {
                case '+': empilhaValor(&vals, a + b); break;
                case '-': empilhaValor(&vals, a - b); break;
                case '*': empilhaValor(&vals, a * b); break;
                case '/': 
                    if (b == 0) {
                        fprintf(stderr, "Erro: divisão por zero\n");
                        exit(1);
                    }
                    empilhaValor(&vals, a / b); break;
                default:
                    fprintf(stderr, "Operador desconhecido: %c\n", *p);
                    exit(1);
            }
            p++;
        }
    }
    if (vals.topo != 1) {
        fprintf(stderr, "Erro: expressão inválida\n");
        exit(1);
    }
    return vals.valores[0];
}

Para estender com mais operações, basta adicionar novos casos no switch e, se necessário, ajustar a precedência no parsing. Por exemplo, para potência (^), podemos definir precedência 3 e implementar pow(a,b).

Tratamento de erros

Um bom programa deve lidar com entradas inválidas de forma amigável, sem encerrar abruptamente. Vamos criar uma função de validação que verifica se a expressão contém apenas caracteres permitidos, se os parênteses estão balanceados e se não há operadores consecutivos inválidos (como 2 + * 3).

Além disso, durante a avaliação, capturamos erros como divisão por zero e pilhas vazias. Em vez de usar exit, podemos retornar um código de erro ou usar setjmp/longjmp para um tratamento mais sofisticado, mas aqui manteremos simples com mensagens de erro e retorno de NAN.

int validarExpressao(const char *expr) {
    int balance = 0;
    char ultimo = '\0';
    for (int i = 0; expr[i] != '\0'; i++) {
        char c = expr[i];
        if (isspace(c)) continue;
        if (isdigit(c) || c == '.') {
            ultimo = 'n';
        } else if (strchr("+-*/", c)) {
            if (ultimo == '\0' || ultimo == '(' || strchr("+-*/", ultimo)) {
                return 0; // operador sem operando
            }
            ultimo = c;
        } else if (c == '(') {
            balance++;
            ultimo = c;
        } else if (c == ')') {
            balance--;
            if (balance < 0) return 0;
            ultimo = c;
        } else {
            return 0; // caractere inválido
        }
    }
    return balance == 0 && (ultimo == 'n' || ultimo == ')');
}

Na função principal, chamamos a validação antes de processar. Se falhar, exibimos uma mensagem e pedimos nova entrada. Também tratamos o caso de divisão por zero na avaliação, retornando um valor especial ou imprimindo erro.

Estrutura

Para manter o código organizado, vamos dividir em módulos: calculadora.h (declarações), calculadora.c (implementação) e main.c (interface com o usuário). Isso facilita a manutenção e reutilização.

No cabeçalho, declaramos as funções públicas: double calcular(const char *expr), que faz todo o processamento e retorna o resultado, e int validarExpressao(const char *expr). O arquivo de implementação contém as funções internas de pilha, parsing e avaliação.

// calculadora.h
#ifndef CALCULADORA_H
#define CALCULADORA_H

double calcular(const char *expr);
int validarExpressao(const char *expr);

#endif
// calculadora.c
#include "calculadora.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <math.h>

// ... (implementação das pilhas e funções) ...

double calcular(const char *expr) {
    if (!validarExpressao(expr)) {
        fprintf(stderr, "Expressão inválida\n");
        return NAN;
    }
    char posfixa[MAX_EXPR * 2];
    infixaParaPosfixa(expr, posfixa);
    return avaliarPosfixa(posfixa);
}

No main.c, criamos um loop que lê expressões do usuário, chama calcular e imprime o resultado. Podemos também adicionar um comando de saída, como sair.

// main.c
#include <stdio.h>
#include <string.h>
#include "calculadora.h"

int main() {
    char expr[MAX_EXPR];
    printf("Calculadora em C\n");
    printf("Digite uma expressão (ou 'sair' para encerrar):\n");
    while (1) {
        printf("> ");
        fgets(expr, sizeof(expr), stdin);
        expr[strcspn(expr, "\n")] = '\0'; // remove newline
        if (strcmp(expr, "sair") == 0) break;
        double resultado = calcular(expr);
        if (!isnan(resultado)) {
            printf("= %g\n", resultado);
        }
    }
    return 0;
}

Essa estrutura modular permite que você adicione novas funcionalidades (como funções trigonométricas) sem bagunçar o código principal.

Boas práticas e observações finais

Ao desenvolver projetos em C, lembre-se de: usar const para parâmetros que não serão modificados, verificar limites de arrays, liberar memória alocada dinamicamente (se usar), e compilar com flags de warning (-Wall -Wextra).

Este projeto pode ser expandido para suportar variáveis, funções matemáticas avançadas, ou até mesmo uma interface gráfica. O importante é ter uma base sólida de parsing e avaliação.

Referências

Exercícios

  1. Implemente a função infixaParaPosfixa para suportar o operador de potência (^) com precedência maior que multiplicação. Teste com 2 ^ 3 ^ 2 e verifique se o resultado é 512 (associatividade à direita).
  2. ✓ Resposta: Para suportar potência, adicione o caso '^' na função precedencia retornando 3. No parsing, quando encontrar ^, empilhe-o sem desempilhar operadores de mesma precedência (para associatividade à direita). Na avaliação, use pow(a,b). Exemplo:
    int precedencia(char op) { ... case '^': return 3; }
  3. Modifique o programa para aceitar números negativos, como -3 + 5. Dica: trate o sinal de menos como unário quando aparecer no início ou após um operador ou parêntese.
  4. ✓ Resposta: No parsing, se encontrar - e o token anterior for um operador, início ou (, trate como unário: insira um 0 na saída e empilhe o operador. Ou use um flag. Implementação simples: na função infixaParaPosfixa, verifique se c == '-' e (i == 0 || strchr("+*/(", infixa[i-1])); se sim, adicione 0 à saída e depois o operador.
  5. Adicione suporte à função raiz quadrada: o usuário pode digitar sqrt(9). Dica: reconheça a palavra "sqrt" e trate como uma função unária.
  6. ✓ Resposta: No parsing, quando encontra a sequência "sqrt", empilhe um marcador e, na avaliação, trate como operador unário. Uma abordagem é substituir a string "sqrt" por um caractere especial, como 'S'. Na avaliação, desempilhe um valor e aplique sqrt.
  7. Escreva uma função avaliarPosfixa que retorne um código de erro em vez de usar exit. Por exemplo, retorne 0 em caso de sucesso e preencha um ponteiro com o resultado.
  8. ✓ Resposta: Exemplo:
    int avaliarPosfixa(const char *posfixa, double *resultado) { ... if (erro) return 0; *resultado = valor; return 1; }
    E em calcular, verifique o retorno.
  9. Refatore o código para usar alocação dinâmica nas pilhas, permitindo expressões de tamanho arbitrário.
  10. ✓ Resposta: Use ponteiros e malloc/realloc. Por exemplo:
    typedef struct { double *valores; int topo; int capacidade; } PilhaValores;
    E implemente funções de redimensionamento.