Hierarquias Fechadas — Sealed Classes e Pattern Matching

Hierarquias Fechadas — Sealed Classes e Pattern Matching

Sealed classes e pattern matching em Java: permits, final, sealed e non-sealed, o switch exaustivo sobre tipos, record patterns com guardas, padrões aninhados e o padrão sem nome, resultados de operação como tipos, case null e o problema da expressão.
Java

• • 18 min de leitura

Como declarar que um tipo tem um conjunto fixo e conhecido de subtipos, e como o compilador usa isso para verificar que cada caso foi tratado.

"Dados sem Cerimônia" terminou com uma pergunta: os enums garantem ao compilador que ele conhece todos os valores possíveis de um tipo, e isso permite um switch sem default e com verificação de exaustividade. Seria possível ter a mesma garantia para tipos, cada um com dados próprios? Um círculo tem raio, um retângulo tem largura e altura, e queremos que o compilador saiba que não existem outras formas. Esta aula apresenta as sealed classes e o pattern matching no switch, que juntos formam uma das mudanças mais importantes do Java moderno. Com eles, fecha-se a Fase 2.

sealed: uma hierarquia com lista de convidados

Uma classe ou interface sealed declara exatamente quais tipos podem estendê-la ou implementá-la:

sealed interface Forma permits Circulo, Retangulo, Triangulo { }

record Circulo(double raio) implements Forma { }
record Retangulo(double largura, double altura) implements Forma { }
record Triangulo(double base, double altura) implements Forma { }

A cláusula permits é a lista de convidados. Qualquer outro tipo que tente implementar Forma é recusado:

Fora.java:3: error: class is not allowed to extend sealed class: Forma (as it is not listed in its 'permits' clause)
record Hexagono(double lado) implements Forma { }
^

Compare com o que vimos até aqui. Uma interface comum, como o Desconto de "Um Tipo, Várias Formas", é aberta: qualquer um pode implementá-la, a qualquer momento, em qualquer lugar. Isso é ótimo para pontos de extensão. Mas há tipos cuja natureza é ser fechada: as formas que um programa de desenho suporta, os resultados possíveis de uma operação, os tipos de nó de uma expressão matemática. Para eles, a abertura não é uma virtude. É uma fonte de casos esquecidos.

Os exemplos desta aula estão em arquivos compactos, com todos os tipos no mesmo arquivo, o que é permitido. Num projeto com pacotes, os tipos permitidos precisam estar no mesmo pacote (ou no mesmo módulo) que o tipo selado. Quando estão todos no mesmo arquivo, a cláusula permits pode até ser omitida, e o compilador deduz a lista.

As subclasses precisam declarar o que são

Quando a hierarquia usa classes, cada subclasse permitida precisa declarar como ela mesma continua a hierarquia, com um de três modificadores:

sealed abstract class Veiculo permits Carro, Moto, Caminhao { }

final class Carro extends Veiculo { }                       // fim da linha
sealed class Caminhao extends Veiculo permits Carreta { }   // fechada de novo
final class Carreta extends Caminhao { }
non-sealed class Moto extends Veiculo { }                   // reaberta
class Scooter extends Moto { }                              // permitido: Moto é aberta
  • final: ninguém mais estende. Os records já são final automaticamente, por isso não precisaram de modificador.
  • sealed: a subclasse também tem uma lista fechada.
  • non-sealed: a partir dali, a hierarquia volta a ser aberta. É uma escolha deliberada e visível, e não um esquecimento.

Omitir o modificador é erro: sealed, non-sealed or final modifiers expected. O Java obriga você a pensar em cada ramo.

switch com padrões de tipo

A razão de fechar uma hierarquia aparece no switch. Desde o Java 21, ele aceita padrões de tipo nos casos:

double area(Forma forma) {
    return switch (forma) {
        case Circulo c -> Math.PI * c.raio() * c.raio();
        case Retangulo r -> r.largura() * r.altura();
        case Triangulo t -> t.base() * t.altura() / 2;
    };
}

Cada caso testa o tipo do objeto e, se o teste passar, cria uma variável já com o tipo certo. É o instanceof com padrão de "Um Tipo, Várias Formas", organizado num switch. E, como Forma é selada, o compilador sabe que esses três casos cobrem todas as possibilidades: não há default, e não precisa haver. Se um caso for esquecido:

