4 pontos por GN⁺ 2023-09-18 | 1 comentários | Compartilhar no WhatsApp
  • 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

 
GN⁺ 2023-09-18
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

    • Não acho que desenvolvedores de Go existentes vão voltar para Java. Trabalhei 10 anos com Java, migrei para Go e não penso em voltar
      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
    • Por causa do Java 21, estou ansioso pelo próximo release do JRuby com threads virtuais. Charles Nutter mostrou em uma apresentação do JRuby em agosto uma demo do impacto disso nas fibers do Ruby, e é bem grande
      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
    • Não sei bem se desenvolvedores de Golang existentes migrariam para Java por causa desse recurso. Mas, em termos de direção, fico me perguntando por que a comunidade Go tem tão poucos contêineres de concorrência e a comunidade Java tem tantos
      Até sync.Map não é algo como o ConcurrentMap gené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 demais
      Sei 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
    • O contraste entre Executor.newVirtualThreadPerTaskExecutor e go mostra bem o ponto central de por que desenvolvedores de Go não migrariam para Java
      Corrigindo: na prática, em vez de go, é mais próximo de try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(...) }
    • O Java 21 também oferece concorrência estruturada em preview (https://openjdk.org/jeps/453). Ela usa a implementação de threads virtuais e, só pelos exemplos, parece muito boa, reduzindo várias dores ao lidar com concorrência baseada em threads
  • 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

    • Eu gostaria que houvesse uma linguagem mais popular com tipos algébricos de dados, mas não sei se realmente quero ver tipos algébricos de dados entrando no Java
      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
    • No começo eu tinha colocado esse título, mas mudei no último momento, e no fim parece que isso desviou a direção do texto
  • 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 o if nã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/switch e que, por causa dessa estrutura, seja impossível de estender.

    • A solução de adicionar métodos à interface se torna problemática se não for possível saber antecipadamente todas as operações de que você vai precisar no futuro.
      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-...
    • A abordagem com sealed classes também permite que qualquer pessoa estenda o código, mas em uma dimensão diferente da abordagem com métodos de interface.
      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 switch em um só lugar, sem quebrar a compatibilidade retroativa.
      Esse é o famoso problema da expressão.
      https://pkolaczk.github.io/in-defense-of-switch/
    • Entendo a recomendação de usar despacho polimórfico em vez de 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, Car pode ter .render(), .collide() e .playSound(). Se depois você adicionar Dog, basta implementar esses três métodos, sem precisar alterar nem recompilar Renderer, PhysicsEngine e SoundEngine. Outro programador também pode adicionar entidades sem introduzir bugs no meu precioso código. Parece bom.
      Mas agora Car e Dog precisam 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 com instanceof; e, ao pensar em gráficos, trabalhar dentro de Graphics.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 colocar getCode() nem conteúdo específico de REST dentro da própria classe Java.
    • Permitir extensão nem sempre faz sentido. Há um motivo para String ser final, e dá até para argumentar que final deveria ser o padrão, com open sendo 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) e Nil(). 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.
    • Há casos de uso válidos para esse tipo de recurso.
      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 null continua existindo e aparece várias vezes neste texto

    • A possibilidade de null é 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ável
      Estou bem animado com o https://jspecify.dev/, uma iniciativa de Google, Meta, Microsoft e outras para padronizar anotações, começando por @Nullable
    • Quando o Valhalla chegar, também haverá nulabilidade explícita, então esse problema deverá ser tratado
    • Ainda não é uma linguagem orientada a expressões
  • A 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 -> b pode ser calculado por exponenciação. Ou seja, (número de valores possíveis de b) ^ (número de valores possíveis de a)

    • De forma mais intuitiva e concisa, um product type equivale ao produto cartesiano de conjuntos
    • Maps e listas também são outros exemplos de tipos exponenciais. Como uma função pura, em teoria, pode ser substituída por uma consulta em um map de valores pré-calculados, a intuição de que eles são equivalentes a funções é natural. Nesse contexto, uma lista é um map especial cujas chaves são inteiros
      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

    • Em qualquer linguagem, dá para criar uma HammerFactoryFactory que cospe HammerFactory. Mas o ecossistema Java incentiva e promove esse tipo de solução. Acho que C# é parecido
      Uma 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 foo seja chamado de getBar, ele funciona, mas pode confundir quem quer acessar bar
    Scala 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?

    • É outra questão de padronização. A padronização em Java é lenta, como em C++. A última nota de rodapé do JEP de record pattern sugere que algo como unapply pode estar em preparação, então a esperança ainda não morreu completamente
  • Java 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