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:
ArgumentExceptione 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:
- 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. - 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
tryem cada método. É preciso um nos pontos onde existe uma decisão a tomar. - 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
- Exceções e tratamento de exceções (Microsoft Learn): https://learn.microsoft.com/dotnet/csharp/fundamentals/exceptions/. É a introdução oficial ao modelo de exceções do C#.
- Instruções de tratamento de exceção (
throw,try,catch,finally): https://learn.microsoft.com/dotnet/csharp/language-reference/statements/exception-handling-statements. É a referência da sintaxe completa, com filtros e relançamento. - Práticas recomendadas para exceções: https://learn.microsoft.com/dotnet/standard/exceptions/best-practices-for-exceptions. É o guia oficial sobre quando lançar, qual tipo usar e como não perder informação.
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(referência da API): https://learn.microsoft.com/dotnet/api/system.argumentoutofrangeexception.throwifnegativeorzero. Documenta os atalhos de validação disponíveis desde o .NET 8.- Krzysztof Cwalina, Jeremy Barton e Brad Abrams, Framework Design Guidelines, 3ª edição (Addison-Wesley, 2020): o capítulo sobre exceções, escrito por quem desenhou a biblioteca do .NET, explica o raciocínio por trás de cada convenção.
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.