Java Fundamentals

Collections, File I/O, and Exception Handling

Working with collections, reading/writing files, and handling errors properly

Lecture Overview

In this lecture, we'll cover three essential topics for practical Java programming:

  • Collections Framework - Managing groups of objects efficiently
  • File I/O - Reading from and writing to files
  • Exception Handling - Proper error management
These skills are crucial for building real applications like the bank account system and geometry exercises!

Wrapper Classes: Objects for Primitives

Primitives (int, double, etc.) are not objects. But sometimes we need objects!

Primitive Wrapper Class Example
int Integer Integer num = 42;
double Double Double price = 19.99;
boolean Boolean Boolean flag = true;
char Character Character letter = 'A';
Why needed? Collections like ArrayList can only store objects, not primitives!

Autoboxing and Unboxing

Java automatically converts between primitives and wrappers:

// Autoboxing: primitive -> wrapper (automatic)
int primitiveInt = 42;
Integer wrapperInt = primitiveInt;  // Autoboxing

// Unboxing: wrapper -> primitive (automatic)
Integer wrapperNum = 100;
int primitiveNum = wrapperNum;  // Unboxing

// This works seamlessly in collections:
List<Integer> numbers = new ArrayList<>();
numbers.add(10);        // Autoboxing: int -> Integer
numbers.add(20);
int sum = numbers.get(0) + numbers.get(1);  // Unboxing
Caution: Unboxing null throws NullPointerException!

Generics: Type-Safe Collections

Generics allow collections to enforce what type they contain:

// Without generics (old style) - NOT type-safe
List list = new ArrayList();
list.add("Hello");
list.add(123);  // Allowed, but dangerous!
String str = (String) list.get(1);  // ClassCastException at runtime!

// With generics - type-safe
List<String> names = new ArrayList<String>();
names.add("Alice");
names.add("Bob");
// names.add(123);  // Compile error! Only Strings allowed
String name = names.get(0);  // No cast needed
The angle brackets <Type> specify what the collection will hold

Generics: Diamond Operator

Java 7+ lets you omit the type on the right side (diamond operator):

// Full syntax
List<String> names = new ArrayList<String>();

// Diamond operator (shorter, preferred)
List<String> names = new ArrayList<>();

// Works with any type
List<Integer> numbers = new ArrayList<>();
List<BankAccount> accounts = new ArrayList<>();
Map<String, Integer> ages = new HashMap<>();

Reading generic types:

  • List<String> = "a List of Strings"
  • Map<String, Integer> = "a Map from Strings to Integers"

Creating Generic Classes

You can create your own generic classes:

// A generic "box" that can hold any type
public class Box<T> {
    private T content;

    public void put(T item) {
        this.content = item;
    }

    public T get() {
        return this.content;
    }
}

// Usage with different types
Box<String> stringBox = new Box<>();
stringBox.put("Hello");
String message = stringBox.get();

Box<Integer> intBox = new Box<>();
intBox.put(42);
int number = intBox.get();
T is a type parameter - a placeholder for the actual type

The Optional Class: Handling Null Safely

The problem with null:

// The billion-dollar mistake
public String getCustomerEmail(long id) {
    Customer customer = findById(id);  // Might return null!
    return customer.getEmail();        // NullPointerException if null!
}

// Traditional null check - verbose and easy to forget
public String getCustomerEmail(long id) {
    Customer customer = findById(id);
    if (customer != null) {
        return customer.getEmail();
    }
    return "unknown";
}
Optional<T> is a container that may or may not contain a value - forces you to handle the "no value" case!

Creating Optional Objects

import java.util.Optional;

// Create Optional with a value
Optional<String> name = Optional.of("Alice");      // Must not be null!

// Create Optional that might be null
Optional<String> maybeName = Optional.ofNullable(getName()); // Safe with null

// Create empty Optional
Optional<String> empty = Optional.empty();         // Explicitly no value

