Calculadora CLI em Zig — Tutorial Passo a Passo

Calculadora CLI em Zig — Tutorial Passo a Passo

Neste tutorial, vamos construir uma calculadora interativa de linha de comando em Zig. Este é um excelente primeiro projeto porque cobre conceitos fundamentais: leitura de entrada do usuário, parsing de strings, tratamento de erros e organização de código.

O Que Vamos Construir

Nossa calculadora vai:

  • Aceitar expressões matemáticas como 3 + 4 ou 10 / 2
  • Suportar as quatro operações básicas (+, -, *, /)
  • Manter um histórico dos últimos cálculos
  • Tratar erros como divisão por zero e entrada inválida
  • Rodar em loop até o usuário digitar sair

Por Que Este Projeto?

Uma calculadora CLI é o “Hello World” dos projetos reais. Ela força você a lidar com problemas que não existem em exemplos triviais: parsing de entrada do usuário (que pode conter qualquer coisa), tratamento robusto de erros e loop de interação. Em Zig, isso nos dá a oportunidade de explorar o sistema de erros da linguagem, que é um dos seus diferenciais.

Pré-requisitos

Passo 1: Estrutura do Projeto

Crie o diretório do projeto e o arquivo de build:

mkdir calculadora-cli
cd calculadora-cli
zig init

Isso cria a estrutura padrão. Vamos trabalhar no arquivo src/main.zig.

Passo 2: Definindo os Tipos

Começamos definindo os tipos que representam nossa calculadora. Em Zig, é uma boa prática definir tipos explícitos em vez de usar primitivos soltos — isso torna o código mais legível e seguro.

const std = @import("std");
const io = std.io;
const fmt = std.fmt;
const mem = std.mem;

/// Operações matemáticas suportadas pela calculadora.
/// Usamos um enum porque o conjunto de operações é fixo e conhecido
/// em tempo de compilação — isso permite que o compilador nos avise
/// se esquecermos de tratar algum caso em um switch.
const Operacao = enum {
    soma,
    subtracao,
    multiplicacao,
    divisao,

    /// Converte um caractere para a operação correspondente.
    /// Retorna erro se o caractere não for um operador válido.
    pub fn deCaractere(c: u8) !Operacao {
        return switch (c) {
            '+' => .soma,
            '-' => .subtracao,
            '*' => .multiplicacao,
            '/' => .divisao,
            else => error.OperadorInvalido,
        };
    }

    /// Retorna o símbolo da operação para exibição.
    pub fn simbolo(self: Operacao) u8 {
        return switch (self) {
            .soma => '+',
            .subtracao => '-',
            .multiplicacao => '*',
            .divisao => '/',
        };
    }
};

/// Representa uma expressão matemática parseada.
/// Separamos o parsing da execução para facilitar testes
/// e permitir exibir a expressão formatada.
const Expressao = struct {
    operando_a: f64,
    operacao: Operacao,
    operando_b: f64,
};

/// Erros possíveis durante o parsing e execução.
const CalcError = error{
    OperadorInvalido,
    NumeroInvalido,
    DivisaoPorZero,
    ExpressaoIncompleta,
    FormatoInvalido,
};

Por que usar um enum para operações? Porque o compilador Zig nos obriga a tratar todos os casos em um switch. Se adicionarmos uma nova operação no futuro, o compilador vai nos avisar de todos os lugares que precisam ser atualizados. Isso é muito mais seguro do que comparar caracteres soltos.

Passo 3: Parser de Expressões

Agora implementamos o parser que transforma a string digitada pelo usuário em uma Expressao estruturada.

/// Faz o parsing de uma string de entrada como "3.5 + 2.1"
/// e retorna uma Expressao estruturada.
///
/// Decisão de design: usamos split simples por espaços em vez de
/// um parser mais sofisticado. Para uma calculadora básica, isso é
/// suficiente e mantém o código acessível para iniciantes.
fn parsearExpressao(entrada: []const u8) CalcError!Expressao {
    // Remove espaços em branco nas bordas
    const limpa = mem.trim(u8, entrada, " \t\r\n");

    if (limpa.len == 0) return CalcError.ExpressaoIncompleta;

    // Dividimos por espaço — esperamos o formato "A op B"
    var partes = mem.splitSequence(u8, limpa, " ");

    // Primeiro operando
    const str_a = partes.next() orelse return CalcError.ExpressaoIncompleta;
    const a = fmt.parseFloat(f64, str_a) catch return CalcError.NumeroInvalido;

    // Operador
    const str_op = partes.next() orelse return CalcError.ExpressaoIncompleta;
    if (str_op.len != 1) return CalcError.OperadorInvalido;
    const op = Operacao.deCaractere(str_op[0]) catch return CalcError.OperadorInvalido;

    // Segundo operando
    const str_b = partes.next() orelse return CalcError.ExpressaoIncompleta;
    const b = fmt.parseFloat(f64, str_b) catch return CalcError.NumeroInvalido;

    return Expressao{
        .operando_a = a,
        .operacao = op,
        .operando_b = b,
    };
}

