16  Operacions agregades: lambda i streams

Objectius

  • Escriure expressions lambda i entendre-les com “funcions curtes sense nom”.
  • Fer servir referències a mètode (Classe::metode) quan simplifiquen.
  • Convertir una col·lecció en un Stream i encadenar operacions.
  • Aplicar les operacions clau: filter, map, reduce i collect.
  • Decidir quan un stream és més clar que un bucle, i quan no.

Hem treballat amb llistes, conjunts i mapes. Ara aprendrem a processar-les d’una manera nova: en comptes de dir pas a pas com recórrer-les (estil imperatiu, amb bucles), diràs què vols obtenir (estil declaratiu). Aquesta és la programació funcional que Java va incorporar a la versió 8 i que avui és omnipresent.

16.1 Expressions lambda

Una expressió lambda és un tros de codi que representa una funció curta i anònima: rep uns paràmetres i produeix un resultat, però no li poses nom ni la declares com un mètode complet. La sintaxi és:

    (paràmetres) -> cos

Serveix per passar comportament com si fos un valor. Fins ara passaves dades a un mètode (números, textos); amb les lambda pots passar accions.

Exemple:

// Una interfície funcional: un sol mètode abstracte
Runnable saluda = () -> System.out.println("Hola!");
saluda.run();   // Hola!

// Amb paràmetres (la interfície Comparator del capítol anterior)
Comparator<String> perLongitud = (a, b) -> a.length() - b.length();

// Amb un paràmetre, cos amb retorn implícit
Function<Integer, Integer> doble = x -> x * 2;
System.out.println(doble.apply(5));   // 10

Una lambda només funciona on s’espera una interfície funcional: una interfície amb un sol mètode abstracte (com Comparator, Runnable, Predicate, Function). La lambda “omple” aquest mètode.

NotaFormes de la lambda
  • Sense paràmetres: () -> ...
  • Un paràmetre: x -> ... (parèntesis opcionals)
  • Diversos: (a, b) -> ...
  • Cos d’una expressió: x -> x * 2 (retorn implícit)
  • Cos de bloc: x -> { int r = x * 2; return r; } (return explícit)
AlertaError típic: return i claus
Function<Integer, Integer> doble = x -> { x * 2; };   // ERROR

Si obres claus { }, el cos és un bloc i necessites return i ;: x -> { return x * 2; }. Si no poses claus, és una expressió i el retorn és implícit: x -> x * 2. No barregis les dues coses.

ConsellMini-repte

Escriu una lambda de tipus Predicate<String> que digui si un text té més de 5 caràcters. Prova-la amb .test("programació").

16.2 Referències a mètode

Sovint una lambda només crida un mètode que ja existeix: x -> x.toUpperCase().

En aquests casos pots escriure una referència a mètode, més curta i llegible, amb ::.

Exemple:

// Lambda  →  Referència a mètode equivalent
x -> x.toUpperCase()          String::toUpperCase
x -> System.out.println(x)    System.out::println
(a, b) -> a.compareTo(b)      String::compareTo
n -> new Llibre(n)            Llibre::new               // Constructor
List<String> noms = List.of("marta", "pau", "aina");
noms.forEach(System.out::println);                      // En comptes de n -> System.out.println(n)
noms.stream().map(String::toUpperCase);                 // En comptes de n -> n.toUpperCase()
ConsellQuan usar-la

Fes servir la referència a mètode quan la lambda no fa res més que cridar aquell mètode. Si la lambda fa càlculs o passa arguments en un altre ordre, deixa-la com a lambda: serà més clara.

16.3 Què és un Stream?

Un Stream és una seqüència de dades que flueix per una cadena d’operacions. No és una col·lecció que guarda dades: és una canonada de processament. Agafes una font (una List, per exemple), hi apliques passos (filtrar, transformar…) i en surt un resultat.

Un stream té tres parts:

  1. Font: d’on surten les dades (llista.stream()).
  2. Operacions intermèdies: transformen el flux i en tornen un altre stream (filter, map, sorted). Són mandroses: no fan res fins que…
  3. Operació terminal — dispara tot el procés i produeix un resultat (collect, count, forEach, reduce).

flowchart LR
    F["Font<br/>List.stream()"] --> I1["filter()<br/>intermèdia"]
    I1 --> I2["map()<br/>intermèdia"]
    I2 --> I3["sorted()<br/>intermèdia"]
    I3 --> T["collect()<br/>TERMINAL"]
    T --> R["Resultat"]