// Example usage
public Optional<Customer> findById(long id) {
    Customer customer = database.find(id);
    return Optional.ofNullable(customer);  // Wraps null safely
}

Checking Optional Values

Optional<String> name = Optional.of("Alice");

// Check if value exists
if (name.isPresent()) {
    System.out.println("Name: " + name.get());
}

// Better: Execute action if present
name.ifPresent(n -> System.out.println("Name: " + n));

// Check if empty (Java 11+)
if (name.isEmpty()) {
    System.out.println("No name provided");
}

// ifPresentOrElse (Java 9+)
name.ifPresentOrElse(
    n -> System.out.println("Hello, " + n),
    () -> System.out.println("Hello, stranger")
);

Getting Values from Optional

Optional<String> name = findName();

// Provide default value
String result = name.orElse("Unknown");

// Provide default via supplier (lazy evaluation)
String result = name.orElseGet(() -> generateDefaultName());

// Throw exception if empty
String result = name.orElseThrow();  // NoSuchElementException
String result = name.orElseThrow(
    () -> new CustomerNotFoundException("Name not found")
);

// AVOID: get() without checking - defeats the purpose!
String result = name.get();  // Throws if empty - bad practice!
Rule: Never call get() without checking - use orElse() family instead!

Transforming Optional Values

Optional<String> name = Optional.of("alice");

// map: Transform the value if present
Optional<String> upperName = name.map(String::toUpperCase);
// Result: Optional["ALICE"]

// map with method chain
Optional<Integer> nameLength = name.map(String::toUpperCase)
                                    .map(String::length);
// Result: Optional[5]

// filter: Keep value only if it matches predicate
Optional<String> longName = name.filter(n -> n.length() > 3);
// Result: Optional["alice"]

Optional<String> shortName = name.filter(n -> n.length() > 10);
// Result: Optional.empty

Optional with flatMap

Use flatMap when your transformation returns an Optional:

class Customer {
    private String email;  // Might be null!

    public Optional<String> getEmail() {
        return Optional.ofNullable(email);
    }
}

Optional<Customer> customer = findById(42);

// map would give Optional<Optional<String>> - nested!
Optional<Optional<String>> nested = customer.map(c -> c.getEmail());

// flatMap "flattens" to Optional<String>
Optional<String> email = customer.flatMap(Customer::getEmail);

// Full chain
String result = findById(42)
    .flatMap(Customer::getEmail)
    .map(String::toLowerCase)
    .orElse("no-email@example.com");

Optional and Streams

// Convert Optional to Stream (Java 9+)
Optional<String> name = Optional.of("Alice");
Stream<String> stream = name.stream();  // Stream with 0 or 1 elements

// Filter nulls from a list using Optional
List<String> names = customers.stream()
    .map(Customer::getName)              // Some might be null
    .filter(Objects::nonNull)            // Remove nulls
    .collect(Collectors.toList());

// With Optional - more explicit
List<String> emails = customers.stream()
    .map(Customer::getEmail)             // Returns Optional<String>
    .flatMap(Optional::stream)           // Keep only present values
    .collect(Collectors.toList());
Best Practice: Return Optional from methods that might not find a value (findById, getEmail, etc.)

Part 1: The Collections Framework

Why do we need collections?
  • Arrays have fixed size - what if we don't know the size in advance?
  • We need flexible data structures that can grow and shrink
  • We need different behaviors: unique items, key-value pairs, ordered vs unordered
The Collections Framework provides ready-to-use data structures!

Collections Hierarchy Overview

The main interfaces in the Collections Framework:
  • List - Ordered collection (allows duplicates)
    • ArrayList, LinkedList
  • Set - Unordered collection (no duplicates)
    • HashSet, TreeSet
  • Map - Key-value pairs
    • HashMap, TreeMap

ArrayList - Dynamic Arrays

ArrayList is the most commonly used collection:
import java.util.ArrayList;
import java.util.List;

