Laboratorul 11: Programare Funcțională, Lambdas și Stream-uri

  • Data publicării: 15.12.2025
  • Data ultimei modificări: 15.12.2025
    • adăugare secțiune despre operații short-circuit.
    • adăugare secțiune [Nice to know] despre Optional.
    • adăugare secțiune despre primitive streams.
    • menționarea boolean chaining când folosim interfețe funcționale de tip Predicate.

Obiective

Scopul acestui laborator este prezentarea unei noi paradigme, concret paradigma funcțională. Aceasta vine ca un superset peste OOP în limbajul Java, tocmai pentru a facilita diverse procese.

Aspectele urmărite sunt:

  • ce este programarea funcțională și cum este integrată în Java.
  • definirea și folosirea lambda expressions.
  • recunoașterea și utilizarea interfețelor funcționale.
  • lucrul cu Stream API pentru procesarea colecțiilor.
  • diferențe dintre stilul imperativ și stilul funcțional de programare.
  • identificarea avantajelor, limitărilor și capcanelor ale abordării funcționale în Java.

Aspectele bonus urmărite sunt:

  • definirea și folosirea Optional în programarea în stil funcțional.
  • paralelizarea codului folosind parallel streams.

  • În acest laborator există mai multe secțiuni marcate [Optional]. Aceste secțiuni cuprind informații bonus care vă pot fi prezentate în timpul laboratorului sau pe care le puteți aprofunda în afara acestuia, ele nefiind necesare pentru laboratoarele viitoare sau pentru teme.
  • De asemenea, veți întâlni câteva secțiuni marcate [Nice to know]. Vă recomandăm ca acestea să aibă prioritate în parcurgerea secțiunilor de tip [Optional], deoarece vă pot oferi informații bonus care să fie și foarte probabil utile pentru teme sau laboratoare viitoare.

🔧 Programarea funcțională

Există mai multe paradigme de programare, fiecare definind un mod diferit de a structura și exprima logica unui program. Cele mai importante sunt programarea imperativă, care descrie execuția pas cu pas, programarea orientată pe obiecte, care modelează aplicația prin obiecte ce combină date și comportament, și programarea funcțională, care pune accent pe transformări de date, funcții pure și evitarea stării mutabile.

Majoritatea limbajelor moderne sunt multi-paradigmă. Java, de exemplu, combină stilul imperativ și orientat pe obiecte cu elemente de programare funcțională (lambda expressions, Stream API), permițând alegerea paradigmei potrivite în funcție de problemă.

