Mastering Java 8: Essential Features for Modern and Efficient Programming

Gaurikhard
6 min readNov 6, 2024

--

In Java 8, lambda expressions provide a concise way to represent anonymous functions. They simplify code by eliminating the need to define parameter types, and they implicitly handle return types, making them highly versatile and expressive. Lambdas are especially useful in functional programming, such as working with comparators, threads, and stream operations. Their key characteristics include:

  • First-class functions: Treat functions as data by passing them as parameters.
  • Higher-order functions: Create functions that accept other functions as arguments or return functions as results.
  • Immutability: Avoid side effects by not altering external state within lambda bodies.
  • Pure functions: Ensure consistent results when given the same inputs.
  • Function composition: Combine small functions to build more complex operations.

Here’s how to use lambda expressions for various purposes:

// Lambda without parameter
() -> System.out.println("Zero parameter lambda");

// Lambda with one parameter
name -> System.out.println("Hello, " + name);

// Lambda with two parameters
(a, b) -> System.out.println("Sum: " + (a + b));

// Lambda with return type
str -> str.length();

// Lambda with explicit parameter type
(Integer a, Integer b) -> a * b;

// Lambda in thread
() -> System.out.println("Thread is running");

// Lambda with forEach
names.forEach(name -> System.out.println(name));

// Lambda with comparator
(s1, s2) -> s1.compareToIgnoreCase(s2);

// Lambda with streams
n -> n * n;

Functional Interface

A functional interface is an interface with a single abstract method, making it suitable for lambda expressions. This simplifies code and makes it easier to express behavior as values. Common examples include Runnable, ActionListener, Callable, and Comparable.

Java 8 introduced the @FunctionalInterface annotation, which enforces that the interface has only one abstract method. This helps avoid accidental addition of methods that would make the interface non-functional.

Examples of Functional Interfaces

1. Consumer

A Consumer accepts a single input and performs an operation on it, but doesn’t return any value

Consumer<String> printName = name -> System.out.println("Hello, " + name);
printName.accept("Alice"); // Output: Hello, Alice

2. Predicate

A Predicate takes an input and returns a boolean value, often used for conditions and filtering.

Predicate<Integer> isEven = num -> num % 2 == 0;
System.out.println(isEven.test(4)); // Output: true
System.out.println(isEven.test(5)); // Output: false

3. Function

A Function takes an input, processes it, and returns a result. It’s useful when you need to transform or map data.

Function<String, Integer> stringLength = str -> str.length();
System.out.println(stringLength.apply("Java")); // Output: 4

4. Supplier

A Supplier provides a result without taking any input, often used when we need to generate or supply values.

Supplier<Double> randomValue = () -> Math.random();
System.out.println(randomValue.get()); // Output: (some random number)

Optional

In Java 8, the Optional class is a container object which may or may not contain a non-null value. This helps avoid NullPointerExceptions by explicitly checking if a value is present before performing operations on it. Optional is especially useful for values that are the result of potentially empty computations or lookups, such as database queries or complex logic that might return null.

Key Methods of Optional

  • of: Creates a Optional with a non-null value.
  • ofNullable: Creates an Optional that may hold a null value.
  • empty: Returns an empty Optional instance.
  • isPresent: Checks if a value is present.
  • ifPresent: Executes a specified action if a value is present.
  • orElse: Returns the value if present; otherwise, returns a default value.
  • orElseGet: Returns the value if present; otherwise, calls a supplier and returns its result.
  • orElseThrow: Returns the value if present; otherwise, throws a specified exception.
import java.util.Optional;

public class OptionalExample {
public static void main(String[] args) {

// Creating Optional with a non-null value
Optional<String> name = Optional.of("Alice");
System.out.println(name.isPresent()); // Output: true
name.ifPresent(n -> System.out.println("Hello, " + n)); // Output: Hello, Alice

// Creating Optional with a null value using ofNullable
Optional<String> emptyName = Optional.ofNullable(null);
System.out.println(emptyName.isPresent()); // Output: false

// Providing a default value with orElse
String defaultName = emptyName.orElse("Default Name");
System.out.println(defaultName); // Output: Default Name

// Providing a value with orElseGet
String suppliedName = emptyName.orElseGet(() -> "Generated Name");
System.out.println(suppliedName); // Output: Generated Name

// Throwing an exception with orElseThrow if value is absent
try {
String errorName = emptyName.orElseThrow(() -> new IllegalArgumentException("Name not found"));
} catch (Exception e) {
System.out.println(e.getMessage()); // Output: Name not found
}

// Transforming the value with map
Optional<String> upperCaseName = name.map(String::toUpperCase);
upperCaseName.ifPresent(System.out::println); // Output: ALICE

// Chaining methods using flatMap
Optional<String> nameLength = name.flatMap(n -> Optional.of("Length: " + n.length()));
nameLength.ifPresent(System.out::println); // Output: Length: 5
}
}

