Enums e Pattern Matching: Nomes para Estados e Perguntas sobre Formas

Enums e Pattern Matching: Nomes para Estados e Perguntas sobre Formas

Enums e pattern matching em C#: valores com nome, números fixos para o banco, o enum que aceita qualquer inteiro e como validar, flags combináveis, todos os padrões (relacional, lógico, de propriedade, posicional, de tupla e de lista) e o ciclo de vida do pedido numa tabela.
Linguagem C#

• • 14 min de leitura

Um pedido pode estar aberto, pago, enviado, entregue ou cancelado, e nunca "pagp" ou "7": dar nomes a um conjunto fechado de possibilidades elimina erros que nenhum teste pegaria a tempo.

Terminamos "Classe, Struct ou Record" com uma pendência: o Pedido não sabe em que etapa está. A tentação imediata é guardar isso numa string ("pago") ou num número (2). Mas nada impede que alguém escreva "Pago", "pagp" ou 7, e o compilador não tem como ajudar. Nesta aula, conhecemos os enums, que dão nomes verificados a um conjunto fechado de valores, e aprofundamos o pattern matching, que já usamos em pedaços em várias aulas e que se revela uma forma inteira de fazer perguntas sobre os dados.

Enums: um conjunto fechado de nomes

Um enum declara um tipo cujos valores possíveis têm nome:

enum StatusPedido
{
    Aberto,
    Pago,
    Enviado,
    Entregue,
    Cancelado
}

Agora StatusPedido.Pago é um valor do tipo StatusPedido, verificado pelo compilador. Um erro de digitação como StatusPedido.Pagp não compila, e o editor oferece autocompletar com as opções válidas:

StatusPedido status = StatusPedido.Aberto;

if (status == StatusPedido.Aberto)
    Console.WriteLine("Ainda dá para adicionar itens.");

Console.WriteLine(status);   // Aberto: o ToString devolve o nome

Por baixo, um enum é um inteiro com nome. O primeiro valor vale 0, e cada seguinte vale um a mais. Você pode fixar os valores explicitamente, o que é recomendado quando eles serão gravados num banco de dados ou trocados com outro sistema:

enum StatusPedido
{
    Aberto = 1,
    Pago = 2,
    Enviado = 3,
    Entregue = 4,
    Cancelado = 9
}

Fixar os números protege os dados já gravados: se alguém inserir um status novo no meio da lista, os existentes não mudam de valor.

A honestidade sobre os enums: nem tudo é verificado

Aqui está um detalhe que surpreende quem vem de linguagens com enums mais rígidos, como Rust. Como um enum do C# é só um inteiro, qualquer inteiro pode ser convertido para ele:

StatusPedido estranho = (StatusPedido)42;
Console.WriteLine(estranho);                          // 42
Console.WriteLine(Enum.IsDefined(estranho));          // False

O compilador aceita a conversão, e o programa passa a ter um status que não corresponde a nenhum nome. Isso acontece na prática quando o valor vem de fora: de um banco de dados, de um arquivo, de uma requisição HTTP. A regra é: valide enums na fronteira do sistema. Para converter texto, use Enum.TryParse, que segue o padrão Try que você já domina:

if (Enum.TryParse("enviado", ignoreCase: true, out StatusPedido lido) && Enum.IsDefined(lido))
    Console.WriteLine($"Status lido: {lido}");

Por que o IsDefined depois do TryParse? Porque Enum.TryParse("42", out ...) também "consegue", já que 42 é um número válido para o tipo subjacente. Só a combinação dos dois garante um nome legítimo. Para listar todos os valores (útil num menu, por exemplo), use Enum.GetValues<StatusPedido>().

Enums de flags: combinações de opções

Às vezes os valores não são exclusivos, mas combináveis: um livro pode estar disponível em impresso e e-book ao mesmo tempo. Para isso existem os enums marcados com [Flags], cujos valores são potências de 2, e cada um ocupa um bit diferente:

