Java Moderno: Elevando o Nível com Imutabilidade, Switch Expressions e Pattern Matching
Introdução
Este artigo fecha um ciclo. Ao longo da série Objects · Immutability · Switch · Pattern Matching, cada tema preparou o terreno para o próximo: começamos nos contratos fundamentais de Object, passamos pela imutabilidade, modernizamos o switch e mergulhamos no Pattern Matching até chegar no ponto onde tudo converge, a exaustividade verificada pelo compilador.
Aqui você encontra as peças conectadas. E o fio condutor é um só: erros que antes explodiam em produção agora nem compilam.
Records, Record Patterns e Sealed/Hidden Classes aparecem de passagem neste artigo porque cada um merece (e vai ganhar) um aprofundamento próprio em artigos dedicados.
1. Objects: os Contratos que Sustentam Tudo
O pitfall clássico: == vs equals()
Todo desenvolvedor Java já caiu ou vai cair nessa armadilha. O operador == compara referências (os dois lados apontam para o mesmo objeto?), enquanto equals() compara conteúdo. O perigo é que às vezes == parece funcionar:
<span class="hljs-type">Integer</span> <span class="hljs-variable">a</span> <span class="hljs-operator">=</span> <span class="hljs-number">127</span>;
<span class="hljs-type">Integer</span> <span class="hljs-variable">b</span> <span class="hljs-operator">=</span> <span class="hljs-number">127</span>;
System.out.println(a == b); <span class="hljs-comment">// true (cache de Integer entre -128 e 127)</span>
<span class="hljs-type">Integer</span> <span class="hljs-variable">c</span> <span class="hljs-operator">=</span> <span class="hljs-number">128</span>;
<span class="hljs-type">Integer</span> <span class="hljs-variable">d</span> <span class="hljs-operator">=</span> <span class="hljs-number">128</span>;
System.out.println(c == d); <span class="hljs-comment">// false (fora do cache, objetos distintos)</span>
<span class="hljs-type">String</span> <span class="hljs-variable">x</span> <span class="hljs-operator">=</span> <span class="hljs-string">"java"</span>;
<span class="hljs-type">String</span> <span class="hljs-variable">y</span> <span class="hljs-operator">=</span> <span class="hljs-string">"java"</span>;
System.out.println(x == y); <span class="hljs-comment">// true (String Constant Pool)</span>
<span class="hljs-type">String</span> <span class="hljs-variable">z</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">String</span>(<span class="hljs-string">"java"</span>);
System.out.println(x == z); <span class="hljs-comment">// false (instância fora do pool)</span>
O código que "funciona" com == graças ao cache de Integer ou ao String Constant Pool é uma bomba-relógio: basta o valor sair da faixa do cache ou a String vir de fora do pool (input do usuário, banco, API) para o comportamento mudar. A regra é simples: para conteúdo, sempre equals().
O novo equals() com instanceof e Flow Scoping
E por falar em equals(), o Pattern Matching aposentou o ritual clássico de implementá-lo. Compare:
<span class="hljs-comment">// ✗ O ritual antigo</span>
<span class="hljs-meta">@Override</span>
<span class="hljs-keyword">public</span> <span class="hljs-type">boolean</span> <span class="hljs-title function_">equals</span><span class="hljs-params">(Object obj)</span> {
<span class="hljs-keyword">if</span> (<span class="hljs-built_in">this</span> == obj) <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>;
<span class="hljs-keyword">if</span> (obj == <span class="hljs-literal">null</span> || getClass() != obj.getClass()) <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;
<span class="hljs-type">Produto</span> <span class="hljs-variable">other</span> <span class="hljs-operator">=</span> (Produto) obj;
<span class="hljs-keyword">return</span> nome.equals(other.nome) && preco.equals(other.preco);
}
<span class="hljs-comment">// ✓ Com Type Pattern (JDK 16+)</span>
<span class="hljs-meta">@Override</span>
<span class="hljs-keyword">public</span> <span class="hljs-type">boolean</span> <span class="hljs-title function_">equals</span><span class="hljs-params">(Object obj)</span> {
<span class="hljs-keyword">return</span> obj <span class="hljs-keyword">instanceof</span> Produto p
&& nome.equals(p.nome)
&& preco.equals(p.preco);
}
Três detalhes fazem a versão nova brilhar:
- Null-safety de graça:
null instanceof Produtoretornafalse, então a checagem explícita de nulo desaparece - Sem cast manual: a binding variable
pjá nasce com o tipo certo - Flow scoping:
psó existe onde o compilador prova que o teste passou. No&&seguinte, o acesso ap.nomeé garantidamente seguro
O flow scoping é mais esperto do que parece. Ele funciona inclusive em negações:
<span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">processar</span><span class="hljs-params">(Object obj)</span> {
<span class="hljs-keyword">if</span> (!(obj <span class="hljs-keyword">instanceof</span> String s)) {
<span class="hljs-keyword">return</span>; <span class="hljs-comment">// se não é String, sai</span>
}
<span class="hljs-comment">// s disponível aqui: o compilador sabe que só se chega</span>
<span class="hljs-comment">// neste ponto se o instanceof foi verdadeiro</span>
System.out.println(s.toUpperCase());
}
Receiver Parameter: o this explícito que quase ninguém conhece
Fechando o bloco de Objects, um recurso obscuro que rendeu boas discussões na série: desde o Java 8, métodos de instância podem declarar o próprio this como primeiro parâmetro, o chamado receiver parameter:
<span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">Conta</span> {
<span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">depositar</span><span class="hljs-params">(Conta <span class="hljs-built_in">this</span>, BigDecimal valor)</span> {
<span class="hljs-comment">// comportamento idêntico ao método sem o receiver parameter</span>
}
}
Ele não muda a semântica nem a assinatura em bytecode. Existe por um único motivo: permitir anotações de tipo sobre o this, algo útil para frameworks de análise estática como o Checker Framework:
<span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">encerrar</span><span class="hljs-params">(<span class="hljs-meta">@Locked</span> Conta <span class="hljs-built_in">this</span>)</span> { ... }
É o tipo de detalhe que separa quem decora sintaxe de quem entende o design da linguagem.
2. Immutability: Estado que Não Trai
A série dedicou um artigo inteiro ao custo cognitivo da imutabilidade de Strings: verbos como replace() e toUpperCase() soam como mutação, mas são fábricas de novos objetos. O resultado precisa ser capturado, senão é descartado em silêncio.
A lição se generaliza: imutabilidade elimina categorias inteiras de bug, principalmente em concorrência. E o Java moderno vem empurrando o ecossistema nessa direção em detalhes que passam despercebidos.
Antes e depois: Collectors.toList() vs toList()
Um exemplo perfeito está na Stream API. Compare:
<span class="hljs-comment">// ✗ Antes (e ainda muito comum em code review)</span>
List<String> nomes = clientes.stream()
.map(Cliente::nome)
.collect(Collectors.toList());
<span class="hljs-comment">// ✓ Depois (JDK 16+)</span>
List<String> nomes = clientes.stream()
.map(Cliente::nome)
.toList();
A diferença vai além da estética:
Collectors.toList() |
Stream.toList() |
|
|---|---|---|
| Desde | JDK 8 | JDK 16 |
| Mutabilidade | Sem garantia (hoje retorna ArrayList mutável) |
Lista não modificável |
| Contrato | Implementação pode mudar | Imutabilidade documentada |
| Verbosidade | Import + chamada longa | Direto na stream |
O detalhe importante: Stream.toList() retorna uma lista não modificável. Qualquer add() ou remove() posterior lança UnsupportedOperationException. Isso é imutabilidade como default, o mesmo princípio das Strings aplicado às coleções que sua pipeline produz. Se o código adiante realmente precisa mutar a lista, a intenção fica explícita: new ArrayList<>(stream.toList()).
3. Switch Expressions: De Instrução a Expressão
O switch clássico era uma instrução de controle de fluxo com uma pegadinha embutida: o fall-through silencioso quando alguém esquece o break. O JEP 361 (JDK 14) transformou o switch em uma expressão que retorna valores.
Arrow syntax: o fim do fall-through acidental
<span class="hljs-comment">// ✗ Antes: break esquecido = bug silencioso</span>
String turno;
<span class="hljs-keyword">switch</span> (hora) {
<span class="hljs-keyword">case</span> <span class="hljs-number">6</span>, <span class="hljs-number">7</span>, <span class="hljs-number">8</span>, <span class="hljs-number">9</span>, <span class="hljs-number">10</span>, <span class="hljs-number">11</span>:
turno = <span class="hljs-string">"manhã"</span>;
<span class="hljs-keyword">break</span>; <span class="hljs-comment">// esqueceu? cai no próximo case</span>
<span class="hljs-keyword">case</span> <span class="hljs-number">12</span>, <span class="hljs-number">13</span>, <span class="hljs-number">14</span>, <span class="hljs-number">15</span>, <span class="hljs-number">16</span>, <span class="hljs-number">17</span>:
turno = <span class="hljs-string">"tarde"</span>;
<span class="hljs-keyword">break</span>;
<span class="hljs-keyword">default</span>:
turno = <span class="hljs-string">"noite"</span>;
}
<span class="hljs-comment">// ✓ Depois: cada braço é isolado por design</span>
<span class="hljs-type">String</span> <span class="hljs-variable">turno</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">switch</span> (hora) {
<span class="hljs-keyword">case</span> <span class="hljs-number">6</span>, <span class="hljs-number">7</span>, <span class="hljs-number">8</span>, <span class="hljs-number">9</span>, <span class="hljs-number">10</span>, <span class="hljs-number">11</span> -> <span class="hljs-string">"manhã"</span>;
<span class="hljs-keyword">case</span> <span class="hljs-number">12</span>, <span class="hljs-number">13</span>, <span class="hljs-number">14</span>, <span class="hljs-number">15</span>, <span class="hljs-number">16</span>, <span class="hljs-number">17</span> -> <span class="hljs-string">"tarde"</span>;
<span class="hljs-keyword">default</span> -> <span class="hljs-string">"noite"</span>;
};
yield substitui o break
Quando um braço precisa de um bloco com lógica, quem devolve o valor é o yield. E aqui vale desfazer uma confusão comum: yield não é um break renomeado. O break interrompia fluxo; o yield produz o valor da expressão:
<span class="hljs-type">int</span> <span class="hljs-variable">taxa</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">switch</span> (categoria) {
<span class="hljs-keyword">case</span> PREMIUM -> <span class="hljs-number">0</span>;
<span class="hljs-keyword">case</span> PADRAO -> {
<span class="hljs-type">int</span> <span class="hljs-variable">base</span> <span class="hljs-operator">=</span> calcularBase();
<span class="hljs-keyword">yield</span> base + ajusteSazonal(); <span class="hljs-comment">// yield entrega o valor do braço</span>
}
};
A distinção semântica importa: numa switch expression não existe "sair sem valor". Todo caminho precisa produzir um resultado, e o compilador cobra isso. É o primeiro sinal da exaustividade que vamos ver adiante.
case null: o fim do boilerplate if (obj == null)
Por décadas, todo switch sobre referência exigia uma guarda externa, porque seletor nulo lança NullPointerException:
<span class="hljs-comment">// ✗ O ritual de sempre</span>
<span class="hljs-keyword">if</span> (obj == <span class="hljs-literal">null</span>) {
<span class="hljs-keyword">return</span> <span class="hljs-string">"sem valor"</span>;
}
<span class="hljs-keyword">return</span> <span class="hljs-keyword">switch</span> (obj) { ... };
Com o Pattern Matching para switch (preview no JDK 17 via JEP 406, finalizado no JDK 21 via JEP 441), o nulo virou um rótulo de primeira classe:
<span class="hljs-comment">// ✓ Nulo tratado dentro do próprio switch</span>
<span class="hljs-type">String</span> <span class="hljs-variable">resultado</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">switch</span> (obj) {
<span class="hljs-keyword">case</span> <span class="hljs-literal">null</span> -> <span class="hljs-string">"sem valor"</span>;
<span class="hljs-keyword">case</span> String s -> <span class="hljs-string">"texto: "</span> + s;
<span class="hljs-keyword">default</span> -> <span class="hljs-string">"outro tipo"</span>;
};
Sem o case null declarado, o comportamento tradicional se mantém: NullPointerException. Ou seja, código legado não muda de comportamento, mas código novo pode finalmente tratar o nulo onde ele pertence.
4. Desvendando o Pattern Matching
Pattern Matching é o mecanismo que testa a forma de um valor e, no mesmo movimento, extrai dados para binding variables. A pergunta deixa de ser "que tipo é isso?" seguida de um cast, e vira uma única operação declarativa.
O switch aceita qualquer objeto
Até o JDK 20, o seletor do switch era restrito a inteiros, enums, Strings e pouco mais. Com o JEP 441, qualquer referência serve como seletor, e os rótulos aceitam padrões de tipo:
String <span class="hljs-title function_">descreve</span><span class="hljs-params">(Object obj)</span> {
<span class="hljs-keyword">return</span> <span class="hljs-keyword">switch</span> (obj) {
<span class="hljs-keyword">case</span> Integer i -> <span class="hljs-string">"inteiro de valor "</span> + i;
<span class="hljs-keyword">case</span> String s -> <span class="hljs-string">"texto de tamanho "</span> + s.length();
<span class="hljs-keyword">case</span> <span class="hljs-literal">null</span> -> <span class="hljs-string">"nulo"</span>;
<span class="hljs-keyword">default</span> -> <span class="hljs-string">"tipo não mapeado"</span>;
};
}
O que era uma escada de if-else com instanceof e cast vira uma estrutura onde cada forma possível do dado tem seu lugar declarado.
Guarded Patterns: refinando com when
O padrão de tipo responde "é uma String?". O guarded pattern acrescenta "e ela me interessa?". A cláusula when (JDK 21) anexa uma condição booleana ao padrão, com a binding variable disponível dentro da guarda:
String <span class="hljs-title function_">classifica</span><span class="hljs-params">(Object obj)</span> {
<span class="hljs-keyword">return</span> <span class="hljs-keyword">switch</span> (obj) {
<span class="hljs-keyword">case</span> String s <span class="hljs-keyword">when</span> s.isBlank() -> <span class="hljs-string">"texto em branco"</span>;
<span class="hljs-keyword">case</span> String s <span class="hljs-keyword">when</span> s.length() > <span class="hljs-number">10</span> -> <span class="hljs-string">"texto longo"</span>;
<span class="hljs-keyword">case</span> String s -> <span class="hljs-string">"texto curto"</span>;
<span class="hljs-keyword">case</span> Integer i <span class="hljs-keyword">when</span> i > <span class="hljs-number">0</span> -> <span class="hljs-string">"positivo"</span>;
<span class="hljs-keyword">case</span> Integer i -> <span class="hljs-string">"zero ou negativo"</span>;
<span class="hljs-keyword">default</span> -> <span class="hljs-string">"outro"</span>;
};
}
Dominância: a ordem importa
E o exemplo acima só compila porque respeita a regra de dominância: o primeiro match vence, então padrões mais específicos precisam vir antes dos mais genéricos. Inverta a ordem e o compilador recusa o código:
<span class="hljs-comment">// ✗ Não compila: String s domina o padrão guardado abaixo</span>
<span class="hljs-keyword">switch</span> (obj) {
<span class="hljs-keyword">case</span> String s -> <span class="hljs-string">"qualquer texto"</span>;
<span class="hljs-keyword">case</span> String s <span class="hljs-keyword">when</span> s.isBlank() -> <span class="hljs-string">"em branco"</span>; <span class="hljs-comment">// inalcançável</span>
}
A regra prática: constantes e padrões guardados primeiro, padrões de tipo puros depois, o caso mais genérico por último. Diferente do fall-through de antigamente, que falhava em silêncio, aqui a ordem errada é erro de compilação. O compilador de novo agindo antes do bug.
Generics no Pattern Matching: o fantasma do Type Erasure
A série já tinha visitado o Type Erasure, e ele reaparece aqui impondo um limite: não dá para testar o parâmetro genérico em runtime, porque ele não existe mais lá.
<span class="hljs-comment">// ✗ Não compila: erasure impede verificar o <String> em runtime</span>
<span class="hljs-keyword">if</span> (obj <span class="hljs-keyword">instanceof</span> List<String> ls) { ... }
<span class="hljs-comment">// ✓ Compila: o wildcard não promete nada sobre o elemento</span>
<span class="hljs-keyword">if</span> (obj <span class="hljs-keyword">instanceof</span> List<?> l) { ... }
Dentro de contextos genéricos, porém, o Pattern Matching colabora com a inferência. Um método genérico pode fazer dispatch por tipo mantendo a assinatura flexível:
<span class="hljs-keyword">static</span> <T> String <span class="hljs-title function_">processa</span><span class="hljs-params">(T valor)</span> {
<span class="hljs-keyword">return</span> <span class="hljs-keyword">switch</span> (valor) {
<span class="hljs-keyword">case</span> Integer i -> <span class="hljs-string">"número: "</span> + i;
<span class="hljs-keyword">case</span> String s -> <span class="hljs-string">"texto: "</span> + s;
<span class="hljs-keyword">case</span> List<?> l -> <span class="hljs-string">"lista com "</span> + l.size() + <span class="hljs-string">" elementos"</span>;
<span class="hljs-keyword">case</span> <span class="hljs-literal">null</span> -> <span class="hljs-string">"nulo"</span>;
<span class="hljs-keyword">default</span> -> <span class="hljs-string">"tipo genérico não mapeado"</span>;
};
}
A regra mental: o Pattern Matching enxerga o que sobrevive ao erasure. Tipos brutos e wildcards sim, parâmetros genéricos concretos não.
Pattern Matching + Predicate: composição funcional
Uma combinação elegante que rendeu um post próprio na série: usar Type Patterns dentro de Predicate para construir filtros type-safe e componíveis:
Predicate<Object> textoRelevante =
obj -> obj <span class="hljs-keyword">instanceof</span> String s && !s.isBlank() && s.length() > <span class="hljs-number">3</span>;
Predicate<Object> numeroPositivo =
obj -> obj <span class="hljs-keyword">instanceof</span> Integer i && i > <span class="hljs-number">0</span>;
List<Object> relevantes = entradas.stream()
.filter(textoRelevante.or(numeroPositivo))
.toList();
O binding funciona normalmente dentro da lambda, e a composição via and(), or() e negate() continua disponível. É Pattern Matching saindo do if e do switch para viver no mundo funcional, com o toList() imutável da seção 2 fechando a pipeline.
5. Exaustividade: Onde Tudo se Encaixa
Todo switch com padrões precisa ser exaustivo: cobrir todos os valores possíveis de entrada. Esqueceu um caso? O código nem compila.
Existem três formas de garantir a completude. O default clássico funciona, mas esconde o problema debaixo do tapete. Um padrão total como case Object o é um pega-tudo mais elegante, mas continua pega-tudo. E a terceira forma é onde o compilador mostra seu poder: quando a hierarquia é selada, tratar cada subtipo permitido prova que a lógica está completa, sem default nenhum:
<span class="hljs-keyword">sealed</span> <span class="hljs-keyword">interface</span> <span class="hljs-title class_">Player</span> <span class="hljs-keyword">permits</span> Tennis, Football { }
String <span class="hljs-title function_">descreve</span><span class="hljs-params">(Player player)</span> {
<span class="hljs-keyword">return</span> <span class="hljs-keyword">switch</span> (player) {
<span class="hljs-keyword">case</span> Tennis t -> <span class="hljs-string">"joga tênis"</span>;
<span class="hljs-keyword">case</span> Football f -> <span class="hljs-string">"joga futebol"</span>;
}; <span class="hljs-comment">// exaustivo, sem default</span>
}
A recompensa vem na manutenção: se daqui a seis meses alguém adicionar Golf à hierarquia, todo switch sobre Player quebra na compilação apontando exatamente onde o novo caso não foi tratado. Com um default genérico, o Golf passaria batido e viraria bug silencioso em produção.
error: the switch expression does not cover all possible input values
💡 No IntelliJ, um Alt+Enter no switch quebrado oferece "Add remaining branches" e gera o case que falta. Refatorar hierarquia selada vira uma sequência de Alt+Enter pelo projeto.
Sealed Classes, Hidden Classes, Records e Record Patterns merecem mais do que um parágrafo, e por isso ganham artigos dedicados na sequência. Por ora, fica o essencial: a exaustividade é o ponto onde a série inteira converge. O equals() limpo do bloco de Objects, o estado imutável do bloco de Immutability, o switch que produz valores e o Pattern Matching que decompõe tipos, tudo culmina no compilador provando que nenhum cenário ficou de fora.
Conclusão
O Java moderno foca em expressividade e segurança em tempo de compilação, e a série mostrou esse movimento em camadas. Nos fundamentos, equals() com Type Pattern e flow scoping aposentou o ritual de null-check e cast, enquanto o pitfall de == vs equals() segue lembrando que referência não é conteúdo. Na imutabilidade, das Strings ao toList() da Stream API, o default da linguagem caminha para estado que não trai. No switch, a arrow syntax e o yield acabaram com o fall-through, e o case null enterrou o boilerplate de guarda. E no Pattern Matching, guarded patterns, dominância, generics e Predicates formam um vocabulário novo para expressar lógica de negócio, coroado pela exaustividade.
O padrão comum a tudo: muito mais intenção, e uma classe inteira de bugs eliminada antes do primeiro deploy.
Isso fecha a série Objects · Immutability · Switch · Pattern Matching. E abre a próxima: Records, Record Patterns e Sealed/Hidden Classes em profundidade. O Java continua evoluindo, e quem domina esse alicerce escreve código limpo, seguro e profissional.
Este artigo fecha a série Objects · Immutability · Switch · Pattern Matching do getcaramelo.dev.
Tem dúvidas ou quer aprofundar algum ponto? Me encontra no LinkedIn ou no GitHub.
Publicado por: Guilherme Gomes - 23/07/2026 10:30
Gostou do conteúdo? Me segue no @getcaramelo.dev
Caramelo.dev