Este artículo es el primero de una serie sobre comprensión profunda de Java 8 Lambda, centrado en las características de lenguaje añadidas en Java 8 (como expresiones lambda y referencias a métodos), conceptos lingüísticos (como tipos objetivo y captura de variables) y los principios de diseño detrás de estas funcionalidades.
Basado en el artículo "State of Lambda" de Brian Goetz, este contenido busca responder preguntas fundamentales sobre el diseño y funcionamiento de las expresiones lambda en Java:
- ¿Por qué se diseñaron las expresiones lambda de esta manera en Java 8?
- ¿Cuál es la relación entre lambda y tipos anónimos?
- ¿Cómo realiza Java 8 la inferencia de tipos para las expresiones lambda?
- ¿Por qué se introdujeron los métodos por defecto en Java 8?
- ¿Cómo procesa el compilador de Java las expresiones lambda?
Advertencia: Si no estás familiarizado con la programación funcional o conceptos como map, filter y reduce, te recomendamos estudiar primero los fundamentos de la programación funcional antes de continuar con este artículo.
Antecedentes
Java es un lenguaje de programación orientado a objetos. Tanto la programación orientada a objetos como la funcional pueden encapsular comportamientos dinámicamente: los primeros usan objetos con métodos, mientras que los segundos usan funciones. Sin embargo, en Java los objetos suelen ser "pesados" debido a la necesidad de definir clases, inicializar campos y métodos.
En muchos casos, los objetos de Java simplemente encapsulan una única función. Por ejemplo, la API de Java define interfaces (conocidas como interfaces de callback) donde los usuarios proporcionan instancias de estas interfaces para definir comportamientos específicos:
public interface ActionListener {
void actionPerformed(ActionEvent e);
}
En lugar de definir una clase completa para implementar ActionListener, los usuarios suelen utilizar clases anónimas para definir el comportamiento inline:
boton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
ui.destellar(e.getModifiers());
}
});
Este patrón es ampliamente utilizado en bibliotecas, especialmente en APIs paralelas, donde se necesita proporcionar código para su ejecución. Con el auge de los patrones de callback y la programación funcional, Java necesitaba una forma más ligera de encapsular código como datos.
Las clases internas anónimas presentan varias limitaciones:
- Sintaxis redundante
- Confusión con
thisy nombres de variables - Semántica poco flexible para la carga de tipos y creación de instancias
- Incapacidad para capturar variables no
final - Falta de abstracción del flujo de control
Java SE 8 aborda la mayoría de estos problemas:
- Sintaxis más concisa y reglas de ámbito local para resolver los problemas 1 y 2
- Expresiones más flexibles y optimizables para eludir el problema 3
- Permitiendo al compilador inferir la "constancia" de las variables para mitigar el problema 4
Interfaces Funcionales
A pesar de sus limitaciones, las clases internas anónimas tienen una característica valiosa: cada objeto de función corresponde a un tipo de interfaz. Esto es beneficioso porque:
- Las interfaces son parte del sistema de tipos de Java
- Las interfaces tienen una representación en tiempo de ejecución natural
- Las interfaces pueden expresar contratos no formales a través de comentarios Javadoc
La interfaz ActionListener mencionada anteriormente tiene solo un método, al igual que muchas interfaces de callback como Runnable y Comparator. A estas interfaces con un solo método las llamamos interfaces funcionales (anteriormente conocidas como tipos SAM o Single Abstract Method).
No es necesario declarar explícitamente que una interfaz es funcional: el compilador lo determina según la estructura de la interfaz. Sin embargo, los autores de API pueden usar la anotación @FunctionalInterface para especificar que una interfaz es funcional, lo que hace que el compilador verifique que cumple con los requisitos.
Se consideró introducir un nuevo tipo de función estructurada ("tipo de flecha"), pero se descartó por varias razones:
- Añadiría complejidad al sistema de tipos de Java
- Causaría divergencia en el estilo de las bibliotecas
- La sintaxis se volvería engorrosa, especialmente con excepciones comprobadas
- Los tipos de función carecerían de representación en tiempo de ejecución, afectando por el borrado de tipos
Por lo tanto, se optó por utilizar tipos existentes: las interfaces funcionales. Java SE 7 ya incluía interfaces funcionales como:
java.lang.Runnablejava.util.concurrent.Callablejava.security.PrivilegedActionjava.util.Comparator
Java SE 8 añade el paquete java.util.function con interfaces funcionales comunes:
Predicate<T>- recibe un objetoTy devuelvebooleanConsumer<T>- recibe un objetoT, no devuelve valorFunction<T, R>- recibe un objetoT, devuelve un objetoRSupplier<T>- proporciona un objetoT(como una fábrica)UnaryOperator<T>- recibe un objetoT, devuelve un objetoTBinaryOperator<T>- recibe dos objetosT, devuelve un objetoT
También se incluyen especializaicones para tipos primitivos (int, long, double) y interfaces para múltiples parámetros como BiFunction<T, U, R>.
Expresiones Lambda
La principal limitación de las clases internas anónimas es su sintaxis redundante. Las expresiones lambda son métodos anónimos que proporcionan una sintaxis ligera, resolviendo el "problema de altura" de las clases internas anónimas.
Algunos ejemplos de expresiones lambda:
(int x, int y) -> x + y
() -> 42
(String s) -> { System.out.println(s); }
La primera expresión recibe dos parámetros enteros y devuelve su suma; la segunda no recibe parámetros y devuelve el entero 42; la tercera recibe una cadena y la imprime en la consola sin devolver valor.
La sintaxis de una expresión lambda consta de una lista de parámetros, el símbolo de flecha -> y un cuerpo de función. El cuerpo puede ser una expresión o un bloque de sentencias:
- Expresión: se ejecuta y devuelve su resultado.
- Bloque de sentencias: se ejecutan secuencialmente como en un método normal, con
return,breakycontinue(este último solo en bucles).
Los cuerpos de expresión son adecuados para lambdas pequeñas, eliminando la necesidad de la palabra clave return y haciendo la sintaxis más concisa.
Ejemplos de expresiones lambda en contexto:
FiltroJava archivo = (Archivo f) -> f.getNombre().endsWith("*.java");
String usuario = ejecutarConPrivilegio(() -> System.getProperty("usuario.nombre"));
new Thread(() -> {
conectarAlServicio();
enviarNotificacion();
}).start();
Tipos Objetivo
El nombre de la interfaz funcional no es parte de la expresión lambda. Entonces, ¿cuál es el tipo de una expresión lambda dada? La respuesta es: su tipo se deriva del contexto. Por ejemplo, en el siguiente código, el tipo de la expresión lambda es ActionListener:
ActionListener escucha = (EventoAccion e) -> ui.destellar(e.getModificadores());
Esto significa que la misma expresión lambda puede tener diferentes tipos en diferentes contextos:
Invocable<string> invocable = () -> "terminado";
AccionPrivilegiada<string> accion = () -> "terminado";
</string></string>
El compilador se encarga de inferir el tipo de las expresiones lambda utilizando el tipo objetivo del contexto. Las expresiones lambda solo pueden aparecer en contextos donde el tipo objetivo es una interfaz funcional.
El compilador verifica que la firma del método del tipo objetivo sea compatible con la expresión lambda. Para que una expresión lambda pueda asignarse a un tipo objetivo T, deben cumplirse todas las siguientes condiciones:
Tes una interfaz funcional- Los parámetros de la expresión lambda corresponden en número y tipo a los de
T - El valor de retorno de la expresión lambda es compatible con el de
T - Las excepciones lanzadas por la expresión lambda son compatibles con las declaradas en
T
Dado que el tipo objetivo "sabe" los tipos de los parámetros formales de la expresión lambda, no es necesario repetirlos. Por ejemplo:
Comparador<string> c = (s1, s2) -> s1.compareToIgnoreCase(s2);
</string>
En este ejemplo, el compilador puede inferir que s1 y s2 son de tipo String. Además, cuando la lambda tiene un solo parámetro cuyo tipo puede inferirse, se pueden omitir los paréntesis:
FiltroJava archivo = f -> f.getNombre().endsWith(".java");
boton.addActionListener(e -> ui.destellar(e.getModificadores()));
Estas mejoras demuestran el diseño de "no convertir el problema de altura en un problema de anchura". Se busca que los elementos sintácticos sean mínimos para que el lector pueda centrarse en la esencia de la expresión lambda.
Contextos de Tipo Objetivo
Las expresiones lambda solo pueden aparecer en contextos con tipo objetivo. Estos contextos incluyen:
- Declaraciones de variables
- Asignaciones
- Sentencias de retorno
- Inicializadores de arrays
- Parámetros de métodos y constructores
- Cuerpos de expresiones lambda
- Expresiones condicionales (
? :) - Expresiones de conversión de tipo (
cast)
En los primeros tres contextos (declaración de variables, asignaciones y sentencias de retorno), el tipo objetivo es el tipo que se está asignando o devolviendo:
Comparador<string> c;
c = (String s1, String s2) -> s1.compareToIgnoreCase(s2);
public Runnable paraMasTarde() {
return () -> {
System.out.println("más tarde");
}
}
</string>
Los inicializadores de arrays son similares a las asignaciones, pero la "variable" es el elemento del array y el tipo se deriva del tipo del array:
filtrarArchivos(new FiltroArchivo[] {
f -> f.existe(), f -> f.sePuedeLeer(), f -> f.getNombre().startsWith("q")
});
La inferencia de tipo para parámetros de métodos es más compleja e involucra resolución de sobrecarga e inferencia de argumentos de tipo. Si hay ambigüedad, se pueden usar conversiones de tipo o expresiones lambda explícitas para proporcionar más información.
Las expresiones lambda también pueden proporcionar tipos objetivo para sus propios cuerpos, permitiendo escribir funciones que devuelven funciones:
Provedor<runnable> c = () -> () -> { System.out.println("hola"); };
</runnable>
Las expresiones condicionales pueden distribuir el tipo objetivo a sus subexpresiones:
Invocable<integer> c = bandera ? (() -> 23) : (() -> 42);
</integer>
Las expresiones de conversión pueden proporcionar explícitamente el tipo de una expresión lambda, útil cuando no se puede determinar el tipo objetivo:
Object o = (Ejecutable) () -> { System.out.println("hola"); };
Ámbito Léxico
Las clases internas anónimas son propensas a errores con nombres de variables y this. Las expresiones lambda tienen una semántica más simple: no heredan ningún nombre de variable de una superclase ni introducen un nuevo ámbito. Las expresiones lambda se basan en el ámbito léxico, lo que significa que las variables en el cuerpo de la lambda tienen la misma semántica que en el entorno exterior.
Por ejemplo, en el siguiente código, this se refiere a la instancia de la clase Hola, no a una instancia de la lambda:
public class Hola {
Runnable r1 = () -> { System.out.println(this); }
Runnable r2 = () -> { System.out.println(toString()); }
public String toString() { return "Hola, mundo"; }
public static void main(String... args) {
new Hola().r1.run();
new Hola().r2.run();
}
}
Las expresiones lambda no pueden ocultar ninguna variable local en su contexto, su comportamiento es consistente con las estructuras de control que tienen parámetros (como bucles for y cláusulas catch).
Captura de Variables
En Java SE 7, las clases internas anónimas solo podían capturar variables declaradas como final. Java SE 8 relaja esta restricción: permite capturar variables locales que son efectivamente finales (no modificadas después de la inicialización).
Invocable<string> invocableHola(String nombre) {
String saludo = "Hola";
return () -> (saludo + ", " + nombre);
}
</string>
Las referencias a this y a campos no calificados se consideran esencialmente el uso de variables locales final. Las expresiones lambda que contienen tales referencias capturan implícitamente la instancia this.
Esta característica es beneficiosa para la gestión de memoria: mientras que las clases internas anónimas mantienen una referencia fuerte a su instancia externa, las lambdas que no capturan miembros externos no mantienen tal referencia.
A pesar de relajar las restricciones sintácticas, modificar variables capturadas sigue estando prohibido, ya que podría provocar condiciones de carrera. Por ejemplo:
int suma = 0;
lista.forEach(e -> { suma += e.tamano(); }); // Ilegal
Las expresiones lambda están cerradas sobre valores, no sobre variables. Para acumular valores, se pueden usar operaciones de reducción del paquete java.util.stream:
int suma = lista.stream()
.mapToInt(e -> e.tamano())
.sum();
Referencias a Métodos
Las expresiones lambda permiten definir métodos anónimos. Las referencias a métodos permiten hacer lo mismo con métodos existentes. Tienen las mismas características que las expresiones lambda (necesitan un tipo objetivo y se convierten en instancias de interfaz funcional), pero no requieren un cuerpo de método.
Por ejemplo, para ordenar un array de Persona por nombre:
class Persona {
private final String nombre;
private final int edad;
public int getEdad() { return edad; }
public String getNombre() {return nombre; }
...
}
Persona[] personas = ...
Comparador<persona> porNombre = Comparador.comparando(p -> p.getNombre());
Arrays.ordenar(personas, porNombre);
</persona>
Se puede reemplazar la expresión lambda con una referencia al método:
Comparador<persona> porNombre = Comparador.comparando(Persona::getNombre);
</persona>
Las firmas de los métodos referenciados pueden adaptarse mediante relajación de tipos, boxing y organización en arrays de parámetros, similar a las llamadas a métodos reales:
Consumidor<integer> b1 = System::exit; // void exit(int status)
Consumidor<string> b2 = Arrays::sort; // void sort(Object[] a)
Consumidor<string> b3 = MiPrograma::main; // void main(String... args)
Runnable r = MiPrograma::mapToInt // void main(String... args)
</string></string></integer>
Tipos de Referencias a Métodos
Existen varios tipos de referencias a métodos:
- Referencia a métodos estáticos:
NombreClase::nombreMetodo - Referencia a métodos de instancia desde una instancia:
referenciaInstancia::nombreMetodo - Referencia a métodos de instancia desde una superclase:
super::nombreMetodo - Referencia a métodos de instancia desde un tipo:
NombreClase::nombreMetodo - Referencia a constructores:
Clase::new - Referencia a constructores de array:
NombreTipo[]::new
Para referencias a métodos estáticos, se usa :: entre el nombre de la clase y el método:
IntBinaryador sumador = Integer::sum;
Para referencias a métodos de instancia desde un objeto específico:
Conjunto<string> nombresConocidos = ...
Predicado<string> esConocido = nombresConocidos::contiene;
</string></string>
La expresión lambda implícita captura el objeto String y usa Set.contains con él.
Para referenciar métodos de instancia de cualquier objeto:
Funcion<string string=""> mayusculas = String::toUpperCase;
</string>
La expresión lambda implícita tiene un parámetro String que se usa con el método toUpperCase.
Los constructores también pueden referenciarse directamente con new:
FabricaSocketImpl fabrica = MiSocketImpl::new;
Si el tipo es genérico, se pueden proporcionar parámetros de tipo o dejar que el compilador los infiera.
Las referencias a constructores de array tienen una sintaxis especial:
IntFuncion<int> creadorArray = int[]::new;
int[] array = creadorArray.aplicar(10); // crea array int[10]
</int>
Métodos por Defecto y Estáticos en Interfaces
Para facilitar la adición de nuevas funcionalidades a bibliotecas existentes, Java SE 8 introduce los métodos por defecto en interfaces. Esto permite añadir nuevos métodos a interfaces ya publicadas sin romper las implementaciones existentes.
Por ejemplo, para añadir un método omitir a la interfaz Iterador:
interface Iterador<e> {
boolean tieneSiguiente();
E siguiente();
void remover();
void omitir(int i) porDefecto {
for ( ; i > 0 && tieneSiguiente(); i -= 1) siguiente();
}
}
</e>
Todas las clases que implementen Iterador heredarán automáticamente el método omitir. Las subclases pueden sobrescribirlo para proporcionar implementaciones más eficientes o con propiedades adicionales como atomicidad.
Además de los métodos por defecto, Java SE 8 permite definir métodos estáticos en interfaces. Esto permite colocar métodos auxiliales directamente en la interfaz en lugar de en clases de utilidad separadas:
public static <t comparable="" extends="" super="" u="">>
Comparador<t> comparando(Funcion<t u=""> extractorClave) {
return (c1, c2) -> extractorClave.aplicar(c1).compareTo(extractorClave.aplicar(c2));
}
</t></t></t>
Herencia de Métodos por Defecto
Los métodos por defecto se heredan de manera similar a otros métodos. Sin embargo, cuando una clase o interfaz tiene múltiples métodos con la misma firma, se aplican reglas de prioridad:
- Los métodos de clase tienen prioridad sobre los métodos por defecto de interfaz.
- Los métodos sobrescritos en otros tipos se ignoran.
Si dos métodos por defecto entran en conflicto o un método por defecto entra en conflicto con un método abstracto, se produce un error de compilación. En estos casos, el programador debe sobrescribir explícitamente el método de la superclase:
interface Robot implementa Artista, Arma {
void dibujar() porDefecto { Artista.super.dibujar(); }
}
Ejemplo Integrado
Veamos cómo las nuevas características de Java 8 trabajan juntas para simplificar el código. Supongamos que queremos ordenar una lista de personas por apellido:
// Antes de Java 8
Lista<persona> personas = ...
Collections.ordenar(personas, new Comparador<persona>() {
public int comparar(Persona x, Persona y) {
return x.getApellido().compareTo(y.getApellido());
}
})
</persona></persona>
Con expresiones lambda:
Lista<persona> personas = ...
Collections.ordenar(personas,
(Persona x, Persona y) -> x.getApellido().compareTo(y.getApellido()));
</persona>
Usando el método comparando de Comparador:
Lista<persona> personas = ...
Collections.ordenar(personas, Comparador.comparando((Persona p) -> p.getApellido()));
</persona>
Con inferencia de tipo y métodos estáticos:
Lista<persona> personas = ...
Collections.ordenar(personas, comparando(p -> p.getApellido()));
</persona>
Reemplazando la lambda con una referencia a método:
Lista<persona> personas = ...
Collections.ordenar(personas, comparando(Persona::getApellido));
</persona>
Finalmente, usando el método ordenar por defecto añadido a Lista:
personas.ordenar(comparando(Persona::getApellido));
Y si queremos ordenar en orden descendente:
personas.ordenar(comparando(Persona::getApellido).invertido());
Resumen
Java SE 8 introduce varias características nuevas: expresiones lambda, referencias a métodos, métodos por defecto y estáticos en interfaces, y una inferencia de tipos más amplia. Aunque no son muchas características, cuando se combinan permiten a los desarrolladores escribir código más claro y conciso, y a los autores de bibliotecas crear bibliotecas paralelas más potentes y fáciles de usar.