[Flags]
enum FormatosDisponiveis
{
    Nenhum = 0,
    Impresso = 1,
    Ebook = 2,
    Audiolivro = 4
}

var formatos = FormatosDisponiveis.Impresso | FormatosDisponiveis.Ebook;   // combina
Console.WriteLine(formatos);                                                // Impresso, Ebook
Console.WriteLine(formatos.HasFlag(FormatosDisponiveis.Ebook));             // True

formatos &= ~FormatosDisponiveis.Impresso;   // remove o impresso
Console.WriteLine(formatos);                 // Ebook

Se você vem do C, reconhece a técnica: são os velhos campos de bits com |, & e ~, agora com nomes, verificação de tipo e um ToString que lista as opções ativas. O atributo [Flags] (atributos entre colchetes ganham aula própria na Fase 3) é o que faz o ToString mostrar a combinação em vez de um número.

Pattern matching: o panorama completo

Desde "O Programa Que Escolhe e Repete" usamos padrões aqui e ali: relacionais no switch de descontos, de tipo em "Herança", de propriedade num exemplo rápido. Agora vamos organizar o repertório inteiro. Um padrão é uma pergunta sobre a forma de um valor, e pode aparecer depois de is, nos braços de uma expressão switch e nos case de um switch clássico.

Padrão constante: o valor é igual a uma constante?

if (status is StatusPedido.Cancelado) { /* ... */ }

Padrões relacionais e lógicos: comparações combinadas com and, or e not:

bool emTransito = status is StatusPedido.Pago or StatusPedido.Enviado;
bool faixaMedia = preco is >= 30m and < 80m;
if (cliente is not null) { /* ... */ }

Repare no is not null: é a forma idiomática de testar nulidade, preferível a != null porque não pode ser alterada por uma sobrecarga de operador.

Padrão de tipo, que você viu em "Herança":

if (produto is LivroImpresso impresso)
    Console.WriteLine(impresso.PesoGramas);

Padrão de propriedade: perguntas sobre as propriedades de um objeto, que podem ser aninhadas:

bool freteGratis = produto is Ebook or LivroImpresso { PesoGramas: < 500, Preco: > 50m };

bool itemCaro = item is { Produto.Preco: > 100m, Quantidade: >= 2 };