ConsellAnalogia

Un stream és una cadena de muntatge d’una fàbrica. Les peces (dades) entren per un extrem i passen per estacions: una les inspecciona i descarta les defectuoses (filter), una altra les pinta (map), una altra les ordena (sorted). Al final de la cinta, s’empaqueten en una caixa (collect).

Mentre no hi ha caixa esperant al final (operació terminal), la cadena no s’engega.

Exemple:

List<String> noms = List.of("Marta", "pau", "Aina", "joan");

List<String> resultat = noms.stream()               // 1. Font
    .filter(n -> n.length() > 3)                    // 2. Intermèdia: llargs
    .map(String::toUpperCase)                       // 2. Intermèdia: majúscules
    .sorted()                                       // 2. Intermèdia: ordena
    .collect(Collectors.toList());                  // 3. Terminal: recull en llista

System.out.println(resultat);                       // [AINA, JOAN, MARTA]
AlertaError típic: stream sense operació terminal

Sense operació terminal, les operacions intermèdies no s’executen mai (són mandroses). Cal tancar la cadena amb collect, forEach, count

Un altre error: reutilitzar un stream ja consumit llança IllegalStateException. Un stream és d’un sol ús.

noms.stream().filter(n -> n.length() > 3).map(String::toUpperCase);   // no fa RES!
ConsellMini-repte

Amb List.of(4, 15, 8, 23, 42), escriu un stream que es quedi només amb els números més grans que 10 i els mostri per pantalla amb forEach.

16.4 Operacions principals

Les operacions principals són:

  • filter: es queda amb els compleixen una condició.
  • map: transforma tots els elements implicats.
  • reduce: ho combinar tot en un sol valor.
  • collect: recull el resultat.

Operació filter

filter rep un Predicate (una condició) i deixa passar només els elements que la compleixen.

List<Integer> edats = List.of(15, 22, 17, 40, 19);

List<Integer> majors = edats.stream()
    .filter(e -> e >= 18)                               // Condició
    .collect(Collectors.toList());
System.out.println(majors);                             // [22, 40, 19]

Operació map

map rep una Function i transforma cada element en un altre (potser d’un altre tipus). El nombre d’elements no canvia; el seu contingut o tipus, sí.

List<String> noms = List.of("marta", "pau", "aina");

List<Integer> longituds = noms.stream()
    .map(String::length)                  // String → Integer
    .collect(Collectors.toList());
System.out.println(longituds);            // [5, 3, 4]

Operació reduce

reduce agrega tots els elements en un de sol (una suma, un producte, el màxim…) combinant-los de dos en dos.

List<Integer> preus = List.of(10, 25, 5, 40);

int total = preus.stream()
    .reduce(0, (a, b) -> a + b);                    // Valor inicial 0, suma acumulada
System.out.println(total);                          // 80

// Per a sumes hi ha una drecera encara més clara:
int total2 = preus.stream().mapToInt(Integer::intValue).sum();

Operació collect

collect és l’operació terminal més versàtil: empaqueta el flux en una estructura. Amb la classe Collectors pots recollir en llista, conjunt, mapa, unir textos o agrupar.

List<String> paraules = List.of("gat", "gos", "girafa", "os", "elefant");

// A llista / conjunt
List<String> ambG = paraules.stream()
    .filter(p -> p.startsWith("g"))
    .collect(Collectors.toList());

// Unir en un text
String frase = paraules.stream()
    .collect(Collectors.joining(", ", "[", "]"));           // [gat, gos, girafa, os, elefant]

// Agrupar per longitud → Map<Integer, List<String>>
Map<Integer, List<String>> perLongitud = paraules.stream()
    .collect(Collectors.groupingBy(String::length));
System.out.println(perLongitud);                            // {2=[os], 3=[gat, gos], 6=[girafa], 7=[elefant]}
NotaCollectors.toList() vs .toList()

Des de Java 16 pots tancar amb .toList() directament (més curt), que retorna una llista immutable.

Collectors.toList() retorna una llista mutable. Si després l’has de modificar, fes servir Collectors.toList() o Collectors.toCollection(ArrayList::new).

Exemple:

record Producte(String nom, String categoria, double preu) {}

