Introduction

Lambda expressions were introduced in Java 8 as a major feature that brought functional programming capabilities to Java. They provide a clear and concise way to implement single-method interfaces (functional interfaces) by using an expression instead of creating anonymous classes.

Basics of Lambda Expressions

What is a Lambda Expression?

A lambda expression is essentially an anonymous function that can be passed around as an object. It consists of:

  • Parameters
  • The lambda operator (->)
  • A body

Basic Syntax

(parameters) -> expression

or

(parameters) -> { statements; }

Examples

Simple lambda with no parameters:

() -> System.out.println("Hello World")

Lambda with one parameter (parentheses are optional with a single parameter):

x -> x * x

Lambda with multiple parameters:

(x, y) -> x + y

Multiple statements in the lambda body:

(x, y) -> {
    int sum = x + y;
    return sum;
}

Functional Interfaces

A functional interface is an interface with exactly one abstract method. Lambda expressions can only be used with functional interfaces.

Built-in Functional Interfaces

Java 8 introduced several functional interfaces in the java.util.function package:

  1. **Predicate

    • Takes a value and returns a boolean
    Predicate<String> isLongerThan5 = s -> s.length() > 5;
    boolean result = isLongerThan5.test("Hello World"); // true
  2. **Consumer

    • Accepts a value and performs an operation without returning anything
    Consumer<String> printUpperCase = s -> System.out.println(s.toUpperCase());
    printUpperCase.accept("hello"); // Prints "HELLO"
  3. Function

    • Transforms a value of type T to a result of type R
    Function<String, Integer> stringLength = s -> s.length();
    int length = stringLength.apply("Hello"); // 5
  4. **Supplier

    • Provides a value without taking any input
    Supplier<Double> randomValue = () -> Math.random();
    double value = randomValue.get(); // Random value between 0 and 1
  5. **BinaryOperator

    • Takes two operands of the same type and returns a result of that type
    BinaryOperator<Integer> add = (a, b) -> a + b;
    int sum = add.apply(5, 3); // 8

Intermediate Concepts

Method References

Method references provide a shorthand notation for lambda expressions that call a single method:

// Instead of 
Consumer<String> printer = s -> System.out.println(s);
// You can write
Consumer<String> printer = System.out::println;

There are four kinds of method references:

  1. Reference to a static method: ClassName::staticMethodName
  2. Reference to an instance method of a particular object: object::instanceMethodName
  3. Reference to an instance method of an arbitrary object of a particular type: ClassName::instanceMethodName
  4. Reference to a constructor: ClassName::new

Capturing Variables

Lambda expressions can capture variables from their enclosing scope:

int factor = 5;
Function<Integer, Integer> multiplier = n -> n * factor;

Important: Captured variables must be effectively final (either declared as final or not modified after initialization).

Advanced Concepts

Composition of Functions

Functional interfaces allow composition to create more complex functions:

Function<Integer, Integer> multiplyBy2 = n -> n * 2;
Function<Integer, Integer> add3 = n -> n + 3;
 
// Compose: first add3, then multiplyBy2
Function<Integer, Integer> add3ThenMultiplyBy2 = multiplyBy2.compose(add3);
// Result: (5 + 3) * 2 = 16
System.out.println(add3ThenMultiplyBy2.apply(5));
 
// AndThen: first multiplyBy2, then add3
Function<Integer, Integer> multiplyBy2ThenAdd3 = multiplyBy2.andThen(add3);
// Result: (5 * 2) + 3 = 13
System.out.println(multiplyBy2ThenAdd3.apply(5));

Using Lambda with Streams API

Lambdas are particularly powerful when used with the Stream API:

List<String> names = Arrays.asList("John", "Alice", "Bob", "Charlie", "Dave");
 
// Filter names longer than 4 characters, convert to uppercase, and collect to a new list
List<String> filteredNames = names.stream()
    .filter(name -> name.length() > 4)
    .map(String::toUpperCase)
    .collect(Collectors.toList());
// Result: [ALICE, CHARLIE]

Creating Custom Functional Interfaces

You can create your own functional interfaces:

@FunctionalInterface
interface TriFunction<T, U, V, R> {
    R apply(T t, U u, V v);
}
 
TriFunction<Integer, Integer, Integer, Integer> sum3 = (a, b, c) -> a + b + c;
int result = sum3.apply(1, 2, 3); // 6

Type Inference & Target Typing

The Java compiler infers the types of lambda parameters based on the context:

// The compiler infers that x is an Integer
Function<Integer, Integer> square = x -> x * x;
 
// Same lambda, different functional interface
IntFunction<Integer> squareInt = x -> x * x;

Exception Handling in Lambdas

Handling exceptions in lambda expressions:

Function<String, Integer> parseIntSafely = s -> {
    try {
        return Integer.parseInt(s);
    } catch (NumberFormatException e) {
        return 0;
    }
};

Recursive Lambdas

Although challenging, it’s possible to create recursive lambda expressions:

// Factorial using recursive lambda
UnaryOperator<Integer> factorial = new UnaryOperator<Integer>() {
    @Override
    public Integer apply(Integer n) {
        return n <= 1 ? 1 : n * this.apply(n - 1);
    }
};
 
// Cannot directly assign recursive lambda, but can work around with AtomicReference
AtomicReference<UnaryOperator<Integer>> factorialRef = new AtomicReference<>();
factorialRef.set(n -> n <= 1 ? 1 : n * factorialRef.get().apply(n - 1));
 
System.out.println(factorialRef.get().apply(5)); // 120

Best Practices

  1. Keep lambdas small and focused - If a lambda gets complex, consider creating a named method.

  2. Use method references when possible for better readability.

  3. Choose descriptive parameter names to enhance code clarity.

  4. Consider using the @FunctionalInterface annotation when creating custom functional interfaces.

  5. Be careful with parallel streams - Lambda expressions should avoid side effects when used with parallel streams.

  6. Be aware of effectively final limitations when capturing variables.

  7. Use built-in functional interfaces from java.util.function instead of creating new ones when possible.

Common Use Cases

  1. Event Handling
button.addActionListener(e -> System.out.println("Button clicked"));
  1. Sorting
Collections.sort(persons, (p1, p2) -> p1.getName().compareTo(p2.getName()));
// Or with method reference
Collections.sort(persons, Comparator.comparing(Person::getName));
  1. Filtering Collections
List<Integer> evenNumbers = numbers.stream()
    .filter(n -> n % 2 == 0)
    .collect(Collectors.toList());
  1. Executing code asynchronously
new Thread(() -> {
    System.out.println("Running in a separate thread");
}).start();
  1. Resource management
processFiles(path -> {
    // Process files here
    return fileResults;
});

Conclusion

Lambda expressions fundamentally changed the way Java developers write code by enabling a more functional programming style. They help create more concise and readable code, especially when working with collections, asynchronous processes, and event-driven programming.

Understanding lambdas is essential for modern Java development, as they are used extensively in Java’s Stream API, Optional class, and many modern Java frameworks and libraries.