Există mai multe tipuri de paradigme de programare:

  • programare imperativă (ex. C, Fortran)
  • programare orientată pe obiecte (ex. Java, C#, C++, Python)
  • programare funcțională (ex. Haskell, Lisp)
  • programare declarativă (ex. SQL, HTML)
  • programare logică (ex. Prolog)
  • programare bazată pe evenimente (ex. JavaScript)

De ce am programa funcțional în Java?

Java a fost conceput inițial ca un limbaj strict orientat pe obiecte, în care:

  • logica este exprimată prin clase,
  • comportamentul este definit prin metode,
  • execuția este descrisă pas cu pas (stil imperativ, ca în C).

Cu toate acestea, pe măsură ce aplicațiile au devenit:

  • mai mari,
  • mai concurente,
  • mai orientate pe procesarea colecțiilor de date,

a apărut nevoia unui stil de programare:

  • mai declarativ,
  • mai ușor de paralelizat,
  • mai puțin dependent de o stare mutabilă.

Începând cu Java 8, limbajul introduce:

  • lambda expressions
  • interfețe funcționale
  • Stream API

care permit scrierea de cod într-un stil funcțional, fără a abandona modelul OOP.

Stilul imperativ vs stilul funcțional

Stilul imperativ (clasic)

În stilul imperativ, programatorul descrie exact pașii de execuție:

List<Integer> result = new ArrayList<>();
 
for (Integer x : numbers) {
    if (x % 2 == 0) {
        result.add(x * 2);
    }
}

Caracteristici:

  • control explicit al fluxului (for, if)
  • variabile temporare
  • stare mutabilă
  • risc crescut de erori (ex: uităm să inițializăm lista)

Reamintim că o stare mutabilă se referă la faptul că o variabilă își poate modifica starea:

int a = 0;
System.out.prtinln(a); // printează 0
a = 2;
System.out.prtinln(a); // acum printează 2, deci este mutable

Stilul funcțional

În stilul funcțional, descriem ce transformări vrem să aplicăm, nu pașii exacți:

List<Integer> result =
    numbers.stream()
           .filter(x -> x % 2 == 0)
           .map(x -> x * 2)
           .toList();

Ce se întâmplă aici?

  • stream() creează o vedere funcțională asupra colecției
  • filter selectează elementele dorite
  • map transformă fiecare element
  • toList() colectează rezultatul final

În exemplul de mai sus, nu modificăm colecția originală. Fiecare pas produce o nouă transformare logică.

📜 Interfețe funcționale și Lambdas

Interfețe funcționale – fundamentul lambda-urilor

Ce este o interfață funcțională?

O interfață funcțională este o interfață care definește exact o metodă abstractă.

@FunctionalInterface
public interface Operation {
    int apply(int a, int b);
}

Această restricție permite compilatorului să știe ce metodă trebuie implementată de o expresie lambda, deoarece există doar o metodă de implementat.

O interfață funcțională poate conține:

  • metode default
  • metode static
  • dar doar o metodă abstractă.

Implementare clasică (fără lambda)

Avem următorul cod care definește cum putem folosi interfața funcțională Operation definită mai sus:

Operation add = new Operation() {
    @Override
    public int apply(int a, int b) {
        return a + b;
    }
};
 
System.out.println(add.apply(2, 3));

Această implementare cu clase anonime funcționează, dar este:

  • greu de citit
  • plin de boilerplate

Lambda Expressions – implementare inline

Ce este o expresie lambda?

O expresie lambda este o implementare inline a metodei unei interfețe funcționale.

Operation add = (a, b) -> a + b;
 
System.out.println(add.apply(2, 3));

Este echivalent logic cu exemplul anterior.

Lambda nu este o funcție. Este o instanță a unei interfețe funcționale.

Structura unei expresii lambda

O expresie lambda poate fi definită în două feluri:

(parametri) -> expresie
(parametri) -> { bloc de cod }

Practic, vom avea mereu:

  1. paranteze în care specificăm numărul de parametrii,
  2. operatorul săgeată () care specifică trecerea la corpul metodei,
  3. o expresie simplă sau un bloc de cod mai complex împrejmuit de acolade.

Exemple:

// o metodă cu un parametru care întoarce dublul lui
x -> x * 2   
 
// o metodă cu doi parametrii care întoarce suma lor                      
(x, y) -> x + y
 
// o metodă fără parametrii care întoarce "void"                      
() -> System.out.println("Hello")
 
// o metodă fără parametrii, mai complexă, care întoarce "void"
() -> {
    System.out.println("Hello");
    System.out.println("World");
}
 
// o metodă cu un parametru, mai complexă, care întoarce un număr
x -> {    
    int y = x + 10;
 
    System.out.println("x is: " + x + " y is: " + y);
 
    return (x * 2) / y;
}

Avem câteva observații legate de expresiile lambda:

  • parantezele pot lipsi dacă avem doar un parametru.
  • putem avea paranteze goale dacă nu avem parametrii.
  • tipul parametrilor nu este specificat, deoarece respecta semnătura metodei funcționale.
  • return-ul este folosit doar când avem nevoie de acolade ca să ajutăm compilatorul să își dea seamă când vrem să dăm return.

Expresiile Lambda pot folosi variabile externe final sau effectively final.

int x = 10; // effectively final
 
Runnable r = () -> {
    System.out.println(x);
};

De ce lambda funcționează doar cu interfețe funcționale?

Interfețele funcționale ne ajută practic să simulăm pointeri la funcții ca în limbajul C.

Totuși, pentru că Java este un limbaj OOP, are o restricție care împiedică folosirea sintaxei din C.

Nu are funcții ca tip de prim ordin

Tip de prim ordin (first-class) este un element care poate:

  1. să fie stocat într-o variabilă
  2. să fie transmis ca parametru
  3. să fie returnat dintr-o funcție
  4. să existe independent de o clasă

Exemple de tipuri first-class în Java:

int x = 10;
String s = "hello";
Object o = new Object();

Însă, Java nu permite:

int add(int a, int b) { return a + b; }
 
// NU este permis
var f = add;

Nu poți scrie:

return add;

Nu poți spune:

void process(Function f) { }

În Java apar aceste restricții pentru că:

  • metodele aparțin claselor
  • nu există funcții independente
  • metodele nu sunt valori
Cum rezolvă interfețe funcționale și expresiile lambda limitările Java

Expresiile lambda funcționează doar cu interfețe funcționale pentru că compilatorul știe ce metodă implementează, deoarece există doar o metodă de definit.

Interfața funcțională este un contract, iar lambda-ul este implementarea acestui contract.

Deci când scriem:

Operation add = (a, b) -> a + b;
 
add.apply(2, 3);

Practic avem următorii pași:

  1. se definește funcția generală în interfața Operation care definește o singură metodă abstractă.
  2. dezvoltatorul definește metoda generală, iar compilatorul știe că această metodă suprascrie singura metodă abstractă din interfață.
  3. rezultatul expresiei lambda este o instanță a interfeței funcționale care este stocată într-o referință care are tipul interfeței funcționale.

  • Dacă am avea mai multe metode abstracte în interfață, compilatorul nu ar știi ce metodă vrea utilizatorul să implementeze.
  • Numele metodei abstracte este relevant doar când aplicăm metoda funcțională, nu și la suprascriere.
  • Deoarece stocăm rezultatul expresiei într-o referință de tip interfață, respectăm principiile OOP, unde totul este declarat sub formă de obiect.

Lambda și this

Într-o lambda:

  • this se referă la clasa exterioară
  • nu există un this propriu

În schimb, într-o clasă anonimă:

  • this se referă la instanța anonimă

Interfețe funcționale standard din JDK

Java oferă, în pachetul java.util.function, un set de interfețe funcționale standard, gândite special pentru a fi folosite împreună cu lambda expressions și Stream API.

Aceste interfețe:

  • elimină necesitatea definirii propriilor interfețe simple,
  • standardizează tipurile de operații funcționale,
  • sunt intens folosite intern de Stream API.

Predicate<T> – condiții logice

Ce reprezintă?

Predicate<T> modelează o condiție booleană aplicată asupra unui obiect de tip T.

Semnătura interfeței
@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);
}
  • Primește: un obiect de tip T
  • Returnează: boolean
  • Scop: testare/filtrare
