Perché le variabili locali utilizzate nei Lambda devono essere definitive o effettivamente definitive?

1. Introduzione

Java 8 ci fornisce lambda e, per associazione, la nozione di variabili effettivamente finali . Ti sei mai chiesto perché le variabili locali catturate in lambda devono essere definitive o effettivamente definitive?

Bene, il JLS ci dà un piccolo suggerimento quando dice "La restrizione alle variabili finali in modo efficace proibisce l'accesso a variabili locali che cambiano dinamicamente, la cui cattura potrebbe introdurre problemi di concorrenza". Ma cosa significa?

Nelle prossime sezioni, approfondiremo questa restrizione e vedremo perché Java l'ha introdotta. Mostreremo esempi per dimostrare come influisce sulle applicazioni a thread singolo e simultanee e sfateremo anche un anti-pattern comune per aggirare questa restrizione.

2. Catturare Lambdas

Le espressioni Lambda possono utilizzare variabili definite in un ambito esterno. Ci riferiamo a questi lambda come acquisizione di lambda . Possono acquisire variabili statiche, variabili di istanza e variabili locali, ma solo le variabili locali devono essere definitive o effettivamente definitive.

Nelle versioni precedenti di Java, ci siamo imbattuti in questo quando una classe interna anonima catturava una variabile locale nel metodo che la circondava: dovevamo aggiungere la parola chiave finale prima della variabile locale affinché il compilatore fosse felice.

Come un po 'di zucchero sintattico, ora il compilatore può riconoscere situazioni in cui, mentre la parola chiave final non è presente, il riferimento non cambia affatto, il che significa che è effettivamente definitivo. Potremmo dire che una variabile è effettivamente finale se il compilatore non si lamentasse se la dichiarassimo finale.

3. Variabili locali nell'acquisizione di Lambda

In poche parole, questo non verrà compilato:

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

start è una variabile locale e stiamo cercando di modificarla all'interno di un'espressione lambda.

Il motivo principale per cui questo non viene compilato è che lambda sta catturando il valore di start , il che significa che ne fa una copia. Forzare la variabile come final evita di dare l'impressione che l'incremento di start all'interno del lambda possa effettivamente modificare il parametro del metodo start .

Ma perché ne fa una copia? Bene, nota che stiamo restituendo il lambda dal nostro metodo. Pertanto, lambda non verrà eseguito fino a quando il parametro del metodo di avvio non viene raccolto in modo obsoleto. Java deve fare una copia di start affinché questo lambda viva al di fuori di questo metodo.

3.1. Problemi di concorrenza

Per divertimento, immaginiamo per un momento che Java abbia consentito alle variabili locali di rimanere in qualche modo connesse ai valori acquisiti.

Cosa dovremmo fare qui:

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

Anche se questo sembra innocente, ha l'insidioso problema della "visibilità". Ricorda che ogni thread ha il proprio stack, quindi come possiamo assicurarci che il nostro ciclo while veda la modifica alla variabile run nell'altro stack? La risposta in altri contesti potrebbe essere l'utilizzo di blocchi sincronizzati o la parola chiave volatile .

Tuttavia, poiché Java impone la restrizione finale effettiva, non dobbiamo preoccuparci di complessità come questa.

4. Variabili statiche o di istanza nell'acquisizione di Lambda

Gli esempi precedenti possono sollevare alcune domande se li confrontiamo con l'uso di variabili statiche o di istanza in un'espressione lambda.

Possiamo compilare il nostro primo esempio semplicemente convertendo la nostra variabile di inizio in una variabile di istanza:

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

Ma perché possiamo cambiare il valore di start qui?

In poche parole, si tratta di dove vengono memorizzate le variabili membro. Le variabili locali sono nello stack, ma le variabili membro sono nell'heap. Poiché abbiamo a che fare con la memoria heap, il compilatore può garantire che lambda avrà accesso all'ultimo valore di start.

Possiamo correggere il nostro secondo esempio facendo lo stesso:

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

La variabile run è ora visibile al lambda anche quando viene eseguita in un altro thread poiché abbiamo aggiunto la parola chiave volatile .

In generale, quando si acquisisce una variabile di istanza, si potrebbe pensare ad essa come a catturare la variabile finale this . Ad ogni modo, il fatto che il compilatore non si lamenti non significa che non dovremmo prendere precauzioni, specialmente in ambienti multithreading.

5. Evitare soluzioni alternative

Per aggirare la restrizione sulle variabili locali, qualcuno potrebbe pensare di utilizzare i contenitori di variabili per modificare il valore di una variabile locale.

Vediamo un esempio che utilizza un array per memorizzare una variabile in un'applicazione a thread singolo:

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

Potremmo pensare che il flusso stia sommando 2 a ciascun valore, ma in realtà sta sommando 0 poiché questo è l'ultimo valore disponibile quando viene eseguito lambda.

Facciamo un ulteriore passo avanti ed eseguiamo la somma in un altro 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; }

Che valore stiamo sommando qui? Dipende da quanto tempo impiega la nostra elaborazione simulata. Se è abbastanza breve da far terminare l'esecuzione del metodo prima che venga eseguito l'altro thread, verrà stampato 6, altrimenti verrà stampato 12.

In generale, questi tipi di soluzioni alternative sono soggetti a errori e possono produrre risultati imprevedibili, quindi dovremmo sempre evitarli.

6. Conclusione

In questo articolo, abbiamo spiegato perché le espressioni lambda possono utilizzare solo variabili locali finali o effettivamente finali. Come abbiamo visto, questa restrizione deriva dalla diversa natura di queste variabili e dal modo in cui Java le memorizza. Abbiamo anche mostrato i pericoli dell'utilizzo di una soluzione alternativa comune.

Come sempre, il codice sorgente completo per gli esempi è disponibile su GitHub.