public class ArrayListExample {
    public static void main(String[] args) {
        // Create an ArrayList of Strings
        List<String> names = new ArrayList<>();

        // Add elements
        names.add("Alice");
        names.add("Bob");
        names.add("Charlie");

        // Access by index
        String first = names.get(0); // "Alice"

        // Size
        int size = names.size(); // 3

        // Check if contains
        boolean hasBob = names.contains("Bob"); // true
    }
}

ArrayList Operations

List<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");
names.add("Charlie");

// Insert at specific position
names.add(1, "David"); // [Alice, David, Bob, Charlie]

// Remove by index
names.remove(0); // [David, Bob, Charlie]

// Remove by value
names.remove("Bob"); // [David, Charlie]

// Update element
names.set(0, "Eve"); // [Eve, Charlie]

// Check if empty
boolean isEmpty = names.isEmpty(); // false

// Clear all elements
names.clear();

Iterating Over Lists

There are several ways to iterate:
List<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");
names.add("Charlie");

// 1. For-each loop (recommended)
for (String name : names) {
    System.out.println(name);
}

// 2. Traditional for loop
for (int i = 0; i < names.size(); i++) {
    System.out.println(names.get(i));
}

// 3. Iterator
Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
    System.out.println(iterator.next());
}

HashSet - Unique Elements

HashSet stores unique elements (no duplicates):
import java.util.HashSet;
import java.util.Set;

public class HashSetExample {
    public static void main(String[] args) {
        Set<String> uniqueNames = new HashSet<>();

        uniqueNames.add("Alice");
        uniqueNames.add("Bob");
        uniqueNames.add("Alice"); // Duplicate - won't be added!

        System.out.println(uniqueNames.size()); // 2
        System.out.println(uniqueNames); // [Alice, Bob] (order not guaranteed)

        // Check membership
        if (uniqueNames.contains("Alice")) {
            System.out.println("Alice is in the set");
        }
    }
}

HashMap - Key-Value Pairs

HashMap stores associations between keys and values:
import java.util.HashMap;
import java.util.Map;

public class HashMapExample {
    public static void main(String[] args) {
        Map<String, Integer> ages = new HashMap<>();

        // Put key-value pairs
        ages.put("Alice", 25);
        ages.put("Bob", 30);
        ages.put("Charlie", 28);

        // Get value by key
        int aliceAge = ages.get("Alice"); // 25

        // Check if key exists
        if (ages.containsKey("Bob")) {
            System.out.println("Bob's age: " + ages.get("Bob"));
        }

        // Size
        System.out.println("Number of people: " + ages.size());
    }
}

HashMap Operations

Map<String, Integer> ages = new HashMap<>();
ages.put("Alice", 25);
ages.put("Bob", 30);

// Update existing key (overwrites old value)
ages.put("Alice", 26);

// Remove entry
ages.remove("Bob");

// Get with default value
int charlieAge = ages.getOrDefault("Charlie", 0); // 0 (not found)

// Check if value exists
boolean has25 = ages.containsValue(25);

// Iterate over entries
for (Map.Entry<String, Integer> entry : ages.entrySet()) {
    System.out.println(entry.getKey() + ": " + entry.getValue());
}

// Iterate over keys only
for (String name : ages.keySet()) {
    System.out.println(name);
}

Practical Example: Bank Accounts Collection

class BankAccount {
    private String accountNumber;
    private String owner;
    private double balance;

    public BankAccount(String accountNumber, String owner, double balance) {
        this.accountNumber = accountNumber;
        this.owner = owner;
        this.balance = balance;
    }
    // Getters and setters...
}

public class Bank {
    private List<BankAccount> accounts = new ArrayList<>();

    public void addAccount(BankAccount account) {
        accounts.add(account);
    }

    public BankAccount findByAccountNumber(String accountNumber) {
        for (BankAccount account : accounts) {
            if (account.getAccountNumber().equals(accountNumber)) {
                return account;
            }
        }
        return null;
    }
}

Exercise 1: Shape Collection

