Java - Week 9

Optional Types, Collecting Results from Streams, Input/Output Streams, and Serialization

Optional Types

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.

Handling Missing / Optional Values

When a method returns an Optional, you have several ways to extract the value safely:

  1. .orElse(defaultValue): Provides a fallback value if the Optional is empty.
  2. .orElseGet(Supplier): Computes a default value lazily only if the Optional is empty.
  3. .orElseThrow(ExceptionSupplier): Throws an exception if no value is present.

Code Example: Extracting Values

Java
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!");
        }
    }
}

Ignoring Missing Values

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.
Java
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.")
        );
    }
}

Creating an Optional Value

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).
Java
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);
    }
}

Transforming and Composing Optionals

Passing on Optional Values with 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.

Java
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

Composing with 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().

Java
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
    }
}
Converting Optional to Stream

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.


Collecting Values from a Stream

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().

Converting Streams to Data Structures

  • To Array: mystream.toArray(String[]::new)
  • To List: mystream.collect(Collectors.toList())
  • To Set: mystream.collect(Collectors.toSet())
  • To Custom Collection: mystream.collect(Collectors.toCollection(TreeSet::new))

Collecting into Maps

When collecting into a Map, you need to specify how to generate the Key and the Value from each stream element using Collectors.toMap().

Java
// 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.

Java
Map<String, Integer> nameToID = people.collect(
    Collectors.toMap(
        Person::getName, // Key
        Person::getId,   // Value
        (existingValue, newValue) -> existingValue // Merge rule: keep the existing one
    )
); 

Grouping and Partitioning Values

The Collectors class provides powerful tools to organize your stream data into groups based on specific criteria.

  1. Grouping (groupingBy): Creates a Map where the keys are the grouping condition, and the values are Lists of items that matched that condition.
    Java
    // Grouping persons by their names
    Map<String, List<Person>> nameToPersons = people.collect(
        Collectors.groupingBy(Person::getName)
    );
  2. Partitioning (partitioningBy): A specialized form of grouping that divides the data into exactly two groups: true and false, based on a Predicate (condition).
    Java
    // 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);

Storing Descriptive Statistics

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.

Java
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.


Input / Output Streams

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.

Byte Streams vs. Character Streams

  • Byte Streams (InputStream / OutputStream): Used to perform reading and writing of 8-bit bytes. Useful for binary data like images or audio files.
  • Character Streams (Reader / Writer): Used to perform reading and writing of 16-bit unicode characters. Useful for text files.

Basic Text File Read/Write Example

Java provides wrapper classes like BufferedReader and BufferedWriter to make file operations efficient and simple.

Java
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

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.

The 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.

Serialization Process Example

We use ObjectOutputStream to write the object, and ObjectInputStream to read the object.

Java
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
The 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).