W3docs

Programmazione Funzionale in Java

Panoramica dei concetti di programmazione funzionale in Java: funzioni di prima classe, immutabilità, funzioni pure e composizione.

La parte precedente — Collections Framework — riguardava i contenitori: strutture dati che raccolgono elementi e le operazioni (add, remove, iterate, sort, binarySearch) che vi operano. Questa parte riguarda un livello diverso dello stesso problema. Invece di dove vivono i dati, ci concentreremo su come esprimere le trasformazioni su di essi — in modo chiaro, compositivo, senza cicli ridondanti o variabili accumulatore.

Questo cambiamento ha un nome. La programmazione funzionale è lo stile in cui il calcolo è espresso come applicazione di funzioni a valori, in cui le funzioni stesse sono valori di prima classe, e in cui i dati sono generalmente trattati come immutabili. Java non è stato progettato come linguaggio funzionale — classi, mutabilità e cicli espliciti sono al suo nucleo — ma a partire da Java 8 ogni programma Java moderno attinge ampiamente dalla cassetta degli strumenti funzionale. Ne hai già scritto un po' nella parte sulle collezioni: list.sort(Comparator.comparing(Person::name)), map.getOrDefault(k, 0), List.copyOf(source). La Parte 12 nomina esplicitamente lo stile e ti fornisce il resto degli strumenti — lambda, interfacce funzionali, i tipi java.util.function, i riferimenti a metodo, Optional e la Stream API — in modo che i pattern che stavi imitando diventino mosse di prima classe.

Quattro idee che definiscono lo stile