Create a geometry management system:
  • Create a Shape abstract class with calculateArea() method
  • Create Circle and Rectangle subclasses
  • Create a ShapeManager class with a List<Shape>
  • Add methods to:
    • Add shapes
    • Calculate total area of all shapes
    • Find the largest shape by area

Part 2: Exception Handling

What is an exception?
  • An exception is an event that disrupts the normal flow of the program
  • Examples: file not found, division by zero, null reference, array index out of bounds
  • Without handling, exceptions crash the program
We need to handle exceptions gracefully!

Try-Catch Block

The basic structure for handling exceptions:
public class ExceptionExample {
    public static void main(String[] args) {
        try {
            // Code that might throw an exception
            int result = 10 / 0; // ArithmeticException!
            System.out.println("Result: " + result);
        } catch (ArithmeticException e) {
            // Handle the exception
            System.out.println("Error: Cannot divide by zero!");
            System.out.println("Message: " + e.getMessage());
        }

        System.out.println("Program continues...");
    }
}
Output: "Error: Cannot divide by zero!" and then "Program continues..."

Multiple Catch Blocks

You can catch different exception types:
public class MultiCatchExample {
    public static void main(String[] args) {
        String[] array = {"10", "20", "abc"};

        for (String str : array) {
            try {
                int number = Integer.parseInt(str);
                int result = 100 / number;
                System.out.println("Result: " + result);
            } catch (NumberFormatException e) {
                System.out.println("Invalid number: " + str);
            } catch (ArithmeticException e) {
                System.out.println("Cannot divide by zero");
            }
        }
    }
}

Finally Block

The finally block always executes (even if exception occurs):
public class FinallyExample {
    public static void main(String[] args) {
        try {
            System.out.println("Opening resource...");
            int result = 10 / 0;
            System.out.println("Result: " + result);
        } catch (ArithmeticException e) {
            System.out.println("Error occurred!");
        } finally {
            // This ALWAYS executes
            System.out.println("Closing resource...");
        }
    }
}

// Output:
// Opening resource...
// Error occurred!
// Closing resource...

