Quando Algo Dá Errado: Exceções em C#

Quando Algo Dá Errado: Exceções em C#

Exceções em C#: como ler um stack trace, try, catch na ordem certa, filtros com when, finally, throw com os atalhos de validação, throw contra throw ex, InnerException, quando usar exceção e quando usar o padrão Try, o custo medido e uma venda que pode falhar.
Linguagem C#

• • 14 min de leitura

Arquivos somem, usuários digitam letras onde se espera um número e o estoque acaba no meio da venda; um programa robusto não é o que nunca encontra erros, é o que sabe o que fazer com eles.

Nas aulas anteriores as exceções apareceram várias vezes de passagem. Um int.Parse("abc") que derrubava o programa. Um checked que transformava estouro em OverflowException. Um índice fora do array. Um foreach que se recusava a continuar sobre uma lista modificada. Em todos esses casos o .NET estava dizendo "aconteceu algo que impede de continuar normalmente". Hoje entendemos esse mecanismo por inteiro: como capturar erros, como lançar os nossos, e, mais importante, quando usar exceções e quando não usar.

O que é uma exceção

Uma exceção é um objeto que descreve um erro e que interrompe o fluxo normal do programa. Quando ela é lançada, o .NET abandona o método atual e sobe pela cadeia de chamadas procurando alguém que a trate. Se ninguém tratar, o programa termina e imprime algo assim:

Unhandled exception. System.FormatException: The input string 'abc' was not in a correct format.
   at System.Number.ThrowFormatException[TChar](ReadOnlySpan`1 value)
   at System.Int32.Parse(String s)
   at Program.<Main>$(String[] args) in /home/voce/aula-08/Program.cs:line 1

Parece assustador, mas é informação preciosa. A primeira linha diz o tipo da exceção (FormatException) e a mensagem. As linhas seguintes são o stack trace, o caminho de chamadas que levou até o erro, do ponto mais interno ao mais externo. A última linha aponta o arquivo e a linha do seu código onde tudo começou. Aprender a ler essa saída economiza horas de depuração.

Compare com o C, onde erros são sinalizados por códigos de retorno (-1, NULL, errno) que qualquer um pode esquecer de verificar. Uma exceção não pode ser ignorada por esquecimento: ou alguém a trata, ou o programa para. Um erro esquecido vira falha visível, e não dado corrompido que se espalha em silêncio.

try e catch

Para tratar uma exceção, envolva o código arriscado em try e diga, em catch, o que fazer:

Console.Write("Quantidade: ");
string? texto = Console.ReadLine();

try
{
    int quantidade = int.Parse(texto!);
    Console.WriteLine($"Você pediu {quantidade}.");
}
catch (FormatException)
{
    Console.WriteLine("Isso não é um número.");
}
catch (OverflowException)
{
    Console.WriteLine("Número grande demais.");
}

(O ! depois de texto diz ao compilador "confie, não é nulo". É um assunto da Fase 3, e aqui ele só silencia um aviso.)

Cada catch trata um tipo de exceção. Quando algo é lançado dentro do try, o .NET procura o primeiro catch compatível, de cima para baixo. Por isso a ordem importa: tipos específicos vêm antes dos genéricos. Todas as exceções derivam de Exception, então um catch (Exception) antes de catch (FormatException) tornaria o segundo inalcançável, e o compilador recusa com o erro CS0160.

Quando você precisa de detalhes, dê um nome à exceção capturada:

catch (FormatException ex)
{
    Console.WriteLine($"Erro: {ex.Message}");
}

As propriedades mais úteis são Message (a descrição), StackTrace (o caminho de chamadas) e InnerException (outra exceção que causou esta, quando houver).

Há também os filtros, que capturam só quando uma condição adicional é verdadeira:

catch (ArgumentException ex) when (ex.ParamName == "quantidade")
{
    Console.WriteLine("Problema especificamente na quantidade.");
}

finally: o que sempre precisa acontecer

Um bloco finally executa sempre, tenha havido exceção ou não, tenha ela sido tratada ou não, e mesmo que o try termine com return:

Console.WriteLine("Abrindo o caixa...");
try
{
    RegistrarVendas();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"Venda recusada: {ex.Message}");
}
finally
{
    Console.WriteLine("Fechando o caixa.");   // executa em qualquer cenário
}

static void RegistrarVendas() => throw new InvalidOperationException("estoque esgotado");

O uso típico do finally é liberar recursos: fechar um arquivo, devolver uma conexão ao banco, destravar algo que foi travado. Se você vem do C, pense nele como o lugar garantido para o fclose ou o free, que em C costuma ser pulado quando um erro faz a função sair mais cedo. O C# tem uma forma ainda mais conveniente para isso, a instrução using, que ganha espaço na aula sobre memória. Ela é construída exatamente sobre o try/finally.

Lançando exceções com throw

Seus próprios métodos também devem lançar exceções quando recebem algo que torna impossível cumprir o que prometem:

static decimal CalcularTotal(decimal preco, int quantidade)
{
    if (preco <= 0)
        throw new ArgumentOutOfRangeException(nameof(preco), "O preço deve ser positivo.");
    if (quantidade <= 0)
        throw new ArgumentOutOfRangeException(nameof(quantidade), "A quantidade deve ser positiva.");

    return preco * quantidade;
}

nameof(preco) produz a string "preco", e, se um dia você renomear o parâmetro, o compilador atualiza junto. Para essas verificações de entrada, que são muito comuns, o .NET moderno oferece atalhos que deixam o método mais limpo:

static decimal CalcularTotal(decimal preco, int quantidade)
{
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(preco);
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantidade);
    return preco * quantidade;
}

static string Normalizar(string titulo)
{
    ArgumentException.ThrowIfNullOrWhiteSpace(titulo);
    return titulo.Trim();
}

Os atalhos preenchem sozinhos o nome do parâmetro e uma mensagem descritiva. Escolher o tipo certo de exceção comunica o problema a quem chama:

  • ArgumentException e suas derivadas (ArgumentNullException, ArgumentOutOfRangeException): o chamador passou um argumento inválido. É um erro de quem chamou.
  • InvalidOperationException: os argumentos estão certos, mas o estado atual não permite a operação, como vender um livro sem estoque ou ler de uma fila vazia.
  • NotSupportedException: a operação não é suportada, como escrever numa coleção somente leitura.

É possível criar tipos próprios de exceção, como uma EstoqueInsuficienteException. Isso exige declarar uma classe, então fica para a Fase 2, quando estudarmos herança.

Relançando sem perder a história

Às vezes você quer registrar o erro e deixá-lo seguir para quem está acima. Use throw; sozinho:

try
{
    ProcessarPedido();
}
catch (Exception ex)
{
    Console.Error.WriteLine($"Falha registrada: {ex.Message}");
    throw;   // relança a MESMA exceção, com o stack trace original
}

Nunca escreva throw ex; nesse caso. Essa forma relança o objeto, mas reinicia o stack trace a partir daquela linha: a informação de onde o erro realmente nasceu se perde. É um erro sutil, que só se percebe na hora de investigar um problema em produção, justamente quando a informação faz falta.

Quando quiser traduzir um erro técnico num erro com mais significado, envolva o original como InnerException:

catch (FormatException ex)
{
    throw new InvalidOperationException("O arquivo do catálogo está corrompido.", ex);
}

Quem receber a nova exceção lê a mensagem de alto nível e ainda tem acesso à causa técnica.

Quando usar exceções, e quando não usar

Aqui mora a parte mais importante da aula. Exceções são para situações excepcionais: coisas que não deveriam acontecer no fluxo normal e que o método não tem como resolver sozinho. Um arquivo de configuração que sumiu, um banco de dados fora do ar, um argumento que viola o contrato do método.

Elas não são para o fluxo esperado. Um usuário digitar um número inválido é esperado: pessoas erram o tempo todo. Por isso o padrão Try (int.TryParse, dictionary.TryGetValue) existe, e por isso o usamos até aqui para entrada de usuário. Há também uma razão de custo: lançar uma exceção é dezenas de vezes mais lento que devolver um bool, porque o .NET precisa capturar o stack trace e procurar tratadores. Num teste com o .NET 10, converter "abc" duzentas mil vezes levou 13 ms com TryParse e 620 ms com int.Parse e catch: cerca de 3 microssegundos por exceção, 48 vezes mais. Numa operação que acontece uma vez, isso é irrelevante. Num laço que valida um milhão de registros, é desastroso.

Três regras práticas:

  1. Não engula exceções. Um catch { } vazio esconde o erro, e o programa segue num estado inválido. É pior do que deixar quebrar. Se você capturou, faça algo significativo: trate, registre ou relance.
  2. Capture onde dá para fazer algo útil. O método que lê um número não sabe se deve perguntar de novo ou abortar. Quem sabe é a interface. Não é preciso um try em cada método. É preciso um nos pontos onde existe uma decisão a tomar.
  3. Tenha uma rede de segurança no topo. Um try/catch (Exception) envolvendo o programa inteiro permite registrar qualquer erro inesperado e encerrar com uma mensagem decente, em vez de um stack trace na cara do usuário.

Juntando tudo: uma venda que pode falhar

A regra "não vender sem estoque" é um bom exemplo de InvalidOperationException. O método de venda não sabe conversar com o usuário. Ele só sabe que não pode cumprir a operação:

using System.Globalization;

CultureInfo.CurrentCulture = new CultureInfo("pt-BR");

var estoque = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
    ["Dom Casmurro"] = 5,
    ["O Cortiço"] = 2,
};

try
{
    while (true)
    {
        Console.Write("\nTítulo (ou Enter para sair): ");
        string titulo = (Console.ReadLine() ?? "").Trim();
        if (titulo.Length == 0)
            break;

        Console.Write("Quantidade: ");
        if (!int.TryParse(Console.ReadLine(), out int quantidade))
        {
            Console.WriteLine("Quantidade inválida.");   // esperado: sem exceção
            continue;
        }

        try
        {
            Vender(estoque, titulo, quantidade);
            Console.WriteLine($"Venda registrada. Restam {estoque[titulo]}.");
        }
        catch (ArgumentException ex)
        {
            Console.WriteLine($"Pedido inválido: {ex.Message}");
        }
        catch (InvalidOperationException ex)
        {
            Console.WriteLine($"Venda recusada: {ex.Message}");
        }
    }
}
catch (Exception ex)
{
    // rede de segurança: qualquer coisa que não prevemos
    Console.Error.WriteLine($"Erro inesperado: {ex}");
    return 1;
}
return 0;

static void Vender(Dictionary<string, int> estoque, string titulo, int quantidade)
{
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantidade);

    if (!estoque.TryGetValue(titulo, out int disponivel))
        throw new ArgumentException($"\"{titulo}\" não está no catálogo.", nameof(titulo));

    if (quantidade > disponivel)
        throw new InvalidOperationException(
            $"há apenas {disponivel} exemplar(es) de \"{titulo}\".");

    estoque[titulo] = disponivel - quantidade;
}

Repare nas três camadas. A entrada de texto inválida é tratada com TryParse, sem exceção, porque é esperada. As violações de regra de negócio são lançadas por Vender e tratadas pelo laço, que as transforma em mensagens e segue atendendo. E um catch (Exception) externo segura qualquer coisa que tenha escapado, registra os detalhes na saída de erro e devolve código 1. $"{ex}" usa o ToString() da exceção, que inclui tipo, mensagem e stack trace completos: exatamente o que se quer num registro de erro.

Uma ressalva sobre as mensagens. As geradas pelos atalhos de validação vêm em inglês, e a Message de uma ArgumentException termina com o nome do parâmetro: numa execução deste programa, um pedido de zero exemplares mostrou quantidade ('0') must be a non-negative and non-zero value. (Parameter 'quantidade'), e um título fora do catálogo, "X" não está no catálogo. (Parameter 'titulo'). Para o programador lendo um log, isso é ótimo. Para o cliente de uma livraria, não. Mensagens de exceção descrevem o erro para quem vai corrigi-lo; o texto que o usuário final lê deve ser escrito pela interface, no catch, a partir do tipo da exceção.

Com as exceções, a Fase 1 está completa. Você começou sem saber o que era o .NET e agora escreve programas interativos com entrada validada, decisões, laços, métodos bem nomeados, coleções e tratamento de erros em camadas. Esse repertório já permite escrever ferramentas de linha de comando úteis de verdade. Mas a última versão da Livraria expõe o limite do que temos: um livro ainda é só uma chave de texto espalhada por vários dicionários, e cada método recebe três ou quatro coleções para fazer um trabalho simples. Falta um jeito de dizer ao C# "isto é um livro": um tipo nosso, com seus dados e suas regras juntos num só lugar. É com ele que a orientação a objetos começa.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Leia um número com int.Parse (não TryParse) e trate separadamente o caso de texto inválido e o de número grande demais. Teste com "abc" e com "99999999999".

Ver resposta

✓ Resposta:

Console.Write("Número: ");
try
{
    int n = int.Parse(Console.ReadLine() ?? "");
    Console.WriteLine($"Você digitou {n}.");
}
catch (FormatException)
{
    Console.WriteLine("Isso não é um número inteiro.");
}
catch (OverflowException)
{
    Console.WriteLine("O número não cabe em um int.");
}

"abc" cai no FormatException. "99999999999" é um número válido no formato, mas passa do máximo de um int (cerca de 2,1 bilhões) e cai no OverflowException. Os dois tipos são independentes, então a ordem entre eles não importa aqui. O exercício mostra por que o padrão Try é preferível para entrada de usuário: a versão com TryParse trata os dois casos com um único if, sem o custo de exceções. Mas, quando é preciso distinguir o motivo da falha, as exceções dão essa informação.

Exercício 2

O código abaixo não compila. Explique o motivo e corrija.

try { int.Parse("x"); }
catch (Exception) { Console.WriteLine("Erro genérico"); }
catch (FormatException) { Console.WriteLine("Formato inválido"); }
Ver resposta

✓ Resposta: FormatException deriva de Exception. Como os catch são avaliados em ordem, qualquer FormatException seria capturada pelo primeiro bloco, e o segundo jamais executaria. O compilador detecta esse código morto e emite o erro CS0160. A correção é inverter a ordem, do mais específico para o mais genérico:

try { int.Parse("x"); }
catch (FormatException) { Console.WriteLine("Formato inválido"); }
catch (Exception) { Console.WriteLine("Erro genérico"); }

Exercício 3

Preveja a ordem exata do que é impresso:

Console.WriteLine(Testar());

static string Testar()
{
    try
    {
        Console.WriteLine("A");
        return "B";
    }
    finally
    {
        Console.WriteLine("C");
    }
}
Ver resposta

✓ Resposta: A, C e B. O try imprime "A" e executa return "B". O valor de retorno é calculado, mas, antes de o método de fato sair, o finally executa e imprime "C". Só então o método devolve "B", que o Console.WriteLine externo imprime. É a garantia central do finally: nem um return escapa dele. É por isso que ele é o lugar certo para liberar recursos.

Exercício 4

Escreva um método CalcularMedia(int[] notas) que lance ArgumentNullException se o array for nulo e ArgumentException se estiver vazio. Por que lançar exceção é melhor do que devolver 0 no caso vazio?

Ver resposta

✓ Resposta:

static double CalcularMedia(int[] notas)
{
    ArgumentNullException.ThrowIfNull(notas);
    if (notas.Length == 0)
        throw new ArgumentException("É preciso ao menos uma nota.", nameof(notas));

    double soma = 0;
    foreach (int nota in notas)
        soma += nota;
    return soma / notas.Length;
}

Devolver 0 para um array vazio seria mentir: zero é uma média possível (um aluno que tirou zero em tudo), e quem chamasse não teria como distinguir "média zero" de "não havia notas". O erro se esconderia e reapareceria longe dali, como um boletim errado. A exceção torna o problema impossível de ignorar e aponta exatamente onde ele está. Se o caso vazio fosse esperado e comum, a alternativa honesta seria um método no padrão Try, TentarCalcularMedia(int[] notas, out double media), e nunca um valor mágico.

Exercício 5

Explique a diferença entre throw; e throw ex; dentro de um catch. Monte um pequeno programa que demonstre essa diferença no stack trace.

Ver resposta

✓ Resposta:

try { Intermediario(); }
catch (Exception ex) { Console.WriteLine(ex.StackTrace); }

static void Intermediario()
{
    try { Origem(); }
    catch (Exception ex)
    {
        throw;        // troque por "throw ex;" e compare a saída
    }
}

static void Origem() => throw new InvalidOperationException("falhou na origem");

Com throw;, o stack trace impresso inclui a linha de Origem, onde a exceção nasceu, e depois a de Intermediario. Com throw ex;, a linha de Origem desaparece, e o rastro começa em Intermediario, como se o erro tivesse nascido ali. throw; relança a exceção preservando sua história. throw ex; a trata como se estivesse sendo lançada pela primeira vez naquele ponto. Num programa real, com dezenas de chamadas, isso significa perder justamente a informação que diz onde está o bug. O compilador ainda emite um aviso sobre a variável ex não usada na versão com throw;, que some se você escrever apenas catch (Exception) ou catch.

Comentários

Mais em Linguagem C#

Razor Pages: HTML montado no servidor para a vitrine da Livraria
Razor Pages: HTML montado no servidor para a vitrine da Livraria

A Livraria ganha uma vitrine com Razor Pages: páginas montadas no servidor…

Interop com C: Chamando Código Nativo com P/Invoke e unsafe
Interop com C: Chamando Código Nativo com P/Invoke e unsafe

Como chamar uma biblioteca em C a partir do C#: LibraryImport, conversão de…

Do Código-Fonte ao Processo: Instalando o .NET e Entendendo o que Acontece ao Rodar
Do Código-Fonte ao Processo: Instalando o .NET e Entendendo o que Acontece ao Rodar

Instalar o .NET 10 e entender o caminho do código até o processador: SDK e…