I linguaggi puramente funzionali (Haskell, Erlang, F#) spingono tutte e quattro al loro limite. Java le applica con moderazione. Le quattro idee:

  1. Le funzioni sono valori di prima classe. Puoi passare una funzione come argomento, restituirne una da un metodo, memorizzarla in un campo o costruirne una a runtime.
  2. Funzioni pure. Una funzione pura dipende solo dai propri input e non cambia nulla di osservabile nel mondo. Dato lo stesso input, restituisce lo stesso output. Niente I/O, niente mutazione di campi, niente branching dipendente dal tempo.
  3. Immutabilità per default. Le strutture dati non vengono modificate in place; le trasformazioni restituiscono nuovi valori. I vecchi riferimenti rimangono validi.
  4. Composizione. Le funzioni più grandi vengono costruite combinando quelle più piccole (f.andThen(g), pred.and(other), cmp.thenComparing(...)), non modificandole.

Nessuna di queste è specifica di Java. Sono un modo di pensare che il linguaggio ora supporta tramite lambda, riferimenti a metodo, la Stream API e le collezioni immutabili che hai appena conosciuto.

1. Le funzioni come valori

Prima di Java 8, non potevi avere una variabile il cui valore fosse una funzione. Potevi passare un oggetto la cui classe aveva un solo metodo — questo erano Runnable, Comparator e ActionListener — ma la sintassi era goffa:

list.sort(new Comparator<String>() {
  @Override
  public int compare(String a, String b) {
    return a.length() - b.length();
  }
});

Il singolo metodo era avvolto in una dichiarazione di classe anonima. Java 8 ha introdotto le espressioni lambda come sintassi concisa per la stessa idea:

list.sort((a, b) -> a.length() - b.length());

La lambda è il valore. Viene compilata in un'istanza di qualsiasi interfaccia funzionale richiesta al sito di chiamata (qui, Comparator<String>). Il prossimo capitolo riguarda interamente la sintassi; per ora il punto è che le funzioni in Java sono ora valori che puoi nominare, memorizzare e passare.

2. Funzioni pure

Una funzione pura è quella il cui valore di ritorno dipende solo dai suoi argomenti e la cui esecuzione non ha effetti collaterali osservabili. Math.sqrt(2) è pura. System.currentTimeMillis() non lo è — restituisce valori diversi tra le chiamate. list.add(x) non lo è — muta list.

Le funzioni pure sono preziose perché:

  • Sono facili da testare — nessuna configurazione, nessun mock, solo assertEquals(expected, f(input)).
  • Sono facili da parallelizzare — due chiamate pure possono girare su thread diversi senza sincronizzazione.
  • Sono facili da cachare — memoizza una volta, restituisci sempre la stessa risposta.
  • Si compongono senza sorpresef(g(x)) fa ciò che suggerisce la lettura.

La maggior parte dei programmi reali utili non è pura al 100% (qualcuno deve scrivere su un database). La disciplina funzionale è rendere il calcolo principale puro e spostare le parti impure — I/O, tempo, casualità, mutazione — ai bordi. Gli Stream incoraggiano questo: una pipeline di operazioni pure è corretta per costruzione; una impura (stream().peek(x -> counter++)...) è una fonte di bug.

3. Immutabilità

L'hai incontrata nell'ultimo capitolo. List.of(...), Set.of(...), Map.of(...) e List.copyOf(...) producono collezioni che non possono essere modificate. I Record (trattati in seguito) ti forniscono classi dati immutabili:

record Point(double x, double y) {
  Point translated(double dx, double dy) {
    return new Point(x + dx, y + dy);     // returns a NEW Point — does not mutate this
  }
}

I valori immutabili sono intrinsecamente thread-safe. Non presentano mai uno stato intermedio "lacerato". Possono essere condivisi liberamente senza copie difensive. E rendono pratiche le funzioni pure — se i valori non possono cambiare, una funzione che ne restituisce uno è garantita essere deterministica per quella parte del mondo.

4. Composizione

La composizione significa "costruire una funzione grande da quelle piccole." In Java, Function, Predicate e Comparator forniscono tutti operatori compositivi:

Function<String, String> trim  = String::trim;
Function<String, String> upper = String::toUpperCase;
Function<String, String> clean = trim.andThen(upper);   // trim, then upper

Predicate<Integer> positive = n -> n > 0;
Predicate<Integer> even     = n -> n % 2 == 0;
Predicate<Integer> posEven  = positive.and(even);

Comparator<String> byLength    = Comparator.comparingInt(String::length);
Comparator<String> lengthThenA = byLength.thenComparing(Comparator.naturalOrder());

L'API compositiva è parte del valore. Non scrivi un helper che prende due predicati e li mette in && — scrivi a.and(b). Lo stile scala: una trasformazione in sei passaggi può essere letta dall'alto in basso come un'unica espressione invece di sei cicli annidati con accumulatori intermedi.

Cosa Java mantiene dal lato imperativo

Java è multi-paradigma. Le funzionalità funzionali aggiunte in Java 8+ convivono con le funzionalità imperative presenti sin dalla versione 1.0. Alcune cose rimangono imperative intenzionalmente:

  • Istruzioni e flusso di controllo. if, for, while, try sono ancora i mattoni di base; le lambda non li sostituiscono, sostituiscono il boilerplate delle classi anonime.
  • Variabili locali mutabili. All'interno del corpo di un metodo, int sum = 0; for (int x : xs) sum += x; è ancora idiomatico.
  • Campi mutabili dove ha senso. Builder, cache e componenti UI con stato mutano ancora.

Il principio: usa lo stile funzionale dove rende il codice più chiaro, non come dogma. Un stream().mapToInt(Integer::intValue).sum() puro è più chiaro di un ciclo scritto a mano. Una pipeline di composizione lambda in sei passaggi che nessuno del tuo team riesce a leggere non lo è.

Un esempio pratico: imperativo vs funzionale, affiancati

Il programma seguente calcola la lunghezza media delle stringhe non vuote in una lista, in due modi. La prima versione è imperativa — un accumulatore mutabile, un ciclo esplicito, una guardia contro la divisione per zero. La seconda versione è funzionale — una pipeline stream di operazioni pure che si legge dall'alto in basso. Il terzo snippet costruisce valori Predicate e Function composti da valori più piccoli, mostrando la composizione in azione.

java— editable, runs on the server

Cosa trarre dall'esecuzione:

  • Entrambe le versioni calcolano la stessa media. Quella imperativa dichiara due contatori mutabili e un corpo del ciclo; quella funzionale concatena cinque operazioni nominate che descrivono ciascuna il cosa, non il come.
  • Predicate.and ha costruito un test composto (notNull.and(notBlank)) da due predicati più piccoli — senza bisogno di un nuovo metodo helper. Questo è la composizione in azione.
  • Function.andThen ha fatto lo stesso per una pipeline che produce valori: trim poi length, espressa come un'unica Function<String, Integer> composita.
  • Ogni operazione nello stream è pura: String::trim, la lambda s -> !s.isEmpty(), String::length — nessuna muta lo stato. Chiamare trimmedLen.apply(\" hi \") due volte ha prodotto la stessa risposta; questa è la garanzia di determinismo che rende le funzioni pure sicure da memoizzare e parallelizzare.

Cosa viene dopo

Il modello mentale è al suo posto: le funzioni sono valori, le trasformazioni pure si compongono, l'immutabilità ti libera da una classe di bug. Il prossimo capitolo, Espressioni Lambda in Java, introduce la sintassi concreta(params) -> body — che rende ergonomico questo stile in Java, oltre alle regole sulla cattura di variabili, il target typing e dove può apparire una lambda.

Esercizio

Pratica
Una funzione 'pura' nel senso della programmazione funzionale è una funzione che...
Una funzione 'pura' nel senso della programmazione funzionale è una funzione che...
Was this page helpful?