Common Exceptions

  • NullPointerException - Accessing methods/fields on null object
    String str = null;
    str.length(); // NullPointerException!
  • ArrayIndexOutOfBoundsException - Invalid array index
    int[] numbers = {1, 2, 3};
    int x = numbers[5]; // ArrayIndexOutOfBoundsException!
  • NumberFormatException - Invalid number conversion
    int num = Integer.parseInt("abc"); // NumberFormatException!
  • IOException - File/network operations (we'll see this next!)

Part 3: File I/O

Why do we need to work with files?
  • Persist data between program runs
  • Read configuration files
  • Export/import data
  • Log information for debugging
File I/O operations can throw IOException - we must handle it!

Writing to a File

Using FileWriter and BufferedWriter:
import java.io.FileWriter;
import java.io.BufferedWriter;
import java.io.IOException;

public class FileWriteExample {
    public static void main(String[] args) {
        try (BufferedWriter writer = new BufferedWriter(new FileWriter("output.txt"))) {
            writer.write("Hello, World!");
            writer.newLine();
            writer.write("This is a second line.");
            writer.newLine();
            System.out.println("File written successfully!");
        } catch (IOException e) {
            System.out.println("Error writing file: " + e.getMessage());
        }
    }
}
Note: try-with-resources automatically closes the file!

Reading from a File

Using FileReader and BufferedReader:
import java.io.FileReader;
import java.io.BufferedReader;
import java.io.IOException;

public class FileReadExample {
    public static void main(String[] args) {
        try (BufferedReader reader = new BufferedReader(new FileReader("input.txt"))) {
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        } catch (IOException e) {
            System.out.println("Error reading file: " + e.getMessage());
        }
    }
}

File Operations with Scanner

Scanner is another way to read files:
import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;

public class ScannerFileExample {
    public static void main(String[] args) {
        try {
            File file = new File("data.txt");
            Scanner scanner = new Scanner(file);

            while (scanner.hasNextLine()) {
                String line = scanner.nextLine();
                System.out.println(line);
            }

            scanner.close();
        } catch (FileNotFoundException e) {
            System.out.println("File not found: " + e.getMessage());
        }
    }
}

PrintWriter - Simple File Writing

PrintWriter provides convenient methods like println():
import java.io.PrintWriter;
import java.io.IOException;

public class PrintWriterExample {
    public static void main(String[] args) {
        try (PrintWriter writer = new PrintWriter("numbers.txt")) {
            for (int i = 1; i <= 10; i++) {
                writer.println("Number: " + i);
            }
            System.out.println("File created successfully!");
        } catch (IOException e) {
            System.out.println("Error: " + e.getMessage());
        }
    }
}

Practical Example: Saving Bank Accounts

import java.io.*;
import java.util.*;

public class BankAccountPersistence {
    public static void saveAccounts(List<BankAccount> accounts, String filename) {
        try (PrintWriter writer = new PrintWriter(filename)) {
            for (BankAccount account : accounts) {
                writer.println(account.getAccountNumber() + "," +
                             account.getOwner() + "," +
                             account.getBalance());
            }
        } catch (IOException e) {
            System.out.println("Error saving accounts: " + e.getMessage());
        }
    }

    public static List<BankAccount> loadAccounts(String filename) {
        List<BankAccount> accounts = new ArrayList<>();
        try (Scanner scanner = new Scanner(new File(filename))) {
            while (scanner.hasNextLine()) {
                String[] parts = scanner.nextLine().split(",");
                accounts.add(new BankAccount(parts[0], parts[1],
                            Double.parseDouble(parts[2])));
            }
        } catch (FileNotFoundException e) {
            System.out.println("File not found: " + e.getMessage());
        }
        return accounts;
    }
}

Exercise 2: Student Grade Persistence

Create a grade management system:
  • Create a Student class with name and grade fields
  • Create a List<Student> and add several students
  • Write a method saveToFile() that saves all students to "students.txt"
    • Format: "Name,Grade" (one student per line)
  • Write a method loadFromFile() that reads students from the file
  • Test by saving, restarting program, and loading

Exercise 3: Log File System

Create a simple logging system:
  • Create a Logger class with method log(String message)
  • Each log entry should include:
    • Timestamp (use LocalDateTime from Lecture 7)
    • Message
  • Write log entries to "application.log" file (append mode)
  • Hint: Use new FileWriter("application.log", true) for append mode

Best Practices

  • Always use try-with-resources for file operations (auto-closes)
    try (BufferedWriter writer = new BufferedWriter(new FileWriter("file.txt"))) {
        // Use writer
    } // Automatically closed
  • Handle exceptions appropriately - don't just catch and ignore!
  • Use BufferedReader/BufferedWriter for better performance
  • Use specific exception types instead of generic Exception
  • Validate file paths before operations
  • Choose the right collection:
    • Need order and duplicates? → ArrayList
    • Need unique elements? → HashSet
    • Need key-value lookups? → HashMap

Summary

Today we learned:
  • Collections Framework
    • ArrayList for dynamic lists
    • HashSet for unique elements
    • HashMap for key-value pairs
  • Exception Handling
    • try-catch-finally blocks
    • Common exception types
  • File I/O
    • Reading and writing text files
    • Persisting object data
You now have the tools to build real, practical applications!

Project Exercise: Complete Bank System

Apply everything you learned to build a complete banking system:
  • Use List<BankAccount> to store multiple accounts
  • Use Map<String, BankAccount> for quick lookup by account number
  • Handle exceptions (invalid amounts, account not found, etc.)
  • Save accounts to file when program exits
  • Load accounts from file when program starts
  • Provide menu: Create Account, Deposit, Withdraw, Transfer, List All, Exit
Practice: Apply these concepts by building real-world applications combining Collections, File I/O, and Exception Handling

Slide Overview