Por que split por espaços? É a abordagem mais simples e funcional para o nosso caso. Um parser mais robusto (que aceitasse 3+4 sem espaços) exigiria um tokenizer, o que adicionaria complexidade sem benefício proporcional neste projeto introdutório.

Passo 4: Motor de Cálculo

Com a expressão parseada, o cálculo em si é direto:

/// Executa o cálculo representado por uma expressão.
/// Retorna erro apenas em caso de divisão por zero.
fn calcular(expr: Expressao) CalcError!f64 {
    return switch (expr.operacao) {
        .soma => expr.operando_a + expr.operando_b,
        .subtracao => expr.operando_a - expr.operando_b,
        .multiplicacao => expr.operando_a * expr.operando_b,
        .divisao => {
            if (expr.operando_b == 0.0) return CalcError.DivisaoPorZero;
            return expr.operando_a / expr.operando_b;
        },
    };
}

Por que verificar divisão por zero explicitamente? Em muitas linguagens, dividir por zero com floats retorna Infinity ou NaN. Embora Zig também faça isso com f64, preferimos tratar como erro explícito para dar feedback claro ao usuário. Essa é uma decisão de UX, não de correção matemática.

Passo 5: Histórico de Operações

Vamos adicionar um histórico para que o usuário possa rever cálculos anteriores. Usamos um buffer de tamanho fixo para evitar alocação dinâmica — uma prática comum e idiomática em Zig.

/// Registro de uma operação realizada.
const RegistroHistorico = struct {
    expressao: [64]u8,
    expressao_len: usize,
    resultado: f64,
};

/// Histórico circular de operações.
/// Usamos um buffer fixo em vez de ArrayList porque:
/// 1. Não precisamos de alocação dinâmica
/// 2. O tamanho máximo é conhecido e pequeno
/// 3. O comportamento circular (sobrescrever antigos) é desejável
const Historico = struct {
    registros: [20]RegistroHistorico,
    quantidade: usize,
    proximo_indice: usize,

    const Self = @This();

    pub fn init() Self {
        return Self{
            .registros = undefined,
            .quantidade = 0,
            .proximo_indice = 0,
        };
    }

    pub fn adicionar(self: *Self, expr: Expressao, resultado: f64) void {
        var buf: [64]u8 = undefined;
        const texto = fmt.bufPrint(&buf, "{d} {c} {d} = {d:.4}", .{
            expr.operando_a,
            expr.operacao.simbolo(),
            expr.operando_b,
            resultado,
        }) catch return;

        self.registros[self.proximo_indice] = .{
            .expressao = buf,
            .expressao_len = texto.len,
            .resultado = resultado,
        };

        self.proximo_indice = (self.proximo_indice + 1) % 20;
        if (self.quantidade < 20) self.quantidade += 1;
    }

    pub fn exibir(self: *const Self, writer: anytype) !void {
        if (self.quantidade == 0) {
            try writer.print("  (nenhum cálculo realizado ainda)\n", .{});
            return;
        }

        var i: usize = 0;
        while (i < self.quantidade) : (i += 1) {
            const idx = if (self.quantidade < 20)
                i
            else
                (self.proximo_indice + i) % 20;

            const reg = self.registros[idx];
            try writer.print("  {s}\n", .{reg.expressao[0..reg.expressao_len]});
        }
    }
};

Passo 6: Formatação de Erros

Uma boa calculadora precisa de mensagens de erro claras:

/// Retorna uma mensagem amigável para cada tipo de erro.
/// Decisão: mensagens em português e descritivas, porque o público
/// é brasileiro e mensagens técnicas em inglês não ajudam um iniciante.
fn mensagemErro(err: CalcError) []const u8 {
    return switch (err) {
        CalcError.OperadorInvalido => "Operador inválido. Use +, -, * ou /",
        CalcError.NumeroInvalido => "Número inválido. Use números como 3, 3.14 ou -2",
        CalcError.DivisaoPorZero => "Erro: divisão por zero não é permitida",
        CalcError.ExpressaoIncompleta => "Expressão incompleta. Use o formato: 3 + 4",
        CalcError.FormatoInvalido => "Formato inválido. Use o formato: número operador número",
    };
}

Passo 7: Loop Principal

Finalmente, montamos o loop interativo que une tudo:

