- Java 21, lanzado el 19 de septiembre de 2023, incorpora en Java una expresión de patrones funcionales más cercana a Kotlin, Rust y C# mediante record patterns y switch pattern matching
- Con la acumulación de switch expressions en Java 14, records y pattern matching con
instanceof en Java 16, y sealed classes en Java 17, en Java 21 encajan las bases para manejar tipos de datos algebraicos
- Los records, con restricciones como final, referencias inmutables y getters estandarizados, permiten descomponer datos de forma estable, y record pattern permite extraer datos anidados directamente en un switch
- Las sealed classes/interfaces solo abren subtipos permitidos y permiten crear modelos cercanos a un sum type; al usar sealed interface junto con record, se pueden limitar variantes como RGB, CMYK, YUV y HSL
- El switch de Java 21 también admite
null case y guardas when, pero un accessor de record incorrecto o una excepción durante la ejecución de una guarda puede derivar en java.lang.MatchException
Pattern matching estabilizado en Java 21
- Java 21 se lanzó el 19 de septiembre de 2023 y admite record patterns en bloques switch y expresiones switch
- Esta sintaxis se considera un punto de inflexión que permite expresar patrones de programación funcional en Java de una forma similar a Kotlin, Rust y C#
- Los principales cambios de sintaxis de las versiones recientes de Java desembocan en el pattern matching de Java 21
- Java 14: estabilización de switch expressions
- Java 16: estabilización de records y pattern matching con
instanceof
- Java 17: estabilización de sealed classes
- Java 21: estabilización de record patterns y switch pattern matching
- Con este conjunto de cambios, Java puede manejar tipos de datos algebraicos (algebraic data types) y sus usos idiomáticos, algo que antes era difícil de expresar
Conceptos mínimos necesarios de teoría de tipos
- Para entender las funciones de Java 21 hacen falta algunos conceptos de teoría de tipos
- El bottom/empty type representa el conjunto de valores que no pueden calcularse y, en los lenguajes de programación comunes, normalmente es el conjunto vacío
Nothing de Kotlin tiene el constructor privado, por lo que no puede existir ninguna instancia
Void de Java tiene el constructor privado, pero puede contener null, así que es difícil considerarlo un verdadero bottom type
- El primitive
void de Java no puede usarse como tipo de variable, por lo que en este aspecto se comporta de manera más cercana
- El top type es el conjunto universal que representa todos los valores de todos los tipos
- En Kotlin,
Any cumple ese rol
Object de Java es difícil de considerar equivalente al top type de otros lenguajes, porque los primitives están separados del modelo de objetos
- El unit type es un tipo que tiene un único valor
- El
void de Java puede tratarse como un unit type en retornos de métodos, pero no puede pasarse como tipo de parámetro
Unit de Kotlin se define como object y también puede usarse como parámetro de método
- El boolean type tiene dos valores,
true y false; también puede expresarse como un unit type nullable, pero no es práctico
Product type y Java records
- Un product type es un tipo que agrupa dos o más tipos componentes, y la cantidad de tipos componentes se convierte en su arity o degree
- El
struct de C es un ejemplo de product type
- Los tipos componentes pueden repetirse, como
int, char *, double, int
- Los tipos repetidos pueden distinguirse si se piensan como pares ordenados junto con el nombre del campo
- Las tuples de Python o Rust también pueden verse como product types; en este caso, el índice cumple el rol de nombre de cada componente
- La record class estabilizada en Java 16 es un buen ejemplo de product type
- Los campos de un record son final, y un record no puede heredarse
- El estado del record se establece en el momento de la creación y se mantiene después
- Sin embargo, si se coloca un mutable data type dentro de un record, no se garantiza la inmutabilidad de su contenido
- En una class común de Java pueden mezclarse estado public/private, estado oculto generado por herencia, mutable/static fields y getters no estándar, lo que dificulta generalizar sus componentes
- Los records garantizan, mediante las siguientes restricciones, una estructura en la que funciones del lenguaje como pattern matching pueden operar de forma estable
- Un record es implícitamente una final class y no puede heredarse
- No puede extender ninguna clase aparte de
java.lang.Record
- No se puede agregar un visibility modifier a un record component
- La referencia al component siempre es final y se trata como inmutable
- El getter predeterminado usa directamente el nombre del campo; el getter del campo
a es a()
- El backing field es implícitamente private y se accede a él mediante el getter
Descomposición de datos anidados con record pattern
- El switch pattern de Java 21 descompone datos de record anidados sin repetir comprobaciones
instanceof ni casts explícitos
- En el ejemplo se usan
record A(Record inner), record B(char b) y record SomeOtherRecord()
- Con el método anterior, después de
if (r instanceof A) había que hacer cast y luego repetir instanceof y cast sobre el valor interno
- El switch pattern extrae directamente valores anidados, como en
case A(B(char a)) -> String.valueOf(a)
- Un bloque switch tiene una estructura más clara que un if-else ladder y es adecuado para extraer rápidamente datos profundamente anidados
- Para ejecutarlo directamente en Java 21, se puede poner el código en
main.java y usar el siguiente comando
java --enable-preview --source 21 main.java
Sum type y sealed classes/interfaces
- Para expresar opciones limitadas se puede usar un enum de Java, pero las representaciones de color con estructuras de datos distintas, como RGB, HSL, YUV y CMYK, son incómodas de manejar solo con enum
- Se puede crear una clase abstracta
Color y clases RGB, CMYK, YUV, HSL mediante polimorfismo basado en herencia, pero una class hierarchy común está abierta
- Un usuario de la biblioteca podría crear una nueva clase como
RYB que herede de Color
- Si la API no pretendía permitir extensiones, una nueva variante podría provocar crashes o bugs sutiles en código distante
- Las sealed classes se usan para expresar el concepto de sum type en Java
- Un sum type es un tipo que puede ser uno de sus componentes en un momento dado
- También se llama tagged union type
- Al usar el modifier
sealed y la cláusula permits, se puede permitir la herencia solo a clases específicas
public sealed class Color permits RGB, CMYK, YUV, HSL {
}
- En una sealed class hierarchy, los herederos directos o indirectos deben tener uno de
sealed, non-sealed o final; si no, se produce un compile error
sealed: solo pueden heredar los tipos nombrados en permits
non-sealed: puede heredarse como una class común
final: es una hoja del árbol de herencia y ya no puede extenderse
Cómo usar sealed interface junto con record
- La destructuración de switch pattern matching funciona con records, pero los records no pueden heredar de clases que no sean
Record
- La solución es usar una sealed interface
- Una sealed interface se comporta de manera similar a una sealed class
- Los records y enums también pueden implementar una sealed interface
- En el ejemplo,
Color se crea como sealed interface y RGB, CMYK, YUV, HSL se implementan 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 + ")";
}
}
- Después, en el switch se pueden extraer directamente los 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?!");
}
}
- Java 21 puede manejar
null case en bloques y expresiones switch, por lo que no hace falta una comprobación null separada antes del switch
- Si
Color es un sealed type, Java puede saber si se cubrieron todos los case, de modo que es posible tener un exhaustive switch sin default case
Guard clause y when
- Java 21 admite guard clause, que agrega una condición adicional a un switch arm
- Una guard clause integra una condición en el case label mediante la palabra clave
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 había que volver a poner
if (red > 200) dentro del cuerpo de case RGB(...)
- Java hace matching de forma eager con el primer case que se evalúa como true, por lo que los case más específicos deben colocarse antes y los menos específicos después
- Después de un case RGB con guarda, se necesita un
case RGB rgb general para mantener la exhaustividad
Casos en que ocurre MatchException
- En el pattern matching de Java 21 también participa
java.lang.MatchException
- Si un record accessor lanza una excepción, el switch pattern puede fallar y producir una
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);
}
}
- En el ejemplo anterior, como el accessor
i() lanza ArithmeticException, el bloque switch lanza MatchException
- Según JEP 441, un record accessor que siempre lanza una excepción es muy anómalo, y también es muy excepcional que un exhaustive pattern switch lance
MatchException
- Incluso en un exhaustive switch, puede ocurrir una excepción si ninguna de las variants especificadas para el selector hace matching
- JEP 441 explica el caso en que un exhaustive switch sobre un enum falla al hacer matching como una situación en la que la enum class cambió después de compilar el switch
- Si ocurre una excepción durante la ejecución de una guard clause, también puede producirse
MatchException
static void example(Object obj) {
switch (obj) {
case R r when (r.i / 0 == 1): System.out.println("It's an R!");
default: break;
}
}
Alcance restante
- Al combinar records, sealed types, switch pattern matching y guard clauses de Java 21, se pueden aplicar building blocks de la programación funcional al código Java
- No se cubren algunos temas, como la forma en que generics interactúa con switch patterns
- En el próximo artículo se tratarán quirks y ejemplos prácticos que pueden usarse para mejorar la forma de escribir código Java
1 comentarios
Opiniones de Hacker News
La característica más importante de Java 21 es el lanzamiento de los hilos virtuales: https://openjdk.org/jeps/444
Por alguna razón, no aparece en el artículo. Si hay una función capaz de atraer a desarrolladores actuales de Go hacia Java, podría ser esta, y también podría convencer a quienes no les gustaban los patrones de concurrencia de estilo reactivo.
Las aplicaciones y bibliotecas Java son demasiado difíciles de razonar y entender en comparación con Go, por temas como herencia, empaquetado, orientación a objetos, herramientas de build, etc.
Go es simple y fácil de entender, leer y mantener. El empaquetado se parece a la forma en que uno organiza archivos en una sola carpeta en una computadora, y las herramientas vienen integradas en el lenguaje. Tampoco da la sensación de que necesites un IDE como IntelliJ para que apenas sea usable.
Quizá eso haya cambiado, pero la mayoría de las bibliotecas Java que veo hoy siguen teniendo ese aspecto.
Hay muchas cosas que me gustan de la JVM y su ecosistema de herramientas, pero escribir código Java ya no me entusiasma. JRuby ofrece, hasta cierto punto, lo mejor de ambos mundos.
La charla está aquí, y la demo de hilos virtuales empieza alrededor del minuto 45:
https://youtu.be/pzm6I4liJlg?si=GtxQ4MThEaNDfC67
Incluso
sync.Mapno es algo como elConcurrentMapgenérico de Java, sino que está especializado para dos casos de uso concretos. Java tiene conjuntos concurrentes, colas, barreras, phasers, pools fork-join, etc. Aunque existan las goroutines, estos contenedores seguirían siendo bastante útiles; al menos fork-join no es tan trivial de implementar. Usar mutexes en todas partes se siente demasiado de bajo nivel.Sé que existen implementaciones de terceros, pero la concurrencia es tan difícil de hacer bien que dudo en adoptar paquetes de terceros si no tienen el nivel de madurez y el respaldo de muchos usuarios y desarrolladores que tienen JCTools de Java o Google Guava.
Executor.newVirtualThreadPerTaskExecutorygomuestra muy bien el punto central de por qué los desarrolladores de Go probablemente no se pasarán a Java.Corrigiendo, en realidad sería más parecido a
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(...) }que ago.Creo que el título del blog no fue una buena elección. El subtítulo oculto es "Algebraic data types in Java", y eso describe mucho mejor el contenido. Un título mejor habría sido Algebraic data types in Java 21.
Quizá por el título, una buena parte de los comentarios aquí se sale del tema. Me habría gustado ver más sobre tipos de datos algebraicos, las ventajas y desventajas de su implementación en Java y comparaciones técnicas con otros lenguajes.
El código Java existente no va a desaparecer, así que me pregunto si mezclar este tipo de código de forma aleatoria realmente mejoraría las cosas.
La función de sealed classes explicada aquí se siente completamente equivocada
La lógica es que, si tienes una interfaz normal, cualquiera puede crear una nueva clase que la implemente, y código como
if (x instanceof Foo) { ... } else if (x instanceof Bar) { ... }se rompe en runtime si alguien agrega una clase nueva. Entonces, con la nueva función de interfaces sealed, si impides que cualquiera cree clases nuevas que implementen esa interfaz, elifno se rompePero ¿la programación orientada a objetos no había pensado ya en este problema y lo había resuelto? Entiendo que hoy la orientación a objetos ya no esté de moda, pero Java es un lenguaje orientado a objetos
La solución es agregar un método a la interfaz y hacer que todas las clases lo implementen. Así, en vez de enumerar todas las opciones en un enorme
if/switch, llamas al métodoEste enfoque es mejor que impedir que el código se extienda; de hecho, permite extenderlo. Un nuevo implementador solo tiene que implementar ese método, y como el compilador lo exige, tampoco puede olvidarlo por accidente
El ejemplo de espacios de color del artículo (RGB, CMYK, etc.) es un muy buen contraejemplo. Si escribí código que usa espacios de color, puede que un usuario o cliente necesite usar un espacio de color extraño y poco común que yo no había imaginado. No quiero crear código imposible de extender por tener una estructura que solo soporta los espacios de color enumerados en un enorme
if/switchLas sealed classes resuelven este problema. Pero, a cambio, aparece un nuevo problema: “¿y si se necesitan más clases extendidas y no puedo conocerlas todas de antemano?”. Al final, la pregunta es si hay una forma de lograr ambas cosas
A este problema se le llama expression problem [1]
Hay lenguajes con tipado estático que pueden resolver el expression problem, y Java es uno de ellos [2]. Sin embargo, la forma de hacerlo en Java sigue siendo muy compleja e incómoda, por lo que casi no se usa. Si quieres quedarte en Haskell o en el mundo de la JVM, Scala hace esto mucho mejor
[1] https://en.wikipedia.org/wiki/Expression_problem
[2] https://koerbitz.me/posts/Solving-the-Expression-Problem-in-...
El enfoque de métodos de interfaz y llamadas virtuales es muy poco flexible cuando quieres agregar nuevas operaciones en vez de nuevas clases. Aunque solo quieras agregar una operación nueva, tienes que ir a todos los implementadores y añadir el nuevo método, y podrías romper implementadores a los que no tienes acceso. Además, métodos que no están relacionados entre sí tienen que definirse dentro de una misma clase, lo que empeora mucho la legibilidad del código, y las llamadas virtuales tampoco son gratis: también afectan el rendimiento
En este caso, una sealed class escala mucho mejor. Agregas un nuevo switch en un solo lugar y listo, sin romper la compatibilidad hacia atrás
Este es el famoso expression problem
https://pkolaczk.github.io/in-defense-of-switch/
instanceof, y también he visto a Bob Martin hablar largamente de eso, pero no estoy de acuerdoPara hacer este despacho polimórfico, el objeto tiene que encargarse por sí mismo de varias preocupaciones
En un videojuego,
Carpuede tener.render(),.collide(),.playSound(). Si más adelante agregasDog, basta con implementar esos tres métodos, sin necesidad de modificar ni recompilarRenderer,PhysicsEngineniSoundEngine. Otro programador también puede agregar entidades sin meter bugs en mi valioso código. Suena bienPero ahora
CaryDogtienen que saber de gráficos, física y sonido. Y las entidades no existen de forma aislada. Los autos y los perros tienen que renderizarse en el orden correcto y pueden taparse entre sí. Las colisiones también tienen que revisarse entre ellos. Como me pasó en una game jam real, también puede darse la situación de que la persona encargada del sonido tenga que entrar en todos los objetos para agregar comportamiento de audioEs mucho mejor trabajar dentro de
Physics.collideAll()cuando piensas en física, usandoinstanceofpara casos especiales si hace falta, y trabajar dentro deGraphics.renderAll()cuando piensas en gráficosEn el desarrollo web backend cotidiano con Java pasa algo parecido. Cuando decides en un controlador REST cómo convertir objetos Java en respuestas HTTP, es mejor verlo todo dentro de un solo método y mapear
{instanceof Forbidden}a 403 y{instanceof NotFound}a 404. No quiero metergetCode()ni contenido específico de REST dentro de la propia clase JavaStringesfinal, e incluso se podría sostener quefinaldebería ser el valor predeterminado y que solo las clases que permitan subclases deberían marcarse explícitamente comoopenEn programación funcional, el ejemplo típico de un tipo suma es una lista. Solo tiene
Element(T head, List tail)yNil(). No hay razón para extenderla y, de hecho, si se extiende, puede convertirse en código incorrecto al combinarse con cualquier función que maneje listasAdemás, el patrón Visitor, que es similar al pattern matching, es muy verboso y depende de un hack basado en la semántica normal de despacho de métodos de Java. En este caso, creo que el pattern matching es varias veces más legible
Por ejemplo, podemos pensar en una interfaz de seguridad que valida tokens de seguridad
Si fuera una interfaz normal, sería fácil implementarla para ignorar los tokens (permitir todo), robarlos o meter un backdoor. Si una clase así se inyecta en el lugar donde se hacen las verificaciones de seguridad, la seguridad puede quedar comprometida
Con una interfaz sealed, no pueden existir nuevas implementaciones no autorizadas. Si recibes un objeto que afirma implementar esa interfaz, tienes la garantía de que es una de las implementaciones verificadas que sí realiza las comprobaciones de seguridad. Es como eliminar de raíz toda una clase de bugs de seguridad y exploits
Es un buen artículo desde la perspectiva de alguien que conoce los tipos suma, pero no conoce bien los tipos suma en Java.
Dicho eso, no sé si solo los tipos suma bastarán para que Java vuelva a gustarme. La amplia posibilidad de
nullsigue ahí, y en este artículo también asoma la cabeza varias veces.nullen Java es un problema grande, pero los frameworks de nulabilidad basados en anotaciones son efectivos y están extendidos por todo el ecosistema. Personalmente, los veo casi indispensables.Me entusiasma mucho https://jspecify.dev/, donde Google, Meta, Microsoft y otros intentan estandarizar anotaciones empezando por
@Nullable.La respuesta del artículo a “¿por qué se llama product type?” no está mal, pero dicho de forma más intuitiva y concisa, la cantidad total de valores posibles en un product type es el producto de la cantidad de valores posibles de sus tipos componentes.
Lo mismo se cumple si cambiamos product por sum.
Curiosamente, la cantidad total de funciones únicas de la forma
a -> b, viendo solo la entrada y la salida, se puede calcular como una potencia. Es decir,(cantidad de valores posibles de b) ^ (cantidad de valores posibles de a).Si lo escribimos matemáticamente, para una lista de Bool, la izquierda es la cantidad de elementos y la derecha es la cantidad total de posibilidades.
0 : 1
1 : 2
2 : 4
3 : 8
4 : 16
5 : 32
Y así sucesivamente.
Estoy esperando a que Project Valhalla se complete y por fin lleguen los tipos valor a Java. Entonces, con tipos suma, tipos valor y corrutinas, parece que se convertirá en un lenguaje bastante decente.
Java en realidad nunca fue un lenguaje realmente malo.
El problema era la gente: la enorme sobreingeniería, demasiados conceptos abstractos que dificultan entender el codebase, hechicería de código en forma de anotaciones como sentencias GOTO inversas, y los frameworks de DI.
Lo que hay que arreglar no es el lenguaje, sino el ecosistema. Hace falta una especie de movimiento de “reforma” dentro del ecosistema Java.
Pasarse a Kotlin, Clojure o Scala no es suficiente.
HammerFactoryFactoryque produzcaHammerFactory. Pero el ecosistema Java fomenta y alienta esta forma de resolver problemas. Creo que C# es parecido.Una cosa que Java realmente necesita son funciones independientes, o funciones con namespace. A veces no hace falta una clase; basta con una función dentro de un módulo o namespace. No entiendo por qué no se puede.
El autor explica por qué hacen falta los Records y menciona que la mayoría de los objetos Java mantienen todos los campos como private y solo permiten acceder a ellos mediante métodos de acceso de lectura y escritura.
Pero no hay una convención impuesta a nivel de lenguaje para definir accesores, así que si al getter de
foolo llamasgetBar, funciona, pero puede confundir a quien quiera acceder abar.Scala admite pattern matching sobre objetos que implementan el método
unapply. ¿Se considera perjudicial este enfoque? ¿Por qué Java no siguió este camino?unapplyesté en preparación, así que la esperanza todavía no está del todo perdida.Java siempre fue un gran lenguaje. Lo que da ganas de vomitar es el ecosistema de estilo enterprise. He visto usar decenas de clases e interfaces para implementar una sola línea de lógica.