List<Producte> cistella = List.of(
    new Producte("Poma", "fruita", 0.50),
    new Producte("Llet", "làctic", 1.20),
    new Producte("Formatge", "làctic", 4.80),
    new Producte("Plàtan", "fruita", 0.35)
);

// Preu total dels làctics
double totalLactics = cistella.stream()
    .filter(p -> p.categoria().equals("làctic"))
    .mapToDouble(Producte::preu)
    .sum();
System.out.println("Làctics: " + totalLactics + " €");   // 6.0 €

// Noms de productes de menys d'1 €, en majúscules i ordenats
List<String> barats = cistella.stream()
    .filter(p -> p.preu() < 1.0)
    .map(p -> p.nom().toUpperCase())
    .sorted()
    .toList();
System.out.println(barats);   // [PLÀTAN, POMA]
ConsellMini-repte

Amb la llista cistella, agrupa els productes per categoria i calcula el preu mitjà de cada categoria.

Pista: Collectors.groupingBy(Producte::categoria, Collectors.averagingDouble(Producte::preu)).

16.5 Bucle o stream?

Els streams no substitueixen els bucles: conviuen. Tria segons la claredat.

Aspecte Bucle for / for-each Stream
Estil Imperatiu (com fer-ho) Declaratiu (què vull)
Llegibilitat en cadenes filtrar+transformar Verbós Molt clar
Modificar variables externes Fàcil Desaconsellat (efectes secundaris)
Sortir a mig recorregut (break) Natural Difícil (cal findFirst, anyMatch)
Depuració pas a pas Còmoda Menys directa
Lògica simple / un sol pas Sovint més clar Pot sobrar
ConsellRegla pràctica

Fes servir streams quan encadenes diverses transformacions sobre una col·lecció (filtrar + transformar + agrupar) i vols codi net i sense variables temporals.

Fes servir bucles quan hi ha lògica complexa, sortides anticipades (break) o quan un stream et quedaria enrevessat.

Si un stream necessita moltes lambdes de bloc amb { }, probablement un bucle seria més clar.

AlertaError típic: efectes secundaris dins d’un stream

Modificar una llista externa des d’un stream desvirtua tot el sentit funcional (i falla en paral·lel). El correcte és recollir el resultat: List<String> sortida = noms.stream().map(String::toUpperCase).toList();

List<String> sortida = new ArrayList<>();
noms.stream().forEach(n -> sortida.add(n.toUpperCase()));   // Funciona, però és mal estil
NotaStreams sobre nombres: IntStream i amics

Per treballar amb primitius sense el cost de l’autoboxing, hi ha IntStream, LongStream i DoubleStream, amb mètodes com sum(), average(), max(), range():

int suma = IntStream.rangeClosed(1, 100).sum();   // 5050

16.6 Resum

  • Una lambda (params) -> cos és una funció curta i anònima que representa un comportament; només encaixa on s’espera una interfície funcional (un sol mètode abstracte).
  • Les referències a mètode (Classe::metode) són la forma curta d’una lambda que només crida un mètode existent.
  • Un Stream és una canonada de processament: font → operacions intermèdies (peresoses) → operació terminal. Sense terminal, no s’executa res.
    • filter: selecciona.
    • map: transforma.
    • reduce: agrega en un sol valor.
    • collect: recull el resultat (llista, mapa, text, agrupació…).
  • Els streams brillen en cadenes de transformacions; els bucles guanyen amb lògica complexa o sortides anticipades. Controla els efectes secundaris dins dels streams.

16.7 Per practicar

  1. Filtratge bàsic. Donada una List<Integer>, obtén amb un stream la llista dels parells, elevats al quadrat i ordenats de gran a petit.
  2. Estadístiques de notes. Amb una List<Double> de notes, calcula amb streams: la mitjana, la nota màxima, quantes són aprovades (≥ 5) i el percentatge d’aprovats.
  3. De llista a mapa. Donada una List<Soci> (nom, DNI), construeix un Map<String, Soci> indexat per DNI amb Collectors.toMap.
  4. Agrupacions. Amb la llista cistella de l’exemple, obtén: (a) productes agrupats per categoria, (b) recompte per categoria, (c) el producte més car de cada categoria.
  5. Refactor. Agafa un bucle for d’algun exercici anterior i reescriu-lo amb streams. Després decideix, amb arguments, quina versió és més clara.