pub fn main() !void {
    const stdout = io.getStdOut().writer();
    const stdin = io.getStdIn().reader();

    var historico = Historico.init();

    // Banner inicial
    try stdout.print(
        \\
        \\╔══════════════════════════════════════╗
        \\║     Calculadora Zig v1.0             ║
        \\║  Digite uma expressão: 3 + 4         ║
        \\║  Comandos: historico, limpar, sair    ║
        \\╚══════════════════════════════════════╝
        \\
    , .{});

    // Buffer para leitura de entrada
    var buf: [256]u8 = undefined;

    while (true) {
        try stdout.print("\n> ", .{});

        // Lê uma linha da entrada padrão
        const linha = stdin.readUntilDelimiterOrEof(&buf, '\n') catch {
            try stdout.print("Erro ao ler entrada.\n", .{});
            continue;
        } orelse break; // EOF = sair

        const entrada = mem.trim(u8, linha, " \t\r");

        // Comandos especiais
        if (mem.eql(u8, entrada, "sair") or mem.eql(u8, entrada, "quit")) {
            try stdout.print("Até logo!\n", .{});
            break;
        }

        if (mem.eql(u8, entrada, "historico")) {
            try stdout.print("\n--- Histórico ---\n", .{});
            try historico.exibir(stdout);
            continue;
        }

        if (mem.eql(u8, entrada, "limpar")) {
            historico = Historico.init();
            try stdout.print("Histórico limpo.\n", .{});
            continue;
        }

        if (entrada.len == 0) continue;

        // Parsear e calcular
        const expr = parsearExpressao(entrada) catch |err| {
            try stdout.print("  {s}\n", .{mensagemErro(err)});
            continue;
        };

        const resultado = calcular(expr) catch |err| {
            try stdout.print("  {s}\n", .{mensagemErro(err)});
            continue;
        };

        try stdout.print("  = {d:.6}\n", .{resultado});
        historico.adicionar(expr, resultado);
    }
}

Passo 8: Testes

Uma das grandes vantagens de Zig é o suporte nativo a testes. Vamos adicionar testes para nossas funções:

test "parsear expressão válida" {
    const expr = try parsearExpressao("3 + 4");
    try std.testing.expectEqual(@as(f64, 3.0), expr.operando_a);
    try std.testing.expectEqual(Operacao.soma, expr.operacao);
    try std.testing.expectEqual(@as(f64, 4.0), expr.operando_b);
}

test "parsear expressão com decimais" {
    const expr = try parsearExpressao("3.14 * 2.0");
    try std.testing.expectApproxEqAbs(@as(f64, 3.14), expr.operando_a, 0.001);
    try std.testing.expectEqual(Operacao.multiplicacao, expr.operacao);
}

test "rejeitar operador inválido" {
    const resultado = parsearExpressao("3 ^ 4");
    try std.testing.expectError(CalcError.OperadorInvalido, resultado);
}

test "rejeitar expressão incompleta" {
    const resultado = parsearExpressao("3 +");
    try std.testing.expectError(CalcError.ExpressaoIncompleta, resultado);
}

test "calcular soma" {
    const expr = Expressao{ .operando_a = 10, .operacao = .soma, .operando_b = 5 };
    const resultado = try calcular(expr);
    try std.testing.expectEqual(@as(f64, 15.0), resultado);
}

test "rejeitar divisão por zero" {
    const expr = Expressao{ .operando_a = 10, .operacao = .divisao, .operando_b = 0 };
    try std.testing.expectError(CalcError.DivisaoPorZero, calcular(expr));
}

Execute os testes com:

zig build test

Compilando e Executando

# Compilar
zig build

# Executar
./zig-out/bin/calculadora-cli

# Ou compilar e executar diretamente
zig run src/main.zig

Exemplo de Uso

╔══════════════════════════════════════╗
║     Calculadora Zig v1.0             ║
║  Digite uma expressão: 3 + 4         ║
║  Comandos: historico, limpar, sair    ║
╚══════════════════════════════════════╝

> 3 + 4
  = 7.000000

> 10 / 3
  = 3.333333

> 5 * 0.5
  = 2.500000

> 10 / 0
  Erro: divisão por zero não é permitida

> historico

--- Histórico ---
  3 + 4 = 7.0000
  10 / 3 = 3.3333
  5 * 0.5 = 2.5000

> sair
Até logo!

Desafios Extras

Quer ir além? Tente estas melhorias:

  1. Suporte a parênteses — implemente parsing de expressões como (3 + 4) * 2
  2. Funções matemáticas — adicione sqrt, pow, sin, cos
  3. Variável ans — use o resultado anterior como operando
  4. Persistência — salve o histórico em arquivo usando I/O de arquivos

Conceitos Aprendidos

  • Leitura de entrada do usuário com stdin
  • Parsing de strings com std.mem e std.fmt
  • Tratamento de erros com catch e tipos de erro personalizados
  • Uso de enum e struct para modelar domínio
  • Buffers de tamanho fixo vs alocação dinâmica
  • Testes unitários nativos

Próximos Passos

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.