C# Exceções Próprias

O .NET tem exceções para os problemas de qualquer programa: argumento inválido, arquivo que não existe, divisão por zero. Mas a lojinha da Ana tem problemas que só ela tem: "o estoque não dá para este pedido", "este produto não está cadastrado". Nesta aula você vai criar as suas próprias classes de exceção, com nome, mensagem e dados que fazem sentido no seu programa, e organizar várias delas numa família.

Você vai aprender
  • Criar uma exceção: uma classe que herda de Exception e passa a mensagem para base.
  • As regras de nome e de herança que todo programador C# segue.
  • Guardar dados extras (o produto, a quantidade) em propriedades da exceção.
  • Os três construtores de costume, inclusive o que recebe a InnerException.
  • Montar uma família: uma exceção mãe do programa e as filhas, capturadas juntas.
  • Quando vale a pena criar uma exceção, e quando uma do .NET basta.
  • Os erros CS0029, CS0155, CS1729 e CS8864.

Esta aula usa herança (: Exception), construtores e base. Todos os programas rodaram na máquina da Ana, com o .NET 10 (C# 14), usando dotnet run arquivo.cs na pasta ~/cs.


A ideia antes do código

Num hospital, "deu um problema" não ajuda ninguém. Os avisos têm nome: "código azul", "código vermelho". Cada nome diz o que aconteceu e chama a equipe certa. Quem é da cardiologia corre para o código azul e ignora os outros.

Uma exceção própria é um aviso com nome. Em vez de lançar uma InvalidOperationException genérica para "estoque insuficiente", a lojinha lança uma EstoqueInsuficienteException. Quem chama pode capturar exatamente esse problema com catch (EstoqueInsuficienteException), sem pegar sem querer os outros.


1. A menor exceção possível

Uma exceção é uma classe como outra qualquer, com uma exigência: ela tem de ser da família Exception.

Vender("caderno", 5, 2);

void Vender(string produto, int pedido, int emEstoque)
{
    if (pedido > emEstoque)
    {
        throw new EstoqueInsuficienteException($"Pedido de {pedido} {produto}(s), mas só há {emEstoque}.");
    }
    Console.WriteLine("Vendido!");
}

class EstoqueInsuficienteException : Exception
{
    public EstoqueInsuficienteException(string mensagem) : base(mensagem)
    {
    }
}
$ dotnet run propria.cs
Unhandled exception. EstoqueInsuficienteException: Pedido de 5 caderno(s), mas só há 2.
   at Program.<<Main>$>g__Vender|0_0(String produto, Int32 pedido, Int32 emEstoque) in /home/ana/cs/propria.cs:line 7
   at Program.<Main>$(String[] args) in /home/ana/cs/propria.cs:line 1
$ echo $?
134
class EstoqueInsuficienteException
O nome da exceção. Por convenção, sempre termina em Exception. O compilador não obriga, mas todo mundo espera.
: Exception
Herda de Exception. É isso que permite usar a classe com throw e catch, e que dá a ela, de graça, a Message, o stack trace, a InnerException…
public EstoqueInsuficienteException(string mensagem)
Um construtor que recebe a mensagem.
: base(mensagem)
Repassa a mensagem para o construtor da classe mãe, Exception, que a guarda em Message (aula base). As chaves ficam vazias: não há mais nada a fazer.
Unhandled exception. EstoqueInsuficienteException: …
No relatório, a sua exceção aparece igual às do .NET. Sem o System. na frente, porque ela não está em namespace nenhum (veja a seção 4).
RegraPor quê
O nome termina em ExceptionQuem lê catch (EstoqueInsuficienteException) sabe na hora o que é. É o padrão de todas as exceções do .NET.
Herde de Exception (ou de uma exceção sua)Não use ApplicationException: a própria Microsoft desistiu dela. E não herde de SystemException, que é das exceções do .NET.
A mensagem é uma frase completaEla aparece no relatório e nos logs. "Pedido de 5 caderno(s), mas só há 2." ajuda; "erro estoque" não.
A classe fica public num projetoQuem usa o seu código precisa enxergá-la para escrever o catch.

Missão: o programa lança uma ProdutoEsgotadoException, mas a classe não existe e o código não compila (CS0246). Crie a classe no fim do arquivo, herdando de Exception, com um construtor que recebe a mensagem e a repassa para base.

int caderno = 0;
if (caderno == 0)
{
    throw new ProdutoEsgotadoException("O caderno esgotou.");
}
Console.WriteLine("Ainda tem caderno.");
int caderno = 0;
if (caderno == 0)
{
    throw new ProdutoEsgotadoException("O caderno esgotou.");
}
Console.WriteLine("Ainda tem caderno.");

class ProdutoEsgotadoException : Exception
{
    public ProdutoEsgotadoException(string mensagem) : base(mensagem)
    {
    }
}

2. Dados extras: o que o catch precisa saber

A grande vantagem de ter a sua classe é poder guardar dados nela. Quem captura a exceção não precisa "ler" a mensagem para descobrir quantas unidades havia: a informação está numa propriedade.

try
{
    Loja.Vender("caderno", 5);
}
catch (EstoqueInsuficienteException e)
{
    Console.WriteLine(e.Message);
    Console.WriteLine($"Que tal levar só {e.Disponivel}?");
}

static class Loja
{
    static readonly Dictionary<string, int> estoque = new() { ["caderno"] = 2, ["caneta"] = 40 };

    public static void Vender(string produto, int pedido)
    {
        int disponivel = estoque[produto];
        if (pedido > disponivel)
        {
            throw new EstoqueInsuficienteException(produto, pedido, disponivel);
        }
        estoque[produto] = disponivel - pedido;
        Console.WriteLine($"Vendido: {pedido} {produto}(s)");
    }
}

class EstoqueInsuficienteException : Exception
{
    public string Produto { get; }
    public int Pedido { get; }
    public int Disponivel { get; }

    public EstoqueInsuficienteException(string produto, int pedido, int disponivel)
        : base($"Pedido de {pedido} {produto}(s), mas só há {disponivel}.")
    {
        Produto = produto;
        Pedido = pedido;
        Disponivel = disponivel;
    }
}
$ dotnet run dados.cs
Pedido de 5 caderno(s), mas só há 2.
Que tal levar só 2?
public int Disponivel { get; }
Uma propriedade só de leitura (aula readonly e init): quem captura lê, mas não muda. Exceções carregam fatos sobre o que aconteceu; não faz sentido alterá-los depois.
EstoqueInsuficienteException(string produto, int pedido, int disponivel)
Agora o construtor recebe os dados, não a mensagem pronta. Quem lança só diz o que aconteceu: new EstoqueInsuficienteException(produto, pedido, disponivel).
: base($"Pedido de {pedido} …")
A exceção monta a própria mensagem a partir dos dados. Assim, todas as mensagens desse problema saem no mesmo formato, em qualquer lugar do programa.
e.Disponivel
No catch, a informação vem pronta, como número. Dá para oferecer uma alternativa ao cliente, sem precisar "garimpar" o 2 de dentro do texto.

A forma curta, com construtor primário C# 12

Com um construtor primário, os parâmetros vão direto no nome da classe e a chamada a base vai junto do : Exception(…):

throw new ProdutoEsgotadoException("caderno");

class ProdutoEsgotadoException(string produto) : Exception($"O produto \"{produto}\" esgotou.")
{
    public string Produto { get; } = produto;
}
$ dotnet run prim.cs
Unhandled exception. ProdutoEsgotadoException: O produto "caderno" esgotou.
   at Program.<Main>$(String[] args) in /home/ana/cs/prim.cs:line 1

É o mesmo resultado, em menos linhas. O guia usa a forma longa na maior parte das aulas, porque ela deixa cada passo à vista; as duas estão certas.

Não dá para fazer com record

Uma exceção parece um bom caso para record (só dados, só leitura), mas records só herdam de outros records, e Exception é uma classe. record ProdutoEsgotadoException(string Mensagem) : Exception(Mensagem); dá o erro CS8864 (veja Quando dá errado). Use class.

Missão: o programa quebra com a exceção da lojinha. Envolva a chamada da primeira linha num try com catch (EstoqueInsuficienteException e) e mostre Que tal levar só 2? usando a propriedade e.Disponivel (não escreva o 2 à mão).

Loja.Vender("caderno", 5);

static class Loja
{
    static readonly Dictionary<string, int> estoque = new() { ["caderno"] = 2, ["caneta"] = 40 };

    public static void Vender(string produto, int pedido)
    {
        int disponivel = estoque[produto];
        if (pedido > disponivel)
        {
            throw new EstoqueInsuficienteException(produto, pedido, disponivel);
        }
        estoque[produto] = disponivel - pedido;
        Console.WriteLine($"Vendido: {pedido} {produto}(s)");
    }
}

class EstoqueInsuficienteException : Exception
{
    public string Produto { get; }
    public int Pedido { get; }
    public int Disponivel { get; }

    public EstoqueInsuficienteException(string produto, int pedido, int disponivel)
        : base($"Pedido de {pedido} {produto}(s), mas só há {disponivel}.")
    {
        Produto = produto;
        Pedido = pedido;
        Disponivel = disponivel;
    }
}
try
{
    Loja.Vender("caderno", 5);
}
catch (EstoqueInsuficienteException e)
{
    Console.WriteLine(e.Message);
    Console.WriteLine($"Que tal levar só {e.Disponivel}?");
}

static class Loja
{
    static readonly Dictionary<string, int> estoque = new() { ["caderno"] = 2, ["caneta"] = 40 };

    public static void Vender(string produto, int pedido)
    {
        int disponivel = estoque[produto];
        if (pedido > disponivel)
        {
            throw new EstoqueInsuficienteException(produto, pedido, disponivel);
        }
        estoque[produto] = disponivel - pedido;
        Console.WriteLine($"Vendido: {pedido} {produto}(s)");
    }
}

class EstoqueInsuficienteException : Exception
{
    public string Produto { get; }
    public int Pedido { get; }
    public int Disponivel { get; }

    public EstoqueInsuficienteException(string produto, int pedido, int disponivel)
        : base($"Pedido de {pedido} {produto}(s), mas só há {disponivel}.")
    {
        Produto = produto;
        Pedido = pedido;
        Disponivel = disponivel;
    }
}

3. Os três construtores de costume

As exceções do .NET têm, quase todas, três construtores: sem nada, com a mensagem, e com a mensagem e a exceção interna (a InnerException, da aula throw). Numa biblioteca que outras pessoas vão usar, é costume oferecer os mesmos três:

public class LojinhaException : Exception
{
    public LojinhaException() { }
    public LojinhaException(string mensagem) : base(mensagem) { }
    public LojinhaException(string mensagem, Exception interna) : base(mensagem, interna) { }
}
LojinhaException() { }
Sem argumentos. A mensagem fica a padrão do .NET (veja abaixo).
LojinhaException(string mensagem) : base(mensagem)
O mais usado: só a mensagem.
LojinhaException(string mensagem, Exception interna) : base(mensagem, interna)
Para embrulhar uma exceção de baixo nível (um FormatException, um IOException) numa da lojinha, sem perder a original.

Se você não escrever construtor nenhum, a classe ganha só o construtor vazio, e a mensagem fica a genérica do .NET:

try
{
    throw new ProdutoEsgotadoException();
}
catch (ProdutoEsgotadoException e)
{
    Console.WriteLine(e.Message);
}

class ProdutoEsgotadoException : Exception
{
}
$ dotnet run semnome.cs
Exception of type 'ProdutoEsgotadoException' was thrown.

"Uma exceção do tipo ProdutoEsgotadoException foi lançada." Funciona, mas não diz qual produto. E, nessa classe sem construtores, new ProdutoEsgotadoException("…") nem compila (CS1729): construtores não são herdados em C#; cada classe declara os seus.


4. Uma família de exceções

Um programa maior tem vários problemas próprios. O costume é criar uma exceção mãe do programa, e as outras como filhas dela. Assim, quem chama escolhe: capturar um problema específico, ou capturar "qualquer problema da lojinha" de uma vez. E, num projeto, elas moram no namespace do programa:

using Lojinha;

string[] pedidos = ["caneta:3", "caderno:5", "borracha:1"];
foreach (string p in pedidos)
{
    string[] partes = p.Split(':');
    try
    {
        Loja.Vender(partes[0], int.Parse(partes[1]));
    }
    catch (LojinhaException e)
    {
        Console.WriteLine($"Não deu: {e.Message} ({e.GetType().Name})");
    }
}
Loja.Vender("lápis", 1);

namespace Lojinha
{
    class LojinhaException : Exception
    {
        public LojinhaException() { }
        public LojinhaException(string mensagem) : base(mensagem) { }
        public LojinhaException(string mensagem, Exception interna) : base(mensagem, interna) { }
    }

    class EstoqueInsuficienteException : LojinhaException
    {
        public EstoqueInsuficienteException(string produto, int disponivel)
            : base($"só há {disponivel} {produto}(s)") { }
    }

    class ProdutoNaoEncontradoException : LojinhaException
    {
        public ProdutoNaoEncontradoException(string produto)
            : base($"\"{produto}\" não está cadastrado") { }
    }

    static class Loja
    {
        static readonly Dictionary<string, int> estoque = new() { ["caderno"] = 2, ["caneta"] = 40 };

        public static void Vender(string produto, int pedido)
        {
            if (!estoque.TryGetValue(produto, out int disponivel))
            {
                throw new ProdutoNaoEncontradoException(produto);
            }
            if (pedido > disponivel)
            {
                throw new EstoqueInsuficienteException(produto, disponivel);
            }
            estoque[produto] = disponivel - pedido;
            Console.WriteLine($"Vendido: {pedido} {produto}(s)");
        }
    }
}
$ dotnet run familia.cs
Vendido: 3 caneta(s)
Não deu: só há 2 caderno(s) (EstoqueInsuficienteException)
Não deu: "borracha" não está cadastrado (ProdutoNaoEncontradoException)
Unhandled exception. Lojinha.ProdutoNaoEncontradoException: "lápis" não está cadastrado
   at Lojinha.Loja.Vender(String produto, Int32 pedido) in /home/ana/cs/familia.cs:line 47
   at Program.<Main>$(String[] args) in /home/ana/cs/familia.cs:line 16
class EstoqueInsuficienteException : LojinhaException
Herda da exceção da lojinha, que herda de Exception. A árvore fica: Exception → LojinhaException → EstoqueInsuficienteException / ProdutoNaoEncontradoException.
: base($"só há …")
Chama o construtor LojinhaException(string mensagem), que por sua vez chama o de Exception.
catch (LojinhaException e)
Pega as duas filhas (lembre: o catch da mãe pega as filhas). O e.GetType().Name mostra qual delas veio.
Lojinha.ProdutoNaoEncontradoException
Agora, dentro do namespace, o relatório mostra o nome completo, como o System.FormatException do .NET. O "lápis" foi vendido fora do try de propósito, para você ver esse relatório.

Repare que um FormatException do int.Parse não seria pego pelo catch (LojinhaException e): ele não é da família. É justamente essa a vantagem: o catch pega os problemas da lojinha, que ele sabe tratar, e deixa os defeitos de programação aparecerem.


Criar a sua ou usar uma do .NET?

SituaçãoUse
Um parâmetro null, fora do intervalo, inválidoAs do .NET: ArgumentNullException, ArgumentOutOfRangeException, ArgumentException
O objeto não está pronto para a operaçãoInvalidOperationException
Um problema do negócio que quem chama vai querer tratar à parte (estoque, saldo, cadastro)Uma exceção sua: EstoqueInsuficienteException
Quem trata precisa de dados (quanto havia, qual produto)Uma exceção sua, com propriedades
Vários problemas próprios, às vezes tratados juntosUma família: LojinhaException e filhas
Só para ter uma mensagem diferenteNão precisa de classe: throw new InvalidOperationException("…")

Uma regra prática: crie uma exceção quando você consegue imaginar alguém escrevendo um catch só para ela. Se ninguém nunca vai capturá-la separada, uma do .NET basta.


Quando dá errado

CS0029: a classe esqueceu o : Exception
throw new ProdutoEsgotado("caderno");

class ProdutoEsgotado
{
    public ProdutoEsgotado(string nome) { }
}
$ dotnet run naoexc.cs
/home/ana/cs/naoexc.cs(1,7): error CS0029: Não é possível converter implicitamente tipo "ProdutoEsgotado" em "System.Exception"

Ocorreu uma falha no build. Corrija os erros de build e execute novamente.

O que significa: o throw só aceita objetos da família Exception, e ProdutoEsgotado não é.

Como consertar: faça a classe herdar de Exception, passe a mensagem para base e, de quebra, ponha o Exception no fim do nome: class ProdutoEsgotadoException : Exception.

CS0155: catch de algo que não é exceção
try { Console.WriteLine("oi"); }
catch (string e) { }
$ dotnet run catchnao.cs
/home/ana/cs/catchnao.cs(2,8): error CS0155: O tipo caught ou thrown deve ser derivado de System.Exception
/home/ana/cs/catchnao.cs(2,15): warning CS0168: A variável "e" está declarada, mas nunca é usada

Ocorreu uma falha no build. Corrija os erros de build e execute novamente.

O que significa: "o tipo capturado (caught) ou lançado (thrown) deve ser derivado de System.Exception". A tradução deixou duas palavras em inglês, mas a ideia é a mesma do erro anterior, do lado do catch.

Como consertar: no catch, só tipos da família Exception.

CS1729: "não contém um construtor que aceita 1 argumentos"
throw new ProdutoEsgotadoException("O caderno esgotou.");

class ProdutoEsgotadoException : Exception
{
}
$ dotnet run semctor.cs
/home/ana/cs/semctor.cs(1,11): error CS1729: "ProdutoEsgotadoException" não contém um construtor que aceita 1 argumentos

Ocorreu uma falha no build. Corrija os erros de build e execute novamente.

O que significa: Exception tem um construtor com mensagem, mas a sua classe não herda construtores. Sem nenhum escrito, ela só tem o vazio.

Como consertar: escreva o construtor: public ProdutoEsgotadoException(string mensagem) : base(mensagem) { }.

CS8864: exceção como record
throw new ProdutoEsgotadoException("O caderno esgotou.");

record ProdutoEsgotadoException(string Mensagem) : Exception(Mensagem);
$ dotnet run rec.cs
/home/ana/cs/rec.cs(3,52): error CS8864: Os registros só podem ser herdados de um objeto ou de outro registro
/home/ana/cs/rec.cs(3,8): error CS0115: "ProdutoEsgotadoException.EqualityContract": não encontrado nenhum método adequado para substituição
/home/ana/cs/rec.cs(3,8): error CS0115: "ProdutoEsgotadoException.Equals(Exception?)": não encontrado nenhum método adequado para substituição
/home/ana/cs/rec.cs(3,8): error CS0115: "ProdutoEsgotadoException.PrintMembers(StringBuilder)": não encontrado nenhum método adequado para substituição

Ocorreu uma falha no build. Corrija os erros de build e execute novamente.

O que significa: o primeiro erro é o que importa: "registros (records) só podem herdar de object ou de outro record". Os três CS0115 são consequência: o compilador tentou gerar os métodos de um record em cima de uma classe que não é record.

Como consertar: use class, com construtor normal ou primário (a forma curta da seção 2).

A mensagem é "Exception of type '…' was thrown."

O que significa: o construtor da sua exceção não repassou a mensagem para base, ou a exceção foi criada com new …() sem argumentos.

Como consertar: confira se o construtor tem : base(mensagem) (e não só guarda a mensagem numa propriedade sua).


Referência

SintaxeO que faz
class XException : ExceptionCria uma exceção própria
public XException(string m) : base(m) { }Construtor que repassa a mensagem para Exception
public XException(string m, Exception i) : base(m, i) { }Construtor que guarda a InnerException
class XException(string p) : Exception($"… {p}")Forma curta, com construtor primário C# 12
public int Disponivel { get; }Dado extra, só de leitura
class FilhaException : MaeExceptionFamília de exceções; catch (MaeException) pega as filhas
Exception of type 'X' was thrown.Mensagem padrão, quando nenhuma foi passada
CS0029 / CS0155throw / catch de um tipo que não é Exception
CS1729Falta o construtor com esses argumentos
CS8864Record não pode herdar de Exception

Exercícios

1. A classe mãe

Complete para criar uma exceção própria:

class SaldoInsuficienteException :

2. Repassar a mensagem

Complete o construtor:

public SaldoInsuficienteException(string mensagem) : { }

3. O nome de costume

Uma exceção para "cliente bloqueado" deve se chamar ClienteBloqueado e terminar com:

ClienteBloqueado

4. ProdutoNaoEncontradoException herda de LojinhaException, que herda de Exception. Quais catch pegam uma ProdutoNaoEncontradoException?

5. Por que guardar Disponivel numa propriedade em vez de só escrevê-lo na mensagem?

6. Em qual caso não vale a pena criar uma exceção própria?

Missão final: a ProdutoNaoEncontradoException herda direto de Exception, então o catch (LojinhaException e) não a pega e o programa quebra na borracha. Mude só a classe mãe dela, para que o mesmo catch trate os dois problemas da lojinha.

string[] pedidos = ["caneta:3", "caderno:5", "borracha:1"];
foreach (string p in pedidos)
{
    string[] partes = p.Split(':');
    try
    {
        Loja.Vender(partes[0], int.Parse(partes[1]));
    }
    catch (LojinhaException e)
    {
        Console.WriteLine($"Não deu: {e.Message}");
    }
}

class LojinhaException : Exception
{
    public LojinhaException(string mensagem) : base(mensagem) { }
}

class EstoqueInsuficienteException : LojinhaException
{
    public EstoqueInsuficienteException(string produto, int disponivel)
        : base($"só há {disponivel} {produto}(s)") { }
}

class ProdutoNaoEncontradoException : Exception
{
    public ProdutoNaoEncontradoException(string produto)
        : base($"\"{produto}\" não está cadastrado") { }
}

static class Loja
{
    static readonly Dictionary<string, int> estoque = new() { ["caderno"] = 2, ["caneta"] = 40 };

    public static void Vender(string produto, int pedido)
    {
        if (!estoque.TryGetValue(produto, out int disponivel))
        {
            throw new ProdutoNaoEncontradoException(produto);
        }
        if (pedido > disponivel)
        {
            throw new EstoqueInsuficienteException(produto, disponivel);
        }
        estoque[produto] = disponivel - pedido;
        Console.WriteLine($"Vendido: {pedido} {produto}(s)");
    }
}
string[] pedidos = ["caneta:3", "caderno:5", "borracha:1"];
foreach (string p in pedidos)
{
    string[] partes = p.Split(':');
    try
    {
        Loja.Vender(partes[0], int.Parse(partes[1]));
    }
    catch (LojinhaException e)
    {
        Console.WriteLine($"Não deu: {e.Message}");
    }
}

class LojinhaException : Exception
{
    public LojinhaException(string mensagem) : base(mensagem) { }
}

class EstoqueInsuficienteException : LojinhaException
{
    public EstoqueInsuficienteException(string produto, int disponivel)
        : base($"só há {disponivel} {produto}(s)") { }
}

class ProdutoNaoEncontradoException : LojinhaException
{
    public ProdutoNaoEncontradoException(string produto)
        : base($"\"{produto}\" não está cadastrado") { }
}

static class Loja
{
    static readonly Dictionary<string, int> estoque = new() { ["caderno"] = 2, ["caneta"] = 40 };

    public static void Vender(string produto, int pedido)
    {
        if (!estoque.TryGetValue(produto, out int disponivel))
        {
            throw new ProdutoNaoEncontradoException(produto);
        }
        if (pedido > disponivel)
        {
            throw new EstoqueInsuficienteException(produto, disponivel);
        }
        estoque[produto] = disponivel - pedido;
        Console.WriteLine($"Vendido: {pedido} {produto}(s)");
    }
}

Resumo

  • Uma exceção própria é uma classe que herda de Exception e tem o nome terminado em Exception.
  • O construtor repassa a mensagem com : base(mensagem); sem isso, a mensagem é "Exception of type '…' was thrown.". Construtores não são herdados (CS1729).
  • Dados extras vão em propriedades só de leitura; a exceção pode montar a própria mensagem a partir deles.
  • Os três construtores de costume: vazio, com mensagem e com mensagem + InnerException.
  • Uma família (LojinhaException e filhas) deixa quem chama escolher entre um problema específico e todos os do programa.
  • Crie uma exceção quando alguém vai querer um catch só para ela; para parâmetros inválidos, use as do .NET. Exceção é class, nunca record (CS8864).