Faltando.java:6: error: the switch expression does not cover all possible input values
    return switch (forma) {
           ^

É a mesma garantia dos enums de "Dados sem Cerimônia", agora para tipos. Se amanhã Hexagono for acrescentado à lista permits, todo switch sobre Forma sem default deixa de compilar, e cada um aponta um lugar que precisa decidir o que fazer com o hexágono.

Record patterns: desmontando o objeto

Records são "portadores transparentes de dados": o Java sabe exatamente quais são os componentes deles. Isso permite um padrão ainda mais expressivo, que testa o tipo e extrai os componentes de uma vez, o record pattern:

String classificar(Forma forma) {
    return switch (forma) {
        case Circulo(double raio) when raio == 0 -> "um ponto";
        case Circulo(double raio) -> "círculo de raio " + raio;
        case Retangulo(double l, double a) when l == a -> "quadrado de lado " + l;
        case Retangulo(double l, double a) -> "retângulo " + l + " x " + a;
        case Triangulo(var base, var altura) -> "triângulo de base " + base;
    };
}
Forma[] formas = {new Circulo(1), new Retangulo(3, 4), new Triangulo(6, 2),
                  new Retangulo(5, 5), new Circulo(0)};
for (Forma f : formas) {
    IO.println("%-30s área %.2f".formatted(classificar(f), area(f)));
}
círculo de raio 1.0            área 3.14
retângulo 3.0 x 4.0            área 12.00
triângulo de base 6.0          área 6.00
quadrado de lado 5.0           área 25.00
um ponto                       área 0.00

Vários recursos aparecem juntos:

  • Retangulo(double l, double a) desmonta o retângulo nos seus componentes, com os nomes que você escolher. Não é preciso chamar r.largura().
  • var dentro do padrão deixa o compilador deduzir o tipo do componente.
  • when acrescenta uma guarda, uma condição extra. O caso só se aplica se o padrão casar e a guarda for verdadeira.
  • A ordem importa. Os casos são testados de cima para baixo. O caso com guarda precisa vir antes do caso geral do mesmo tipo, senão nunca seria alcançado. Quando um caso torna outro inalcançável, o compilador recusa: this case label is dominated by a preceding case label.

A exaustividade continua valendo: os casos com guarda não contam para a cobertura, porque a guarda pode ser falsa. Por isso cada tipo precisa de um caso sem guarda (ou coberto de outra forma).

Padrões aninhados e o padrão sem nome

Os padrões se encaixam uns dentro dos outros, e é aí que o recurso mostra toda a sua força. Considere uma pequena linguagem de expressões aritméticas, em que uma expressão é um número, uma soma, uma multiplicação ou uma negação de outras expressões:

sealed interface Expr permits Num, Soma, Mult, Neg { }

record Num(double valor) implements Expr { }
record Soma(Expr esquerda, Expr direita) implements Expr { }
record Mult(Expr esquerda, Expr direita) implements Expr { }
record Neg(Expr operando) implements Expr { }

É uma estrutura recursiva: uma soma contém duas expressões, que podem ser outras somas. Avaliar uma expressão é uma recursão natural, como as de "Dividir para Resolver":

double avaliar(Expr e) {
    return switch (e) {
        case Num(double v) -> v;
        case Soma(Expr a, Expr b) -> avaliar(a) + avaliar(b);
        case Mult(Expr a, Expr b) -> avaliar(a) * avaliar(b);
        case Neg(Expr a) -> -avaliar(a);
    };
}

A definição do código se parece com a definição matemática: o valor de uma soma é a soma dos valores das partes. Agora, uma operação que olha mais fundo, a simplificação algébrica:

Expr simplificar(Expr e) {
    return switch (e) {
        case Mult(Num(double um), Expr outro) when um == 1 -> simplificar(outro);
        case Mult(Expr outro, Num(double um)) when um == 1 -> simplificar(outro);
        case Mult(Num(double zero), Expr _) when zero == 0 -> new Num(0);
        case Neg(Neg(Expr dentro)) -> simplificar(dentro);
        case Soma(Expr a, Expr b) -> new Soma(simplificar(a), simplificar(b));
        case Mult(Expr a, Expr b) -> new Mult(simplificar(a), simplificar(b));
        case Neg(Expr a) -> new Neg(simplificar(a));
        case Num n -> n;
    };
}

O caso Mult(Num(double um), Expr outro) when um == 1 se lê quase como a regra algébrica: "uma multiplicação cujo lado esquerdo é o número 1 é igual ao outro lado". Neg(Neg(Expr dentro)) reconhece uma dupla negação em qualquer profundidade da árvore. E o _ em Expr _ é o padrão sem nome, finalizado no Java 22: ele casa com qualquer coisa e declara que o valor não interessa, porque zero vezes qualquer coisa é zero.

Expr f = new Soma(new Mult(new Num(1), new Num(7)), new Neg(new Neg(new Num(5))));
IO.println(mostrar(f) + "  ->  " + mostrar(simplificar(f)) + " = " + avaliar(f));
(1.0 * 7.0 + --5.0)  ->  (7.0 + 5.0) = 12.0

(O método mostrar, que converte a expressão em texto, segue o mesmo modelo de avaliar, com um caso por tipo.) Escrever simplificar com instanceof e casts, ou com polimorfismo tradicional, exigiria muito mais código e seria bem mais difícil de ler. Compiladores, interpretadores e ferramentas que analisam código são construídos exatamente sobre estruturas assim.

Resultados como tipos

Um uso muito prático das hierarquias fechadas: representar os resultados possíveis de uma operação. O sacar de "O que Fica Dentro, o que Fica Fora" devolvia um boolean. Um false dizia que o saque falhou, mas não por quê: saldo insuficiente? Conta bloqueada? E quanto faltou? Com um tipo selado, cada resultado carrega os próprios dados:

sealed interface ResultadoSaque permits Sucesso, SaldoInsuficiente, ContaBloqueada { }

record Sucesso(double novoSaldo) implements ResultadoSaque { }

record SaldoInsuficiente(double saldo, double solicitado) implements ResultadoSaque {
    double faltando() {
        return solicitado - saldo;
    }
}

record ContaBloqueada(String motivo) implements ResultadoSaque { }
ResultadoSaque sacar(double saldo, boolean bloqueada, double valor) {
    if (bloqueada) {
        return new ContaBloqueada("suspeita de fraude");
    }
    if (valor > saldo) {
        return new SaldoInsuficiente(saldo, valor);
    }
    return new Sucesso(saldo - valor);
}

String mensagem(ResultadoSaque resultado) {
    return switch (resultado) {
        case Sucesso(double novoSaldo) -> "Saque realizado. Saldo: %.2f".formatted(novoSaldo);
        case SaldoInsuficiente s -> "Faltam %.2f para este saque.".formatted(s.faltando());
        case ContaBloqueada(String motivo) -> "Conta bloqueada: " + motivo;
    };
}
Saque realizado. Saldo: 300.00
Faltam 300.00 para este saque.
Conta bloqueada: suspeita de fraude

Quem chama sacar recebe um valor que obriga a considerar cada resultado possível, e o compilador verifica que nenhum foi esquecido. Um boolean pode ser ignorado sem que ninguém perceba. Um ResultadoSaque num switch não pode ter casos esquecidos. Essa técnica é uma alternativa às exceções para falhas esperadas, que fazem parte do funcionamento normal do sistema. A escolha entre as duas é discutida em "Quando o Contrato se Quebra".

Essa combinação de records (um tipo formado por vários valores juntos) com sealed (um tipo que é uma de várias alternativas) tem nome na teoria das linguagens: tipos algébricos de dados. Linguagens como Haskell, OCaml, Rust e Scala os usam há muito tempo, e o Java os incorporou entre as versões 16 e 21.

switch de padrões sobre qualquer tipo

O switch com padrões funciona com qualquer tipo de referência, e não só com hierarquias seladas. Quando o tipo é aberto, como Object, o default volta a ser obrigatório, porque o compilador não tem como conhecer todos os casos:

String descrever(Object o) {
    return switch (o) {
        case null -> "nulo";
        case Integer i when i > 100 -> "inteiro grande: " + i;
        case Integer i -> "inteiro: " + i;
        case String s when s.isBlank() -> "texto vazio";
        case String s -> "texto de " + s.length() + " letras";
        case int[] arr -> "array com " + arr.length + " inteiros";
        default -> "outra coisa: " + o.getClass().getSimpleName();
    };
}

Repare no case null. Tradicionalmente, um switch sobre null lança NullPointerException imediatamente, e isso continua valendo quando não há um case null. Com ele, o null passa a ser um caso como outro qualquer. É uma escolha explícita: se o null é um valor esperado ali, trate-o. Se não é, a exceção avisa que algo deu errado antes.

Existe ainda uma extensão em preview no Java 25, que permite padrões com tipos primitivos (case int i when i > 0). Por estar em preview, ela exige uma opção especial de compilação e ainda pode mudar, e por isso fica fora deste curso até ser finalizada.

Polimorfismo ou pattern matching?

Esta aula parece contradizer "Um Tipo, Várias Formas", que ensinou a trocar cadeias de instanceof e switch por métodos polimórficos. Não há contradição: são duas ferramentas para dois tipos de mudança. É um dilema conhecido como problema da expressão (expression problem):

  • Com polimorfismo, o comportamento fica nos tipos. Acrescentar um tipo é fácil: uma classe nova, e nada mais muda. Acrescentar uma operação é difícil: é preciso incluir um método em todas as classes da hierarquia.
  • Com pattern matching sobre tipos selados, o comportamento fica nas operações. Acrescentar uma operação é fácil: um método novo com um switch, e nenhum tipo muda. Acrescentar um tipo é difícil: é preciso alterar todos os switch. Mas o compilador aponta cada um deles.

A pergunta de projeto é: o que vai mudar com mais frequência, os tipos ou as operações? Os descontos de uma loja mudam todo mês, com novas promoções, mas as operações sobre um desconto são sempre as mesmas, aplicar e descrever. Polimorfismo. As formas de uma expressão aritmética quase nunca mudam, mas as operações sobre elas se multiplicam: avaliar, simplificar, mostrar, derivar, otimizar. Pattern matching. Há também uma questão de quem controla o código. Se terceiros vão criar tipos novos, como plugins, a hierarquia precisa ser aberta. Se o conjunto de tipos é uma decisão do seu próprio sistema, fechá-la dá garantias que a abertura não dá.

Com esta aula termina a segunda fase do curso, e ela foi a mais densa até aqui. Você aprendeu a criar os próprios tipos com classes, a construí-los válidos e protegê-los com encapsulamento, a derivar tipos uns dos outros com herança, a separar contratos de implementações com interfaces e classes abstratas, e a aproveitar tudo isso com polimorfismo. Entendeu a diferença entre identidade e igualdade, e o contrato do equals e do hashCode. Conheceu os recursos modernos que reduzem a cerimônia: records para valores, enums para conjuntos fixos, e sealed types com pattern matching para hierarquias fechadas. Esse é o vocabulário com que todo o resto do Java é escrito.

Um detalhe ficou em aberto várias vezes nesta fase. No HashSet<Ponto> de "Iguais ou o Mesmo?", no EnumMap<Dia, Integer> de "Dados sem Cerimônia", os sinais de menor e maior apareceram com a promessa de uma explicação futura. Eles marcam os genéricos, o recurso que permite escrever uma classe ou um método uma única vez e usá-lo com qualquer tipo, sem perder a verificação do compilador.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Acrescente um Hexagono(double lado) à hierarquia Forma da aula. Liste tudo o que precisa mudar e explique como o compilador ajuda. A área de um hexágono regular é (3√3 / 2) × lado².

Ver resposta

✓ Resposta: Primeiro, declarar record Hexagono(double lado) implements Forma { } e acrescentá-lo à cláusula permits de Forma. Sem isso, a declaração do record é recusada, por não estar na lista de convidados. Assim que a lista muda, os switch de area e de classificar deixam de compilar com "does not cover all possible input values", e é preciso acrescentar um caso a cada um. Em area: case Hexagono(double lado) -> 3 * Math.sqrt(3) / 2 * lado * lado;. Em classificar: case Hexagono(double lado) -> "hexágono de lado " + lado;. Nenhum lugar pode ser esquecido, porque cada switch sem default sobre Forma vira um erro de compilação até ser atualizado. Se algum deles tivesse um default, compilaria, e o hexágono cairia no caso genérico sem que ninguém percebesse.

Exercício 2

Escreva um método Forma escalar(Forma forma, double fator) que devolva uma forma nova com todas as dimensões multiplicadas pelo fator, usando record patterns.

Ver resposta

✓ Resposta:

Forma escalar(Forma forma, double fator) {
    return switch (forma) {
        case Circulo(double raio) -> new Circulo(raio * fator);
        case Retangulo(double l, double a) -> new Retangulo(l * fator, a * fator);
        case Triangulo(double b, double a) -> new Triangulo(b * fator, a * fator);
    };
}

escalar(new Retangulo(3, 4), 2) devolve Retangulo[largura=6.0, altura=8.0]. Cada caso desmonta a forma, calcula as dimensões novas e monta uma forma nova do mesmo tipo, porque os records são imutáveis. O método é uma operação nova sobre a hierarquia, acrescentada sem tocar em nenhum dos records, o lado fácil do problema da expressão. Com o polimorfismo tradicional, seria preciso acrescentar um método escalar a cada uma das classes.

Exercício 3

No método classificar, o que aconteceria se o caso case Circulo(double raio) -> ... viesse antes de case Circulo(double raio) when raio == 0 -> ...? E se o caso com guarda fosse o único caso para Circulo?

Ver resposta

✓ Resposta: Na primeira situação, o caso sem guarda casa com todo círculo, e o caso com guarda, depois dele, nunca seria alcançado. O compilador detecta isso e recusa com "this case label is dominated by a preceding case label". Essa regra de dominância evita código morto escrito por engano. Na segunda situação, sem um caso incondicional para Circulo, os círculos de raio diferente de zero não seriam cobertos por nenhum caso. Como as guardas não contam para a exaustividade, porque podem ser falsas, o compilador recusa o switch com "does not cover all possible input values". A regra prática: casos específicos, com guarda, primeiro, e o caso geral de cada tipo por último.

Exercício 4

Acrescente a subtração à linguagem de expressões com um record Sub(Expr esquerda, Expr direita). Quais métodos precisam mudar? Acrescente também a simplificar a regra de que x - 0 é igual a x.

Ver resposta

✓ Resposta: Declara-se record Sub(Expr esquerda, Expr direita) implements Expr { } e acrescenta-se Sub à cláusula permits. Os três métodos param de compilar e precisam de um caso novo. Em avaliar: case Sub(Expr a, Expr b) -> avaliar(a) - avaliar(b);. Em mostrar: case Sub(Expr a, Expr b) -> "(" + mostrar(a) + " - " + mostrar(b) + ")";. Em simplificar, dois casos, na ordem certa:

case Sub(Expr a, Num(double zero)) when zero == 0 -> simplificar(a);
case Sub(Expr a, Expr b) -> new Sub(simplificar(a), simplificar(b));

O caso específico, com a guarda, vem antes do geral, pela regra de dominância do exercício anterior. Aqui está o lado difícil do problema da expressão: um tipo novo exigiu mexer em todas as operações. Mas o compilador listou cada uma delas, e é impossível entregar a subtração pela metade.

Exercício 5

Para cada situação, diga se usaria uma hierarquia aberta com polimorfismo ou uma hierarquia selada com pattern matching, e justifique: (a) os meios de pagamento de uma loja, que ganha um novo a cada semestre (pix, cartão, boleto, carteira digital...), cada um com a mesma interface de cobrança; (b) os eventos de um jogo (colisão, pontuação, fim de fase, pausa), tratados de formas diferentes por vários sistemas (som, placar, gravação, rede); (c) os plugins de exportação de um editor de texto, escritos por terceiros.

Ver resposta

✓ Resposta: (a) Hierarquia aberta com polimorfismo. Os tipos mudam com frequência e as operações são estáveis (cobrar, estornar, descrever). Um meio novo é uma classe nova que implementa a interface, e nada mais muda. (b) Hierarquia selada com pattern matching. O conjunto de eventos é uma decisão do próprio jogo e muda pouco, mas as operações sobre eventos se multiplicam: cada sistema tem um switch que decide o que fazer com cada evento, e um sistema novo é só mais um método, sem tocar nos eventos. Se um evento for acrescentado, o compilador aponta cada sistema que precisa tratá-lo. (c) Hierarquia aberta, obrigatoriamente. Se terceiros criam os tipos, você não pode listá-los numa cláusula permits, e selar a hierarquia impediria a própria existência dos plugins. A decisão combina duas perguntas: o que muda com mais frequência, tipos ou operações, e quem controla a criação de tipos novos.

Comentários

Mais em Java

Recursos que se Fecham Sozinhos — try-with-resources, Arquivos e NIO.2
Recursos que se Fecham Sozinhos — try-with-resources, Arquivos e NIO.2

Como ler e gravar arquivos com a API NIO.2 do Java (Path e Files), tratar a…

Extensão — Janelas em Java: AWT, Swing e JavaFX
Extensão — Janelas em Java: AWT, Swing e JavaFX

Nesta aula você conhece a história das interfaces gráficas em Java, do AWT de…

Extensão — PostgreSQL: o Banco Servidor, JSONB e Arrays
Extensão — PostgreSQL: o Banco Servidor, JSONB e Arrays

O projeto das Trilhas da Ilha passa para o PostgreSQL, instalado na máquina ou…