¿Por qué las variables locales utilizadas en lambdas tienen que ser finales o efectivamente finales?

1. Introducción

Java 8 nos da lambdas y, por asociación, la noción de variables efectivamente finales . ¿Alguna vez se preguntó por qué las variables locales capturadas en lambdas tienen que ser definitivas o efectivamente definitivas?

Bueno, el JLS nos da una pequeña pista cuando dice "La restricción a las variables finales efectivamente prohíbe el acceso a las variables locales que cambian dinámicamente, cuya captura probablemente introduciría problemas de concurrencia". ¿Pero, qué significa?

En las siguientes secciones, profundizaremos en esta restricción y veremos por qué Java la introdujo. Mostraremos ejemplos para demostrar cómo afecta a las aplicaciones concurrentes y de un solo subproceso , y también desacreditaremos un anti-patrón común para evitar esta restricción.

2. Captura de Lambdas

Las expresiones lambda pueden utilizar variables definidas en un ámbito externo. Nos referimos a estas lambdas como lambdas de captura . Pueden capturar variables estáticas, variables de instancia y variables locales, pero solo las variables locales deben ser definitivas o efectivamente finales.

En versiones anteriores de Java, nos encontramos con esto cuando una clase interna anónima capturó una variable local al método que la rodeaba; necesitábamos agregar la palabra clave final antes de la variable local para que el compilador estuviera satisfecho.

Como un poco de azúcar sintáctico, ahora el compilador puede reconocer situaciones en las que, si bien la palabra clave final no está presente, la referencia no cambia en absoluto, lo que significa que es efectivamente final. Podríamos decir que una variable es efectivamente final si el compilador no se quejaría si la declaramos final.

3. Variables locales en la captura de lambdas

En pocas palabras, esto no se compilará:

Supplier incrementer(int start) { return () -> start++; }

start es una variable local y estamos intentando modificarla dentro de una expresión lambda.

La razón básica por la que esto no se compilará es que la lambda está capturando el valor de inicio , es decir, haciendo una copia. Forzar la variable a ser final evita dar la impresión de que incrementar el inicio dentro de la lambda podría modificar el parámetro del método de inicio .

Pero, ¿por qué hace una copia? Bueno, observe que estamos devolviendo la lambda de nuestro método. Por lo tanto, lambda no se ejecutará hasta que el parámetro del método de inicio obtenga la basura recolectada. Java tiene que hacer una copia de start para que esta lambda viva fuera de este método.

3.1. Problemas de concurrencia

Para la diversión, imaginemos por un momento que Java no permite que las variables locales de alguna manera permanecer conectados a sus valores capturados.

¿Qué debemos hacer aquí?

public void localVariableMultithreading() { boolean run = true; executor.execute(() -> { while (run) { // do operation } }); run = false; }

Si bien esto parece inocente, tiene el insidioso problema de la "visibilidad". Recordemos que cada hilo tiene su propia pila, y así ¿cómo nos aseguramos de que nuestro tiempo de bucle ve el cambio en el plazo variable en la otra pila? La respuesta en otros contextos podría ser el uso de bloques sincronizados o la palabra clave volátil .

Sin embargo, debido a que Java impone la restricción final efectiva, no tenemos que preocuparnos por complejidades como esta.

4. Variables estáticas o de instancia en la captura de Lambdas

Los ejemplos anteriores pueden plantear algunas preguntas si los comparamos con el uso de variables estáticas o de instancia en una expresión lambda.

Podemos compilar nuestro primer ejemplo simplemente convirtiendo nuestra variable de inicio en una variable de instancia:

private int start = 0; Supplier incrementer() { return () -> start++; }

Pero, ¿por qué podemos cambiar el valor de empezar aquí?

En pocas palabras, se trata de dónde se almacenan las variables miembro. Las variables locales están en la pila, pero las variables miembro están en el montón. Debido a que estamos tratando con memoria de pila, el compilador puede garantizar que lambda tendrá acceso al último valor de inicio.

Podemos arreglar nuestro segundo ejemplo haciendo lo mismo:

private volatile boolean run = true; public void instanceVariableMultithreading() { executor.execute(() -> { while (run) { // do operation } }); run = false; }

La variable de ejecución ahora es visible para el lambda incluso cuando se ejecuta en otro hilo, ya que agregamos la palabra clave volátil .

En términos generales, al capturar una variable de instancia, podríamos pensar en ella como capturar la variable final this . De todos modos, el hecho de que el compilador no se queje no significa que no debamos tomar precauciones, especialmente en entornos de subprocesos múltiples.

5. Evite las soluciones alternativas

Para evitar la restricción de las variables locales, alguien puede pensar en usar titulares de variables para modificar el valor de una variable local.

Veamos un ejemplo que usa una matriz para almacenar una variable en una aplicación de un solo subproceso:

public int workaroundSingleThread() { int[] holder = new int[] { 2 }; IntStream sums = IntStream .of(1, 2, 3) .map(val -> val + holder[0]); holder[0] = 0; return sums.sum(); }

Podríamos pensar que la secuencia suma 2 a cada valor, pero en realidad suma 0, ya que este es el último valor disponible cuando se ejecuta la lambda.

Vayamos un paso más allá y ejecutemos la suma en otro hilo:

public void workaroundMultithreading() { int[] holder = new int[] { 2 }; Runnable runnable = () -> System.out.println(IntStream .of(1, 2, 3) .map(val -> val + holder[0]) .sum()); new Thread(runnable).start(); // simulating some processing try { Thread.sleep(new Random().nextInt(3) * 1000L); } catch (InterruptedException e) { throw new RuntimeException(e); } holder[0] = 0; }

¿Qué valor estamos sumando aquí? Depende de cuánto tiempo lleve nuestro procesamiento simulado. Si es lo suficientemente corto como para permitir que la ejecución del método termine antes de que se ejecute el otro hilo, imprimirá 6; de lo contrario, imprimirá 12.

En general, este tipo de soluciones son propensas a errores y pueden producir resultados impredecibles, por lo que siempre debemos evitarlas.

6. Conclusión

En este artículo, explicamos por qué las expresiones lambda solo pueden usar variables locales finales o efectivamente finales. Como hemos visto, esta restricción proviene de la diferente naturaleza de estas variables y de cómo Java las almacena en la memoria. También hemos mostrado los peligros de utilizar una solución alternativa común.

Como siempre, el código fuente completo de los ejemplos está disponible en GitHub.