Exemplu
Predicate<Integer> isEven = x -> x % 2 == 0;

Utilizare:

isEven.test(10); // true

Predicate este util și pentru înlănțuirea condițiilor boolean prin folosirea metodelor and(…), or(…), negate(…).

Predicate<Integer> isPositive = x -> x > 0;
Predicate<Integer> isEven = x -> x % 2 == 0;
 
Predicate<Integer> positiveAndEven =
    isPositive.and(isEven);
 
System.out.println(positiveAndEven.test(4));  // true
System.out.println(positiveAndEven.test(3));  // false
System.out.println(positiveAndEven.test(-2)); // false

Function<T, R> – transformări

Ce reprezintă?

Function<T, R> modelează o transformare dintr-un tip T într-un tip R.

Semnătura
@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);
}
  • Primește: T
  • Returnează: R
  • Scop: transformare
Exemplu
Function<String, Integer> length = s -> s.length();

Utilizare:

System.out.println(length.apply("ana")); // 3

Consumer<T> – operații cu efect secundar

Ce reprezintă?

Consumer<T> modelează o operație care primește un obiect T, nu returnează nimic, dar produce de obicei un side-effect.

Semnătura
@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
}
  • Primește: T
  • Returnează: nimic (void)
  • Scop: efect secundar (side effects)
Exemplu
Consumer<String> printer = s -> System.out.println(s);

Utilizare:

printer.accept("Hello World"); // Hello World

Consumer este periculos în programarea funcțională:

  • introduce stare mutabilă
  • rupe ideea de funcții pure
  • face codul mai greu de paralelizat

Folosește Consumer doar:

  • la afișare
  • la logging
  • la colectare controlată

  • side effects = Un side effect apare atunci când o funcție modifică ceva din afara ei sau depinde de ceva din afara ei, în afară de parametrii primiți.
    int total = 0;
     
    void add(int x) {
        total += x;   // modifică stare externă
    }
    add(5);
    add(5);
     
    // rezultatul depinde de istoricul apelurilor, nu doar de parametri
  • funcție pură = Nu are side effects și pentru aceiași parametri, returnează mereu același rezultat.
    int sum(int a, int b) {
        return a + b;
    }