A segunda linha usa o padrão de propriedade estendido (C# 10): Produto.Preco navega de uma propriedade para outra sem precisar aninhar chaves.

Padrão posicional: desconstrói um record ou uma tupla e testa as partes:

string Quadrante(Coordenada c) => c switch
{
    (> 0, > 0) => "nordeste",
    (> 0, < 0) => "noroeste",
    (< 0, > 0) => "sudeste",
    (< 0, < 0) => "sudoeste",
    _ => "sobre um eixo"
};

Padrão de tupla: uma tupla é um agrupamento leve de valores, escrito entre parênteses, como (status, acao). Já usamos uma para trocar variáveis em "Guardando Muitos Valores". Combinada com o switch, ela permite decidir com base em vários valores de uma vez, e é a ferramenta perfeita para máquinas de estado, como veremos no fim da aula.

Padrões de lista (C# 11): perguntas sobre a forma de uma sequência:

string Descrever(int[] numeros) => numeros switch
{
    [] => "vazio",
    [var unico] => $"só o {unico}",
    [var primeiro, var segundo] => $"{primeiro} e {segundo}",
    [var primeiro, .., var ultimo] => $"de {primeiro} a {ultimo}"
};

O .. casa com "qualquer quantidade de elementos no meio". Padrões de lista funcionam com arrays, listas e qualquer tipo que tenha Count ou Length e um indexador.

Por fim, os braços de uma expressão switch aceitam guardas com when, para condições que nenhum padrão expressa:

string Avaliar(Pedido p) => p switch
{
    { Status: StatusPedido.Cancelado } => "cancelado",
    { Total: > 500m } when p.Itens.Count == 1 => "um único item caro",
    { Total: > 500m } => "pedido grande",
    _ => "pedido comum"
};

Um aviso sobre exaustividade com enums: um switch que cobre todos os nomes de um enum, mas não tem o braço _, recebe o aviso CS8524. O compilador lembra que, como vimos, (StatusPedido)42 é possível, e esse valor não teria braço. Leve o aviso a sério: um braço _ => throw new ArgumentOutOfRangeException(...) documenta que valores fora da lista são um erro.

Juntando tudo: o ciclo de vida do pedido

O Pedido ganha um status e as regras de transição. A pergunta "posso ir do estado A para o B?" é naturalmente um par de valores, e um switch de tupla responde em uma tabela legível:

using static StatusPedido;   // permite escrever Aberto em vez de StatusPedido.Aberto

sealed class Pedido
{
    private static int _proximoNumero = 1000;
    private readonly List<ItemPedido> _itens = [];

    public Pedido() => Numero = ++_proximoNumero;

    public int Numero { get; }
    public StatusPedido Status { get; private set; } = Aberto;
    public IReadOnlyList<ItemPedido> Itens => _itens.AsReadOnly();

    public decimal Subtotal
    {
        get
        {
            decimal soma = 0;
            foreach (var item in _itens) soma += item.Subtotal;
            return soma;
        }
    }

    public void Adicionar(Produto produto, int quantidade)
    {
        if (Status is not Aberto)
            throw new InvalidOperationException($"O pedido {Numero} está {Status} e não aceita itens.");
        _itens.Add(new ItemPedido(produto, quantidade));
    }

    public void Pagar()
    {
        if (_itens is [])
            throw new InvalidOperationException("Não é possível pagar um pedido vazio.");
        MudarPara(Pago);
    }

    public void Enviar() => MudarPara(Enviado);
    public void ConfirmarEntrega() => MudarPara(Entregue);
    public void Cancelar() => MudarPara(Cancelado);

    private void MudarPara(StatusPedido novo)
    {
        if (!TransicaoPermitida(Status, novo))
            throw new InvalidOperationException(
                $"O pedido {Numero} não pode passar de {Status} para {novo}.");
        Status = novo;
    }

    private static bool TransicaoPermitida(StatusPedido de, StatusPedido para) => (de, para) switch
    {
        (Aberto, Pago)       => true,
        (Aberto, Cancelado)  => true,
        (Pago, Enviado)      => true,
        (Pago, Cancelado)    => true,
        (Enviado, Entregue)  => true,
        _                    => false
    };

    public string Resumo() => _itens switch
    {
        [] => $"Pedido {Numero} ({Status}): vazio",
        [var unico] => $"Pedido {Numero} ({Status}): {unico.Quantidade} x {unico.Produto.Titulo}",
        [var primeiro, ..] =>
            $"Pedido {Numero} ({Status}): {primeiro.Produto.Titulo} e mais {_itens.Count - 1} item(ns)"
    };
}

A diretiva using static StatusPedido; vai no topo do arquivo e permite usar os nomes do enum sem o prefixo, deixando a tabela de transições limpa. O método TransicaoPermitida é a especificação do negócio: quem ler aquelas cinco linhas entende o ciclo de vida inteiro do pedido, e qualquer transição fora da tabela (reabrir um pedido entregue, enviar um cancelado) é recusada pelo braço _.

Em Resumo, o padrão de lista distingue vazio, um item e vários itens. No último braço, usamos [var primeiro, ..] e calculamos o restante com _itens.Count - 1. Também daria para capturar o restante com [var primeiro, .. var resto]: o recorte por intervalo, que os arrays aceitam desde "Guardando Muitos Valores", vale para a List<T> desde o .NET 8, que ganhou o método Slice. Mas o recorte cria uma lista nova a cada chamada, e aqui só queremos contar os itens. Para isso, _itens.Count - 1 não copia nada.

Usando o pedido:

var dom = new LivroImpresso("Dom Casmurro", "Machado de Assis", 59.90m, pesoGramas: 350, estoque: 10);

var pedido = new Pedido();
pedido.Adicionar(dom, 2);
pedido.Pagar();
pedido.Enviar();
Console.WriteLine(pedido.Resumo());   // Pedido 1001 (Enviado): 2 x Dom Casmurro

try
{
    pedido.Cancelar();                 // Enviado → Cancelado não está na tabela
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);     // O pedido 1001 não pode passar de Enviado para Cancelado.
}

Nenhuma string mágica, nenhum número solto e nenhum estado impossível. A combinação de enum com tupla e pattern matching transformou uma regra de negócio, que costuma se espalhar em ifs por todo o sistema, numa tabela de cinco linhas num único método.

A Livraria tem agora um modelo de domínio de verdade: produtos numa hierarquia, políticas de desconto intercambiáveis, objetos de valor, itens como records e um pedido com ciclo de vida verificado. Tudo isso, porém, mora num único projeto de console, misturado com o código de interface. Quando chegarmos à API e ao site, eles vão precisar exatamente destas mesmas classes, e copiá-las seria o erro de sempre. Organizar o código em projetos separados, com nomes que evitam conflitos e dependências declaradas, e aproveitar bibliotecas escritas por outras pessoas, é o que fecha esta fase.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Declare um enum DiaDaSemana e escreva um método EhFimDeSemana(DiaDaSemana dia) usando um padrão lógico com or.

Ver resposta

✓ Resposta:

static bool EhFimDeSemana(DiaDaSemana dia) => dia is DiaDaSemana.Sabado or DiaDaSemana.Domingo;

enum DiaDaSemana { Domingo, Segunda, Terca, Quarta, Quinta, Sexta, Sabado }

O padrão is A or B é equivalente a dia == A || dia == B, mas se lê como a própria frase e não repete a variável. Os nomes do enum não levam acento (Terca, Sabado). Identificadores com acento são permitidos em C#, mas a convenção quase universal é evitá-los, para não criar atrito com teclados, ferramentas e sistemas que os tratam mal. Vale saber que o .NET já tem um enum pronto, System.DayOfWeek, com os dias em inglês. Em código de produção, prefira-o a reinventar.

Exercício 2

Preveja a saída e explique cada linha:

var s = (StatusPedido)42;
Console.WriteLine(s);
Console.WriteLine(Enum.IsDefined(s));
Console.WriteLine(Enum.TryParse("42", out StatusPedido t));
Ver resposta

✓ Resposta: Imprime 42, False e True. Como um enum é um inteiro por baixo, a conversão explícita (StatusPedido)42 é aceita mesmo sem nome correspondente, e o ToString mostra o número por não ter um nome a exibir. Enum.IsDefined confirma que 42 não é um valor declarado. E Enum.TryParse("42", ...) devolve True: ele aceita tanto nomes quanto números, e 42 é um número válido para um int. É por isso que, ao ler enums de fontes externas, é preciso combinar TryParse com IsDefined. A primeira só garante que o texto virou um valor do tipo, e a segunda garante que esse valor tem nome.

Exercício 3

Usando o enum FormatosDisponiveis da aula, comece com um livro disponível só em impresso, acrescente e-book e audiolivro, remova o impresso e, no fim, verifique se ele ainda está disponível em algum formato digital.

Ver resposta

✓ Resposta:

var formatos = FormatosDisponiveis.Impresso;
formatos |= FormatosDisponiveis.Ebook | FormatosDisponiveis.Audiolivro;
formatos &= ~FormatosDisponiveis.Impresso;

var digitais = FormatosDisponiveis.Ebook | FormatosDisponiveis.Audiolivro;
bool temDigital = (formatos & digitais) != FormatosDisponiveis.Nenhum;

Console.WriteLine(formatos);     // Ebook, Audiolivro
Console.WriteLine(temDigital);   // True

|= liga bits, &= ~X desliga o bit de X (o ~ inverte todos os bits de X, e o & mantém tudo menos ele). O teste final usa & com a máscara dos formatos digitais: se sobrar algum bit ligado, há pelo menos um formato digital. Repare que HasFlag(digitais) responderia outra pergunta, "tem todos os formatos digitais?", e por isso a interseção com & é a forma certa para "tem algum". Declarar Nenhum = 0 é uma recomendação oficial para enums de flags: dá nome ao "nada ligado" e torna comparações como esta legíveis.

Exercício 4

Escreva um método que diga se um Produto tem frete grátis usando só padrões: e-books e audiolivros sempre têm; livros impressos têm se pesarem menos de 500 g e custarem mais de R$ 50; os demais, não.

Ver resposta

✓ Resposta:

static bool TemFreteGratis(Produto produto) => produto is
    Ebook or
    Audiolivro or
    LivroImpresso { PesoGramas: < 500, Preco: > 50m };

A regra inteira cabe numa expressão que se lê quase como o enunciado. LivroImpresso { PesoGramas: < 500, Preco: > 50m } combina o padrão de tipo com o de propriedade, e as vírgulas dentro das chaves significam "e". Os três padrões unidos por or formam a regra completa. Um alerta de desenho que já apareceu em "Herança": se esta regra for usada em muitos lugares, talvez ela devesse virar um método virtual TemFreteGratis() em Produto. Padrões de tipo são ótimos em regras de negócio pontuais, que atravessam a hierarquia, e em pontos de exibição, mas não devem substituir o polimorfismo.

Exercício 5

Escreva um método que use padrões de lista para descrever um array de inteiros como: "vazio", "um elemento: X", "começa e termina com X" (quando o primeiro e o último são iguais e há pelo menos dois elementos) ou "de X a Y".

Ver resposta

✓ Resposta:

static string Descrever(int[] v) => v switch
{
    [] => "vazio",
    [var x] => $"um elemento: {x}",
    [var primeiro, .., var ultimo] when primeiro == ultimo => $"começa e termina com {primeiro}",
    [var primeiro, .., var ultimo] => $"de {primeiro} a {ultimo}"
};

Console.WriteLine(Descrever([]));             // vazio
Console.WriteLine(Descrever([7]));            // um elemento: 7
Console.WriteLine(Descrever([3, 8, 1, 3]));   // começa e termina com 3
Console.WriteLine(Descrever([2, 4, 6]));      // de 2 a 6

O padrão [var primeiro, .., var ultimo] casa com qualquer array de dois ou mais elementos. O .. aceita zero elementos no meio, por isso [5, 5] também cai no terceiro braço. A condição "primeiro igual ao último" não é expressável por um padrão, porque compara duas partes entre si, e por isso usa uma guarda when. A ordem dos braços importa: o braço com guarda precisa vir antes do genérico, senão nunca seria alcançado. Note também que o switch é exaustivo sem _: vazio, um elemento e dois ou mais cobrem todos os tamanhos possíveis, e o compilador sabe disso.

Comentários

Mais em Linguagem C#

Assincronia Avançada: Muitas Tarefas, Cancelamento e Fluxos Assíncronos
Assincronia Avançada: Muitas Tarefas, Cancelamento e Fluxos Assíncronos

Assincronia avançada no C#: Task.WhenAll e as exceções múltiplas, WaitAsync e…

Da Rivalidade com o Java ao Código Aberto: a História do C#
Da Rivalidade com o Java ao Código Aberto: a História do C#

A história do C#: Anders Hejlsberg, do Turbo Pascal ao Delphi, o processo da…

Reflexão e Atributos: Quando o Programa Examina a Si Mesmo
Reflexão e Atributos: Quando o Programa Examina a Si Mesmo

Reflexão e atributos em C#: o objeto Type, ler propriedades e invocar métodos…