This is an old revision of the document!
Laboratorul 11: Programare Funcțională, Lambdas și Stream-uri
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.
Î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.
🏷️ Programare funcțională
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:
a apărut nevoia unui stil de programare:
Începând cu Java 8, limbajul introduce:
lambda expressions
interfețe funcționale
-
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:
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:
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:
paranteze în care specificăm numărul de parametrii,
operatorul săgeată (→) care specifică trecerea la corpul metodei,
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:
să fie stocat într-o variabilă
să fie transmis ca parametru
să fie returnat dintr-o funcție
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ă:
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:
se definește funcția generală în interfața Operation care definește o singură metodă abstractă.
dezvoltatorul definește metoda generală, iar compilatorul știe că această metodă suprascrie singura metodă abstractă din interfață.
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:
În schimb, într-o clasă 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);
}
Exemplu
Predicate<Integer> isEven = x -> x % 2 == 0;
Utilizare:
isEven.test(10); // true
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);
}
Exemplu
Consumer<String> printer = s -> System.out.println(s);
Utilizare:
printer.accept("Hello World"); // Hello World
Consumer este
periculos în programarea funcțională:
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:
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ț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:
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.
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.
[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?
Când NU sunt recomandate?
Nu este recomandat să folosim parallel streams când:
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
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