Method References

Method references in Java 8 offer a concise way to refer to existing methods by name rather than using lambda expressions. They are particularly useful when a lambda expression simply calls a single method. Let’s go through each type of method reference with examples.

1. Reference to a Static Method

This type of method reference is used to refer to a static method in a class.

Syntax: ClassName::staticMethodName

import java.util.function.Function;

public class StaticMethodReference {
public static int square(int number) {
return number * number;
}

public static void main(String[] args) {
Function<Integer, Integer> squareFunction = StaticMethodReference::square;
System.out.println(squareFunction.apply(5)); // Output: 25
}
}

2. Reference to an Instance Method of a Particular Object

This method reference refers to an instance method of a specific object.

Syntax: instance::methodName

import java.util.function.Supplier;

public class InstanceMethodReference {
public void sayHello() {
System.out.println("Hello, World!");
}

public static void main(String[] args) {
InstanceMethodReference instance = new InstanceMethodReference();
Supplier<Void> greeting = instance::sayHello;
greeting.get(); // Output: Hello, World!
}
}

3. Reference to an Instance Method of an Arbitrary Object of a Particular Type

This type of method reference applies to an instance method of an arbitrary object of a particular type. It is often used with collections and streams.

Syntax: ClassName::instanceMethodName

import java.util.Arrays;
import java.util.List;

public class ArbitraryObjectMethodReference {
public static void main(String[] args) {
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");

// Using method reference to print each name
names.forEach(System.out::println); // Output: Alice Bob Charlie
}
}

4. Reference to a Constructor

This type of method reference refers to a constructor, allowing us to create new instances with method references.
Syntax: ClassName::new

import java.util.function.Supplier;

public class ConstructorReference {
public ConstructorReference() {
System.out.println("Constructor called!");
}

public static void main(String[] args) {
Supplier<ConstructorReference> instanceSupplier = ConstructorReference::new;
instanceSupplier.get(); // Output: Constructor called!
}
}

Streams

The Java 8 Stream API allows us to process collections in a functional and declarative way, which makes code more readable and concise. Here’s an overview of Stream components and examples of how to use them.

Components of Streams

  1. Sequence of Elements: A stream is a sequence of elements that supports sequential and parallel operations.
  2. Source: The source of a stream can be a collection, array, or I/O channel.
  3. Aggregate Operations: Streams provide aggregate operations, such as map, filter, and reduce, that allow us to perform transformations on data.
  4. Pipelining: Stream operations are typically chained to form a pipeline.
  5. Internal Iteration: Streams manage the iteration internally, so there’s no need for external loops.

Stream Operations

Streams have two types of operations: Intermediate and Terminal. Intermediate operations transform a stream, while terminal operations produce a result.
1. Intermediate Operations

filter()

Filters elements based on a given condition. Returns a stream with elements that match the condition.

Example:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
List<Integer> evenNumbers = numbers.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());

map()

Transforms each element in a stream to another value.

Example:

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
List<Integer> nameLengths = names.stream()
.map(String::length)
.collect(Collectors.toList());

sorted()

Sorts the elements in a stream.

Example:

List<Integer> unsortedNumbers = Arrays.asList(3, 1, 4, 1, 5, 9);
List<Integer> sortedNumbers = unsortedNumbers.stream()
.sorted()
.collect(Collectors.toList());

2. Terminal Operations

collect()

Collects the result of intermediate operations and returns a collection or another final result.

Example:

List<String> fruits = Arrays.asList("apple", "banana", "cherry", "apple");
Set<String> uniqueFruits = fruits.stream()
.collect(Collectors.toSet());

forEach()

Performs an action for each element in the stream.

Example:

List<String> items = Arrays.asList("apple", "banana", "cherry");
items.stream().forEach(System.out::println);
// Output:
// apple
// banana
// cherry

reduce()

Reduces the elements to a single value by applying a specified operation.

Example:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
.reduce(0, Integer::sum);

3. Short-Circuit Operations

anyMatch()

Returns true if any element in the stream matches the given condition.

Example:

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
boolean hasNameStartingWithA = names.stream()
.anyMatch(name -> name.startsWith("A"));

findfirst()

Finds the first element in the stream that matches the condition, or returns an empty Optional if none match.

Example:

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
Optional<String> nameStartingWithB = names.stream()
.filter(name -> name.startsWith("B"))
.findFirs

Combining Stream Functions

Streams allow us to combine multiple operations in a single pipeline.

Example: Find the square of the first even number greater than 3

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
Optional<Integer> result = numbers.stream()
.filter(n -> n > 3)
.filter(n -> n % 2 == 0)
.map(n -> n * n)
.findFirst();

--

--

No responses yet