Supplier<T> – furnizori de valori

Ce reprezintă?

Supplier<T> produce o valoare fără a primi nimic.

Semnătura
@FunctionalInterface
public interface Supplier<T> {
    T get();
}
  • Primește: nimic
  • Returnează: T
  • Scop: generare valori
Exemplu
Supplier<Double> random = () -> Math.random();

Utilizare:

System.out.println(random.get()); // un număr aleator

TL;DR interfețe funcționale din JDK

Interfață Metodă abstractă Primește Returnează Descriere
Predicate<T> boolean test(T t) T boolean Testează o condiție
Function<T, R> R apply(T t) T R Transformă un tip în altul
Consumer<T> void accept(T t) T void Consumă un obiect (side effects)
Supplier<T> T get() nimic T Furnizează o valoare

Method References

Un method reference este o formă prescurtată a unei expresii lambda, atunci când lambda-ul apelează direct o metodă existentă fără logică suplimentară.

Exemple de echivalență:

x -> System.out.println(x)
System.out::println
x -> x.length()
String::length
() -> new ArrayList<>()
ArrayList::new

Tipuri de Method References

1. Static method

Integer::parseInt

2. Metodă a unei instanțe (pe un obiect)

System.out::println

3. Metodă a unei instanțe (pe un tip)

String::toUpperCase

4. Constructor

ArrayList::new

🌊 Stream API – procesarea funcțională a colecțiilor

Ce este un Stream?

Un Stream reprezintă o vedere funcțională asupra unei surse de date (de obicei o colecție), care permite aplicarea unei succesiuni de transformări într-un mod declarativ.

Un stream:

  • nu este o colecție
  • nu stochează date
  • nu modifică sursa
  • descrie ce se întâmplă cu datele, nu cum se întâmplă

Exemplu:

numbers.stream()
       .filter(x -> x % 2 == 0)
       .map(x -> x * 2)
       .toList();

Explicație:

  • numbers este sursa
  • stream() creează un stream
  • filter și map sunt transformări
  • toList() produce rezultatul final

Stream ≠ Colecție

Este esențial să nu confundăm aceste concepte pentru a nu avea erori în cod.

Colecție Stream
stochează date procesează date
este concretă este o abstracție
poate fi iterată de mai multe ori se consumă o singură dată
permite modificări este imutabil logic

Exemplu greșit:

Stream<Integer> s = numbers.stream();
s.forEach(el -> System.out.println(el));
s.forEach(el -> System.out.println(el)); // EROARE

Un stream nu poate fi reutilizat.

Procesare Lazy (leneșă)

Operațiile pe stream-uri sunt lazy, adică ele nu se execută imediat.

numbers.stream()
       .filter(x -> {
           System.out.println("filter: " + x);
           return x % 2 == 0;
       });

Codul de mai sus nu afișează nimic, deoarece nu există o operație terminală.

Execuția începe doar când apare o operație terminală (cum ar fi count()):

numbers.stream()
       .filter(x -> x % 2 == 0)
       .count();

Tipuri de operații în Stream API

Operații intermediare

Operațiile intermediare:

  • returnează un Stream
  • sunt lazy
  • pot fi înlănțuite

Exemple:

  • filter - Păstrează doar elementele care respectă o condiție.
  • map - Transformă fiecare element într-un alt element.
  • flatMap - Transformă fiecare element într-un stream și apoi aplatizează rezultatul.
  • sorted - Sortează elementele stream-ului, natural sau cu un comparator.
  • distinct - Elimină elementele duplicate (pe baza equals()).
  • limit - Păstrează doar primele N elemente din stream.
  • peek - Permite observarea elementelor fără a le modifica (debug/logging).

Exemplu:

numbers.stream()
       .filter(x -> x > 0)
       .map(x -> x * 2);

În codul de mai sus nu se execută nimic încă, dar pot fi înlănțuite mai multe operații.

Exemple pentru fiecare operație intermediară

Exemple pentru fiecare operație intermediară

1. filter – selectează elemente

Cod:

List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
 
List<Integer> even =
    numbers.stream()
           .filter(x -> x % 2 == 0)
           .toList();
 
System.out.println(even);

Output:

[2, 4, 6]

Explicație: Sunt păstrate doar elementele care respectă condiția x % 2 == 0.

2. map – transformă elemente

Cod:

List<String> names = List.of("Ana", "Ion", "Maria");
 
List<Integer> lengths =
    names.stream()
         .map(name -> name.length())
         .toList();
 
System.out.println(lengths);

Output:

[3, 3, 5]

Explicație: Fiecare String este transformat într-un Integer (lungimea sa).

3. flatMap – transformă și aplatizează

Cod:

List<List<String>> lists = List.of(
    List.of("a", "b"),
    List.of("c", "d")
);
 
List<String> flat =
    lists.stream()
         .flatMap(list -> list.stream())
         .toList();
 
System.out.println(flat);

Output:

[a, b, c, d]

Explicație: List<List<String» este „aplatizată” într-un singur List<String>.

flatMap este folosit când fiecare element produce o colecție. Dacă am fi folosit map:

lists.stream()
     .map(List::stream)
     .toList();

am fi avut rezultatul Stream<Stream<String». Aveți grijă să folosiți map și flatMap corespunzător.

4. sorted – sortează elemente

Cod:

List<Integer> numbers = List.of(5, 1, 4, 2, 3);
 
List<Integer> sorted =
    numbers.stream()
           .sorted()
           .toList();

Output:

[1, 2, 3, 4, 5]

Explicație: sorted ordonează elementele folosind ordinea naturală sau un Comparator.

5. distinct – elimină duplicate

Cod:

List<Integer> numbers = List.of(1, 2, 2, 3, 3, 3);
 
List<Integer> unique =
    numbers.stream()
           .distinct()
           .toList();

Output:

[1, 2, 3]

Explicație: Duplicatele sunt eliminate folosind equals().

6. limit – restrânge stream-ul

Cod:

List<Integer> numbers = List.of(10, 20, 30, 40, 50);
 
List<Integer> firstThree =
    numbers.stream()
           .limit(3)
           .toList();
 
System.out.println(firstThree);

Output:

[10, 20, 30]

Explicație: Sunt păstrate doar primele 3 elemente.

7. peek – inspectează elemente (debug)

Cod:

List<Integer> numbers = List.of(1, 2, 3, 4);
 
List<Integer> result =
    numbers.stream()
           .peek(x -> System.out.println("Before: " + x))
           .map(x -> x * 2)
           .peek(x -> System.out.println("After: " + x))
           .toList();
 
System.out.println("Result: " + result);

Output:

Before: 1
After: 2
Before: 2
After: 4
Before: 3
After: 6
Before: 4
After: 8
Result: [2, 4, 6, 8]

Explicație:

  • peek afișează elementele în timpul execuției pipeline-ului
  • valorile nu sunt modificate de peek
  • execuția are loc doar pentru că există toList()

Operații terminale

Operațiile terminale:

  • returnează un rezultat concret
  • declanșează execuția întregului pipeline
  • consumă stream-ul

Exemple:

  • forEach - Aplică o acțiune fiecărui element din stream.
  • toList - Colectează elementele într-o Listă imutabilă (Java 16+).
  • collect - Colectează elementele într-o structură folosind un Collector.
  • reduce - Combină toate elementele într-o singură valoare.
  • count - Returnează numărul de elemente din stream.
  • findFirst - Returnează primul element din stream (dacă există).
  • anyMatch - Verifică dacă există cel puțin un element care respectă o condiție.

Exemplu:

long count = numbers.stream()
                    .filter(x -> x > 0)
                    .count();

Un stream are exact o singură operație terminală.

Exemple pentru fiecare operație terminală

Exemple pentru fiecare operație terminală

1. forEach – aplică o acțiune fiecărui element

Cod:

List<String> names = List.of("Ana", "Ion", "Maria");
 
names.stream()
     .forEach(name -> System.out.println(name));

Output:

Ana
Ion
Maria

Explicație: forEach aplică o acțiune (Consumer) fiecărui element. Este operație terminală și produce de obicei side effects (afișare, logging).

2. toList – colectează într-o listă

Cod:

List<Integer> numbers = List.of(1, 2, 3, 4);
 
List<Integer> doubled =
    numbers.stream()
           .map(x -> x * 2)
           .toList();
 
System.out.println(doubled);

Output:

[2, 4, 6, 8]

Explicație: toList() colectează elementele într-o Listă imutabilă (Java 16+).

3. collect – colectare flexibilă

Cod:

List<String> names = List.of("Ana", "Ion", "Ana");
 
