- Lançado em 19 de setembro de 2023, o Java 21 trouxe record patterns e switch pattern matching, aproximando o Java de expressões funcionais vistas em Kotlin, Rust e C#
- A base para lidar com tipos de dados algébricos no Java 21 foi sendo construída com o acúmulo de recursos como switch expressions no Java 14, records e pattern matching com
instanceof no Java 16, e sealed classes no Java 17
- records impõem restrições como
final, referências imutáveis e getters padronizados, o que permite decompor dados com mais segurança; com record patterns, dá para extrair dados aninhados diretamente no switch
- sealed classes/interfaces abrem apenas os subtipos permitidos, criando um modelo próximo de sum types; ao combinar sealed interface com record, é possível limitar variações como RGB, CMYK, YUV e HSL
- O switch do Java 21 também suporta
case null e guard com when, mas accessors de record incorretos ou exceções durante a execução de guards podem levar a java.lang.MatchException
Pattern matching estabilizado no Java 21
- O Java 21 foi lançado em 19 de setembro de 2023 e adiciona suporte a record patterns em blocos e expressões
switch
- Essa sintaxe é vista como um ponto de virada, permitindo expressar no Java padrões de programação funcional de forma semelhante a Kotlin, Rust e C#
- As principais mudanças de sintaxe nas versões recentes do Java convergiram para o pattern matching do Java 21
- Java 14: estabilização de switch expressions
- Java 16: estabilização de records e pattern matching com
instanceof
- Java 17: estabilização de sealed classes
- Java 21: estabilização de record patterns e switch pattern matching
- Com esse conjunto de mudanças, o Java passou a conseguir lidar com tipos de dados algébricos (algebraic data types) e com formas idiomáticas de usá-los, algo que antes era difícil de expressar
Conceitos mínimos necessários de teoria de tipos
- Para entender os recursos do Java 21, é preciso conhecer alguns conceitos de teoria de tipos
- O bottom/empty type representa o conjunto de valores que não podem ser calculados; em linguagens de programação comuns, normalmente é o conjunto vazio
- Em Kotlin,
Nothing não pode ter instâncias porque seu construtor é private
- Em Java,
Void tem construtor private, mas pode conter null, então é difícil considerá-lo um verdadeiro bottom type
- O
void primitivo do Java não pode ser usado como tipo de variável, então nesse aspecto se comporta de forma mais próxima
- O top type é o conjunto universal que representa todos os valores de todos os tipos
- Em Kotlin,
Any cumpre esse papel
- Em Java,
Object é diferente do top type de outras linguagens porque os tipos primitivos ficam separados do modelo de objetos
- O unit type é um tipo que possui apenas um valor
- Em Java,
void pode ser tratado como unit type no retorno de métodos, mas não pode ser passado como tipo de parâmetro
- Em Kotlin,
Unit é definido como object e também pode ser usado como parâmetro de método
- O boolean type tem dois valores,
true e false; também pode ser representado como um unit type anulável, mas isso não é prático
Product type e Java records
- Um product type é um tipo formado pela combinação de dois ou mais tipos componentes, e a quantidade de componentes é chamada de arity ou degree
- O
struct da linguagem C é um exemplo de product type
- Tipos componentes como
int, char *, double e int podem se repetir
- Quando há repetição, é possível distingui-los pensando em pares ordenados com o nome do campo
- Tuplas em Python ou Rust também podem ser vistas como product types; nesse caso, o índice faz o papel do nome de cada componente
- A record class, estabilizada no Java 16, é um bom exemplo de product type
- Os campos de um record são
final, e um record não pode ser herdado
- O estado do record é definido no momento da criação e permanece depois disso
- Ainda assim, se um tipo mutável for colocado dentro do record, isso não garante a imutabilidade do conteúdo interno
- Uma classe Java comum pode misturar estado public/private, estado oculto vindo de herança, campos mutable/static e getters fora do padrão, o que dificulta generalizar seus componentes
- records garantem, por meio das restrições abaixo, uma estrutura em que recursos da linguagem como pattern matching funcionam de forma estável
- um record é implicitamente uma classe
final e não pode ser herdado
- não pode estender nenhuma classe além de
java.lang.Record
- não é possível aplicar visibility modifiers aos record components
- referências de component são sempre
final e tratadas como imutáveis
- o getter padrão usa exatamente o nome do campo; o getter do campo
a é a()
- o backing field é implicitamente
private e o acesso acontece via getter
Decompondo dados aninhados com record pattern
- O switch pattern do Java 21 decompõe dados de record aninhados sem repetir verificações
instanceof e casts explícitos
- No exemplo, são usados
record A(Record inner), record B(char b) e record SomeOtherRecord()
- Na forma antiga, seria preciso fazer
if (r instanceof A), depois cast, e então repetir instanceof e cast sobre o valor interno
- Com switch pattern, é possível extrair o valor aninhado diretamente com algo como
case A(B(char a)) -> String.valueOf(a)
- O bloco
switch tem estrutura mais clara do que uma escada de if-else e é adequado para extrair rapidamente dados profundamente aninhados
- Para executar diretamente no Java 21, basta colocar o código em
main.java e usar o comando abaixo
java --enable-preview --source 21 main.java
Sum type e sealed classes/interfaces
- Para representar escolhas limitadas, é possível usar
enum no Java, mas fica incômodo lidar apenas com enum quando as representações de cor, como RGB, HSL, YUV e CMYK, têm estruturas de dados diferentes
- Dá para criar uma classe abstrata
Color com polimorfismo baseado em herança e subclasses como RGB, CMYK, YUV e HSL, mas uma hierarquia de classes comum é aberta
- Um usuário da biblioteca poderia criar uma nova classe como
RYB herdando de Color
- Se a API não foi pensada para extensão, uma nova variante pode causar crashes ou bugs sutis em partes distantes do código
- sealed classes são usadas para expressar no Java o conceito de sum type
- Um sum type é um tipo que, em um dado momento, pode ser um entre vários componentes possíveis
- Também é chamado de tagged union type
- Com o modificador
sealed e a cláusula permits, é possível permitir herança apenas para classes específicas
public sealed class Color permits RGB, CMYK, YUV, HSL {
}
- Em uma hierarquia de sealed class, herdeiros diretos ou indiretos devem ser marcados como
sealed, non-sealed ou final; caso contrário, ocorre erro de compilação
sealed: apenas os tipos nomeados em permits podem herdar
non-sealed: permite herança livre, como uma classe comum
final: vira uma folha da árvore de herança e não pode mais ser estendido
Usando sealed interface com record
- O destructuring do switch pattern funciona com records, mas records não podem herdar de classes além de
Record
- A solução é usar sealed interface
- Uma sealed interface funciona de forma semelhante a uma sealed class
- records e enums também podem implementar uma sealed interface
- No exemplo,
Color vira uma sealed interface, e RGB, CMYK, YUV e HSL são implementados como records
public sealed interface Color permits RGB, CMYK, YUV, HSL {
String getDescription();
}
record RGB(int red, int green, int blue) implements Color {
public String getDescription() {
return "RGB Color: (" + red + ", " + green + ", " + blue + ")";
}
}
- Depois disso, o switch pode extrair diretamente os valores de cada record
switch (color) {
case RGB(int red, int green, int blue) -> {
}
case CMYK(double cyan, double magenta, double yellow, double black) -> {
}
case YUV(int y, int u, int v) -> {
}
case HSL hsl -> {
System.out.println(hsl.getDescription());
}
case null -> {
System.out.println("How did color become null?!");
}
}
- O Java 21 permite tratar
case null em blocos e expressões switch, então não é necessário fazer uma checagem separada antes do switch
- Se
Color for um tipo sealed, o Java consegue saber se todos os casos foram cobertos, permitindo um switch exaustivo sem default
Guard clause e when
- O Java 21 suporta guard clauses para adicionar condições extras a braços do
switch
- A guard clause integra a condição ao label do case com a palavra-chave
when
switch (color) {
case RGB(int red, int green, int blue) when red > 200 -> {
System.out.println("Very red.");
}
case RGB rgb when rgb.green > 100 -> {
System.out.println("Sort of green...");
}
case RGB rgb -> {
System.out.println("Not that red...");
}
}
- Antes, seria necessário colocar novamente um
if (red > 200) dentro do corpo do case RGB(...)
- Como o Java faz a correspondência de forma ansiosa com o primeiro case que avalia para true, casos mais específicos devem vir antes e os menos específicos depois
- Depois de um case
RGB com guard, é necessário um case RGB rgb genérico para manter a exaustividade
Quando ocorre MatchException
- O pattern matching do Java 21 também introduz o envolvimento adicional de
java.lang.MatchException
- Se um record accessor lançar uma exceção, o switch pattern falha e pode ocorrer
MatchException
record R(int i) {
public int i() {
return i / 0;
}
}
static void exampleAnR(R r) {
switch(r) {
case R(var i): System.out.println(i);
}
}
- Nesse exemplo, como o accessor
i() lança ArithmeticException, o bloco switch lança MatchException
- Segundo o JEP 441, um record accessor que sempre lança exceção é algo bastante anormal, e também é muito raro que um pattern switch exaustivo lance
MatchException
- Mesmo em um
switch exaustivo, pode haver exceção se nenhuma variante especificada corresponder ao selector
- O JEP 441 explica, no caso de
switch exaustivo com enum, que a falha de correspondência pode acontecer se a classe enum mudar depois que o switch foi compilado
- Também pode ocorrer
MatchException se uma exceção for lançada durante a execução de uma guard clause
static void example(Object obj) {
switch (obj) {
case R r when (r.i / 0 == 1): System.out.println("It's an R!");
default: break;
}
}
Escopo restante
- Ao combinar records, sealed types, switch pattern matching e guard clauses no Java 21, é possível aplicar ao código Java blocos fundamentais de programação funcional
- Alguns temas, como a forma como generics interagem com switch patterns, não são abordados aqui
- O próximo texto deve tratar de quirks e exemplos práticos que podem ajudar a melhorar a forma de escrever código Java
1 comentários
Opiniões do Hacker News
O maior recurso do Java 21 é o lançamento das threads virtuais: https://openjdk.org/jeps/444
Por algum motivo, isso ficou de fora do texto. Se existe um recurso capaz de atrair desenvolvedores de Go para Java, talvez seja este; e também parece capaz de convencer quem não gostava dos padrões de concorrência em estilo reativo
Aplicações e bibliotecas Java são difíceis demais de raciocinar e entender em comparação com Go, por causa de herança, empacotamento, orientação a objetos, ferramentas de build etc.
Go é simples e fácil de entender, ler e manter. O empacotamento se parece com organizar arquivos em uma única pasta no computador, e as ferramentas já vêm embutidas na linguagem. Também não dá aquela sensação de que você precisa de uma IDE como o IntelliJ para a coisa ficar minimamente usável
Talvez isso tenha mudado, mas a maioria das bibliotecas Java que vejo hoje em dia ainda parece assim
Gosto de muita coisa na JVM e no ecossistema de ferramentas dela, mas escrever código Java agora já não me agrada muito. O JRuby entrega, em certa medida, o melhor dos dois lados
A apresentação está aqui, e a demo de threads virtuais aparece por volta dos 45 minutos
https://youtu.be/pzm6I4liJlg?si=GtxQ4MThEaNDfC67
Até
sync.Mapnão é algo como oConcurrentMapgenérico do Java, mas é especializado para dois casos de uso específicos. Java tem conjuntos concorrentes, filas, barreiras, phasers, pools fork-join etc. Mesmo com goroutines, esses contêineres ainda seriam bastante úteis; pelo menos fork-join não é uma implementação tão trivial. Usar mutex em todo lugar parece baixo nível demaisSei que existem implementações de terceiros, mas concorrência é difícil demais de acertar; então, se não for algo maduro, com muitos usuários e desenvolvedores por trás, como o JCTools do Java ou o Google Guava, fico receoso de adotar um pacote de terceiro
Executor.newVirtualThreadPerTaskExecutoregomostra bem o ponto central de por que desenvolvedores de Go não migrariam para JavaCorrigindo: na prática, em vez de
go, é mais próximo detry (var executor = Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(...) }Acho que o título do post no blog foi uma escolha ruim. O subtítulo oculto é "Algebraic data types in Java", e isso descreve muito melhor o conteúdo. Um título melhor teria sido Algebraic data types in Java 21
Talvez por causa do título, muitos comentários aqui saíram do tema. Eu queria ver mais sobre tipos algébricos de dados, os prós e contras da implementação em Java e comparações técnicas com outras linguagens
O código Java existente não vai desaparecer, então fico pensando se misturar esse tipo de código aleatoriamente realmente tornaria as coisas melhores
O recurso de sealed classes descrito aqui me parece algo completamente equivocado.
A lógica é que, se houver uma interface comum, qualquer pessoa pode criar uma nova classe que a implemente, e um código como
if (x instanceof Foo) { ... } else if (x instanceof Bar) { ... }quebra em tempo de execução se alguém adicionar uma nova classe. Então, com o novo recurso de interfaces sealed, ninguém poderia criar uma nova classe que implemente aquela interface, e assim oifnão quebraria.Mas a programação orientada a objetos já não pensou nesse problema e o resolveu? Sei que OO saiu de moda, mas Java é uma linguagem orientada a objetos.
A solução é adicionar um método à interface e fazer todas as classes o implementarem. Assim, em vez de listar todas as opções em um enorme
if/switch, basta chamar o método.Essa abordagem é melhor do que impedir a extensão do código; na verdade, ela permite a extensão. Um novo implementador só precisa implementar esse método, e como o compilador exige isso, não dá para esquecê-lo por acidente.
O exemplo de espaços de cor do texto (RGB, CMYK etc.) é um ótimo contraexemplo. Se escrevi código que usa espaços de cor, pode ser que um usuário ou cliente precise usar algum espaço de cor estranho e raro em que eu não pensei. Eu não quero criar um código que só suporte os espaços de cor listados em um enorme
if/switche que, por causa dessa estrutura, seja impossível de estender.Sealed classes resolvem esse problema. Mas, em troca, criam um novo problema: "e se forem necessárias mais classes de extensão e não for possível conhecer todas elas de antemão?" No fim, a pergunta é se há uma forma de alcançar as duas coisas.
Esse problema é chamado de problema da expressão (expression problem) [1].
Existem linguagens de tipagem estática que conseguem resolver o problema da expressão, e Java é uma delas [2]. Porém, em Java, a forma de fazer isso ainda é muito complexa e inconveniente, então quase não é usada. Haskell, ou Scala se você quiser ficar no mundo da JVM, faz isso muito melhor.
[1] https://en.wikipedia.org/wiki/Expression_problem
[2] https://koerbitz.me/posts/Solving-the-Expression-Problem-in-...
Métodos de interface e chamadas virtuais são muito pouco flexíveis quando você quer adicionar uma nova operação em vez de adicionar uma nova classe. Mesmo para adicionar uma única operação nova, é preciso ir a todas as implementações e inserir um novo método, o que também pode quebrar implementações às quais você não tem acesso. Métodos sem relação entre si precisam ser definidos dentro da mesma classe, prejudicando bastante a legibilidade do código; além disso, chamadas virtuais não são gratuitas e também afetam o desempenho.
Nesse caso, sealed classes escalam muito melhor. Basta adicionar um novo
switchem um só lugar, sem quebrar a compatibilidade retroativa.Esse é o famoso problema da expressão.
https://pkolaczk.github.io/in-defense-of-switch/
instanceof, e também vi Bob Martin falar longamente sobre isso, mas não concordo.Para fazer esse tipo de despacho polimórfico, o objeto precisa lidar sozinho com várias preocupações.
Em um videogame,
Carpode ter.render(),.collide()e.playSound(). Se depois você adicionarDog, basta implementar esses três métodos, sem precisar alterar nem recompilarRenderer,PhysicsEngineeSoundEngine. Outro programador também pode adicionar entidades sem introduzir bugs no meu precioso código. Parece bom.Mas agora
CareDogprecisam conhecer gráficos, física e som. Além disso, entidades não existem isoladamente. Carros e cães precisam ser renderizados na ordem correta e podem se ocultar mutuamente. Colisões também precisam ser verificadas entre eles. Como já vivi em game jams reais, pode surgir uma situação em que a pessoa responsável pelo som precisa entrar em todos os objetos para adicionar comportamento sonoro.É muito melhor, ao pensar em física, trabalhar dentro de
Physics.collideAll()e, se necessário, tratar casos especiais cominstanceof; e, ao pensar em gráficos, trabalhar dentro deGraphics.renderAll().No desenvolvimento web Java de backend cotidiano acontece algo parecido. Ao decidir, em um controlador REST, como transformar objetos Java em respostas HTTP, é melhor ver tudo em um único método e mapear
{instanceof Forbidden}para 403 e{instanceof NotFound}para 404. Eu não quero colocargetCode()nem conteúdo específico de REST dentro da própria classe Java.Stringserfinal, e dá até para argumentar quefinaldeveria ser o padrão, comopensendo declarado explicitamente apenas nas classes que devem permitir subclasses.Na programação funcional, o exemplo clássico de tipo soma é a lista. Ela tem apenas
Element(T head, List tail)eNil(). Não há motivo para estendê-la; na verdade, se ela for estendida, pode se tornar código incorreto quando combinada com todas as funções que manipulam listas.Além disso, o padrão Visitor, que é semelhante a pattern matching, é muito verboso e depende de um hack que explora a semântica de despacho de métodos comum do Java. Aqui, considero pattern matching várias vezes mais legível.
Por exemplo, podemos imaginar uma interface de segurança que valida tokens de segurança.
Se for uma interface comum, é fácil implementá-la para ignorar tokens (permitindo tudo), extrair tokens ou inserir um backdoor. Se uma classe assim for injetada no ponto em que a verificação de segurança é feita, a segurança pode ser comprometida.
Com uma interface sealed, novas implementações não autorizadas não podem existir. Se você recebeu um objeto que afirma implementar aquela interface, há garantia de que ele é uma das implementações verificadas que realmente executam a checagem de segurança. Isso elimina por completo uma classe inteira de bugs de segurança e exploits.
É um bom texto do ponto de vista de quem conhece tipos soma, mas não conhece bem os tipos soma em Java
Ainda assim, não sei se só os tipos soma serão suficientes para me fazer gostar de Java. A possibilidade ampla de
nullcontinua existindo e aparece várias vezes neste textonullé um grande problema em Java, mas frameworks de nulabilidade baseados em anotações são eficazes e já se espalharam por todo o ecossistema. Pessoalmente, acho quase indispensávelEstou bem animado com o https://jspecify.dev/, uma iniciativa de Google, Meta, Microsoft e outras para padronizar anotações, começando por
@NullableA resposta do texto para "por que se chama product type?" não está errada, mas, de forma mais intuitiva e concisa, o número total de valores possíveis em um product type é a multiplicação do número de valores possíveis dos tipos que o compõem
Se trocar product por sum, isso também vale do mesmo jeito
Curiosamente, olhando apenas para entrada e saída, o número total de funções únicas na forma
a -> bpode ser calculado por exponenciação. Ou seja,(número de valores possíveis de b) ^ (número de valores possíveis de a)Escrevendo matematicamente, em uma lista de Bool, à esquerda está o número de elementos, e à direita, o número total de possibilidades
0 : 1
1 : 2
2 : 4
3 : 8
4 : 16
5 : 32
E assim por diante
Estou esperando o Project Valhalla ser concluído para que Java finalmente ganhe tipos valor. Aí, com tipos soma, tipos valor e corrotinas, parece que vai se tornar uma linguagem bem decente
Java, originalmente, não era uma linguagem realmente ruim
O problema eram as pessoas. O problema eram o enorme excesso de engenharia, conceitos abstratos demais que dificultavam entender a base de código, feitiçaria de código em forma de anotações que parecem instruções GOTO reversas, e frameworks de DI
O que precisa ser consertado não é a linguagem, mas o ecossistema. Dentro do ecossistema Java, é necessário algum tipo de movimento de "Reforma"
Só migrar para Kotlin, Clojure ou Scala não basta
HammerFactoryFactoryque cospeHammerFactory. Mas o ecossistema Java incentiva e promove esse tipo de solução. Acho que C# é parecidoUma coisa de que Java realmente precisa são funções independentes, ou funções com namespace. Às vezes não é preciso uma classe; uma função dentro de um módulo ou namespace basta. Não entendo por que isso não pode existir
O autor explica por que Records são necessários, apontando que a maioria dos objetos Java deixa todos os campos como private e só permite acesso por métodos acessores de leitura e escrita
Mas não há uma convenção obrigatória em nível de linguagem para definir acessores; então, mesmo que o getter de
fooseja chamado degetBar, ele funciona, mas pode confundir quem quer acessarbarScala oferece suporte a pattern matching para objetos que implementam o método
unapply. Será que essa abordagem é considerada prejudicial? Por que Java não seguiu esse caminho?unapplypode estar em preparação, então a esperança ainda não morreu completamenteJava sempre foi uma ótima linguagem. O que dá vontade de vomitar é o ecossistema enterprise. Já vi dezenas de classes e interfaces serem usadas para implementar uma única linha de lógica