Optional Types, Collecting Results from Streams, Input/Output Streams, and Serialization
When dealing with operations like filtering a stream and finding
the maximum value, there is a possibility that the stream becomes
empty (e.g., the filter removes everything). In such cases,
returning a primitive or a normal object might lead to a
NullPointerException. Java solves this by using the
Optional<T> class, which acts as a container
that may or may not contain a non-null value.
When a method returns an Optional, you have several
ways to extract the value safely:
.orElse(defaultValue): Provides a fallback value if the Optional is empty.
.orElseGet(Supplier): Computes a default value lazily only if the Optional is
empty.
.orElseThrow(ExceptionSupplier): Throws an exception if no value is present.
Code Example: Extracting Values
import java.util.Optional;
import java.util.stream.Stream;
public class OptionalExtractionExample {
public static void main(String[] args) {
// Simulating an empty stream by filtering out all elements
Optional<Double> maxRand = Stream.generate(Math::random)
.limit(100)
.filter(n -> n < 0.001) // Strict filter likely causing an empty stream
.max(Double::compareTo); // Returns Optional<Double>
// 1. Using orElse()
Double val1 = maxRand.orElse(-1.0); // Returns -1.0 if maxRand is empty
System.out.println("Value from orElse: " + val1);
// 2. Using orElseGet()
Double val2 = maxRand.orElseGet(() -> Math.random()); // Computes a default value lazily if maxRand is empty
System.out.println("Value from orElseGet: " + val2);
// 3. Using orElseThrow()
try {
Double val3 = maxRand.orElseThrow(IllegalStateException::new); // Throws an exception if maxRand is empty
} catch (IllegalStateException e) {
System.out.println("Exception caught: Stream was empty!");
}
}
}
Sometimes, you only want to execute code if the value is actually present, completely ignoring the empty state.
ifPresent(Consumer): Executes the block of code only if the value exists.
Otherwise, it does nothing.
ifPresentOrElse(Consumer, Runnable): Performs one action if present, and a different fallback
action if empty.
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class OptionalActionExample {
public static void main(String[] args) {
Optional<Double> val = Optional.of(42.5);
Optional<Double> emptyVal = Optional.empty();
List<Double> results = new ArrayList<>();
// 1. ifPresent
val.ifPresent(v -> results.add(v)); // Or results::add
System.out.println("Results list: " + results);
// 2. ifPresentOrElse
emptyVal.ifPresentOrElse(
v -> results.add(v),
() -> System.out.println("No max value found, skipping.")
);
}
}
If you are writing a method that might not return a valid result
(like dividing by zero), you should return an
Optional
Optional.of(v): Creates an Optional containing the
value v. (Will throw an exception if
v is null)
Optional.empty(): Creates an empty Optional box.
Optional.ofNullable(v): Automatically converts a
potentially null value into an Optional (returns empty if null).
public class OptionalCreation {
// Example of manually returning empty vs a value
public static Optional<Double> inverse(Double x) {
if (x == 0) {
return Optional.empty();
} else {
return Optional.of(1 / x);
}
}
// Example using ofNullable for automatic handling
public static Optional<Double> inverseShorthand(Double x) {
return Optional.ofNullable(x == 0 ? null : 1 / x);
}
}
map()
Just like with Streams, you can use the map() method
on an Optional. It applies a function to the value
only if it is present. If the input is empty, the
output remains an empty Optional.
Optional<Double> maxRand = Optional.of(5.0);
// Applies the lambda only because maxRand has a value
Optional<Double> maxRandSqr = maxRand.map(v -> v * v);
System.out.println(maxRandSqr.get()); // Outputs 25.0
flatMap()
A problem arises when composing functions that
both return Optionals. Suppose function
f() returns Optional<T>, and class
T has a method g() that returns
Optional<U>
If you use map(), you will end up with nested
Optionals: Optional<Optional<U>>. To
prevent this nesting and flatten the structure into a simple
Optional<U>, we use flatMap().
import java.util.Optional;
public class FlatMapExample {
public static Optional<Double> inverse(Double x) {
if (x == 0) return Optional.empty();
return Optional.of(1 / x);
}
public static Optional<Double> squareRoot(Double x) {
if (x < 0) return Optional.empty();
return Optional.of(Math.sqrt(x));
}
public static void main(String[] args) {
Double x = 4.0;
// Composing safe inverse and safe squareRoot.
// flatMap prevents it from becoming Optional<Optional<Double>>
Optional<Double> result = inverse(x).flatMap(FlatMapExample::squareRoot);
result.ifPresent(val -> System.out.println("Result is: " + val)); // Outputs 0.5
}
}
If you have a Stream<Optional<T>> and
you want to extract only the present values into a
Stream<T>, you can pass it through a
flatMap using Optional::stream. This
effectively unwraps the present values and silently drops the
empty ones.
Streams process data, but they don't store it. To gather the
processed elements back into a data structure (or summarize them),
we use terminal operations like collect() or
toArray().
mystream.toArray(String[]::new)
mystream.collect(Collectors.toList())
mystream.collect(Collectors.toSet())
mystream.collect(Collectors.toCollection(TreeSet::new))
When collecting into a Map, you need to specify how to generate
the Key and the Value from each stream element using
Collectors.toMap().
// Assuming a 'people' Stream of 'Person' objects
Map<Integer, Person> idToPerson = people.collect(
Collectors.toMap(
Person::getId, // Key Mapper
Function.identity() // Value Mapper (the Person object itself)
)
);
Handling Duplicate Keys:
If your stream generates duplicate keys,
Collectors.toMap will throw an
IllegalStateException. To fix this, you provide a
third argument: a merging function.
Map<String, Integer> nameToID = people.collect(
Collectors.toMap(
Person::getName, // Key
Person::getId, // Value
(existingValue, newValue) -> existingValue // Merge rule: keep the existing one
)
);
The Collectors class provides powerful tools to
organize your stream data into groups based on specific criteria.
groupingBy): Creates a
Map where the keys are the grouping condition, and the values
are Lists of items that matched that condition.
// Grouping persons by their names
Map<String, List<Person>> nameToPersons = people.collect(
Collectors.groupingBy(Person::getName)
);
partitioningBy): A
specialized form of grouping that divides the data into exactly
two groups: true and false, based on a
Predicate (condition).
// Partitioning names starting with 'A' and the rest
Map<Boolean, List<Person>> aAndOtherPersons = people.collect(
Collectors.partitioningBy(
p -> p.getName().substring(0, 1).equals("A")
)
);
List<Person> startingLetterA = aAndOtherPersons.get(true);
List<Person> others = aAndOtherPersons.get(false);
If you have a stream and you want to know its count, min, max,
sum, and average simultaneously, you don't need to traverse the
stream multiple times. You can use
summarizingInt.
import java.util.IntSummaryStatistics;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class StatsExample {
public static void main(String[] args) {
Stream<String> words = Stream.of("Java", "Stream", "API", "Is", "Great");
// Gathers statistics about the length of the strings
IntSummaryStatistics summary = words.collect(
Collectors.summarizingInt(String::length)
);
System.out.println("Count: " + summary.getCount());
System.out.println("Max length: " + summary.getMax());
System.out.println("Average length: " + summary.getAverage());
}
}
Note: Java also provides LongSummaryStatistics and
DoubleSummaryStatistics for different numeric
types.
In Java, reading from and writing to external sources (like files, network connections, or the console) is handled via I/O Streams. An I/O Stream represents an input source or an output destination.
InputStream /
OutputStream): Used to perform reading and writing
of 8-bit bytes. Useful for binary data like images or audio
files.
Reader /
Writer): Used to perform reading and writing of
16-bit unicode characters. Useful for text files.
Java provides wrapper classes like BufferedReader and
BufferedWriter to make file operations efficient and
simple.
import java.io.*;
public class FileIOExample {
public static void main(String[] args) {
String fileName = "example.txt";
// WRITING TO A FILE
// The try-with-resources statement ensures streams are closed automatically
try (BufferedWriter writer = new BufferedWriter(new FileWriter(fileName))) {
writer.write("Hello, Welcome to Java I/O!");
writer.newLine();
writer.write("This is the second line.");
System.out.println("Successfully wrote to the file.");
} catch (IOException e) {
System.out.println("An error occurred during writing: " + e.getMessage());
}
// READING FROM A FILE
try (BufferedReader reader = new BufferedReader(new FileReader(fileName))) {
String line;
System.out.println("\nReading from file:");
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
} catch (IOException e) {
System.out.println("An error occurred during reading: " + e.getMessage());
}
}
}
Output:
Successfully wrote to the file.
Reading from file:
Hello, Welcome to Java I/O!
This is the second line.
Serialization is the process of converting the
state of an object into a byte stream. This byte stream can be
saved to a database, sent across a network, or saved to a file.
Deserialization is the reverse process: reading
the byte stream and converting it back into a Java object.
Serializable Interface
For an object to be serialized, its class must implement the
java.io.Serializable interface. This is a
marker interface (it has no methods) that simply tells
the Java Virtual Machine (JVM) that the objects of this class are
allowed to be serialized.
We use ObjectOutputStream to write the object, and
ObjectInputStream to read the object.
import java.io.*;
// 1. Class must implement Serializable
class Student implements Serializable {
private static final long serialVersionUID = 1L; // Recommended best practice
int id;
String name;
// transient keyword prevents a field from being serialized
transient String password;
public Student(int id, String name, String password) {
this.id = id;
this.name = name;
this.password = password;
}
}
public class SerializationExample {
public static void main(String[] args) {
Student s1 = new Student(101, "Alice", "secret123");
String filename = "student.ser";
// SERIALIZATION (Writing Object)
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(filename))) {
out.writeObject(s1);
System.out.println("Object has been serialized.");
} catch (IOException e) {
e.printStackTrace();
}
// DESERIALIZATION (Reading Object)
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream(filename))) {
Student deserializedStudent = (Student) in.readObject();
System.out.println("\nObject has been deserialized.");
System.out.println("ID: " + deserializedStudent.id);
System.out.println("Name: " + deserializedStudent.name);
// Password will be null because it was marked 'transient'
System.out.println("Password: " + deserializedStudent.password);
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
}
}
Output:
Object has been serialized.
Object has been deserialized.
ID: 101
Name: Alice
Password: null
transient Keyword
If there are specific fields in your class that you do not want
to save or transmit (like sensitive passwords or temporary
states), you can mark them with the
transient keyword. When the object is serialized,
transient fields are ignored. Upon deserialization, they take
their default values (e.g., null for objects,
0 for integers).