Set<String> uniqueNames =
    names.stream()
         .collect(Collectors.toSet());
 
System.out.println(uniqueNames);

Output:

[Ana, Ion]

Explicație: collect folosește un Collector pentru a construi structuri precum:

  • List
  • Set
  • Map
  • grouping / partitioning

4. reduce – agregare într-o singură valoare

Cod:

List<Integer> numbers = List.of(1, 2, 3, 4);
 
int sum =
    numbers.stream()
           .reduce(0, (a, b) -> a + b);
 
System.out.println(sum);

Output:

10

Explicație: reduce combină elementele pas cu pas într-un singur rezultat.

5. count – numără elementele

Cod:

List<String> words = List.of("a", "b", "c", "d");
 
long count =
    words.stream()
         .count();
 
System.out.println(count);

Output:

4

Explicație: count returnează numărul de elemente din stream.

6. findFirst – primul element

Cod:

List<Integer> numbers = List.of(10, 20, 30);
 
Optional<Integer> first =
    numbers.stream()
           .findFirst();
 
System.out.println(first);

Output:

Optional[10]

Explicație: findFirst returnează un Optional<T> pentru a evita NullPointerException.

7. anyMatch – verifică existența unei potriviri

Cod:

List<Integer> numbers = List.of(1, 3, 5, 6);
 
boolean hasEven =
    numbers.stream()
           .anyMatch(x -> x % 2 == 0);
 
System.out.println(hasEven);

Output:

true

Explicație: anyMatch verifică dacă există cel puțin un element care respectă condiția.

Operații short-circuit

Unele operații pot opri execuția înainte de final.

Exemple:

  • anyMatch - Verifică dacă există cel puțin un element în stream care respectă o condiție (se oprește la prima potrivire).
  • findFirst - Returnează primul element din stream, respectând ordinea (rezultat: Optional<T>)
  • findAny – Returnează orice element din stream (fără garanție de ordine, optim pentru parallel streams).
  • limit – Restrânge stream-ul la primele N elemente, oprind procesarea după atingerea limitei.

Exemplu:

boolean hasEven =
    numbers.stream()
           .peek(x -> System.out.println(x))
           .anyMatch(x -> x % 2 == 0);

În exemplul de mai sus, stream-ul printează elementele și se oprește la primul element par.

Legătura dintre Stream API și Interfețele Funcționale

Stream API este construit direct peste interfețele funcționale din java.util.function, astfel că anumite metode din stream-uri acceptă doar anumite interfețe funcționale:

Metodă Stream Interfață funcțională
`filter` `Predicate<T>`
`map` `Function<T, R>`
`forEach` `Consumer<T>`
`reduce` `BinaryOperator<T>`
`anyMatch` `Predicate<T>`

Exemplu:

numbers.stream()
       .filter(x -> x % 2 == 0)       // Predicate<Integer>
       .map(x -> x * 2)               // Function<Integer, Integer>
       .forEach(System.out::println); // Consumer<Integer>

Side effects în Stream-uri

Stream-urile NU ar trebui să modifice stare externă, din motivele prezentate mai sus.

Exemplu greșit:

int sum = 0;
 
numbers.stream()
       .forEach(x -> sum += x); // side effect

Exemplu corect:

int sum = numbers.stream().mapToInt(x -> x).sum();

Ordinea execuției în Stream-uri

Stream-urile nu funcționează pe etape separate. Fiecare element trece prin toate etapele separat de execuția celorlalte elemente.

Exemplu:

numbers.stream()
       .filter(x -> {
           System.out.println("filter " + x);
           return x % 2 == 0;
       })
       .map(x -> {
           System.out.println("map " + x);
           return x * 2;
       })
       .forEach(System.out::println);

Output:

filter 1
filter 2
map 2
4
filter 3
filter 4
map 4
8

Fiecare element trece complet prin pipeline înainte de următorul.

Primitive Streams

Stream-urile generice (Stream<Integer>) folosesc:

  • boxing
  • unboxing

Asta înseamnă că lucrul cu ele este mai lent și consumă mai multă memorie.

Exemplu folosire boxing:

int sum =
    numbers.stream()
           .map(x -> x)
           .reduce(0, Integer::sum);

Exemplu folosire primitive streams:

int sum =
    numbers.stream()
           .mapToInt(x -> x)
           .sum();

Tipuri de Primitive Streams

