Pourquoi les variables locales utilisées dans les Lambdas doivent-elles être finales ou effectivement définitives?

1. Introduction

Java 8 nous donne des lambdas, et par association, la notion de variables effectivement finales . Vous êtes-vous déjà demandé pourquoi les variables locales capturées dans les lambdas doivent être finales ou effectivement définitives?

Eh bien, le JLS nous donne un petit indice quand il dit: «La restriction aux variables finales effectivement interdit l'accès à des variables locales à changement dynamique, dont la capture entraînerait probablement des problèmes de concurrence.» Mais qu'est-ce que ça veut dire?

Dans les sections suivantes, nous approfondirons cette restriction et verrons pourquoi Java l'a introduite. Nous montrerons des exemples pour montrer comment cela affecte les applications à un seul thread et simultanées , et nous démystifierons également un anti-modèle commun pour contourner cette restriction.

2. Capturer des lambdas

Les expressions Lambda peuvent utiliser des variables définies dans une portée externe. Nous nous référons à ces lambdas comme capturant des lambdas . Ils peuvent capturer des variables statiques, des variables d'instance et des variables locales, mais seules les variables locales doivent être finales ou effectivement finales.

Dans les versions précédentes de Java, nous avons rencontré cela lorsqu'une classe interne anonyme capturait une variable locale à la méthode qui l'entourait - nous devions ajouter le mot-clé final avant la variable locale pour que le compilateur soit satisfait.

En tant que sucre syntaxique, le compilateur peut maintenant reconnaître les situations où, bien que le mot-clé final ne soit pas présent, la référence ne change pas du tout, ce qui signifie qu'elle est effectivement définitive. Nous pourrions dire qu'une variable est effectivement finale si le compilateur ne se plaignait pas si nous la déclarions finale.

3. Variables locales lors de la capture de Lambdas

En termes simples, cela ne compilera pas:

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

start est une variable locale, et nous essayons de la modifier à l'intérieur d'une expression lambda.

La raison fondamentale pour laquelle cela ne compile pas est que le lambda capture la valeur de start , ce qui signifie en faire une copie. Forcer la variable à être définitive évite de donner l'impression que l'incrémentation de start à l'intérieur du lambda pourrait en fait modifier le paramètre de la méthode de démarrage .

Mais pourquoi fait-il une copie? Eh bien, notez que nous retournons le lambda de notre méthode. Ainsi, le lambda ne sera pas exécuté tant que le paramètre de méthode de démarrage n'aura pas été récupéré. Java doit faire une copie de start pour que ce lambda vive en dehors de cette méthode.

3.1. Problèmes de concurrence

Pour le plaisir, imaginons un instant que Java ait permis aux variables locales de rester en quelque sorte connectées à leurs valeurs capturées.

Que devons-nous faire ici:

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

Bien que cela semble innocent, cela pose le problème insidieux de la «visibilité». Rappelons que chaque thread obtient sa propre pile, et alors comment pouvons-nous assurer que notre tout boucle voit le changement à la course variable dans l'autre pile? La réponse dans d'autres contextes pourrait être l'utilisation de blocs synchronisés ou du mot-clé volatile .

Cependant, comme Java impose la restriction effectivement finale, nous n'avons pas à nous soucier de complexités comme celle-ci.

4. Variables statiques ou d'instance lors de la capture de Lambdas

Les exemples précédents peuvent soulever des questions si nous les comparons avec l'utilisation de variables statiques ou d'instance dans une expression lambda.

Nous pouvons faire compiler notre premier exemple simplement en convertissant notre variable de démarrage en une variable d'instance:

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

Mais pourquoi pouvons-nous changer la valeur de start ici?

En termes simples, il s'agit de l'emplacement de stockage des variables membres. Les variables locales sont sur la pile, mais les variables membres sont sur le tas. Parce que nous avons affaire à de la mémoire de tas, le compilateur peut garantir que le lambda aura accès à la dernière valeur de start.

Nous pouvons corriger notre deuxième exemple en faisant de même:

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

La variable d' exécution est maintenant visible par le lambda même lorsqu'elle est exécutée dans un autre thread depuis que nous avons ajouté le mot-clé volatile .

De manière générale, lors de la capture d'une variable d'instance, nous pourrions la considérer comme capturant la variable finale this . Quoi qu'il en soit, le fait que le compilateur ne se plaint pas ne signifie pas que nous ne devrions pas prendre de précautions, en particulier dans les environnements multithreading.

5. Évitez les solutions de contournement

Afin de contourner la restriction sur les variables locales, quelqu'un peut penser à utiliser des détenteurs de variable pour modifier la valeur d'une variable locale.

Voyons un exemple qui utilise un tableau pour stocker une variable dans une application à un seul thread:

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(); }

Nous pourrions penser que le flux additionne 2 à chaque valeur, mais il additionne en fait 0 car c'est la dernière valeur disponible lorsque le lambda est exécuté.

Allons plus loin et exécutons la somme dans un autre thread:

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; }

Quelle valeur résumons-nous ici? Cela dépend de la durée de notre traitement simulé. S'il est assez court pour laisser l'exécution de la méthode se terminer avant que l'autre thread ne soit exécuté, il affichera 6, sinon, il affichera 12.

En général, ces types de solutions de contournement sont sujettes aux erreurs et peuvent produire des résultats imprévisibles, nous devons donc toujours les éviter.

6. Conclusion

Dans cet article, nous avons expliqué pourquoi les expressions lambda ne peuvent utiliser que des variables locales finales ou effectivement finales. Comme nous l'avons vu, cette restriction vient de la nature différente de ces variables et de la manière dont Java les stocke en mémoire. Nous avons également montré les dangers de l'utilisation d'une solution de contournement commune.

Comme toujours, le code source complet des exemples est disponible à l'adresse over sur GitHub.