How does Functional Programming differ from Imperative Style? #
What is Imperative Style?
- Uses step-by-step logic to solve a problem.
- Focuses on how to achieve the result.
What is Functional Programming?
- Uses declarative code instead of loops.
- Focuses on what needs to be done, not how.
Example 1: Imperative Code (Using Loops)
Explicitly loops through the list and keeps a running sum.
import java.util.List;
public class FP02Imperative {
private static int addListImperative(List<Integer> numbers) {
int sum = 0;
for (int number : numbers) {
sum += number;
}
return sum;
}
}
Example 2: Functional Programming (Using Streams)
Uses stream()
and reduce()
to compute the sum in a single line.
import java.util.List;
public class FP02Functional {
private static int addListFunctional(List<Integer> numbers) {
return numbers.stream().reduce(0, Integer::sum);
}
}
Key Differences
Feature | Imperative Style | Functional Programming |
---|---|---|
Style | Step-by-step logic | Declarative (focus on what, not how) |
Looping | Uses for /while loops |
Uses Streams |
State Management | Uses mutable variables (sum ) |
Avoids mutation |
Code Readability | More verbose | More concise & readable |
What are the core concepts of Functional Programming? Provide an example. #
courses.stream()
.map(course -> course.toUpperCase())
.forEach(System.out::println);
1. Stream
- What? A sequence of elements that supports functional-style operations.
- Why? Allows processing of data in a declarative and efficient way.
- Example:
courses.stream()
creates a stream from the list of courses.- This stream enables pipeline processing without modifying the original list.
2. Lambda Function
- What? An anonymous function that represents a short piece of code.
- Why? Makes code concise and readable.
- Example:
course -> course.toUpperCase()
is a lambda function that converts each course name to uppercase.- It eliminates the need for writing a separate method.
3. Method Reference
- What? A shorthand for calling a method using
ClassName::methodName
orobject::methodName
. - Why? Further simplifies lambda expressions when an existing method can be used directly.
- Example:
System.out::println
is a method reference that replacesx -> System.out.println(x)
.
What are different ways to create Streams in Java? #
1. From Collections (Lists, Sets, Maps)
- What? Convert collections into streams.
- Why? Allows functional operations on lists, sets, and maps.
- How? Use
.stream()
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
Stream<Integer> numberStream = numbers.stream();
numberStream.forEach(System.out::println);
//{Java=100, Python=80}
Map<String, Integer> courses
= Map.of("Java", 100, "Python", 80);
//["Java", "Python"]
Stream<String> keyStream = courses.keySet().stream();
//[100, 80]
Stream<Integer> valueStream = courses.values().stream();
//{Java=100, Python=80}
Stream<Map.Entry<String, Integer>> entryStream
= courses.entrySet().stream();
2. Using Stream.of()
- What? Create a stream from individual elements.
- Why? Useful for small, fixed data sets.
- How? Use
Stream.of()
.
Stream<String> stream = Stream.of("Apple", "Banana", "Cherry");
stream.forEach(System.out::println);
3. Using Arrays.stream()
- What? Convert primitive arrays into streams.
- How? Use
Arrays.stream()
.
int[] numbers = {10, 20, 30, 40, 50};
IntStream intStream = Arrays.stream(numbers);
intStream.forEach(System.out::println);
4. Using Stream.builder()
- What? Manually build a stream.
- Why? Allows adding elements dynamically.
- How? Use
Stream.builder()
.
Stream.Builder<String> builder = Stream.builder();
builder.add("Java").add("Python").add("JavaScript");
Stream<String> stream = builder.build();
stream.forEach(System.out::println);
5. Using Stream.generate()
- What? Create an stream of generated values.
- Why? Useful for lazy evaluation or dynamic data.
- How? Use
Stream.generate()
.
Stream<Double> randomNumbers
= Stream.generate(Math::random).limit(5);
randomNumbers.forEach(System.out::println);
6. Using Stream.iterate()
- What? Create a stream by applying a function.
- Why? Useful for sequences (e.g., even numbers).
- How? Use
Stream.iterate()
.
Stream<Integer> evenNumbers
= Stream.iterate(0, n -> n + 2).limit(5);
evenNumbers.forEach(System.out::println);
7. Using Files (Files.lines()
)
- What? Read a file as a stream of lines.
- Why? Helps process large files efficiently.
- How? Use
Files.lines()
.
Stream<String> fileStream
= Files.lines(Paths.get("file.txt"));
fileStream.forEach(System.out::println);
8. Using IntStream.range()
- What? Create a stream of numbers within a range.
- Why? Useful for generating sequences.
- How? Use
IntStream.range()
// Output: 1234
IntStream.range(1, 5).forEach(System.out::print);
// Output: 12345
IntStream.rangeClosed(1, 5).forEach(System.out::print);
Comparison Table
Method | Use Case |
---|---|
collection.stream() |
Convert List, Set, Map to a stream |
Stream.of() |
Create a stream from values |
Arrays.stream() |
Efficient for primitive arrays |
Stream.builder() |
Manually construct a stream |
Stream.generate() |
Infinite stream using random values |
Stream.iterate() |
Infinite stream using a pattern |
Files.lines() |
Stream file lines |
IntStream.range() |
Number range stream |
What operations can you apply to a Java Stream? #
1. Intermediate Operations
- What? Transform or filter elements and return a new stream.
- Lazy Execution: These operations do not execute until a terminal operation is called.
Operation | Purpose | Example |
---|---|---|
filter() | Retains elements that satisfy a condition. | numbers.stream().filter(n -> n % 2 == 0); |
map() | Transforms each element in the stream. | courses.stream().map(course -> course.length()); |
distinct() | Removes duplicate elements. | numbers.stream().distinct(); |
sorted() | Sorts elements in natural or custom order. | courses.stream().sorted(); |
limit(n) | Takes only the first n elements. |
numbers.stream().limit(3); |
skip(n) | Skips the first n elements. |
numbers.stream().skip(2); |
takeWhile(predicate) | Takes elements while the condition is true, stops at first failure. | numbers.stream().takeWhile(n -> n < 10); |
dropWhile(predicate) | Drops elements while the condition is true, then takes the rest. | numbers.stream().dropWhile(n -> n < 10); |
2. Terminal Operations
- What? Consumes the stream and produces a result.
- Trigger Execution: The stream is processed when a terminal operation is called.
Operation | Purpose | Example |
---|---|---|
forEach() | Iterates over each element and performs an action. | numbers.stream(). forEach(System.out::println); |
collect() | Gathers elements into a collection (List, Set, Map). | List<Integer> squared = numbers.stream(). map(n -> n * n). collect(Collectors.toList()); |
reduce() | Combines elements to produce a single result. | int sum = numbers.stream().reduce(0, Integer::sum); |
count() | Returns the number of elements in the stream. | long count = numbers.stream().count(); |
findFirst() | Retrieves the first element of the stream. | Optional<Integer> first = numbers.stream().findFirst(); |
findAny() | Retrieves any element (non-deterministic). | Optional<Integer> any = numbers.stream().findAny(); |
anyMatch() | Returns true if any element matches the condition. |
numbers.stream().anyMatch(n -> n > 10); |
allMatch() | Returns true if all elements match the condition. |
numbers.stream().allMatch(n -> n > 0); |
noneMatch() | Returns true if no element matches the condition. |
numbers.stream().noneMatch(n -> n < 0); |
What should you keep in mind when working with Streams? #
1. Streams Do Not Store Data
- What? Streams process data on the fly without storing it.
- Why? This makes them memory efficient, especially for large datasets.
- Example:
Stream<Integer> numberStream = List.of(1, 2, 3, 4, 5).stream();
2. Streams Are Lazy
- What? Intermediate operations (filter, map, etc.) are not executed immediately.
- Why? Streams only process data when a terminal operation (e.g.,
forEach
,collect
,reduce
) is called. - Example:
List.of(1, 2, 3, 4, 5).stream().map(n -> { System.out.println("Mapping: " + n); return n * 2; }); // No output because there is no terminal operation
- Intermediate operations (like
filter()
andmap()
) are lazy. - NOT executed immediately when encountered
- Instead, Java "chains" these operations and waits for a terminal operation (like
findFirst()
) to trigger their execution.
- Instead, Java "chains" these operations and waits for a terminal operation (like
3. Streams Are Immutable
- What? Stream operations do not modify the original collection.
- Why? This ensures functional purity and avoids side effects.
- Example:
List<Integer> numbers = List.of(1, 2, 3); // Original list remains unchanged numbers.stream().map(n -> n * 2);
4. Streams Cannot Be Reused
- What? A stream is consumed once a terminal operation is called.
- Why? You need to create a new stream if you need to process the data again.
- Example:
Stream<Integer> stream = numbers.stream(); // ✅ Works stream.forEach(System.out::println); // ❌ ERROR: Stream already consumed! stream.forEach(System.out::println);
Can you provide some examples of Lambda Functions? #
Lambda expressions are anonymous functions that simplify writing code by reducing boilerplate.
Syntax of Lambda Functions
- Parameters: The input to the function.
- Arrow (
->
): Separates parameters from the function body. - Body: Contains the logic of the function.
(parameters) -> { body }
Filtering Even Numbers Using Lambda
numbers.stream()
.filter(number -> number % 2 == 0)
.forEach(System.out::println);
Mapping Course Names to Uppercase
courses.stream()
.map(course -> course.toUpperCase())
.forEach(System.out::println);
Sorting Courses by Length of Name
courses.stream()
.sorted((c1, c2) -> c1.length() - c2.length())
.forEach(System.out::println);
Using UnaryOperator
to Triple a Number
UnaryOperator<Integer> triple = x -> x * 3;
System.out.println(triple.apply(10)); // Output: 30
When should you use Method References instead of Lambda Functions? #
Method references are shortcuts for writing lambda expressions that call an existing method. They use the syntax ClassName::methodName
or object::methodName
.
📌 1. Calling a Static Method
- When? When an existing static method can be directly referenced without modifications.
- Why? It improves readability and eliminates redundant lambda expressions.
- Example 1: Java Utility Method
- Using Method Reference
courses.stream() .forEach(System.out::println);
- Equivalent Lambda Expression
courses.stream() .forEach(course -> System.out.println(course));
- Using Method Reference
- Example 2: Custom Utility Method
- Using Method Reference
numbers.stream() .filter(FP01Functional::isEven) .forEach(System.out::println);
- Equivalent Lambda Expression
numbers.stream() .filter(number -> FP01Functional.isEven(number)) .forEach(number -> System.out.println(number));
- Using Method Reference
📌 2. Calling an Instance Method
- When? When calling an instance method on each element.
- Using Method Reference
courses.stream() .map(String::toUpperCase) .forEach(System.out::println);
- Equivalent Lambda Expression
courses.stream() .map(course -> course.toUpperCase()) .forEach(course -> System.out.println(course));
📌 3. When creating new objects - Constructor Reference
- When? When creating new objects inside a stream.
- Using Constructor Reference
Supplier<String> stringSupplier = String::new;
- Equivalent Lambda Expression
Supplier<String> stringSupplier = () -> new String();
What are Functional Interfaces? Provide some examples. #
- Core component of Java's functional programming approach
- Contains exactly one abstract method
- Allow passing behavior as an argument using lambda expressions
- Examples:
Consumer
processes an input.@FunctionalInterface public interface Consumer<T> { void accept(T t); } Consumer<Integer> printOdd = number -> { if (number % 2 != 0) { System.out.println(number); } }; //What happens in the background? public class PrintOdd implements Consumer<Integer> { @Override public void accept(Integer number) { if (number % 2 != 0) { System.out.println(number); } } }
Function
transforms data and returns the result.@FunctionalInterface public interface Function<T, R> { R apply(T t); } Function<Integer, Integer> squareFunction = number -> number * number; //What happens in the background? public class SquareFunction implements Function<Integer, Integer> { @Override public Integer apply(Integer number) { return number * number; } }
Predicate
tests a condition.@FunctionalInterface public interface Predicate<T> { boolean test(T t); } Predicate<Integer> isEven = number -> number % 2 == 0; //What happens in the background? public class IsEvenPredicate implements Predicate<Integer> { @Override public boolean test(Integer number) { return number % 2 == 0; } }
BiFunction
combines two inputs into a result.@FunctionalInterface public interface BiFunction<T, U, R> { R apply(T t, U u); } BiFunction<Integer, Integer, Integer> maxFunction = (x, y) -> x > y ? x : y; //What happens in the background? public class MaxFunction implements BiFunction<Integer, Integer, Integer> { @Override public Integer apply(Integer x, Integer y) { return x > y ? x : y; } }
What are some key Functional Interfaces, and how do they work? #
1. Predicate (Conditional Testing)
- Purpose: Tests a condition on input and returns a boolean.
- Example: Checking if a number is even.
- Using Lambda (Preferred)
Predicate<Integer> isEvenPredicate = x -> x % 2 == 0;
- Equivalent Anonymous Class
Predicate<Integer> isEvenPredicate2 = new Predicate<>() { @Override public boolean test(Integer x) { return x % 2 == 0; } };
- Usage in Streams
numbers.stream() .filter(isEvenPredicate) .forEach(System.out::println);
2. Function (Mapping Input to Output)
- Purpose: Accepts one argument and returns a transformed value.
- Example: Squaring a number.
- Using Lambda (Preferred)
Function<Integer, Integer> square = x -> x * x;
- Equivalent Anonymous Class
Function<Integer, Integer> square = new Function<>() { @Override public Integer apply(Integer x) { return x * x; } };
- Usage in Streams
numbers.stream() .map(squareFunction) .forEach(System.out::println);
3. Consumer (Processing Without Return)
- Purpose: Accepts an input but does not return anything.
- Example: Printing values.
- Using Lambda (Preferred)
Consumer<Integer> sysoutConsumer = System.out::println;
- Equivalent Anonymous Class
Consumer<Integer> sysoutConsumer2 = new Consumer<>() { @Override public void accept(Integer x) { System.out.println(x); } };
- Usage in Streams
numbers.stream().forEach(sysoutConsumer);
4. BinaryOperator (Two Inputs, Same Type)
- Purpose: Takes two arguments and returns a result of the same type.
- Example: Summing two numbers.
- Using Lambda
BinaryOperator<Integer> sum = (a, b) -> a + b;
- Using Method Reference
BinaryOperator<Integer> sum = Integer::sum;
- Equivalent Anonymous Class
BinaryOperator<Integer> sumBinaryOperator2 = new BinaryOperator<>() { @Override public Integer apply(Integer a, Integer b) { return Integer.sum(a, b); } };
- Usage in Reduce Operation
int sum = numbers.stream().reduce(0, sumBinaryOperator); System.out.println(sum);
5. Supplier (Generating Values Without Input)
- Purpose: Provides a value without any input.
- Example: Returning a random number.
- Using Lambda (Preferred)
Supplier<Integer> randomIntegerSupplier = () -> new Random().nextInt(100);
- Equivalent Anonymous Class
Supplier<Integer> randomIntegerSupplier2 = new Supplier<>() { @Override public Integer get() { return new Random().nextInt(100); } };
- Usage
System.out.println(randomIntegerSupplier.get());
6. UnaryOperator (Single Input, Same Type)
- Purpose: Takes one argument and returns a result of the same type.
- Example: Tripling a number.
- Using Lambda (Preferred)
UnaryOperator<Integer> tripleOperator = x -> 3 * x;
- Equivalent Anonymous Class
UnaryOperator<Integer> tripleOperator2 = new UnaryOperator<>() { @Override public Integer apply(Integer x) { return 3 * x; } };
- Usage in Streams
numbers.stream() .map(tripleOperator) .forEach(System.out::println);
7. BiPredicate (Conditional Testing with Two Inputs)
- Purpose: Tests two inputs and returns a boolean.
- Example: Checking if two numbers are equal.
- Using Lambda (Preferred)
BiPredicate<Integer, Integer> isEqual = (a, b) -> a.equals(b);
- Equivalent Anonymous Class
BiPredicate<Integer, Integer> isEqual2 = new BiPredicate<>() { @Override public boolean test(Integer a, Integer b) { return a.equals(b); } };
- Usage
System.out.println(isEqual.test(5, 5)); // Output: true
8. BiFunction (Two Inputs, One Output)
- Purpose: Accepts two inputs and returns one output.
- Example: Concatenating strings.
- Using Lambda (Preferred)
BiFunction<String, String, String> concat = (a, b) -> a + " " + b;
- Equivalent Anonymous Class
BiFunction<String, String, String> concat2 = new BiFunction<>() { @Override public String apply(String a, String b) { return a + " " + b; } };
- Usage
// Output: Hello World System.out.println(concat.apply("Hello", "World"));
9. BiConsumer (Processing Two Inputs Without Return)
- Purpose: Accepts two inputs but does not return anything.
- Example: Printing a key-value pair.
- Using Lambda (Preferred)
BiConsumer<String, Integer> printKeyValue = (key, value) -> System.out.println(key + ": " + value);
- Equivalent Anonymous Class
BiConsumer<String, Integer> printKeyValue2 = new BiConsumer<>() { @Override public void accept(String key, Integer value) { System.out.println(key + ": " + value); } };
- Usage
printKeyValue.accept("Java", 8); // Output: Java: 8
Summary
Functional Interface | Purpose | Example Lambda |
---|---|---|
Predicate |
Returns true or false |
x -> x % 2 == 0 |
Function<T, R> | Maps input T to output R |
x -> x * x |
Consumer |
Processes input T without return |
System.out::println |
BinaryOperator |
Takes two T inputs, returns T |
(a, b) -> a + b |
Supplier |
Generates a T value |
() -> new Random().nextInt(100) |
UnaryOperator |
Takes T , returns T |
x -> x * 3 |
BiPredicate<T, U> | Tests two inputs, returns boolean |
(a, b) -> a.equals(b) |
BiFunction<T, U, R> | Takes two inputs, returns R |
(a, b) -> a + " " + b |
BiConsumer<T, U> | Processes two inputs without return | (k, v) -> System.out.println(k + v) |
Why were Primitive Functional Interfaces introduced in Java? #
Overview
- What? Special interfaces for primitive data types like
int
,double
,long
. - Why? Avoids boxing/unboxing overhead, making code faster and memory-efficient.
- Example: Use
IntBinaryOperator
instead ofBinaryOperator<Integer>
. - Before (Less Efficient)
BinaryOperator<Integer> sum = (x, y) -> x + y;
- After (Better Performance)
IntBinaryOperator sum = (x, y) -> x + y;
- Key Benefits
- Faster Execution: No need to wrap/unwrap primitive values.
- Less Memory Usage: No
Integer
, justint
! - Cleaner Code: Works directly with primitives, reducing complexity.
Other Primitive Interfaces
Interface | Purpose | Example Type |
---|---|---|
IntPredicate |
Checks conditions | int -> boolean |
IntFunction<R> |
Maps int to result |
int -> R |
IntConsumer |
Accepts int , no return |
int -> void |
IntBinaryOperator |
Operates on two int |
(int, int) -> int |
Other Examples: LongPredicate
, LongFunction
, LongConsumer
, DoublePredicate
, DoubleFunction
, DoubleConsumer
and a lot of others!
When Do You Use
Collectors.groupingBy
? #
- What? Groups elements of a stream into a
Map
based on a key. - Why? Simplifies grouping, counting, and aggregating data in Java Streams.
public record Course(
String name, String category,
int reviewScore, int students) {}
List<Course> courses = List.of(
new Course("AWS", "Cloud", 95, 200000),
new Course("Azure", "Cloud", 90, 150000),
new Course("Docker", "Cloud", 85, 180000),
new Course("Kubernetes", "Cloud", 88, 170000),
new Course("Spring", "Framework", 97, 220000),
new Course("Spring Boot", "Framework", 93, 210000),
new Course("Microservices", "Microservices", 98, 250000),
new Course("FullStack", "FullStack", 96, 230000)
);
Group Courses by Category
Map<String, List<Course>> coursesByCategory = courses.stream()
.collect(Collectors.groupingBy(Course::category));
System.out.println(coursesByCategory);
Output:
//Simplified only to show course name
{Cloud=[AWS, Azure, Docker, Kubernetes],
Framework=[Spring, Spring Boot],
Microservices=[Microservices],
FullStack=[FullStack]}
Count Courses in Each Category
Map<String, Long> courseCountByCategory = courses.stream()
.collect(Collectors.groupingBy(
Course::category, Collectors.counting()));
System.out.println(courseCountByCategory);
Output:
{Cloud=4, Framework=2, Microservices=1, FullStack=1}
Find Highest Rated Course in Each Category
Map<String, Optional<Course>> highestRatedCourseByCategory = courses.stream()
.collect(Collectors.groupingBy(
Course::category,
Collectors.maxBy(Comparator.comparing(Course::reviewScore))
));
System.out.println(highestRatedCourseByCategory);
Output:
{Cloud=Optional[AWS],
Framework=Optional[Spring],
Microservices=Optional[Microservices],
FullStack=Optional[FullStack]}
Get List of Course Names per Category
Map<String, List<String>> courseNamesByCategory = courses.stream()
.collect(Collectors.groupingBy(
Course::category,
Collectors.mapping(Course::name, Collectors.toList())
));
System.out.println(courseNamesByCategory);
Output:
{Cloud=[AWS, Azure, Docker, Kubernetes],
Framework=[Spring, Spring Boot],
Microservices=[Microservices],
FullStack=[FullStack]}
How does the Optional class help handle missing values? #
- What? A container that may or may not hold a value.
- Why? Avoids
NullPointerException
by explicitly handling missing values. - Example:
// Safe handling of null
Optional<String> optional
= Optional.ofNullable(getValueFromDatabase());
optional.ifPresent(System.out::println);
Method | Purpose | Example Usage |
---|---|---|
Optional.of(value) |
Creates an Optional with a non-null value |
Optional.of("Hello") |
Optional.ofNullable(value) |
Creates an Optional (null safe) |
Optional.ofNullable("Hello") |
Optional.empty() |
Creates an empty Optional |
Optional.empty() |
isPresent() |
Checks if a value is present | opt.isPresent() |
ifPresent(Consumer) |
Executes code if value exists | opt.ifPresent(System.out::println) |
orElse(defaultValue) |
Returns value or default if empty | opt.orElse("Default") |
orElseGet(Supplier) |
Returns value or calls supplier function | opt.orElseGet(() -> "Generated") |
orElseThrow() |
Throws an exception if value is missing | opt.orElseThrow(() -> new RuntimeException("No Value")) |
How do Parallel Streams enhance performance in Java? #
- Modern multi-core processors: Today's computers have multiple cores.
- They can run different tasks at the same time.
- Traditional Programming: Old-style coding uses loops and shared variables.
- Why is this a problem? Many threads need to share the same data (e.g., a sum variable).
- This makes parallel execution hard.
//Traditional int sum = 0; for (int number : numbers) { sum += number; } return sum;
- Functional Programming: Uses stateless streams.
- Why is this good? No shared data → Easy to run in parallel.
//Functional return numbers.stream().reduce(0, Integer::sum);
- Why is this good? No shared data → Easy to run in parallel.
- How does Java parallelize streams?
parallel()
tells Java to use multiple cores.- Java splits the stream into smaller parts.
- Each part runs on a different core.
- Finally, Java combines all results.
// Sequential long sum = LongStream.range(0, 1000000000L) .sum(); // Parallel on Existing Streams long parallelSum = LongStream.range(0, 1000000000L) .parallel() .sum();
How Does It Work?
- Creates a Stream of Longs →
LongStream.range(0, 1000000000L)
generates numbers from0
to999,999,999
. - Enables Parallel Processing →
.parallel()
splits the range into chunks and processes them across multiple cores. - Calculates the Sum Efficiently →
.sum()
combines results from all threads.
parallel()
vs parallelStream()
Feature | parallel() |
parallelStream() |
---|---|---|
Applied On | Any existing stream | Collections (List , Set , etc.) |
Purpose | Converts a sequential stream to parallel | Directly creates a parallel stream |
When to Use? | When you already have a stream | When starting from a collection |
Example | list.stream().parallel() |
list.parallelStream() |
Example - Using parallel()
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
numbers.stream()
.parallel()
.forEach(System.out::println);
Example - Using parallelStream()
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
numbers.parallelStream()
.forEach(System.out::println);
In what ways does Functional Programming simplify Java code? #
1. Creating a Thread
- Before FP:
// ❌ Traditional way (Verbose) Thread thread1 = new Thread(new Runnable() { @Override public void run() { System.out.println("Thread running..."); } }); thread1.start();
- With FP (Lambda Expression): Shorter and more readable.
// ✅ Functional way (Lambda) Thread thread2 = new Thread( () -> System.out.println("Thread running...")); thread2.start();
2. Creating a Comparator
- Before FP:
import java.util.*; List<String> names = Arrays.asList("John", "Alice", "Bob"); // ❌ Traditional way Collections.sort(names, new Comparator<String>() { @Override public int compare(String s1, String s2) { return s1.compareTo(s2); } });
- With FP: Uses a lambda function, reducing code complexity.
// ✅ Functional way (Lambda) names.sort((s1, s2) -> s1.compareTo(s2));
3. Listing Files in a Directory
- Before FP: Uses a for-loop.
import java.nio.file.*; Path path = Paths.get("."); // ❌ Traditional way File[] files = new File(".").listFiles(); for (File file : files) { System.out.println(file.getName()); }
- With FP: Uses
Files.list()
with method reference.// ✅ Functional way (Streams) Files.list(path).forEach(System.out::println);
4. Filtering & Transforming a List
- Before FP: Uses loops and conditionals.
List<String> words = Arrays.asList("apple", "banana", "cherry"); // ❌ Traditional way List<String> ucWords = new ArrayList<>(); for (String word : words) { if (word.length() > 5) { uppercaseWords.add(word.toUpperCase()); } }
- With FP: Uses
stream().filter().map()
.// ✅ Functional way (Streams) List<String> ucWordsFp = words.stream() .filter(word -> word.length() > 5) .map(String::toUpperCase) .toList();
How do Higher-Order Functions, Behavior Parameterization, and First-Class Functions differ? #
To be frank, they are all very similar concepts!
Quick Comparison
Concept | What It Is? |
---|---|
Higher-Order Functions | Function that returns or takes a function as a parameter |
Behavior Parameterization | Passing behavior (a function) dynamically (as a parameter) |
Functions as First-Class Citizens | Treats functions like values (Store a function in a variable, Pass a function to a method, Return a function) |
Example: Returning a Function
- This function returns a predicate based on a cutoff value.
- This allows dynamic filtering without hardcoding conditions.
- The returned predicate is then applied to a stream.
public static Predicate<Course> createPredicate(int cutoff) { return course -> course.getReviewScore() > cutoff; } // Usage Predicate<Course> highScore = createPredicate(95); List<Course> filtered = courses.stream() .filter(highScore) .collect(Collectors.toList());
Example: Passing a Function
- This method accepts a predicate as an argument, allowing flexible filtering logic.
- Different conditions can be applied without modifying the method, making the code more reusable.
public static void filterAndPrint( List<Integer> numbers, Predicate<Integer> predicate) { numbers.stream() .filter(predicate) .forEach(System.out::println); } // Usage filterAndPrint(numbers, x -> x % 2 == 0); filterAndPrint(numbers, x -> x % 2 != 0); filterAndPrint(numbers, x -> x % 3 == 0);
What are the benefits of using Functional Programming? #
What?
- Computation is treated as the evaluation of mathematical functions
List<String> transformedNumbers = numbers.stream() .map(x -> x * x) // Step 1: Square each number .map(x -> x + 10) // Step 2: Add 10 .map(x -> "Value: " + x) // Step 3: Convert .toList(); // Collect the result
- Core idea: focus on "what to do" rather than "how to do it."
Key Principles:
- First-Class Functions: Functions are treated as values, meaning they can be passed as arguments, returned from other functions, or assigned to variables.
- Immutability: Avoids modifying variables or data.
- No Side Effects: Functions do NOT affect other parts of the program (e.g., modifying global variables).
Why Use Functional Programming?
- Declarative Style → Focuses on what needs to be done, not how, making code cleaner and easier to read.
- Immutability → Data is not modified, reducing unexpected side effects and making debugging easier.
- Concise Code → Less boilerplate compared to traditional loops and conditionals.
- Thread Safety → No shared state means safer parallel execution without race conditions.
- Parallel Processing → Works well with streams and multi-core processors, improving performance.