Tip Clasă
int IntStream
long LongStream
double DoubleStream

Metode utile

Metodă Ce face Tip returnat Observații importante
mapToInt(…) Transformă un Stream<T> într-un IntStream IntStream Evită autoboxing (Integerint)
sum() Calculează suma elementelor din IntStream int Returnează 0 dacă stream-ul e gol
average() Calculează media aritmetică a elementelor OptionalDouble Folosește Optional pentru a evita 0/NaN
min() Găsește valoarea minimă din stream OptionalInt Stream-ul poate fi gol
max() Găsește valoarea maximă din stream OptionalInt Stream-ul poate fi gol

Pentru calcule numerice, este mai bine să folosiți primitive streams.

[Nice to know] Optional<T> – evitarea valorilor null

Ce este Optional<T>?

Optional<T> este un container care poate:

  • să conțină o valoare
  • sau să fie gol

Scopul lui este să evite folosirea lui null și apariția erorilor de tip NullPointerException.

Optional<String> opt = Optional.of("hello");
Optional<String> empty = Optional.empty();

De ce avem nevoie de Optional?

Fără Optional avem următoarea situație:

String name = getName();
if (name != null) {
    System.out.println(name.length());
}

În schimb, cu Optional putem scrie codul de mai sus astfel:

Optional<String> name = getName();
name.ifPresent(n -> System.out.println(n.length()));

Prin folosirea Optional, codul:

  • este mai sigur
  • este mai expresiv
  • forțează tratarea cazului „lipsește valoarea”

Metode importante din Optional

1. isPresent()

if (optional.isPresent()) {
    System.out.println(optional.get());
}

Ce face:

  • Verifică dacă Optional conține o valoare.

Problema:

  • Duce ușor la cod de tip null-check mascat
  • Necesită apel ulterior la get(), care poate arunca excepție dacă greșim logica

Această metodă nu este recomandată în stil funcțional modern.

2. ifPresent(Consumer<T>)

optional.ifPresent(value -> System.out.println(value));

Ce face:

  • Execută codul doar dacă valoarea există
  • Nu necesită get()
  • Evită NullPointerException

Când se folosește:

  • pentru afișare
  • logging
  • efecte secundare controlate

3. orElse(T other)

String value = optional.orElse("default");

Ce face:

  • Returnează valoarea din Optional
  • Dacă este empty, returnează valoarea furnizată (“default”)

Când se folosește:

  • când valoarea default este constantă sau foarte ieftină

Argumentul este evaluat întotdeauna, chiar dacă Optional are valoare, deci este important să avem grijă la apelurile de metodă folosite ca valoare default:

optional.orElse(computeDefault()); // computeDefault() se execută oricum

4. orElseGet(…) (lazy)

String value = optional.orElseGet(() -> computeDefault());

Ce face:

  • Returnează valoarea din Optional
  • Dacă este empty, apelează Supplier-ul

Când se folosește:

  • când valoarea default:
    • este costisitoare
    • implică I/O
    • creează obiecte

Față de metoda orElse, dacă includem ca valoare default un apel de metodă, aceasta este apelată doar dacă este necesar.

// recomandat
optional.orElseGet(this::computeDefault);

Această metodă este preferată față de orElse(T other).

5. orElseThrow()

String value = optional.orElseThrow();

Ce face:

  • Returnează valoarea dacă există
  • Aruncă NoSuchElementException dacă este empty

Când se folosește:

  • când lipsa valorii este o eroare de logică
  • când vrei să forțezi tratarea cazului empty

Echivalent logic cu:

if (optional.isEmpty()) {
    throw new NoSuchElementException();
}
return optional.get();

Varianta cu excepție custom:

String value = optional.orElseThrow(
    () -> new IllegalStateException("Valoare lipsă")
);

Optional și Stream API

Optional poate fi cuplat cu Stream API pentru a crea secvențe de cod avansate:

Optional<Integer> firstEven =
    numbers.stream()
           .filter(x -> x % 2 == 0)
           .findFirst();

În exemplul de mai sus, findFirst() nu întoarce direct valoarea, ci un Optional<T>.

Good practices

Nu folosiți get() direct. Dacă sunteți tentați să folosiți get(), probabil metoda potrivită este:

  • ifPresent
  • orElseGet
  • orElseThrow

Ce nu este Optional

Optional se folosește mai ales ca valoare de return, dar este important să știm că nu este:

  • înlocuitor universal pentru toate valorile
  • destinat câmpurilor de clasă
  • destinat serializării

Optional<T> vs. Optional Primitives

Optional<T>

Optional<T> este varianta generică, care funcționează cu orice tip obiect.

Caracteristici:

  • folosește tipuri obiect (Integer, Double, String, etc.)
  • implică autoboxing pentru tipurile primitive
  • este flexibil, dar mai puțin eficient pentru calcule numerice

Exemplu:

Optional<Integer> max =
    numbers.stream()
           .max(Integer::compareTo);
 
max.ifPresent(System.out::println);

OptionalInt/OptionalLong/OptionalDouble

OptionalInt / OptionalLong și OptionalDouble sunt versiuni specializate pentru primitive.

Caracteristici:

  • lucrează direct cu int
  • nu folosește autoboxing
  • mai eficient din punct de vedere al performanței
  • folosit în special împreună cu IntStream

Exemplu:

OptionalInt max =
    numbers.stream()
           .mapToInt(x -> x)
           .max();
 
if (max.isPresent()) {
    System.out.println(max.getAsInt());
}

Când folosim fiecare?

Folosește Optional<T> când:

  • lucrezi cu obiecte
  • rezultatul vine dintr-un Stream<T>
  • tipul nu este numeric

Folosește OptionalInt când:

  • lucrezi cu valori numerice primitive
  • folosești mapToInt
  • vrei cod mai eficient și mai clar

[Optional] Parallel Streams

Un parallel stream împarte procesarea pe mai multe thread-uri, folosind ForkJoinPool.

numbers.parallelStream()
       .map(x -> x * 2)
       .toList();

sau:

numbers.parallelStream()
       .map(x -> x * 2)
       .toList();

Parallel Streams reprezintă o metodă foarte ușoară de a introduce fire paralel de execuție, fără să introducă complexitate în cod prin programarea multi-threaded tradițională.

Când sunt utile?

Pot fi utile când:

  • avem seturi mari de date
  • operațiile sunt costisitoare
  • funcțiile sunt pure (fără side effects)

Când NU sunt recomandate?

Nu este recomandat să folosim parallel streams când:

  • modificăm starea externă
  • folosim forEach cu side effects
  • ordinea contează
  • setul de date este mic

Exemplu problematic:

int sum = 0;
 
numbers.parallelStream()
       .forEach(x -> sum += x); // RACE CONDITION

Exemplu corect:

int sum =
    numbers.parallelStream()
           .mapToInt(x -> x)
           .sum();

forEach nu garantează ordinea, în schimb forEachOrdered garantează ordinea în detrimentul vitezei de execuție.

Exerciții

  • Exercițiile vor fi făcute pe platforma Devmind Code. Găsiți exercițiile din acest laborator în contestul aferent.
  • Vă recomandăm să copiați scheletul și să faceți exercițiile mai întâi în IntelliJ, deoarece acolo aveți acces la o serie de instrumente specifice unui IDE. După ce ați terminat exercițiile puteți să le copiați pe Devmind Code.

Task 1 (10p)

Vi se dă scheletul pentru o aplicație ce simulează o situație mai mult sau mai puțin fictivă. Mai multe firme cooperează cu o bancă pentru a oferi salariul și beneficiile salariațiilor lor. Firmele au mai mulți salariați și mai multe proiecte în istoric. Este posibil ca unii salariați să fi schimbat firma la care lucrează, dar li se pastrează în istoricul personal proiectele la care au lucrat. Un salariat poate avea mai multe conturi bancare făcute prin firma la care lucrează.

Aveți de implementat:

  • TODO-urile din clasele Business, Bank si Employee - întoarceți tipurile corespunzatoare de date, dar asigurați-vă că sunt imutabile (hint: Collections.unmodifiable*)
  • metodele statice din clasele BankReport și BusinessReport

Pentru testare sunt folosite 10 teste, fiecare dintre acesta reprezentand 10 puncte.

Dezambiguizare:

  • Customer = Employee of the Business
  • Business = a client of the Bank
  • Customers of the Bank = all the Employees that work for the Businesses that are clients of the Bank

Resurse și link-uri utile

poo-ca-cd/laboratoare/programare-functionala-lambda-si-streamuri.txt · Last modified: 2025/12/15 11:44 by florian_luis.micu
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0