Skip to main content

2º Java Book - OOP Foundations

Chapter 1: Classes vs Objects

Quick Theory: The Object-Oriented mindset is about modeling the real world using software constructs. Instead of thinking about procedures or steps, we think about "things" or "entities" and how they behave and interact. This approach often leads to more maintainable, scalable, and understandable code by organizing complexity around coherent units.

At the core of OOP are Classes and Objects. Think of a Class as a blueprint or a template for creating something – for example, a blueprint for a house. It defines what attributes (like number of rooms, color) and behaviors (like open door, turn on lights) that thing will have. An Object, on the other hand, is a concrete instance built from that blueprint – an actual house standing on a street. You can build many houses from one blueprint, and each house (object) will be distinct, even if they share the same structure.

Professional Code:

// Example 1: Defining a simple 'Person' class (the blueprint)
// A class is a template for creating objects.
class Person {
    // Attributes (state) - these describe the characteristics of a Person.
    String name;
    int age;

    // We'll add methods (behavior) in a later chapter.
}
// Example 2: Instantiating a 'Person' object in the main method
public class ObjectCreationDemo {
    public static void main(String[] args) {
        // Here, we're using the 'Person' blueprint to create an actual 'Person' object.
        // The 'new' keyword is used to allocate memory for a new object.
        // 'person1' is a reference variable that holds the memory address of the new Person object.
        Person person1 = new Person();

        // Now we can access and set the attributes of this specific 'person1' object.
        person1.name = "Alice";
        person1.age = 30;

        // Printing the details of the object.
        // By default, printing an object will show its class name and a hash code.
        // We'll learn how to make this output more meaningful in Chapter 5.
        System.out.println("Person 1 object reference: " + person1);
        System.out.println("Person 1 Name: " + person1.name);
        System.out.println("Person 1 Age: " + person1.age);
    }
}
// Example 3: Creating multiple distinct objects from the same class
public class MultipleObjectsDemo {
    public static void main(String[] args) {
        // Create the first Person object.
        Person personA = new Person();
        personA.name = "Bob";
        personA.age = 25;

        // Create a second, completely distinct Person object from the same 'Person' blueprint.
        Person personB = new Person();
        personB.name = "Charlie";
        personB.age = 35;

        // Notice that personA and personB are independent instances.
        // Changes to one do not affect the other.
        System.out.println("--- Details for Person A ---");
        System.out.println("Name: " + personA.name);
        System.out.println("Age: " + personA.age);
        System.out.println("Object reference: " + personA); // Different memory address

        System.out.println("\n--- Details for Person B ---");
        System.out.println("Name: " + personB.name);
        System.out.println("Age: " + personB.age);
        System.out.println("Object reference: " + personB); // Different memory address
    }
}

Clean Code Tip: When designing a class, strive for a single responsibility. A class should have one, and only one, reason to change. This is the "S" in the SOLID principles (Single Responsibility Principle). For instance, a Person class should manage person-related data and behavior, not database persistence or UI display.

Exercise: Create a simple class named Book. It should have two attributes: title (String) and author (String). In a main method, create two Book objects, set their title and author, and then print out the details of each book.

Solution:

// Define the Book class
class Book {
    String title;
    String author;
}

public class BookExerciseSolution {
    public static void main(String[] args) {
        // Create the first Book object
        Book book1 = new Book();
        book1.title = "The Hitchhiker's Guide to the Galaxy";
        book1.author = "Douglas Adams";

        // Create the second Book object
        Book book2 = new Book();
        book2.title = "1984";
        book2.author = "George Orwell";

        // Print details for book1
        System.out.println("--- Book 1 Details ---");
        System.out.println("Title: " + book1.title);
        System.out.println("Author: " + book1.author);

        // Print details for book2
        System.out.println("\n--- Book 2 Details ---");
        System.out.println("Title: " + book2.title);
        System.out.println("Author: " + book2.author);
    }
}

Chapter 2: Attributes & Methods

Quick Theory: In Object-Oriented Programming, objects encapsulate both data (state) and the operations that can be performed on that data (behavior). Attributes, also known as fields or member variables, define the state of an object—what it is or what it has. Methods, on the other hand, define the behavior of an object—what it does or what can be done to it. Together, attributes and methods give objects their complete functionality and identity.

Objects interact by calling each other's methods. This interaction is the cornerstone of object-oriented design, allowing complex systems to be built from smaller, manageable, and interconnected components. For example, a Driver object might interact with a Car object by calling its startEngine() or accelerate() methods, modifying the car's internal state (like speed) and triggering its behaviors.

Professional Code:

// Example 1: Car class with attributes (state) and methods (behavior)
class Car {
    // Attributes - represent the state of a Car object.
    String make;
    String model;
    int year;
    int speed; // Current speed of the car, initialized to 0 by default.

    // Methods - represent the behavior of a Car object.

    // Method to start the car's engine.
    void startEngine() {
        System.out.println(make + " " + model + "'s engine started.");
        // Starting the engine might implicitly set a state like 'isRunning = true',
        // but for simplicity, we'll just print a message for now.
    }

    // Method to accelerate the car.
    // It takes an 'amount' as a parameter to increase the speed.
    void accelerate(int amount) {
        if (amount > 0) {
            speed += amount; // Increase the car's speed.
            System.out.println(make + " " + model + " is accelerating. Current speed: " + speed + " km/h.");
        } else {
            System.out.println("Acceleration amount must be positive.");
        }
    }

    // Method to brake the car.
    void brake(int amount) {
        if (amount > 0 && speed - amount >= 0) {
            speed -= amount; // Decrease the car's speed.
            System.out.println(make + " " + model + " is braking. Current speed: " + speed + " km/h.");
        } else if (speed - amount < 0) {
            speed = 0; // Cannot have negative speed.
            System.out.println(make + " " + model + " has stopped. Current speed: " + speed + " km/h.");
        } else {
            System.out.println("Brake amount must be positive.");
        }
    }

    // Method to display the current status of the car.
    void displayStatus() {
        System.out.println("Car: " + make + " " + model + " (" + year + ")");
        System.out.println("Current Speed: " + speed + " km/h.");
    }
}
// Example 2: Creating a Car object and calling its methods
public class CarOperationsDemo {
    public static void main(String[] args) {
        // Create a new Car object.
        Car myCar = new Car();

        // Set its attributes (state).
        myCar.make = "Toyota";
        myCar.model = "Camry";
        myCar.year = 2022;

        // Call its methods (behavior).
        myCar.displayStatus(); // Show initial status.
        myCar.startEngine();   // Start the engine.
        myCar.accelerate(50);  // Accelerate by 50 km/h.
        myCar.accelerate(20);  // Accelerate by another 20 km/h.
        myCar.brake(30);       // Brake by 30 km/h.
        myCar.displayStatus(); // Show updated status.
        myCar.brake(100);      // Brake beyond current speed to stop.
        myCar.displayStatus();
    }
}
// Example 3: Objects interacting with each other (a simple scenario)
class Driver {
    String name;

    // Constructor to initialize the driver's name (we'll cover constructors in Chapter 3).
    Driver(String name) {
        this.name = name;
    }

    // A driver's behavior: driving a car.
    void driveCar(Car carToDrive, int accelerationAmount, int brakeAmount) {
        System.out.println("\n" + name + " is now driving the " + carToDrive.make + " " + carToDrive.model + ".");
        carToDrive.startEngine();
        carToDrive.accelerate(accelerationAmount);
        carToDrive.brake(brakeAmount);
        carToDrive.displayStatus();
    }
}

public class ObjectInteractionDemo {
    public static void main(String[] args) {
        // Create a Car object.
        Car sedan = new Car();
        sedan.make = "Honda";
        sedan.model = "Civic";
        sedan.year = 2023;

        // Create a Driver object.
        Driver john = new Driver("John");

        // The Driver object interacts with the Car object by calling its methods.
        john.driveCar(sedan, 60, 20);

        // Another driver, another car.
        Car suv = new Car();
        suv.make = "Ford";
        suv.model = "Explorer";
        suv.year = 2024;

        Driver jane = new Driver("Jane");
        jane.driveCar(suv, 80, 40);
    }
}

Clean Code Tip: Name methods clearly using verbs that describe the action they perform (e.g., startEngine(), calculateArea(), processOrder()). Name attributes using nouns that describe the state they hold (e.g., make, model, speed). This makes the code self-documenting and easier to understand, reflecting the natural language of the problem domain.

Exercise: Enhance your Dog class from Chapter 1. Add attributes for name (String) and breed (String). Then, add a method called bark() that prints "[Dog's Name] says Woof! Woof!" and a method called displayInfo() that prints the dog's name and breed. In your main method, create a Dog object, set its attributes, and call its methods.

Solution:

class Dog {
    String name;
    String breed;
    // We can also add an 'age' attribute to follow the example.
    int age;

    // Method to make the dog bark
    void bark() {
        System.out.println(name + " says Woof! Woof!");
    }

    // Method to display the dog's information
    void displayInfo() {
        System.out.println("Dog Name: " + name);
        System.out.println("Breed: " + breed);
        System.out.println("Age: " + age + " years");
    }
}

public class DogExerciseSolution {
    public static void main(String[] args) {
        // Create a Dog object
        Dog myDog = new Dog();

        // Set its attributes
        myDog.name = "Buddy";
        myDog.breed = "Golden Retriever";
        myDog.age = 5;

        // Call its methods
        myDog.displayInfo();
        myDog.bark();

        // Create another Dog object
        Dog neighborDog = new Dog();
        neighborDog.name = "Max";
        neighborDog.breed = "German Shepherd";
        neighborDog.age = 3;

        neighborDog.displayInfo();
        neighborDog.bark();
    }
}

Chapter 3: Constructors

Quick Theory: Constructors are special methods used to initialize objects. When you create an object using the new keyword (e.g., new Person()), a constructor is invoked. Their primary purpose is to ensure that a newly created object is in a valid and usable state right from its inception. If you don't define any constructor in your class, Java provides a default, no-argument constructor implicitly. However, once you define any constructor, Java no longer provides the default one.

Constructors can be "no-arg" (taking no arguments) or "parameterized" (taking one or more arguments). Parameterized constructors are very common as they allow you to set the initial state of an object with specific values at the time of its creation. The this keyword is crucial within a constructor (or any instance method) to refer to the current object itself, particularly useful for distinguishing between an instance variable and a local parameter with the same name.

Professional Code:

// Example 1: Default (implicit) and No-Arg (explicit) Constructors
class Product {
    String name;
    double price;

    // If you don't define any constructor, Java provides a default no-arg constructor.
    // However, if you define *any* constructor, Java doesn't provide the default one.
    // It's good practice to explicitly define a no-arg constructor if you need one,
    // especially if you also define parameterized constructors.

    // Explicit No-Arg Constructor
    // It takes no arguments and typically sets default values or performs basic initialization.
    public Product() {
        this.name = "Unknown Product"; // Initialize with a default name.
        this.price = 0.0;              // Initialize with a default price.
        System.out.println("Product created using no-arg constructor.");
    }
}
// Example 2: Parameterized Constructor and the 'this' keyword
class OrderItem {
    int itemId;
    String description;
    int quantity;
    double unitPrice;

    // Parameterized Constructor
    // This constructor takes arguments to initialize the object's state upon creation.
    // The 'this' keyword is used to differentiate between the instance variable (e.g., this.itemId)
    // and the local parameter (e.g., itemId).
    public OrderItem(int itemId, String description, int quantity, double unitPrice) {
        this.itemId = itemId;           // Assign parameter 'itemId' to the instance variable 'this.itemId'.
        this.description = description; // Assign parameter 'description' to 'this.description'.
        this.quantity = quantity;       // Assign parameter 'quantity' to 'this.quantity'.
        this.unitPrice = unitPrice;     // Assign parameter 'unitPrice' to 'this.unitPrice'.
        System.out.println("OrderItem created: " + description + " (Qty: " + quantity + ").");
    }

    // Method to calculate the total cost for this order item.
    public double calculateTotal() {
        return quantity * unitPrice;
    }

    // A no-arg constructor can also be provided alongside parameterized ones.
    public OrderItem() {
        this(0, "Default Item", 1, 0.0); // Chaining to the parameterized constructor using 'this()'
        System.out.println("OrderItem created using no-arg constructor (defaulted).");
    }
}
// Example 3: Demonstrating different constructors in Main method
public class ConstructorDemo {
    public static void main(String[] args) {
        // Using the no-arg constructor for Product
        Product p1 = new Product();
        System.out.println("Product 1 Name: " + p1.name + ", Price: " + p1.price); // Shows default values

        // Creating an OrderItem using the parameterized constructor
        OrderItem item1 = new OrderItem(101, "Laptop", 1, 1200.00);
        System.out.println("Item 1 Total: $" + item1.calculateTotal());

        // Creating another OrderItem using the parameterized constructor
        OrderItem item2 = new OrderItem(102, "Mouse", 2, 25.50);
        System.out.println("Item 2 Total: $" + item2.calculateTotal());

        // Creating an OrderItem using the no-arg constructor (which chains to the parameterized one)
        OrderItem item3 = new OrderItem();
        System.out.println("Item 3 Description: " + item3.description + ", Total: $" + item3.calculateTotal());

        // We could also create a Product and set its values manually, but constructors are for initial setup.
        Product p2 = new Product();
        p2.name = "Coffee Mug";
        p2.price = 15.99;
        System.out.println("Product 2 Name: " + p2.name + ", Price: " + p2.price);
    }
}

Clean Code Tip: Always provide constructors that ensure an object is created in a valid and consistent state. Avoid creating objects that require multiple subsequent calls to setters to become usable. If an object must have certain attributes to be valid, then a parameterized constructor requiring those attributes is appropriate.

Exercise: Modify your Dog class. Add:

  1. A no-argument constructor that sets a default name (e.g., "Unnamed") and breed (e.g., "Mixed").
  2. A parameterized constructor that takes name, breed, and age as arguments to initialize the dog. In your main method, create one Dog object using the no-arg constructor and another using the parameterized constructor. Call displayInfo() for both.

Solution:

class Dog {
    String name;
    String breed;
    int age;

    // 1. No-arg constructor
    public Dog() {
        this.name = "Unnamed";
        this.breed = "Mixed";
        this.age = 0; // Default age
        System.out.println("No-arg Dog constructor called.");
    }

    // 2. Parameterized constructor
    public Dog(String name, String breed, int age) {
        this.name = name;
        this.breed = breed;
        this.age = age;
        System.out.println("Parameterized Dog constructor called for " + name + ".");
    }

    void bark() {
        System.out.println(name + " says Woof! Woof!");
    }

    void displayInfo() {
        System.out.println("--- Dog Info ---");
        System.out.println("Name: " + name);
        System.out.println("Breed: " + breed);
        System.out.println("Age: " + age + " years");
    }
}

public class DogConstructorExerciseSolution {
    public static void main(String[] args) {
        // Create a Dog object using the no-arg constructor
        Dog defaultDog = new Dog();
        defaultDog.displayInfo();
        defaultDog.bark();

        System.out.println("\n------------------\n");

        // Create a Dog object using the parameterized constructor
        Dog namedDog = new Dog("Luna", "Siberian Husky", 2);
        namedDog.displayInfo();
        namedDog.bark();

        System.out.println("\n------------------\n");

        // We can still modify the default dog's attributes after creation if needed
        defaultDog.name = "Pudding";
        defaultDog.breed = "Poodle";
        defaultDog.age = 7;
        defaultDog.displayInfo();
    }
}

Chapter 4: Encapsulation (Access Modifiers)

Quick Theory: Encapsulation is one of the fundamental principles of Object-Oriented Programming. It's the mechanism of bundling data (attributes) and the methods (behaviors) that operate on that data within a single unit, which is typically a class. More importantly, encapsulation involves restricting direct access to some of an object's components, meaning that internal state is hidden from the outside world. This "data hiding" ensures that the object's internal state can only be accessed or modified in controlled ways, usually through public methods.

The power of private and public access modifiers comes into play here. private members (attributes or methods) are only accessible from within the class itself, providing a protective barrier. public members are accessible from anywhere, forming the "interface" that other classes use to interact with the object. By hiding data (private) and exposing controlled access through public methods (Getters to read, Setters to write), we maintain the integrity of the object's state, prevent misuse, and allow internal implementation details to change without affecting external code that uses the class.

Professional Code:

// Example 1: BankAccount with private balance and public methods (Deposit/Withdraw)
class BankAccount {
    // Attributes should ideally be private to protect the internal state.
    // This prevents external code from directly manipulating 'balance'.
    private String accountNumber;
    private String accountHolder;
    private double balance; // This is the sensitive data we want to protect.

    // Constructor to initialize a BankAccount object.
    public BankAccount(String accountNumber, String accountHolder, double initialBalance) {
        this.accountNumber = accountNumber;
        this.accountHolder = accountHolder;
        // Perform validation during initialization.
        if (initialBalance >= 0) {
            this.balance = initialBalance;
        } else {
            System.err.println("Initial balance cannot be negative. Setting to 0.");
            this.balance = 0;
        }
    }

    // Public method to deposit money. This is a controlled way to change 'balance'.
    public void deposit(double amount) {
        if (amount > 0) {
            this.balance += amount;
            System.out.println("Deposited $" + amount + ". New balance: $" + this.balance);
        } else {
            System.err.println("Deposit amount must be positive.");
        }
    }

    // Public method to withdraw money. This is another controlled way to change 'balance'.
    public void withdraw(double amount) {
        if (amount > 0 && this.balance >= amount) {
            this.balance -= amount;
            System.out.println("Withdrew $" + amount + ". New balance: $" + this.balance);
        } else if (amount <= 0) {
            System.err.println("Withdrawal amount must be positive.");
        } else {
            System.err.println("Insufficient funds. Current balance: $" + this.balance);
        }
    }

    // Public method to get the current balance. This is the only way to read 'balance' from outside.
    public double getBalance() {
        return this.balance;
    }

    // Getters for other private attributes.
    public String getAccountNumber() {
        return accountNumber;
    }

    public String getAccountHolder() {
        return accountHolder;
    }
}
// Example 2: Using Getters and Setters for other attributes
// This demonstrates how to expose read/write access to private fields in a controlled manner.
class UserProfile {
    private String username;
    private String email;
    private int age; // Assume age must be positive

    public UserProfile(String username, String email, int age) {
        this.username = username;
        // Basic validation in constructor
        setEmail(email); // Use the setter to apply validation
        setAge(age);     // Use the setter to apply validation
    }

    // Getter for username (read-only access)
    public String getUsername() {
        return username;
    }

    // Setter for username (if we want to allow modification after creation)
    public void setUsername(String username) {
        // We could add validation here, e.g., check for length, unique username.
        this.username = username;
    }

    // Getter for email
    public String getEmail() {
        return email;
    }

    // Setter for email with validation
    public void setEmail(String email) {
        if (email != null && email.contains("@") && email.contains(".")) {
            this.email = email;
        } else {
            System.err.println("Invalid email format for: " + email + ". Email not set.");
            // Optionally, throw an exception or set a default/null value
        }
    }

    // Getter for age
    public int getAge() {
        return age;
    }

    // Setter for age with validation
    public void setAge(int age) {
        if (age > 0) {
            this.age = age;
        } else {
            System.err.println("Age must be positive. Age not set or kept as previous.");
        }
    }
}
// Example 3: Demonstrating encapsulation in the Main method
public class EncapsulationDemo {
    public static void main(String[] args) {
        // --- BankAccount Demo ---
        BankAccount myAccount = new BankAccount("123456789", "John Doe", 1000.0);

        // Accessing data through public getters
        System.out.println("Account Holder: " + myAccount.getAccountHolder());
        System.out.println("Account Number: " + myAccount.getAccountNumber());
        System.out.println("Current Balance: $" + myAccount.getBalance());

        // Modifying data through public methods (controlled behavior)
        myAccount.deposit(500.0);
        myAccount.withdraw(200.0);
        myAccount.withdraw(2000.0); // Attempt to withdraw too much
        myAccount.deposit(-100.0);  // Attempt to deposit negative amount

        System.out.println("Final Balance: $" + myAccount.getBalance());

        // myAccount.balance = 999999.0; // This would cause a compile-time error
                                        // because 'balance' is private. This is encapsulation in action!

        System.out.println("\n--- UserProfile Demo ---");
        UserProfile user1 = new UserProfile("jsmith", "john.smith@example.com", 25);
        System.out.println("Username: " + user1.getUsername());
        System.out.println("Email: " + user1.getEmail());
        System.out.println("Age: " + user1.getAge());

        // Modify attributes using setters with built-in validation
        user1.setEmail("invalid-email"); // This will print an error and not change the email.
        System.out.println("Email after invalid attempt: " + user1.getEmail());

        user1.setAge(-5); // This will print an error and not change the age.
        System.out.println("Age after invalid attempt: " + user1.getAge());

        user1.setAge(26); // Valid change
        System.out.println("Age after valid change: " + user1.getAge());
    }
}

Clean Code Tip: Always hide your class's internal data (attributes) by declaring them private. Expose access to this data only through public Getters (read-only) and Setters (write-only) methods. This practice, known as Encapsulation, allows you to control how the data is accessed and modified, enforce validation rules, and ensures that changes to the internal representation of data don't break external code (a key aspect of the "O" in SOLID - Open/Closed Principle).

Exercise: Take your Car class. Make its make, model, year, and speed attributes private. Create public Getters for make, model, year, and speed. Also, create a public Setter for speed that only allows setting positive speed and prevents speed from exceeding a MAX_SPEED constant (e.g., 200 km/h). Modify your accelerate and brake methods to use and respect the MAX_SPEED.

Solution:

class Car {
    // Private attributes for encapsulation
    private String make;
    private String model;
    private int year;
    private int speed; // Current speed, default 0

    // Constant for maximum speed
    private static final int MAX_SPEED = 200;

    // Constructor to initialize the car
    public Car(String make, String model, int year) {
        this.make = make;
        this.model = model;
        this.year = year;
        this.speed = 0; // Initialize speed to 0
        System.out.println("Car " + make + " " + model + " (" + year + ") created.");
    }

    // Public Getters for attributes
    public String getMake() {
        return make;
    }

    public String getModel() {
        return model;
    }

    public int getYear() {
        return year;
    }

    public int getSpeed() {
        return speed;
    }

    // Public Setter for speed with validation
    public void setSpeed(int newSpeed) {
        if (newSpeed >= 0 && newSpeed <= MAX_SPEED) {
            this.speed = newSpeed;
            System.out.println(make + " " + model + " speed adjusted to: " + this.speed + " km/h.");
        } else if (newSpeed < 0) {
            System.err.println("Speed cannot be negative. Speed remains " + this.speed + " km/h.");
        } else { // newSpeed > MAX_SPEED
            System.err.println("Cannot exceed MAX_SPEED (" + MAX_SPEED + " km/h). Speed remains " + this.speed + " km/h.");
        }
    }

    // Method to accelerate the car, respecting MAX_SPEED
    public void accelerate(int amount) {
        if (amount > 0) {
            int potentialSpeed = this.speed + amount;
            if (potentialSpeed <= MAX_SPEED) {
                this.speed = potentialSpeed;
                System.out.println(make + " " + model + " accelerating. Current speed: " + this.speed + " km/h.");
            } else {
                this.speed = MAX_SPEED;
                System.out.println(make + " " + model + " accelerated to MAX_SPEED. Current speed: " + this.speed + " km/h.");
            }
        } else {
            System.err.println("Acceleration amount must be positive.");
        }
    }

    // Method to brake the car
    public void brake(int amount) {
        if (amount > 0) {
            int potentialSpeed = this.speed - amount;
            if (potentialSpeed >= 0) {
                this.speed = potentialSpeed;
                System.out.println(make + " " + model + " braking. Current speed: " + this.speed + " km/h.");
            } else {
                this.speed = 0; // Cannot have negative speed
                System.out.println(make + " " + model + " has stopped. Current speed: " + this.speed + " km/h.");
            }
        } else {
            System.err.println("Brake amount must be positive.");
        }
    }

    public void startEngine() {
        System.out.println(make + " " + model + "'s engine started.");
    }

    public void displayStatus() {
        System.out.println("--- Car Status ---");
        System.out.println("Make: " + getMake()); // Using getter
        System.out.println("Model: " + getModel()); // Using getter
        System.out.println("Year: " + getYear());   // Using getter
        System.out.println("Speed: " + getSpeed() + " km/h."); // Using getter
        System.out.println("------------------");
    }
}

public class CarEncapsulationExerciseSolution {
    public static void main(String[] args) {
        Car mySportsCar = new Car("Porsche", "911", 2023);
        mySportsCar.displayStatus();

        mySportsCar.startEngine();
        mySportsCar.accelerate(100);
        mySportsCar.accelerate(80);
        mySportsCar.accelerate(50); // This should hit max speed or cap it
        mySportsCar.displayStatus();

        // Try to set speed directly (should use setter)
        // mySportsCar.speed = 250; // Compile-time error: 'speed' has private access

        // Use the setter for controlled modification
        mySportsCar.setSpeed(150); // Valid change
        mySportsCar.setSpeed(-10); // Invalid change
        mySportsCar.setSpeed(300); // Invalid change (exceeds MAX_SPEED)

        mySportsCar.brake(mySportsCar.getSpeed()); // Stop the car using its current speed.
        mySportsCar.displayStatus();
    }
}

Chapter 5: The 'toString()' Method

Quick Theory: Every class in Java implicitly or explicitly inherits from the Object class. The Object class provides a basic implementation for several methods, one of which is toString(). This method's purpose is to return a string representation of the object. By default, Object's toString() method returns a string consisting of the class name, an '@' sign, and the unsigned hexadecimal representation of the object's hash code (e.g., ClassName@hashCode). While this is useful for unique object identification in memory, it's rarely informative for debugging or logging application-specific object details.

To get a meaningful textual representation of an object's state, it is standard practice to override the toString() method in your own classes. By doing so, you can define exactly what information about your object (typically its attributes) should be included in its string representation. This is incredibly valuable for debugging, logging, and simply understanding an object's current state at any point during program execution.

Professional Code:

// Example 1: Product class WITHOUT overriding toString()
class ProductWithoutToString {
    String productId;
    String name;
    double price;

    public ProductWithoutToString(String productId, String name, double price) {
        this.productId = productId;
        this.name = name;
        this.price = price;
    }
    // No toString() method explicitly defined here.
}
// Example 2: Product class WITH overriding toString()
class ProductWithToString {
    String productId;
    String name;
    double price;

    public ProductWithToString(String productId, String name, double price) {
        this.productId = productId;
        this.name = name;
        this.price = price;
    }

    // Override the toString() method to provide a meaningful string representation.
    // This makes debugging and logging much easier.
    @Override // This annotation indicates that this method overrides a method in a superclass.
    public String toString() {
        // We'll return a string containing the object's important attributes.
        // String.format is useful for creating formatted strings.
        return String.format("Product [ID=%s, Name=%s, Price=%.2f]", productId, name, price);
        // Or simply:
        // return "Product [ID=" + productId + ", Name=" + name + ", Price=" + price + "]";
    }
}
// Example 3: Demonstrating printing objects before and after toString() override
public class ToStringDemo {
    public static void main(String[] args) {
        // --- Without custom toString() ---
        ProductWithoutToString p1_no_string = new ProductWithoutToString("P101", "Laptop", 1200.00);
        ProductWithoutToString p2_no_string = new ProductWithoutToString("P102", "Keyboard", 75.50);

        System.out.println("--- Products without overridden toString() ---");
        // When you print an object directly, Java implicitly calls its toString() method.
        // Since it's not overridden, it calls Object's default toString().
        System.out.println(p1_no_string);
        System.out.println(p2_no_string);
        System.out.println(); // For spacing

        // --- With custom toString() ---
        ProductWithToString p1_with_string = new ProductWithToString("P201", "Smartphone", 999.99);
        ProductWithToString p2_with_string = new ProductWithToString("P202", "Headphones", 150.00);

        System.out.println("--- Products with overridden toString() ---");
        // Now, when you print the object, it calls our custom toString() method,
        // providing a much more readable output.
        System.out.println(p1_with_string);
        System.out.println(p2_with_string);

        // This is also useful for logging, error messages, etc.
        String logMessage = "Detected issue with product: " + p1_with_string;
        System.out.println("\nLog message example: " + logMessage);
    }
}

Clean Code Tip: Always override the toString() method for your domain objects (classes that represent real-world entities or core application concepts). A well-implemented toString() method provides a clear, concise, and helpful textual representation of an object's state, which is invaluable for debugging, logging, and understanding program flow.

Exercise: Override the toString() method for your Dog class (from previous exercises). The toString() method should return a string like: "Dog [Name: [name], Breed: [breed], Age: [age] years]". In your main method, create a few Dog objects and print them directly to the console to see the effect of your overridden method.

Solution:

class Dog {
    private String name;
    private String breed;
    private int age;

    public Dog() {
        this("Unnamed", "Mixed", 0); // Chain to parameterized constructor
    }

    public Dog(String name, String breed, int age) {
        this.name = name;
        this.breed = breed;
        this.age = age;
    }

    // Getters for attributes (good practice, though not strictly needed for toString)
    public String getName() { return name; }
    public String getBreed() { return breed; }
    public int getAge() { return age; }

    public void bark() {
        System.out.println(name + " says Woof! Woof!");
    }

    // Override the toString() method
    @Override
    public String toString() {
        // Return a formatted string representing the Dog object's state
        return String.format("Dog [Name: %s, Breed: %s, Age: %d years]", name, breed, age);
    }
}

public class DogToStringExerciseSolution {
    public static void main(String[] args) {
        Dog dog1 = new Dog("Buddy", "Golden Retriever", 5);
        Dog dog2 = new Dog("Max", "German Shepherd", 3);
        Dog dog3 = new Dog(); // Using the no-arg constructor

        System.out.println("--- Printing Dog objects ---");
        System.out.println(dog1); // Implicitly calls dog1.toString()
        System.out.println(dog2); // Implicitly calls dog2.toString()
        System.out.println(dog3); // Implicitly calls dog3.toString()

        System.out.println("\n--- Dog actions ---");
        dog1.bark();
        dog2.bark();

        // We can also use toString() explicitly if needed, but it's often implicit.
        String dogDetails = dog1.toString();
        System.out.println("\nDog 1 details (from explicit call): " + dogDetails);
    }
}

Chapter 6: Static vs Instance

Quick Theory: In Java, members of a class (attributes and methods) can be either instance or static. The key difference lies in their ownership and lifecycle. Instance members belong to a specific object (an "instance") of a class. Each object created from a class will have its own copy of instance attributes, and instance methods operate on the state of that particular object. They can only be accessed via an object reference.

Static members, on the other hand, belong to the class itself, not to any specific object. There is only one copy of a static attribute, shared by all instances of the class, and static methods can be called directly on the class name without creating an object. Think of Math.sqrt(): you don't need to create a Math object to use it (new Math().sqrt(16) would be incorrect); you just call Math.sqrt(16). This is because sqrt is a static method, operating independently of any specific Math object's state. Static members are useful for utility functions, constants, or shared data that doesn't vary per object.

Professional Code:

// Example 1: Counter class with instance and static variables
class Counter {
    // Instance variable: Each object of Counter will have its own 'instanceCount'.
    // It keeps track of a count specific to that instance.
    private int instanceCount;

    // Static variable: Belongs to the class itself, not to any specific object.
    // There's only one 'totalInstances' across all Counter objects.
    private static int totalInstances = 0; // Initialized once when the class is loaded.

    public Counter() {
        // When a new Counter object is created, we increment both counts.
        this.instanceCount = 0; // Initialize instance-specific count
        totalInstances++;       // Increment the class-wide total count
        System.out.println("New Counter object created. Total instances: " + totalInstances);
    }

    // Instance method: Operates on the 'instanceCount' of THIS specific object.
    public void incrementInstanceCount() {
        this.instanceCount++;
        System.out.println("Instance count for this object: " + this.instanceCount);
    }

    // Static method: Operates on 'totalInstances' (the class-level data).
    // It can be called without creating an object.
    public static int getTotalInstances() {
        // A static method cannot directly access instance variables (like 'instanceCount')
        // because it doesn't belong to a specific object.
        return totalInstances;
    }

    public int getInstanceCount() {
        return instanceCount;
    }
}
// Example 2: Utility class with static methods
// Static methods are often used for utility classes that don't need object state.
class CalculatorUtils {
    // Static constant: A value that belongs to the class and doesn't change.
    // 'final' means its value cannot be reassigned after initialization.
    public static final double PI = 3.1415926535;

    // Static method to calculate the area of a circle.
    // It doesn't need any object-specific data, just parameters.
    public static double calculateCircleArea(double radius) {
        if (radius < 0) {
            throw new IllegalArgumentException("Radius cannot be negative.");
        }
        return PI * radius * radius;
    }

    // Static method to sum two numbers.
    public static int add(int a, int b) {
        return a + b;
    }

    // Static method to format a string, demonstrating a common utility pattern.
    public static String formatGreeting(String name) {
        return "Hello, " + name + "!";
    }
}
// Example 3: Demonstrating static vs. instance behavior
public class StaticVsInstanceDemo {
    public static void main(String[] args) {
        // --- Counter Demo ---
        System.out.println("--- Counter Demo ---");
        // Accessing static method directly via the class name.
        System.out.println("Initial total instances: " + Counter.getTotalInstances()); // 0

        Counter c1 = new Counter(); // Constructor increments totalInstances to 1
        c1.incrementInstanceCount(); // Increments c1's instanceCount
        c1.incrementInstanceCount(); // Increments c1's instanceCount again
        System.out.println("c1's instance count: " + c1.getInstanceCount());
        System.out.println("Current total instances: " + Counter.getTotalInstances()); // 1

        Counter c2 = new Counter(); // Constructor increments totalInstances to 2
        c2.incrementInstanceCount(); // Increments c2's instanceCount
        System.out.println("c2's instance count: " + c2.getInstanceCount());
        System.out.println("Current total instances: " + Counter.getTotalInstances()); // 2

        // Notice that c1.instanceCount (2) and c2.instanceCount (1) are independent,
        // but Counter.totalInstances (2) is shared.

        // --- CalculatorUtils Demo ---
        System.out.println("\n--- CalculatorUtils Demo ---");
        // Accessing static constant directly via the class name.
        System.out.println("Value of PI: " + CalculatorUtils.PI);

        // Calling static methods directly via the class name.
        double area = CalculatorUtils.calculateCircleArea(5.0);
        System.out.println("Area of circle with radius 5: " + area);

        int sum = CalculatorUtils.add(10, 20);
        System.out.println("Sum of 10 and 20: " + sum);

        String greeting = CalculatorUtils.formatGreeting("Alice");
        System.out.println(greeting);

        // We don't need to create an object of CalculatorUtils to use its methods.
        // CalculatorUtils myUtil = new CalculatorUtils(); // This is often unnecessary for utility classes.
    }
}

Clean Code Tip: Use static for methods that do not depend on the state of an object and for constants that are shared across all instances of a class or relate globally to the class itself. Avoid using static for mutable state that should be unique to each object, as this can lead to hard-to-track bugs due to shared mutable state.

Exercise: Create a Circle class. It should have an instance attribute radius (double). It should also have a static constant PI (double, value 3.14159). Add an instance method getArea() that calculates the area based on the circle's radius. Then, add a static method getCircumference(double radius) that calculates circumference for any given radius, without needing a Circle object. In your main method, demonstrate creating Circle objects and calling both instance and static methods.

Solution:

class Circle {
    // Static constant: Belongs to the class, shared by all instances.
    public static final double PI = 3.14159;

    // Instance attribute: Belongs to each individual Circle object.
    private double radius;

    // Constructor to initialize the radius of a specific Circle object.
    public Circle(double radius) {
        if (radius < 0) {
            throw new IllegalArgumentException("Radius cannot be negative.");
        }
        this.radius = radius;
        System.out.println("Circle created with radius: " + this.radius);
    }

    // Instance method: Calculates area for *this specific* Circle object.
    // It uses the 'radius' attribute of the current instance.
    public double getArea() {
        return PI * this.radius * this.radius;
    }

    // Getter for radius (instance method)
    public double getRadius() {
        return radius;
    }

    // Setter for radius (instance method)
    public void setRadius(double radius) {
        if (radius < 0) {
            throw new IllegalArgumentException("Radius cannot be negative.");
        }
        this.radius = radius;
        System.out.println("Radius updated to: " + this.radius);
    }

    // Static method: Calculates circumference for *any* given radius.
    // It doesn't rely on the 'radius' attribute of a specific Circle object.
    // It can be called directly on the class.
    public static double getCircumference(double anyRadius) {
        if (anyRadius < 0) {
            throw new IllegalArgumentException("Radius cannot be negative.");
        }
        return 2 * PI * anyRadius;
    }

    @Override
    public String toString() {
        return String.format("Circle [Radius: %.2f, Area: %.2f]", radius, getArea());
    }
}

public class CircleStaticVsInstanceExerciseSolution {
    public static void main(String[] args) {
        // --- Accessing Static Members ---
        System.out.println("Value of PI from Circle class: " + Circle.PI);

        // Calling a static method directly on the class name.
        double circumferenceOfRadius10 = Circle.getCircumference(10.0);
        System.out.println("Circumference for radius 10.0: " + circumferenceOfRadius10);

        System.out.println("\n--- Creating and using Circle Objects (Instance Members) ---");
        // Create a Circle object (instance)
        Circle circle1 = new Circle(7.5);

        // Access instance method 'getArea()' for circle1
        System.out.println("Area of circle1: " + circle1.getArea());
        System.out.println("Radius of circle1: " + circle1.getRadius());
        System.out.println(circle1);

        // Create another Circle object (another instance)
        Circle circle2 = new Circle(3.0);
        System.out.println("Area of circle2: " + circle2.getArea());
        System.out.println(circle2);

        // You can use the static method with an object's radius, but it's called on the class.
        System.out.println("Circumference of circle1 (using static method): " + Circle.getCircumference(circle1.getRadius()));

        // Modify circle1's radius and see the area change (instance behavior)
        circle1.setRadius(10.0);
        System.out.println(circle1);
    }
}

Chapter 7: Inheritance (Extends)

Quick Theory: Inheritance is a fundamental concept in Object-Oriented Programming that promotes reusability and specialization. It allows a new class (the subclass or child class) to inherit properties (attributes) and behaviors (methods) from an existing class (the superclass or parent class). This means the subclass automatically gets all the non-private members of its superclass, saving development time and ensuring a consistent base structure.

The power of inheritance also lies in its flexibility to model "is-a" relationships. For example, a Dog is a Animal, and a Car is a Vehicle. The subclass can then extend or specialize the inherited features, adding its unique attributes and methods, or even modifying the behavior of inherited methods (which we'll cover in the next chapter). The super() keyword is crucial in a subclass's constructor to explicitly call a constructor of its parent class, ensuring that the parent part of the object is properly initialized before the child's specific initialization.

Professional Code:

// Example 1: Animal (Superclass) and Dog (Subclass) demonstrating basic inheritance

// Superclass: Animal
class Animal {
    String name;
    int age;

    // Constructor for the Animal class
    public Animal(String name, int age) {
        this.name = name;
        this.age = age;
        System.out.println("Animal constructor called for " + name);
    }

    // Method common to all animals
    public void eat() {
        System.out.println(name + " is eating.");
    }

    public void sleep() {
        System.out.println(name + " is sleeping.");
    }
}

// Subclass: Dog, inherits from Animal using the 'extends' keyword
class Dog extends Animal {
    String breed;

    // Constructor for Dog
    // The 'super(name, age)' call invokes the constructor of the parent class (Animal).
    // This must be the very first statement in the subclass constructor.
    public Dog(String name, int age, String breed) {
        super(name, age); // Initialize inherited 'name' and 'age' from Animal
        this.breed = breed; // Initialize Dog-specific 'breed'
        System.out.println("Dog constructor called for " + name + " (Breed: " + breed + ")");
    }

    // Dog-specific method
    public void bark() {
        System.out.println(name + " barks: Woof! Woof!");
    }

    // Dog can also use inherited methods like eat() and sleep()
    public void displayDogInfo() {
        System.out.println("--- Dog Info ---");
        System.out.println("Name: " + name);   // 'name' is inherited from Animal
        System.out.println("Age: " + age);     // 'age' is inherited from Animal
        System.out.println("Breed: " + breed); // 'breed' is specific to Dog
    }
}
// Example 2: Demonstrating chained inheritance (Vehicle -> Car -> ElectricCar)

// Grandparent class: Vehicle
class Vehicle {
    String manufacturer;
    int year;

    public Vehicle(String manufacturer, int year) {
        this.manufacturer = manufacturer;
        this.year = year;
        System.out.println("Vehicle constructor called for " + manufacturer);
    }

    public void start() {
        System.out.println("Vehicle started.");
    }
}

// Parent class: Car, inherits from Vehicle
class Car extends Vehicle {
    String model;
    int numberOfDoors;

    public Car(String manufacturer, int year, String model, int numberOfDoors) {
        super(manufacturer, year); // Call Vehicle's constructor
        this.model = model;
        this.numberOfDoors = numberOfDoors;
        System.out.println("Car constructor called for " + model);
    }

    public void drive() {
        System.out.println("The " + manufacturer + " " + model + " is driving.");
    }
}

// Child class: ElectricCar, inherits from Car (and indirectly from Vehicle)
class ElectricCar extends Car {
    int batteryCapacityKWh;

    public ElectricCar(String manufacturer, int year, String model, int numberOfDoors, int batteryCapacityKWh) {
        super(manufacturer, year, model, numberOfDoors); // Call Car's constructor
        this.batteryCapacityKWh = batteryCapacityKWh;
        System.out.println("ElectricCar constructor called with " + batteryCapacityKWh + " kWh battery.");
    }

    public void charge() {
        System.out.println("The " + model + " is charging its " + batteryCapacityKWh + " kWh battery.");
    }

    public void displayElectricCarInfo() {
        System.out.println("--- Electric Car Info ---");
        System.out.println("Manufacturer: " + manufacturer); // Inherited from Vehicle
        System.out.println("Year: " + year);                 // Inherited from Vehicle
        System.out.println("Model: " + model);               // Inherited from Car
        System.out.println("Doors: " + numberOfDoors);       // Inherited from Car
        System.out.println("Battery: " + batteryCapacityKWh + " kWh"); // Specific to ElectricCar
    }
}
// Main method to demonstrate inheritance
public class InheritanceDemo {
    public static void main(String[] args) {
        // Demonstrate Animal and Dog
        Animal generalAnimal = new Animal("Babe", 2);
        generalAnimal.eat();
        generalAnimal.sleep();

        System.out.println("\n--- Dog Object ---");
        Dog myDog = new Dog("Buddy", 5, "Golden Retriever");
        myDog.displayDogInfo();
        myDog.eat();  // Inherited from Animal
        myDog.bark(); // Specific to Dog
        myDog.sleep(); // Inherited from Animal

        System.out.println("\n--- ElectricCar Object (Chained Inheritance) ---");
        ElectricCar tesla = new ElectricCar("Tesla", 2023, "Model 3", 4, 75);
        tesla.displayElectricCarInfo();
        tesla.start(); // Inherited from Vehicle
        tesla.drive(); // Inherited from Car
        tesla.charge(); // Specific to ElectricCar
    }
}

Clean Code Tip: Use inheritance only when there's a clear "is-a" relationship (e.g., a Dog is an Animal). If a class "has-a" relationship (e.g., a Car has an Engine), prefer composition (where one class contains an object of another class) over inheritance. Overuse of inheritance can lead to rigid hierarchies.

Exercise: Create an Employee class with attributes name (String), employeeId (String), and salary (double). It should have a constructor and a displayInfo() method. Then, create a Manager class that extends Employee. Manager should add an attribute department (String) and override the displayInfo() method to include the department information. In your main method, create an Employee and a Manager object and call displayInfo() for both.

Solution:

// Superclass: Employee
class Employee {
    String name;
    String employeeId;
    double salary;

    public Employee(String name, String employeeId, double salary) {
        this.name = name;
        this.employeeId = employeeId;
        this.salary = salary;
        System.out.println("Employee created: " + name);
    }

    public void displayInfo() {
        System.out.println("--- Employee Info ---");
        System.out.println("Name: " + name);
        System.out.println("Employee ID: " + employeeId);
        System.out.println("Salary: $" + String.format("%.2f", salary));
    }
}

// Subclass: Manager, inherits from Employee
class Manager extends Employee {
    String department;

    public Manager(String name, String employeeId, double salary, String department) {
        super(name, employeeId, salary); // Call Employee's constructor
        this.department = department;
        System.out.println("Manager created: " + name + " (Dept: " + department + ")");
    }

    // Override displayInfo() to add department information
    // We'll dive into @Override more in the next chapter!
    @Override
    public void displayInfo() {
        super.displayInfo(); // Call the parent's displayInfo() first
        System.out.println("Department: " + department);
        System.out.println("---------------------");
    }
}

public class EmployeeHierarchySolution {
    public static void main(String[] args) {
        Employee emp1 = new Employee("Alice Wonderland", "EMP001", 60000.00);
        emp1.displayInfo();
        System.out.println(); // Spacing

        Manager mgr1 = new Manager("Bob The Builder", "MGR001", 90000.00, "Construction");
        mgr1.displayInfo();
        System.out.println(); // Spacing

        Employee emp2 = new Employee("Charlie Chaplin", "EMP002", 55000.00);
        emp2.displayInfo();
        System.out.println(); // Spacing

        Manager mgr2 = new Manager("Diana Prince", "MGR002", 95000.00, "Marketing");
        mgr2.displayInfo();
    }
}

Chapter 8: Method Overriding (@Override)

Quick Theory: Method Overriding is a key aspect of polymorphism (which we'll cover later) and allows for customizing inherited behavior, enhancing flexibility. When a subclass provides a specific implementation for a method that is already defined in its superclass, it is said to be overriding that method. The method signature (name, return type, and parameters) must be exactly the same as in the superclass. This ensures reusability of the method name, but with specialized logic for the child class.

The @Override annotation is highly recommended when overriding a method. It's a compiler instruction that tells Java you intend to override a superclass method. If you make a mistake in the signature, the compiler will alert you, preventing subtle bugs. It's important to distinguish overriding from overloading: Overloading involves methods with the same name but different parameter lists within the same class (or across hierarchy), while overriding involves methods with identical signatures in a subclass to provide a specialized implementation.

Professional Code:

// Example 1: Animal (superclass) with makeSound() overridden by Dog and Cat

// Superclass: Animal
class Animal {
    String name;

    public Animal(String name) {
        this.name = name;
    }

    // This method will be overridden by subclasses.
    public void makeSound() {
        System.out.println(name + " makes a generic animal sound.");
    }
}

// Subclass: Dog, overrides makeSound()
class Dog extends Animal {
    public Dog(String name) {
        super(name);
    }

    // @Override annotation is good practice. It tells the compiler
    // that this method is intended to override a method in the superclass.
    // If the signature doesn't match, the compiler will throw an error.
    @Override
    public void makeSound() {
        System.out.println(name + " barks: Woof! Woof!");
    }

    // Dog-specific method
    public void fetch() {
        System.out.println(name + " fetches the ball!");
    }
}

// Subclass: Cat, also overrides makeSound()
class Cat extends Animal {
    public Cat(String name) {
        super(name);
    }

    @Override
    public void makeSound() {
        System.out.println(name + " meows: Meow!");
    }
    
    // Cat-specific method
    public void scratch() {
        System.out.println(name + " scratches the furniture!");
    }
}
// Example 2: Vehicle with start() method, overridden by Car, and using super.method()

// Superclass: Vehicle
class Vehicle {
    String type;

    public Vehicle(String type) {
        this.type = type;
    }

    public void start() {
        System.out.println("The " + type + " is starting its engine.");
    }

    // Example of an overloaded method (different parameters) - NOT overriding
    public void start(String keyType) {
        System.out.println("The " + type + " is starting with a " + keyType + " key.");
    }

    public void stop() {
        System.out.println("The " + type + " has stopped.");
    }
}

// Subclass: Car, overrides start() and uses super.start()
class Car extends Vehicle {
    String model;

    public Car(String model) {
        super("Car"); // Type for Vehicle
        this.model = model;
    }

    @Override
    public void start() {
        // Calling the superclass's (Vehicle's) start() method first.
        // This allows us to reuse the parent's logic and then add specific logic.
        super.start();
        System.out.println("The " + model + " specific startup routine is engaged.");
        System.out.println("Checking fuel levels for " + model + "...");
    }

    // This is overloading, not overriding. Same method name, different parameters.
    public void start(boolean pushButton) {
        if (pushButton) {
            System.out.println("The " + model + " is starting with a push button.");
        } else {
            // Can call the overloaded parent method as well if appropriate
            super.start("mechanical");
        }
    }

    public void drive() {
        System.out.println("The " + model + " is driving down the road.");
    }
}
// Main method to demonstrate method overriding and overloading
public class MethodOverridingDemo {
    public static void main(String[] args) {
        // --- Animal Sounds Demo ---
        Animal animal = new Animal("Generic");
        animal.makeSound(); // Calls Animal's makeSound()

        Dog dog = new Dog("Buddy");
        dog.makeSound();    // Calls Dog's overridden makeSound()
        dog.fetch();

        Cat cat = new Cat("Whiskers");
        cat.makeSound();    // Calls Cat's overridden makeSound()
        cat.scratch();

        System.out.println("\n--- Vehicle/Car Startup Demo ---");
        Vehicle truck = new Vehicle("Truck");
        truck.start();        // Calls Vehicle's start()
        truck.start("electronic"); // Calls Vehicle's overloaded start()

        Car sedan = new Car("Honda Civic");
        sedan.start();        // Calls Car's overridden start(), which also calls super.start()
        sedan.start(true);    // Calls Car's overloaded start() with boolean parameter
        sedan.drive();
        sedan.stop();
    }
}

Clean Code Tip: Always use the @Override annotation when you intend to override a method. This is not just documentation; it's a critical safety mechanism that allows the compiler to catch errors if your method signature doesn't actually match a superclass method, saving you from subtle bugs at runtime.

Exercise: Create a Shape class with a method draw() that prints "Drawing a generic shape." Create two subclasses, Circle and Rectangle, both extending Shape. Override the draw() method in each subclass to print "Drawing a Circle." and "Drawing a Rectangle." respectively. In Rectangle, also add an area() method. Demonstrate calling draw() for all three types of objects.

Solution:

// Superclass: Shape
class Shape {
    public void draw() {
        System.out.println("Drawing a generic shape.");
    }
}

// Subclass: Circle
class Circle extends Shape {
    @Override
    public void draw() {
        System.out.println("Drawing a Circle.");
    }
}

// Subclass: Rectangle
class Rectangle extends Shape {
    private double width;
    private double height;

    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public void draw() {
        System.out.println("Drawing a Rectangle.");
    }

    public double calculateArea() {
        return width * height;
    }
}

public class ShapeDrawingSolution {
    public static void main(String[] args) {
        Shape genericShape = new Shape();
        Circle myCircle = new Circle();
        Rectangle myRectangle = new Rectangle(10, 5);

        System.out.println("--- Drawing various shapes ---");
        genericShape.draw(); // Calls Shape's draw()
        myCircle.draw();     // Calls Circle's overridden draw()
        myRectangle.draw();  // Calls Rectangle's overridden draw()

        System.out.println("\n--- Rectangle Specific ---");
        System.out.println("Rectangle Area: " + myRectangle.calculateArea());
    }
}

Chapter 9: Abstract Classes & Methods

Quick Theory: Abstract classes are special classes that cannot be instantiated directly; they serve as blueprints for other classes, embodying the "is-a" relationship in a more forceful way. They are designed to be inherited from, providing a common base structure and some default implementations for subclasses. What makes them unique is the ability to declare abstract methods. An abstract method has a signature but no body; it must be implemented by any concrete (non-abstract) subclass. This mechanism enforces a contract, ensuring that all concrete subclasses provide a specific behavior, promoting reusability of the general structure while guaranteeing flexibility in implementation details.

An abstract class can have both abstract and concrete (regular) methods, as well as instance variables and constructors. Its primary purpose is to define a common interface and possibly some shared functionality for a group of related classes, while deferring certain specific implementations to its descendants. This is ideal when you want to define a general type but know that some of its behaviors are too specialized to implement in a generic way, leaving them to the subclasses to define.

Professional Code:

// Example 1: Abstract Shape class with abstract calculateArea() and concrete displayInfo()

// Abstract class: Cannot be instantiated directly.
// It serves as a base for specific shapes.
abstract class Shape {
    String color;

    public Shape(String color) {
        this.color = color;
    }

    // Abstract method: Has no body and must be implemented by concrete subclasses.
    // This forces all shapes to define how they calculate their area.
    public abstract double calculateArea();

    // Concrete method: Has an implementation and can be inherited directly.
    public void displayInfo() {
        System.out.println("This is a " + color + " shape.");
    }
}

// Concrete subclass: Circle, extends Shape and implements calculateArea()
class Circle extends Shape {
    double radius;

    public Circle(String color, double radius) {
        super(color); // Call Shape's constructor
        this.radius = radius;
    }

    // Must implement the abstract method from Shape.
    @Override
    public double calculateArea() {
        return Math.PI * radius * radius;
    }

    // Can add Circle-specific methods
    public void roll() {
        System.out.println("The " + color + " circle is rolling.");
    }
}

// Concrete subclass: Rectangle, extends Shape and implements calculateArea()
class Rectangle extends Shape {
    double width;
    double height;

    public Rectangle(String color, double width, double height) {
        super(color); // Call Shape's constructor
        this.width = width;
        this.height = height;
    }

    // Must implement the abstract method from Shape.
    @Override
    public double calculateArea() {
        return width * height;
    }
}
// Example 2: Abstract Employee class with abstract calculateSalary()

// Abstract class: Employee
abstract class Employee {
    String name;
    String employeeId;

    public Employee(String name, String employeeId) {
        this.name = name;
        this.employeeId = employeeId;
    }

    // Abstract method: Salary calculation varies greatly depending on employee type.
    // Subclasses *must* define how their salary is calculated.
    public abstract double calculateSalary();

    // Concrete method: General information for all employees.
    public void displayBasicInfo() {
        System.out.println("Employee Name: " + name + ", ID: " + employeeId);
    }
}

// Concrete subclass: FullTimeEmployee
class FullTimeEmployee extends Employee {
    double monthlySalary;

    public FullTimeEmployee(String name, String employeeId, double monthlySalary) {
        super(name, employeeId);
        this.monthlySalary = monthlySalary;
    }

    @Override
    public double calculateSalary() {
        return monthlySalary; // Full-time employees have a fixed monthly salary.
    }

    public void processBenefits() {
        System.out.println(name + " is eligible for full-time benefits.");
    }
}

// Concrete subclass: PartTimeEmployee
class PartTimeEmployee extends Employee {
    double hourlyRate;
    int hoursWorked;

    public PartTimeEmployee(String name, String employeeId, double hourlyRate, int hoursWorked) {
        super(name, employeeId);
        this.hourlyRate = hourlyRate;
        this.hoursWorked = hoursWorked;
    }

    @Override
    public double calculateSalary() {
        return hourlyRate * hoursWorked; // Part-time employees get paid by the hour.
    }

    public void trackHours() {
        System.out.println(name + " has worked " + hoursWorked + " hours this period.");
    }
}
// Main method to demonstrate abstract classes and methods
public class AbstractClassDemo {
    public static void main(String[] args) {
        // --- Shape Demo ---
        // Shape s = new Shape("green"); // Compile-time error: Cannot instantiate abstract class

        Circle redCircle = new Circle("Red", 5.0);
        redCircle.displayInfo();
        System.out.println("Circle Area: " + redCircle.calculateArea());
        redCircle.roll();

        System.out.println(); // Spacing

        Rectangle blueRectangle = new Rectangle("Blue", 10.0, 4.0);
        blueRectangle.displayInfo();
        System.out.println("Rectangle Area: " + blueRectangle.calculateArea());

        System.out.println("\n--- Employee Demo ---");
        FullTimeEmployee ftEmployee = new FullTimeEmployee("John Doe", "FT101", 5000.0);
        ftEmployee.displayBasicInfo();
        System.out.println("Full-time Salary: $" + String.format("%.2f", ftEmployee.calculateSalary()));
        ftEmployee.processBenefits();

        System.out.println(); // Spacing

        PartTimeEmployee ptEmployee = new PartTimeEmployee("Jane Smith", "PT202", 25.0, 160);
        ptEmployee.displayBasicInfo();
        System.out.println("Part-time Salary: $" + String.format("%.2f", ptEmployee.calculateSalary()));
        ptEmployee.trackHours();
    }
}

Clean Code Tip: Use abstract classes when you have a strong "is-a" relationship, and you want to provide a common base with some implemented (concrete) methods, but also enforce that all concrete subclasses implement certain specific behaviors (abstract methods). This creates a clear hierarchy and ensures adherence to a design contract within that hierarchy.

Exercise: Create an abstract class named Vehicle. It should have a private String brand and a constructor. It should have an abstract method startEngine() and a concrete method stopEngine() that prints "Engine stopped.". Then, create two concrete subclasses: Car and Motorcycle. Both must extend Vehicle and implement startEngine() to print specific startup messages (e.g., "Car engine starting...", "Motorcycle engine starting..."). In your main method, instantiate a Car and a Motorcycle and call their methods.

Solution:

// Abstract class: Vehicle
abstract class Vehicle {
    private String brand;

    public Vehicle(String brand) {
        this.brand = brand;
    }

    public String getBrand() { // Getter for private attribute
        return brand;
    }

    // Abstract method: Must be implemented by subclasses
    public abstract void startEngine();

    // Concrete method: Implemented here, inherited by subclasses
    public void stopEngine() {
        System.out.println(brand + " engine stopped.");
    }
}

// Concrete subclass: Car
class Car extends Vehicle {
    public Car(String brand) {
        super(brand);
    }

    @Override
    public void startEngine() {
        System.out.println(getBrand() + " car engine starting with a turn of the key.");
    }

    public void honk() {
        System.out.println(getBrand() + " car honks: Beep beep!");
    }
}

// Concrete subclass: Motorcycle
class Motorcycle extends Vehicle {
    public Motorcycle(String brand) {
        super(brand);
    }

    @Override
    public void startEngine() {
        System.out.println(getBrand() + " motorcycle engine roaring to life.");
    }

    public void wheelie() {
        System.out.println(getBrand() + " motorcycle is doing a wheelie!");
    }
}

public class VehicleAbstractExerciseSolution {
    public static void main(String[] args) {
        // Vehicle genericVehicle = new Vehicle("Generic"); // Compile-time error

        Car myCar = new Car("Toyota");
        myCar.startEngine();
        myCar.honk();
        myCar.stopEngine();

        System.out.println(); // Spacing

        Motorcycle myMotorcycle = new Motorcycle("Harley-Davidson");
        myMotorcycle.startEngine();
        myMotorcycle.wheelie();
        myMotorcycle.stopEngine();
    }
}

Chapter 10: Interfaces (Implements)

Quick Theory: Interfaces in Java define a contract: a set of abstract methods that a class must implement if it wants to adhere to that interface. Unlike abstract classes, which model "is-a" relationships in a hierarchy, interfaces model "can-do" capabilities or behaviors. A class can implement multiple interfaces, allowing it to take on various "roles" or capabilities without being forced into a single inheritance hierarchy. This dramatically increases flexibility as it decouples the definition of behavior from its implementation, enabling different, unrelated classes to share common functionalities.

Interfaces are purely abstract by default (all fields are public static final and all methods are public abstract before Java 8). Since Java 8, interfaces can also include default methods, which provide a default implementation that implementing classes can use directly or override. This addition allows interfaces to evolve without breaking existing code and provides some reusability of method implementations within the contract itself. Interfaces cannot have constructors or instance variables.

Professional Code:

// Example 1: Flyable interface implemented by Bird and Airplane

// Interface: Defines a contract for anything that can fly.
// All methods in an interface are implicitly public and abstract before Java 8.
interface Flyable {
    void fly();          // Abstract method: no body.
    void land();         // Abstract method: no body.
    
    // Default method (Java 8+): Provides a default implementation.
    // Classes can use this directly or override it.
    default void reportFlightStatus() {
        System.out.println("Currently in flight. Altitude unknown.");
    }
}

// Class: Bird, implements the Flyable interface
class Bird implements Flyable {
    String name;

    public Bird(String name) {
        this.name = name;
    }

    // Must implement all abstract methods from Flyable.
    @Override
    public void fly() {
        System.out.println(name + " is flapping its wings and flying.");
    }

    @Override
    public void land() {
        System.out.println(name + " is gracefully landing on a branch.");
    }
    
    // Can optionally override default methods
    @Override
    public void reportFlightStatus() {
        System.out.println(name + " is flying high, probably looking for worms.");
    }
}

// Class: Airplane, implements the Flyable interface
class Airplane implements Flyable {
    String model;

    public Airplane(String model) {
        this.model = model;
    }

    // Must implement all abstract methods from Flyable.
    @Override
    public void fly() {
        System.out.println(model + " is soaring through the sky with jet engines.");
    }

    @Override
    public void land() {
        System.out.println(model + " is making a smooth landing on the runway.");
    }
    
    // Using the default method without overriding.
    // reportFlightStatus() will print "Currently in flight. Altitude unknown."
}
// Example 2: Multiple interfaces (Drivable, Maintainable) implemented by Car

// Interface 1: Drivable (defines driving capabilities)
interface Drivable {
    void accelerate();
    void brake();
    
    // Default method for Drivable
    default void horn() {
        System.out.println("Default horn sound.");
    }
}

// Interface 2: Maintainable (defines maintenance capabilities)
interface Maintainable {
    void scheduleMaintenance();
    void performService();
}

// Class: SportsCar, implements both Drivable and Maintainable interfaces
class SportsCar implements Drivable, Maintainable {
    String brand;
    String model;

    public SportsCar(String brand, String model) {
        this.brand = brand;
        this.model = model;
    }

    // Implementing Drivable methods
    @Override
    public void accelerate() {
        System.out.println(brand + " " + model + " is accelerating with immense power!");
    }

    @Override
    public void brake() {
        System.out.println(brand + " " + model + " is braking hard.");
    }
    
    // Overriding the default horn method from Drivable
    @Override
    public void horn() {
        System.out.println(brand + " " + model + " blasts a loud, sporty horn!");
    }

    // Implementing Maintainable methods
    @Override
    public void scheduleMaintenance() {
        System.out.println("Scheduling performance maintenance for " + brand + " " + model + ".");
    }

    @Override
    public void performService() {
        System.out.println("Performing engine tune-up and oil change for " + brand + " " + model + ".");
    }
    
    // SportsCar-specific method
    public void activateNitro() {
        System.out.println(brand + " " + model + " activates nitro boost!");
    }
}
// Main method to demonstrate interfaces and default methods
public class InterfaceDemo {
    public static void main(String[] args) {
        // --- Flyable Demo ---
        System.out.println("--- Bird Flight ---");
        Bird eagle = new Bird("Eagle");
        eagle.fly();
        eagle.reportFlightStatus(); // Calls overridden default method
        eagle.land();

        System.out.println("\n--- Airplane Flight ---");
        Airplane boeing = new Airplane("Boeing 747");
        boeing.fly();
        boeing.reportFlightStatus(); // Calls the default method from interface
        boeing.land();

        // --- Multiple Interfaces Demo ---
        System.out.println("\n--- SportsCar Capabilities ---");
        SportsCar ferrari = new SportsCar("Ferrari", "488 GTB");
        ferrari.accelerate();
        ferrari.horn(); // Calls overridden horn method
        ferrari.brake();
        ferrari.activateNitro();

        ferrari.scheduleMaintenance();
        ferrari.performService();
    }
}

Clean Code Tip: Use interfaces to define capabilities or contracts that different, often unrelated, classes can fulfill. This promotes loose coupling and allows for highly flexible designs where classes can interact based on their capabilities rather than their concrete types. "Program to an interface, not an implementation" is a core tenet of good OOP design.

Exercise: Create an interface Edible with a method howToEat() that returns a String. Create two classes: Apple and Chicken. Both should implement Edible. Apple's howToEat() should return "Bite into it." and Chicken's howToEat() should return "Cook and then eat.". In your main method, create objects of both classes and call their howToEat() method.

Solution:

// Interface: Edible
interface Edible {
    String howToEat();
}

// Class: Apple, implements Edible
class Apple implements Edible {
    @Override
    public String howToEat() {
        return "Bite into it.";
    }

    public String getType() {
        return "Fruit";
    }
}

// Class: Chicken, implements Edible
class Chicken implements Edible {
    @Override
    public String howToEat() {
        return "Cook and then eat.";
    }

    public String getPart() {
        return "Breast";
    }
}

public class EdibleInterfaceExerciseSolution {
    public static void main(String[] args) {
        Apple anApple = new Apple();
        Chicken aChicken = new Chicken();

        System.out.println("--- How to eat different things ---");
        System.out.println("Apple: " + anApple.howToEat());
        System.out.println("Chicken: " + aChicken.howToEat());

        System.out.println("\n--- Object specific methods ---");
        System.out.println("Apple type: " + anApple.getType());
        System.out.println("Chicken part: " + aChicken.getPart());
    }
}

Chapter 11: Polymorphism

Quick Theory: Polymorphism, meaning "many forms," is a core concept in OOP that allows objects of different classes to be treated as objects of a common type (either a superclass or an interface). This concept significantly enhances reusability and flexibility by enabling you to write generic code that can operate on a variety of objects, without needing to know their exact concrete type at compile time. The actual method executed is determined at runtime based on the object's real type, a principle known as "dynamic method dispatch" or "runtime polymorphism."

Polymorphism works hand-in-hand with inheritance and interfaces. If a Dog is a Animal, then a Dog object can be referred to by an Animal reference. Similarly, if a Car implements Drivable, a Car object can be referred to by a Drivable reference. This means you can create collections of different types of animals (ArrayList<Animal>) or different types of drivable objects (List<Drivable>) and interact with them using the common methods defined in the superclass or interface, letting each object respond in its own specific way.

Professional Code:

// Example 1: Polymorphism with an Animal hierarchy

// Base class for polymorphism
class Animal {
    private String name;

    public Animal(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void makeSound() {
        System.out.println(name + " makes a generic animal sound.");
    }
}

// Subclass Dog
class Dog extends Animal {
    public Dog(String name) {
        super(name);
    }

    @Override
    public void makeSound() {
        System.out.println(getName() + " barks: Woof! Woof!");
    }

    public void fetch() {
        System.out.println(getName() + " fetches the stick.");
    }
}

// Subclass Cat
class Cat extends Animal {
    public Cat(String name) {
        super(name);
    }

    @Override
    public void makeSound() {
        System.out.println(getName() + " meows: Meow!");
    }

    public void purr() {
        System.out.println(getName() + " purrs softly.");
    }
}

// Subclass Bird
class Bird extends Animal {
    public Bird(String name) {
        super(name);
    }

    @Override
    public void makeSound() {
        System.out.println(getName() + " chirps: Tweet! Tweet!");
    }

    public void fly() {
        System.out.println(getName() + " is flying.");
    }
}
// Example 2: Polymorphism with an interface (Payable)

// Interface: Defines a contract for anything that can be paid.
interface Payable {
    double getPaymentAmount();
    void processPayment();
}

// Class: Invoice, implements Payable
class Invoice implements Payable {
    private String partNumber;
    private int quantity;
    private double pricePerItem;

    public Invoice(String partNumber, int quantity, double pricePerItem) {
        this.partNumber = partNumber;
        this.quantity = quantity;
        this.pricePerItem = pricePerItem;
    }

    @Override
    public double getPaymentAmount() {
        return quantity * pricePerItem;
    }

    @Override
    public void processPayment() {
        System.out.println("Processing payment for Invoice #" + partNumber + ". Amount: $" + String.format("%.2f", getPaymentAmount()));
    }
    
    public void sendInvoiceEmail() {
        System.out.println("Invoice email sent for " + partNumber);
    }
}

// Class: Employee (reusing from Chapter 7/9 concepts, now implements Payable)
class Employee implements Payable {
    private String name;
    private String employeeId;
    private double monthlySalary; // Simplified for payment

    public Employee(String name, String employeeId, double monthlySalary) {
        this.name = name;
        this.employeeId = employeeId;
        this.monthlySalary = monthlySalary;
    }

    @Override
    public double getPaymentAmount() {
        return monthlySalary;
    }

    @Override
    public void processPayment() {
        System.out.println("Processing monthly salary for " + name + " (ID: " + employeeId + "). Amount: $" + String.format("%.2f", getPaymentAmount()));
    }
    
    public void generatePayStub() {
        System.out.println("Pay stub generated for " + name);
    }
}
// Main method to demonstrate polymorphism
import java.util.ArrayList;
import java.util.List;

public class PolymorphismDemo {
    public static void main(String[] args) {
        // --- Animal Polymorphism Demo ---
        System.out.println("--- Animal Polymorphism ---");
        // Create a list that can hold any object of type Animal or its subclasses.
        List<Animal> animals = new ArrayList<>();
        animals.add(new Dog("Buddy"));
        animals.add(new Cat("Whiskers"));
        animals.add(new Bird("Tweety"));
        animals.add(new Animal("Unknown Creature")); // Can add the base type too

        // Iterate through the list. Even though we add different types of animals,
        // we can treat them all as 'Animal' objects.
        // The 'makeSound()' method called will be the specific overridden version
        // for each object at runtime (dynamic method dispatch).
        for (Animal animal : animals) {
            animal.makeSound(); // This is polymorphism in action!
        }
        
        // This demonstrates flexibility: we can add more Animal types later
        // without changing this loop.

        // --- Payable Interface Polymorphism Demo ---
        System.out.println("\n--- Payable Interface Polymorphism ---");
        // Create a list that can hold any object that implements the Payable interface.
        List<Payable> payables = new ArrayList<>();
        payables.add(new Invoice("PN-9876", 2, 125.00));
        payables.add(new Employee("Alice Smith", "E001", 3500.00));
        payables.add(new Invoice("PN-5432", 1, 750.50));

        // Process payments for all payable items.
        // The 'processPayment()' method will behave differently for Invoice and Employee,
        // even though we call it through the common 'Payable' interface.
        for (Payable payableItem : payables) {
            payableItem.processPayment();
        }

        System.out.println("\n--- Mixed Polymorphism ---");
        // You can even assign a subclass object to a superclass reference variable
        Animal polyDog = new Dog("Rex");
        polyDog.makeSound(); // Calls Dog's makeSound()
        // polyDog.fetch(); // Compile-time error: 'fetch' is not defined in Animal (compile-time type)

        Payable polyEmployee = new Employee("Bob Johnson", "E002", 4000.00);
        polyEmployee.processPayment(); // Calls Employee's processPayment()
        // polyEmployee.generatePayStub(); // Compile-time error: 'generatePayStub' not defined in Payable
    }
}

Clean Code Tip: Always "Program to an interface, not an implementation." This means that wherever possible, declare variables, method parameters, and return types using the most general type possible (an interface or a superclass), rather than a specific concrete class. This makes your code more flexible, reusable, and easier to maintain or extend in the future.

Exercise: Create an abstract class MediaItem with title (String) and duration (int minutes). It should have a constructor and an abstract method displayDetails(). Create two subclasses: Movie and Book (yes, a book has a "duration" in reading time). Movie should add a director (String) and override displayDetails(). Book should add author (String) and override displayDetails(). In your main method, create an ArrayList<MediaItem>, add a Movie and a Book object, then iterate through the list and call displayDetails() for each.

Solution:

import java.util.ArrayList;
import java.util.List;

// Abstract class: MediaItem
abstract class MediaItem {
    protected String title;
    protected int durationMinutes; // Could be movie length or reading time

    public MediaItem(String title, int durationMinutes) {
        this.title = title;
        this.durationMinutes = durationMinutes;
    }

    public String getTitle() {
        return title;
    }

    public abstract void displayDetails();
}

// Subclass: Movie
class Movie extends MediaItem {
    private String director;

    public Movie(String title, int durationMinutes, String director) {
        super(title, durationMinutes);
        this.director = director;
    }

    @Override
    public void displayDetails() {
        System.out.println("--- Movie ---");
        System.out.println("Title: " + title);
        System.out.println("Director: " + director);
        System.out.println("Duration: " + durationMinutes + " minutes");
    }

    public void playMovie() {
        System.out.println("Playing movie: " + title);
    }
}

// Subclass: Book
class Book extends MediaItem {
    private String author;

    public Book(String title, int durationMinutes, String author) {
        super(title, durationMinutes);
        this.author = author;
    }

    @Override
    public void displayDetails() {
        System.out.println("--- Book ---");
        System.out.println("Title: " + title);
        System.out.println("Author: " + author);
        System.out.println("Approx. Reading Time: " + durationMinutes + " minutes");
    }

    public void readBook() {
        System.out.println("Reading book: " + title);
    }
}

public class MediaItemPolymorphismExerciseSolution {
    public static void main(String[] args) {
        // Create a list to hold various MediaItems
        List<MediaItem> library = new ArrayList<>();

        // Add Movie and Book objects to the list, treating them as MediaItem
        library.add(new Movie("Inception", 148, "Christopher Nolan"));
        library.add(new Book("The Lord of the Rings", 1500, "J.R.R. Tolkien"));
        library.add(new Movie("The Matrix", 136, "Lana Wachowski, Lilly Wachowski"));
        library.add(new Book("1984", 400, "George Orwell"));

        System.out.println("--- Displaying all library items ---");
        for (MediaItem item : library) {
            item.displayDetails(); // Polymorphic call: calls Movie's or Book's displayDetails()
            System.out.println(); // Spacing
        }
        
        System.out.println("\n--- Demonstrating type-specific actions ---");
        // We cannot call playMovie() or readBook() directly on 'item' from the loop
        // because 'item' is of type MediaItem, which doesn't have those methods.
        // We'll learn how to do this safely with casting in the next chapter.
    }
}

Chapter 12: Casting Objects & Instanceof

Quick Theory: While polymorphism allows us to treat objects of different types uniformly through a common superclass or interface reference (upcasting), sometimes we need to access methods or attributes specific to the original, more specialized class. This is where casting comes in, specifically downcasting (casting a superclass reference to a subclass type). Downcasting allows us to temporarily treat a generalized object as its more specific type, enabling access to its unique functionalities. However, downcasting is inherently risky because if the object is not actually an instance of the target subclass, a ClassCastException will occur at runtime.

To safely perform downcasting, Java provides the instanceof operator. This operator checks if an object is an instance of a particular class or an interface. It returns true if the object is compatible with the specified type, and false otherwise. By using instanceof before attempting a downcast, we ensure that the cast is valid, which significantly improves code robustness and prevents runtime errors. Java 16+ also introduced instanceof pattern matching, simplifying the syntax for this common pattern, enhancing flexibility and reusability of safe type checks.

Professional Code:

// Reusing Animal hierarchy from Chapter 11
class Animal {
    private String name;

    public Animal(String name) {
        this.name = name;
    }

    public String getName() { return name; }

    public void makeSound() {
        System.out.println(name + " makes a generic animal sound.");
    }
}

class Dog extends Animal {
    public Dog(String name) {
        super(name);
    }

    @Override
    public void makeSound() {
        System.out.println(getName() + " barks: Woof! Woof!");
    }

    public void fetch() {
        System.out.println(getName() + " fetches the stick.");
    }
}

class Cat extends Animal {
    public Cat(String name) {
        super(name);
    }

    @Override
    public void makeSound() {
        System.out.println(getName() + " meows: Meow!");
    }

    public void purr() {
        System.out.println(getName() + " purrs softly.");
    }
}
// Example 1: Demonstrating safe downcasting with instanceof

import java.util.ArrayList;
import java.util.List;

public class CastingAndInstanceofDemo {
    public static void main(String[] args) {
        List<Animal> pets = new ArrayList<>();
        pets.add(new Dog("Buddy"));
        pets.add(new Cat("Whiskers"));
        pets.add(new Dog("Lucy"));
        pets.add(new Animal("Unknown pet")); // A generic Animal

        System.out.println("--- Iterating and performing type-specific actions ---");
        for (Animal pet : pets) {
            pet.makeSound(); // Polymorphic call

            // Using instanceof to safely check the type before downcasting
            if (pet instanceof Dog) {
                // Downcast: Treat the 'Animal' reference 'pet' as a 'Dog'
                Dog dog = (Dog) pet;
                dog.fetch(); // Now we can call Dog-specific methods
            } else if (pet instanceof Cat) {
                // Downcast: Treat the 'Animal' reference 'pet' as a 'Cat'
                Cat cat = (Cat) pet;
                cat.purr(); // Now we can call Cat-specific methods
            } else {
                System.out.println(pet.getName() + " is just a generic animal.");
            }
            System.out.println(); // Spacing
        }

        // --- Unsafe Casting Example (will cause ClassCastException) ---
        System.out.println("--- Demonstrating unsafe casting (will crash) ---");
        Animal genericAnimal = new Animal("Lion");
        
        // This line would cause a ClassCastException at runtime
        // because a generic Animal cannot be cast to a Dog.
        // Dog potentialDog = (Dog) genericAnimal;
        // potentialDog.fetch(); // This line would never be reached
        
        System.out.println("If the above commented lines were active, a ClassCastException would occur.");
    }
}
// Example 2: instanceof Pattern Matching (Java 16+)
// This simplifies the 'if (obj instanceof Type) { Type castedObj = (Type) obj; }' pattern.

import java.util.ArrayList;
import java.util.List;

public class InstanceofPatternMatchingDemo {

    public static void processAnimal(Animal animal) {
        animal.makeSound();

        // Old way (pre-Java 16):
        // if (animal instanceof Dog) {
        //     Dog dog = (Dog) animal;
        //     dog.fetch();
        // }

        // New way (Java 16+): instanceof Pattern Matching
        // If 'animal' is a Dog, it's automatically cast to 'dog' for the scope of the if block.
        if (animal instanceof Dog dog) {
            dog.fetch(); // 'dog' is directly available and already cast.
        } else if (animal instanceof Cat cat) {
            cat.purr(); // 'cat' is directly available and already cast.
        } else {
            System.out.println(animal.getName() + " cannot perform specific actions.");
        }
    }

    public static void main(String[] args) {
        System.out.println("--- Instanceof Pattern Matching Demo (Java 16+) ---");
        processAnimal(new Dog("Rex"));
        System.out.println();
        processAnimal(new Cat("Mittens"));
        System.out.println();
        processAnimal(new Animal("Zebra"));
    }
}

Clean Code Tip: Minimize downcasting as much as possible. If you frequently find yourself using instanceof checks followed by downcasts, it often indicates a potential design flaw. Consider whether polymorphism (by moving the specific method up to the superclass or an interface) or the Visitor pattern could provide a more elegant and extensible solution, reducing the need for explicit type checks.

Exercise: Reuse the MediaItem, Movie, and Book classes from the previous chapter. Create an ArrayList<MediaItem>. Add a Movie and a Book object. Iterate through the list. Inside the loop, use instanceof to check if the current MediaItem is a Movie or a Book. If it's a Movie, downcast it and call its playMovie() method. If it's a Book, downcast it and call its readBook() method.

Solution:

import java.util.ArrayList;
import java.util.List;

// Reusing MediaItem hierarchy
abstract class MediaItem {
    protected String title;
    protected int durationMinutes;

    public MediaItem(String title, int durationMinutes) {
        this.title = title;
        this.durationMinutes = durationMinutes;
    }

    public String getTitle() {
        return title;
    }

    public abstract void displayDetails();
}

class Movie extends MediaItem {
    private String director;

    public Movie(String title, int durationMinutes, String director) {
        super(title, durationMinutes);
        this.director = director;
    }

    @Override
    public void displayDetails() {
        System.out.println("Movie: " + title + " (Dir: " + director + ", " + durationMinutes + " min)");
    }

    public void playMovie() {
        System.out.println("Now playing: " + title + " by " + director);
    }
}

class Book extends MediaItem {
    private String author;

    public Book(String title, int durationMinutes, String author) {
        super(title, durationMinutes);
        this.author = author;
    }

    @Override
    public void displayDetails() {
        System.out.println("Book: " + title + " (Auth: " + author + ", ~" + durationMinutes + " min read)");
    }

    public void readBook() {
        System.out.println("Now reading: " + title + " by " + author);
    }
}

public class MediaItemCastingExerciseSolution {
    public static void main(String[] args) {
        List<MediaItem> myCollection = new ArrayList<>();
        myCollection.add(new Movie("Interstellar", 169, "Christopher Nolan"));
        myCollection.add(new Book("Dune", 750, "Frank Herbert"));
        myCollection.add(new Movie("Arrival", 116, "Denis Villeneuve"));
        myCollection.add(new Book("The Alchemist", 200, "Paulo Coelho"));
        myCollection.add(new MediaItem("Generic Item", 0) { // Anonymous inner class for an unknown media type
            @Override
            public void displayDetails() {
                System.out.println("Unknown Media Item: " + title);
            }
        });

        System.out.println("--- Processing Media Collection ---");
        for (MediaItem item : myCollection) {
            item.displayDetails(); // Polymorphic call

            // Using instanceof (with pattern matching for Java 16+)
            if (item instanceof Movie movie) {
                movie.playMovie(); // Call Movie-specific method
            } else if (item instanceof Book book) {
                book.readBook();   // Call Book-specific method
            } else {
                System.out.println("Cannot perform specific action for: " + item.getTitle());
            }
            System.out.println(); // Spacing
        }
    }
}

Chapter 13: ArrayList Foundations

Quick Theory: When dealing with collections of data in programming, a fundamental decision involves choosing between fixed-size arrays and dynamic lists. Fixed-size arrays, like String[] names = new String[10], require you to specify their size at the time of creation. This can become a significant limitation if the number of elements you need to store changes during program execution, forcing you to manually create new, larger arrays and copy elements, which is cumbersome and error-prone.

The Java Collections Framework provides dynamic lists, with ArrayList being the most commonly used, to overcome these limitations. ArrayLists automatically resize themselves as elements are added or removed, offering immense flexibility and ease of use. They are built on top of arrays but abstract away the resizing mechanism, allowing developers to focus on managing data rather than array capacity, thereby significantly enhancing code reusability and maintainability.

Professional Code:

import java.util.ArrayList;
import java.util.List; // Good practice to program to the interface

// Custom object to store in our ArrayList
class Product {
    String name;
    double price;

    public Product(String name, double price) {
        this.name = name;
        this.price = price;
    }

    // A useful toString() method for easy printing
    @Override
    public String toString() {
        return "Product [Name: " + name + ", Price: $" + String.format("%.2f", price) + "]";
    }
}
// Example 1: Creating an ArrayList and adding elements
public class ArrayListAddDemo {
    public static void main(String[] args) {
        // Declare a List reference, initialize with ArrayList.
        // The type parameter <Product> specifies that this list will hold Product objects.
        // This is generic programming, ensuring type safety.
        List<Product> shoppingCart = new ArrayList<>();

        System.out.println("--- Initial Shopping Cart ---");
        System.out.println("Is cart empty? " + shoppingCart.isEmpty()); // Check if the list is empty
        System.out.println("Cart size: " + shoppingCart.size());        // Get current number of elements (0)

        // Adding Product objects to the ArrayList.
        // The 'add()' method appends the element to the end of the list.
        shoppingCart.add(new Product("Laptop", 1200.00));
        shoppingCart.add(new Product("Mouse", 25.50));
        shoppingCart.add(new Product("Keyboard", 75.00));

        System.out.println("\n--- Shopping Cart After Adding Items ---");
        System.out.println("Cart size: " + shoppingCart.size()); // Current number of elements (3)
        System.out.println("Is cart empty? " + shoppingCart.isEmpty());
        System.out.println("Contents: " + shoppingCart); // Prints the list using elements' toString()
    }
}
// Example 2: Accessing and Updating Elements, size() vs length
public class ArrayListAccessUpdateDemo {
    public static void main(String[] args) {
        List<Product> inventory = new ArrayList<>();
        inventory.add(new Product("Monitor", 300.00));
        inventory.add(new Product("Webcam", 50.00));
        inventory.add(new Product("Headphones", 100.00));

        System.out.println("--- Initial Inventory ---");
        System.out.println("Inventory size: " + inventory.size());
        System.out.println(inventory);

        // Accessing elements by index using get(index).
        // Indices are 0-based, just like arrays.
        Product firstProduct = inventory.get(0);
        System.out.println("\nFirst product: " + firstProduct.name);

        Product thirdProduct = inventory.get(2);
        System.out.println("Third product: " + thirdProduct.name);

        // Updating an element at a specific index using set(index, element).
        // This replaces the existing element at that index.
        System.out.println("\n--- Updating Second Product ---");
        Product updatedWebcam = new Product("HD Webcam Pro", 75.00);
        inventory.set(1, updatedWebcam); // Replace "Webcam" with "HD Webcam Pro"

        System.out.println("Inventory after update: " + inventory);
        System.out.println("Inventory size: " + inventory.size());

        // Key difference: ArrayList uses size() method for current element count.
        // Arrays use the .length field for their fixed capacity.
        // int[] fixedArray = new int[5];
        // System.out.println(fixedArray.length); // 5 (capacity)
        // System.out.println(inventory.size()); // 3 (actual elements)
    }
}
// Example 3: Removing Elements from an ArrayList
public class ArrayListRemoveDemo {
    public static void main(String[] args) {
        List<Product> cart = new ArrayList<>();
        Product laptop = new Product("Laptop", 1200.00);
        Product mouse = new Product("Mouse", 25.50);
        Product keyboard = new Product("Keyboard", 75.00);
        Product monitor = new Product("Monitor", 300.00);

        cart.add(laptop);
        cart.add(mouse);
        cart.add(keyboard);
        cart.add(monitor);

        System.out.println("--- Initial Cart ---");
        System.out.println("Size: " + cart.size());
        System.out.println(cart);

        // Removing by index: Removes the element at the specified position.
        // All subsequent elements shift left (index decreases by 1).
        System.out.println("\n--- Removing item at index 1 (Mouse) ---");
        Product removedByIndex = cart.remove(1); // Removes mouse
        System.out.println("Removed: " + removedByIndex.name);
        System.out.println("Size: " + cart.size());
        System.out.println(cart); // Now Laptop, Keyboard, Monitor

        // Removing by object: Removes the first occurrence of the specified object.
        // For custom objects, this relies on the `equals()` method.
        // If `equals()` is not overridden, it checks for memory address equality.
        System.out.println("\n--- Removing 'keyboard' object ---");
        boolean removedByObject = cart.remove(keyboard); // Removes keyboard
        System.out.println("Was Keyboard removed? " + removedByObject);
        System.out.println("Size: " + cart.size());
        System.out.println(cart); // Now Laptop, Monitor

        // Trying to remove an object not in the list
        System.out.println("\n--- Trying to remove 'NonExistent' product ---");
        boolean removedNonExistent = cart.remove(new Product("NonExistent", 0.0));
        System.out.println("Was NonExistent removed? " + removedNonExistent); // False
        System.out.println("Size: " + cart.size());
        System.out.println(cart);

        // Clearing all elements from the list
        System.out.println("\n--- Clearing the entire cart ---");
        cart.clear();
        System.out.println("Size after clear: " + cart.size());
        System.out.println("Is cart empty? " + cart.isEmpty());
    }
}

Clean Code Tip: When choosing a collection type, opt for a List (like ArrayList) when the order of elements is important, and duplicate elements are allowed. If you don't need random access by index, using List as the interface type (List<Product> products = new ArrayList<>();) is good practice, as it provides flexibility to switch to other List implementations later (e.g., LinkedList) without altering surrounding code.

Exercise: Create a Student class with studentId (String) and name (String). Create an ArrayList<Student>. Add three Student objects. Display the initial list, then remove one student by studentId (you'll need to manually iterate to find and remove), and finally, display the updated list and its size.

Solution:

import java.util.ArrayList;
import java.util.List;
import java.util.Iterator; // Needed for safe removal during iteration

class Student {
    String studentId;
    String name;

    public Student(String studentId, String name) {
        this.studentId = studentId;
        this.name = name;
    }

    // toString() for easy printing
    @Override
    public String toString() {
        return "Student [ID: " + studentId + ", Name: " + name + "]";
    }

    // Override equals() and hashCode() if we want to remove by object reference,
    // or if we ever put Students in a Set or as keys in a Map.
    // For this exercise, we'll manually iterate to find by ID.
    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Student student = (Student) obj;
        return studentId.equals(student.studentId);
    }

    @Override
    public int hashCode() {
        return studentId.hashCode();
    }
}

public class StudentListExerciseSolution {
    public static void main(String[] args) {
        List<Student> students = new ArrayList<>();

        students.add(new Student("S001", "Alice Smith"));
        students.add(new Student("S002", "Bob Johnson"));
        students.add(new Student("S003", "Charlie Brown"));

        System.out.println("--- Initial Student List (Size: " + students.size() + ") ---");
        for (Student s : students) {
            System.out.println(s);
        }

        // Task: Remove student with ID "S002"
        String idToRemove = "S002";
        System.out.println("\n--- Attempting to remove student with ID: " + idToRemove + " ---");

        // Iterate safely using an Iterator to remove elements.
        // Modifying a list during an enhanced for-loop will throw ConcurrentModificationException.
        Iterator<Student> iterator = students.iterator();
        while (iterator.hasNext()) {
            Student currentStudent = iterator.next();
            if (currentStudent.studentId.equals(idToRemove)) {
                iterator.remove(); // Safely removes the current element
                System.out.println("Removed: " + currentStudent.name);
                break; // Assuming IDs are unique, we can stop after finding.
            }
        }

        System.out.println("\n--- Updated Student List (Size: " + students.size() + ") ---");
        for (Student s : students) {
            System.out.println(s);
        }
    }
}

Chapter 14: Sorting Collections

Quick Theory: Sorting collections is a common and essential task in software development, whether for presenting data in a logical order, optimizing search operations, or preparing data for other algorithms. While simple data types like String and Integer have a natural ordering that Java understands implicitly, sorting custom objects (like Book by title or price) requires explicit instructions on how to compare them.

The Java Collections Framework provides powerful tools for sorting, notably the Collections.sort() method. To enable sorting of custom objects, you typically use one of two interfaces: Comparable or Comparator. The Comparable interface allows a class to define its "natural ordering" (e.g., a Book knows how to compare itself to another Book based on its title), while the Comparator interface provides a way to define multiple custom sort orders, external to the class itself, offering greater flexibility and reusability for different sorting needs.

Professional Code:

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator; // For custom sorting logic
import java.util.List;

// Custom object: Book class
// To sort Books by their natural order (e.g., title), we make it Comparable.
class Book implements Comparable<Book> {
    String title;
    String author;
    int pages;
    double price;

    public Book(String title, String author, int pages, double price) {
        this.title = title;
        this.author = author;
        this.pages = pages;
        this.price = price;
    }

    // Getters are good practice for private fields, but for simplicity here,
    // we'll access them directly.
    public String getTitle() { return title; }
    public String getAuthor() { return author; }
    public int getPages() { return pages; }
    public double getPrice() { return price; }

    @Override
    public String toString() {
        return "Book [Title: '" + title + "', Author: '" + author + "', Pages: " + pages + ", Price: $" + String.format("%.2f", price) + "]";
    }

    // Implementing Comparable interface defines the "natural ordering" for Book objects.
    // Here, we define it to sort books alphabetically by title.
    @Override
    public int compareTo(Book otherBook) {
        // String's compareTo() method provides lexicographical comparison.
        // It returns:
        // - a negative integer if this.title comes before otherBook.title
        // - zero if they are equal
        // - a positive integer if this.title comes after otherBook.title
        return this.title.compareTo(otherBook.title);
    }
}
// Example 1: Sorting a list of Strings (using natural order)
public class SimpleSortingDemo {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        names.add("Charlie");
        names.add("Alice");
        names.add("Bob");
        names.add("Diana");

        System.out.println("--- Unsorted Names ---");
        System.out.println(names);

        // Collections.sort() uses the natural order for String (alphabetical).
        // Strings naturally implement Comparable<String>.
        Collections.sort(names);

        System.out.println("\n--- Sorted Names ---");
        System.out.println(names);
    }
}
// Example 2: Sorting a list of custom Objects using Comparable (natural order)
public class CustomObjectSortingComparableDemo {
    public static void main(String[] args) {
        List<Book> books = new ArrayList<>();
        books.add(new Book("The Hitchhiker's Guide to the Galaxy", "Douglas Adams", 193, 12.99));
        books.add(new Book("1984", "George Orwell", 328, 9.50));
        books.add(new Book("Brave New World", "Aldous Huxley", 311, 10.75));
        books.add(new Book("To Kill a Mockingbird", "Harper Lee", 281, 8.99));

        System.out.println("--- Unsorted Books ---");
        for (Book book : books) {
            System.out.println(book);
        }

        // Collections.sort() will now use the compareTo() method defined in our Book class
        // to sort books by title.
        Collections.sort(books);

        System.out.println("\n--- Books Sorted by Title (Natural Order - Comparable) ---");
        for (Book book : books) {
            System.out.println(book);
        }
    }
}
// Example 3: Sorting a list of custom Objects using Comparator (custom order)
public class CustomObjectSortingComparatorDemo {
    public static void main(String[] args) {
        List<Book> books = new ArrayList<>();
        books.add(new Book("The Hitchhiker's Guide to the Galaxy", "Douglas Adams", 193, 12.99));
        books.add(new Book("1984", "George Orwell", 328, 9.50));
        books.add(new Book("Brave New World", "Aldous Huxley", 311, 10.75));
        books.add(new Book("To Kill a Mockingbird", "Harper Lee", 281, 8.99));
        books.add(new Book("Dune", "Frank Herbert", 412, 14.99));

        System.out.println("--- Unsorted Books ---");
        for (Book book : books) {
            System.out.println(book);
        }

        // --- Sorting by Price using a Comparator ---
        // A Comparator is a separate class (or anonymous/lambda) that defines a comparison logic.
        // It allows sorting by criteria other than the natural order.
        Comparator<Book> priceComparator = new Comparator<Book>() {
            @Override
            public int compare(Book b1, Book b2) {
                // Compare books based on their price.
                // Double.compare handles floating-point precision issues correctly.
                return Double.compare(b1.getPrice(), b2.getPrice());
            }
        };

        // Sort using the custom Comparator.
        Collections.sort(books, priceComparator);

        System.out.println("\n--- Books Sorted by Price (Custom Order - Comparator) ---");
        for (Book book : books) {
            System.out.println(book);
        }

        // --- Sorting by Pages using a Lambda Comparator (Java 8+) ---
        // Lambdas provide a concise way to create single-method interfaces like Comparator.
        // This sorts in descending order of pages.
        Collections.sort(books, (b1, b2) -> Integer.compare(b2.getPages(), b1.getPages()));

        System.out.println("\n--- Books Sorted by Pages (Descending - Lambda Comparator) ---");
        for (Book book : books) {
            System.out.println(book);
        }
    }
}

Clean Code Tip: Use the Comparable interface when your class has one obvious, "natural" way to order its objects (e.g., sorting Person by lastName). Use the Comparator interface when you need to define multiple different sorting criteria, or when you cannot modify the class you want to sort. This provides maximum flexibility for varied sorting requirements.

Exercise: Reuse your Student class from Chapter 13 (with studentId and name). Make the Student class Comparable so that students are naturally sorted alphabetically by their name. Create an ArrayList<Student>, add several students (some out of order), and then use Collections.sort() to sort them. Print the sorted list.

Solution:

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

// Student class now implements Comparable<Student>
class Student implements Comparable<Student> {
    String studentId;
    String name;

    public Student(String studentId, String name) {
        this.studentId = studentId;
        this.name = name;
    }

    public String getStudentId() { return studentId; }
    public String getName() { return name; }

    @Override
    public String toString() {
        return "Student [ID: " + studentId + ", Name: " + name + "]";
    }

    // Define the natural order: sort by student name alphabetically.
    @Override
    public int compareTo(Student otherStudent) {
        return this.name.compareTo(otherStudent.name);
    }

    // Necessary for equality checks if ever used in Set/Map keys
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Student student = (Student) o;
        return studentId.equals(student.studentId);
    }

    @Override
    public int hashCode() {
        return studentId.hashCode();
    }
}

public class StudentSortingExerciseSolution {
    public static void main(String[] args) {
        List<Student> students = new ArrayList<>();

        students.add(new Student("S003", "Charlie Brown"));
        students.add(new Student("S001", "Alice Smith"));
        students.add(new Student("S004", "David Lee"));
        students.add(new Student("S002", "Bob Johnson"));

        System.out.println("--- Unsorted Student List ---");
        for (Student s : students) {
            System.out.println(s);
        }

        // Use Collections.sort() to sort the list.
        // It will use the compareTo() method defined in the Student class.
        Collections.sort(students);

        System.out.println("\n--- Sorted Student List (by Name) ---");
        for (Student s : students) {
            System.out.println(s);
        }

        // Example of sorting by ID using a Comparator (lambda)
        System.out.println("\n--- Sorted Student List (by ID using Comparator) ---");
        Collections.sort(students, (s1, s2) -> s1.getStudentId().compareTo(s2.getStudentId()));
        for (Student s : students) {
            System.out.println(s);
        }
    }
}

Chapter 15: The HashSet (Unique Data)

Quick Theory: In many scenarios, you need a collection that guarantees uniqueness among its elements. For example, a list of registered users where each username must be distinct, or a set of tags for an article where each tag appears only once. While an ArrayList allows duplicates, manually checking for uniqueness before every insertion is inefficient and prone to errors.

The Set interface, a core part of the Java Collections Framework, is specifically designed for collections that contain no duplicate elements. HashSet, a common implementation of Set, offers highly efficient addition, removal, and lookup operations (typically constant time, O(1), on average). It achieves this speed by using a hash table under the hood. For custom objects to work correctly in a HashSet (i.e., for uniqueness and lookups to function as expected), it is critical to properly override both the equals() and hashCode() methods in your custom class. Without them, HashSet might consider two semantically identical objects as distinct due to different memory addresses, violating the uniqueness contract.

Professional Code:

import java.util.HashSet;
import java.util.Set; // Program to the interface

// Custom object: User class
// Crucially, equals() and hashCode() must be overridden for HashSet to work correctly.
class User {
    int id;
    String username;
    String email;

    public User(int id, String username, String email) {
        this.id = id;
        this.username = username;
        this.email = email;
    }

    public int getId() { return id; }
    public String getUsername() { return username; }
    public String getEmail() { return email; }

    @Override
    public String toString() {
        return "User [ID: " + id + ", Username: '" + username + "', Email: '" + email + "']";
    }

    // --- CRITICAL for HashSet: Override equals() and hashCode() ---
    // Two User objects are considered equal if they have the same ID.
    @Override
    public boolean equals(Object o) {
        // If the objects are the same instance, they are equal.
        if (this == o) return true;
        // If the other object is null or not of the same class, they are not equal.
        if (o == null || getClass() != o.getClass()) return false;
        // Cast the object to User type.
        User user = (User) o;
        // Compare based on the 'id' field for equality.
        return id == user.id;
    }

    // hashCode() must be consistent with equals().
    // If two objects are equal according to the equals(Object) method,
    // then calling the hashCode method on each of the two objects must produce the same integer result.
    @Override
    public int hashCode() {
        // Use a utility from Objects class for better hashCode generation.
        // return Objects.hash(id); // Requires java.util.Objects
        // A simpler way for a single int field:
        return Integer.hashCode(id);
    }
}
// Example 1: Demonstrating HashSet uniqueness and adding elements
public class HashSetUniquenessDemo {
    public static void main(String[] args) {
        Set<User> uniqueUsers = new HashSet<>();

        User user1 = new User(1, "alice.smith", "alice@example.com");
        User user2 = new User(2, "bob.builder", "bob@example.com");
        User user3 = new User(1, "alice.smith.duplicate", "alice_dup@example.com"); // Same ID as user1

        System.out.println("--- Adding Users to HashSet ---");
        System.out.println("Added user1: " + uniqueUsers.add(user1)); // true (first addition)
        System.out.println("Added user2: " + uniqueUsers.add(user2)); // true
        System.out.println("Added user3 (duplicate ID): " + uniqueUsers.add(user3)); // false (rejected due to user1's ID and equals/hashCode)

        System.out.println("\n--- Current Users in Set (Size: " + uniqueUsers.size() + ") ---");
        // The output order of elements in a HashSet is not guaranteed due to hashing.
        for (User user : uniqueUsers) {
            System.out.println(user);
        }

        // Note: Even though user3 had a different username/email, because its ID was 1,
        // and User.equals() checks by ID, it was considered a duplicate of user1.
    }
}
// Example 2: Checking for existence and removing elements from HashSet
public class HashSetOperationsDemo {
    public static void main(String[] args) {
        Set<User> activeUsers = new HashSet<>();
        activeUsers.add(new User(101, "admin", "admin@domain.com"));
        activeUsers.add(new User(102, "moderator", "mod@domain.com"));
        activeUsers.add(new User(103, "guest", "guest@domain.com"));

        System.out.println("--- Initial Active Users (Size: " + activeUsers.size() + ") ---");
        System.out.println(activeUsers);

        // Checking for existence using contains().
        // This is very fast (average O(1)) in a HashSet.
        User searchUser1 = new User(102, "moderator", "mod@domain.com"); // ID 102
        System.out.println("\nDoes set contain user with ID 102? " + activeUsers.contains(searchUser1)); // true

        User searchUser2 = new User(999, "nonexistent", "none@domain.com"); // ID 999
        System.out.println("Does set contain user with ID 999? " + activeUsers.contains(searchUser2)); // false

        // Removing an element. Relies on equals() and hashCode() as well.
        System.out.println("\n--- Removing user with ID 103 ---");
        User userToRemove = new User(103, "guest", "guest@domain.com"); // ID 103
        System.out.println("Removed user with ID 103? " + activeUsers.remove(userToRemove)); // true

        System.out.println("--- Active Users After Removal (Size: " + activeUsers.size() + ") ---");
        System.out.println(activeUsers);

        // Trying to remove a non-existent user
        System.out.println("\n--- Trying to remove non-existent user ---");
        User userNonExistent = new User(105, "somebody", "somebody@domain.com");
        System.out.println("Removed user with ID 105? " + activeUsers.remove(userNonExistent)); // false

        System.out.println("--- Final Active Users (Size: " + activeUsers.size() + ") ---");
        System.out.println(activeUsers);
    }
}

Clean Code Tip: Whenever you use custom objects in hash-based collections like HashSet or HashMap (as keys), it is absolutely critical to correctly override both the equals() and hashCode() methods. equals() defines when two objects are semantically the same, and hashCode() provides a hash code consistent with equals(). Failing to do so will lead to unexpected behavior, such as duplicate elements being added to a Set or Map keys not being found, breaking the collection's contract.

Exercise: Create a Course class with courseCode (String) and title (String). Override equals() and hashCode() so that two Course objects are considered equal if they have the same courseCode. Create a HashSet<Course>. Add a few unique courses, then try to add a course with a duplicate courseCode but a different title. Display the HashSet and its final size to observe the uniqueness enforcement.

Solution:

import java.util.HashSet;
import java.util.Objects; // For Objects.hash()
import java.util.Set;

class Course {
    String courseCode;
    String title;

    public Course(String courseCode, String title) {
        this.courseCode = courseCode;
        this.title = title;
    }

    public String getCourseCode() { return courseCode; }
    public String getTitle() { return title; }

    @Override
    public String toString() {
        return "Course [Code: " + courseCode + ", Title: '" + title + "']";
    }

    // Override equals() based on courseCode
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Course course = (Course) o;
        return Objects.equals(courseCode, course.courseCode); // Compare courseCode for equality
    }

    // Override hashCode() consistent with equals()
    @Override
    public int hashCode() {
        return Objects.hash(courseCode); // Hash based on courseCode
    }
}

public class CourseHashSetExerciseSolution {
    public static void main(String[] args) {
        Set<Course> availableCourses = new HashSet<>();

        Course javaCourse1 = new Course("CS101", "Introduction to Java");
        Course pythonCourse = new Course("CS102", "Python for Beginners");
        Course dataStructuresCourse = new Course("CS201", "Data Structures & Algorithms");

        System.out.println("--- Adding Courses ---");
        System.out.println("Added CS101: " + availableCourses.add(javaCourse1));
        System.out.println("Added CS102: " + availableCourses.add(pythonCourse));
        System.out.println("Added CS201: " + availableCourses.add(dataStructuresCourse));

        // Attempt to add a course with a duplicate courseCode but different title
        Course javaCourse2_duplicateCode = new Course("CS101", "Advanced Java Programming");
        System.out.println("Attempted to add CS101 (Advanced Java): " + availableCourses.add(javaCourse2_duplicateCode)); // This should be false

        System.out.println("\n--- Final Available Courses (Size: " + availableCourses.size() + ") ---");
        for (Course course : availableCourses) {
            System.out.println(course);
        }

        // Expected output: only 3 courses because "CS101" is treated as a duplicate.
        // The exact "CS101" object in the set depends on insertion order and internal hashing.
    }
}

Chapter 16: HashMap (Key-Value Pairs)

Quick Theory: Many real-world data structures rely on associating a unique identifier (a key) with a specific piece of information (a value). Think of a dictionary (word -> definition), a phone book (name -> phone number), or a registry (ID -> User details). While lists allow you to store collections of items, retrieving a specific item by an arbitrary unique identifier (other than its index) requires iterating through the entire list, which becomes inefficient for large datasets.

The Map interface, specifically its HashMap implementation, is the answer to this problem in Java. A HashMap stores data as key-value pairs, where each key is unique and maps to exactly one value. This structure allows for incredibly fast lookups (average O(1) time) when retrieving a value using its key. This makes HashMap the most important and frequently used collection when you need efficient access to data based on a unique identifier, significantly boosting reusability and flexibility in data management. Similar to HashSet, for custom objects used as keys, correctly overriding equals() and hashCode() is paramount.

Professional Code:

import java.util.HashMap;
import java.util.Map; // Program to the interface

// Custom object to store as values in our HashMap
class Order {
    String orderId;
    String customerName;
    double totalAmount;
    String status;

    public Order(String orderId, String customerName, double totalAmount, String status) {
        this.orderId = orderId;
        this.customerName = customerName;
        this.totalAmount = totalAmount;
        this.status = status;
    }

    public String getOrderId() { return orderId; }
    public String getCustomerName() { return customerName; }
    public double getTotalAmount() { return totalAmount; }
    public String getStatus() { return status; }

    public void setStatus(String status) {
        this.status = status;
    }

    @Override
    public String toString() {
        return "Order [ID: " + orderId + ", Customer: '" + customerName + "', Total: $" + String.format("%.2f", totalAmount) + ", Status: " + status + "]";
    }

    // If Order objects were to be used as KEYS in a HashMap, we would also need
    // to override equals() and hashCode() based on 'orderId'.
    // For this example, we use String keys, so Order's default equals/hashCode are fine.
}
// Example 1: Creating a HashMap and using put()
public class HashMapPutDemo {
    public static void main(String[] args) {
        // Create a HashMap where String (orderId) is the key and Order object is the value.
        Map<String, Order> orders = new HashMap<>();

        System.out.println("--- Initial Orders Map (Size: " + orders.size() + ") ---");
        System.out.println("Is map empty? " + orders.isEmpty());

        // Using put(key, value) to add entries.
        // If the key already exists, put() replaces the old value with the new one
        // and returns the old value. Otherwise, it returns null.
        orders.put("ORD001", new Order("ORD001", "Alice Wonderland", 150.75, "Pending"));
        orders.put("ORD002", new Order("ORD002", "Bob Builder", 230.00, "Shipped"));
        orders.put("ORD003", new Order("ORD003", "Charlie Chaplin", 75.20, "Delivered"));

        System.out.println("\n--- Orders Map After Adding (Size: " + orders.size() + ") ---");
        System.out.println(orders); // Prints the map using toString() of its entries

        // Adding an order with an existing key (ORD002) - this will update the value.
        System.out.println("\n--- Updating Order ORD002 ---");
        Order oldOrder = orders.put("ORD002", new Order("ORD002", "Bob Builder", 250.00, "Processing"));
        System.out.println("Old order for ORD002 was: " + oldOrder);
        System.out.println("Current orders: " + orders); // ORD002 value is now updated
    }
}
// Example 2: Using get(), containsKey(), remove() with HashMap
public class HashMapOperationsDemo {
    public static void main(String[] args) {
        Map<String, Order> currentOrders = new HashMap<>();
        currentOrders.put("A101", new Order("A101", "John Doe", 50.00, "Pending"));
        currentOrders.put("B202", new Order("B202", "Jane Smith", 120.50, "Shipped"));
        currentOrders.put("C303", new Order("C303", "Peter Jones", 200.00, "Delivered"));

        System.out.println("--- Current Orders Map (Size: " + currentOrders.size() + ") ---");
        System.out.println(currentOrders);

        // Retrieving a value using get(key). Returns null if key not found.
        String searchKey1 = "B202";
        Order orderB202 = currentOrders.get(searchKey1);
        if (orderB202 != null) {
            System.out.println("\nRetrieved order " + searchKey1 + ": " + orderB202.getCustomerName());
            orderB202.setStatus("In Transit"); // Modify the retrieved object
            System.out.println("Updated status for B202: " + currentOrders.get(searchKey1).getStatus());
        } else {
            System.out.println("\nOrder " + searchKey1 + " not found.");
        }

        String searchKey2 = "D404";
        Order orderD404 = currentOrders.get(searchKey2);
        System.out.println("Retrieved order " + searchKey2 + ": " + orderD404); // null

        // Checking if a key exists using containsKey(key).
        System.out.println("\nDoes map contain key 'A101'? " + currentOrders.containsKey("A101")); // true
        System.out.println("Does map contain key 'X999'? " + currentOrders.containsKey("X999")); // false

        // Removing an entry using remove(key). Returns the value associated with the key.
        System.out.println("\n--- Removing order 'C303' ---");
        Order removedOrder = currentOrders.remove("C303");
        if (removedOrder != null) {
            System.out.println("Removed order: " + removedOrder);
        } else {
            System.out.println("Order 'C303' not found for removal.");
        }
        System.out.println("Map after removal (Size: " + currentOrders.size() + "): " + currentOrders);

        // Clear all entries
        currentOrders.clear();
        System.out.println("\nMap cleared. Size: " + currentOrders.size());
    }
}
// Example 3: Iterating over a HashMap
import java.util.Set;

public class HashMapIterationDemo {
    public static void main(String[] args) {
        Map<String, Order> orders = new HashMap<>();
        orders.put("ORD001", new Order("ORD001", "Alice", 150.75, "Pending"));
        orders.put("ORD002", new Order("ORD002", "Bob", 230.00, "Shipped"));
        orders.put("ORD003", new Order("ORD003", "Charlie", 75.20, "Delivered"));

        System.out.println("--- Iterating over Keys ---");
        // Get a Set of all keys in the map.
        Set<String> orderIds = orders.keySet();
        for (String id : orderIds) {
            Order order = orders.get(id); // Retrieve value using key
            System.out.println("Order ID: " + id + ", Customer: " + order.getCustomerName());
        }

        System.out.println("\n--- Iterating over Values ---");
        // Get a Collection of all values in the map.
        // Note: The Collection of values can contain duplicates if different keys map to identical values.
        for (Order order : orders.values()) {
            System.out.println("Order Status: " + order.getStatus() + ", Total: $" + String.format("%.2f", order.getTotalAmount()));
        }

        System.out.println("\n--- Iterating over Key-Value Pairs (EntrySet) ---");
        // The most efficient way to iterate, as it gives you both key and value
        // without an extra lookup (orders.get(id)).
        for (Map.Entry<String, Order> entry : orders.entrySet()) {
            String id = entry.getKey();
            Order order = entry.getValue();
            System.out.println("Entry -> Key: " + id + ", Value: " + order);
        }
    }
}

Clean Code Tip: Choose a Map (specifically HashMap for general use) when you need to store data as key-value pairs and quickly retrieve values using a unique identifier. This is ideal for scenarios like user registries, caching data by ID, or configuration settings. For custom objects used as keys, remember the equals() and hashCode() rule.

Exercise: Reuse your Student class (studentId, name). Create a HashMap<String, Student> where studentId is the key and the Student object is the value. Add three Student objects to the map. Then, retrieve and print the name of the student with a specific studentId. Check if another studentId exists in the map. Finally, remove one student by studentId and print the map's final size.

Solution:

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

// Reusing Student class (with proper equals/hashCode for future map key usage)
class Student {
    String studentId;
    String name;

    public Student(String studentId, String name) {
        this.studentId = studentId;
        this.name = name;
    }

    public String getStudentId() { return studentId; }
    public String getName() { return name; }

    @Override
    public String toString() {
        return "Student [ID: " + studentId + ", Name: " + name + "]";
    }

    // Crucial if Student objects were to be used as keys in a HashMap/HashSet
    // Here, studentId is our primary key.
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Student student = (Student) o;
        return studentId.equals(student.studentId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(studentId);
    }
}

public class StudentHashMapExerciseSolution {
    public static void main(String[] args) {
        // HashMap where key is studentId (String) and value is a Student object
        Map<String, Student> studentRegistry = new HashMap<>();

        // Add students
        studentRegistry.put("S001", new Student("S001", "Alice Smith"));
        studentRegistry.put("S002", new Student("S002", "Bob Johnson"));
        studentRegistry.put("S003", new Student("S003", "Charlie Brown"));

        System.out.println("--- Initial Student Registry (Size: " + studentRegistry.size() + ") ---");
        System.out.println(studentRegistry);

        // Retrieve student by ID "S002"
        String studentIdToFind = "S002";
        Student foundStudent = studentRegistry.get(studentIdToFind);
        if (foundStudent != null) {
            System.out.println("\nFound Student with ID " + studentIdToFind + ": " + foundStudent.getName());
        } else {
            System.out.println("\nStudent with ID " + studentIdToFind + " not found.");
        }

        // Check if student ID "S004" exists
        String nonExistentId = "S004";
        System.out.println("Does student ID " + nonExistentId + " exist? " + studentRegistry.containsKey(nonExistentId));

        // Remove student with ID "S001"
        String studentIdToRemove = "S001";
        System.out.println("\n--- Removing student with ID: " + studentIdToRemove + " ---");
        Student removedStudent = studentRegistry.remove(studentIdToRemove);
        if (removedStudent != null) {
            System.out.println("Removed: " + removedStudent.getName());
        } else {
            System.out.println("Student with ID " + studentIdToRemove + " not found for removal.");
        }

        System.out.println("\n--- Final Student Registry (Size: " + studentRegistry.size() + ") ---");
        System.out.println(studentRegistry);
    }
}

Chapter 17: Iterating with Iterators & Streams

Quick Theory: To process elements within a collection, you need to iterate over them. Traditionally, Java offered for loops (indexed or enhanced for-each loops) and the Iterator interface. The for-each loop is convenient for simply visiting each element, but it doesn't allow safe removal of elements from the collection during iteration. The Iterator provides explicit control over the iteration process, crucially offering a remove() method that safely modifies the underlying collection without triggering ConcurrentModificationExceptions.

With Java 8, the Streams API was introduced, providing a powerful, functional-style approach to processing collections. Streams allow you to declaratively define a sequence of operations (like filtering, mapping, and collecting) on elements, focusing on what to do rather than how to do it. This enhances both reusability (common operations as methods) and flexibility (chainable operations). While Iterator is good for modifying a collection during iteration, Streams are excellent for transforming or querying data without altering the original collection.

Professional Code:

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.stream.Collectors; // For collecting stream results

// Reusing Product class from Chapter 13
class Product {
    String name;
    double price;
    boolean inStock;

    public Product(String name, double price, boolean inStock) {
        this.name = name;
        this.price = price;
        this.inStock = inStock;
    }

    public String getName() { return name; }
    public double getPrice() { return price; }
    public boolean isInStock() { return inStock; }

    @Override
    public String toString() {
        return "Product [Name: " + name + ", Price: $" + String.format("%.2f", price) + ", In Stock: " + inStock + "]";
    }
}
// Example 1: Basic Iteration with Enhanced For-Loop
public class ForEachIterationDemo {
    public static void main(String[] args) {
        List<Product> products = new ArrayList<>();
        products.add(new Product("Laptop", 1200.00, true));
        products.add(new Product("Mouse", 25.50, false)); // Out of stock
        products.add(new Product("Keyboard", 75.00, true));
        products.add(new Product("Webcam", 50.00, true));
        products.add(new Product("Monitor", 300.00, false)); // Out of stock

        System.out.println("--- All Products (Enhanced For-Loop) ---");
        // The enhanced for-loop (for-each) is the simplest way to iterate
        // when you only need to read elements.
        for (Product p : products) {
            System.out.println(p.getName() + " - " + (p.isInStock() ? "Available" : "Out of Stock"));
        }

        // Limitation: Cannot safely remove elements during this loop.
        // The following line would cause a ConcurrentModificationException:
        // for (Product p : products) { if (!p.isInStock()) products.remove(p); }
    }
}
// Example 2: Iterating and Safely Removing with Iterator
public class IteratorRemoveDemo {
    public static void main(String[] args) {
        List<Product> products = new ArrayList<>();
        products.add(new Product("Laptop", 1200.00, true));
        products.add(new Product("Mouse", 25.50, false));
        products.add(new Product("Keyboard", 75.00, true));
        products.add(new Product("Webcam", 50.00, true));
        products.add(new Product("Monitor", 300.00, false));

        System.out.println("--- Initial Products ---");
        System.out.println(products);

        System.out.println("\n--- Removing Out-of-Stock Products using Iterator ---");
        // An Iterator allows you to safely remove elements from the collection
        // while iterating through it.
        Iterator<Product> iterator = products.iterator();
        while (iterator.hasNext()) { // Check if there's a next element
            Product p = iterator.next(); // Get the next element
            if (!p.isInStock()) {
                System.out.println("Removing: " + p.getName());
                iterator.remove(); // Safely remove the current element
            }
        }

        System.out.println("\n--- Products After Removal ---");
        System.out.println(products);
    }
}
// Example 3: Iterating and Filtering with Streams (Java 8+)
public class StreamsDemo {
    public static void main(String[] args) {
        List<Product> products = new ArrayList<>();
        products.add(new Product("Laptop", 1200.00, true));
        products.add(new Product("Mouse", 25.50, false));
        products.add(new Product("Keyboard", 75.00, true));
        products.add(new Product("Webcam", 50.00, true));
        products.add(new Product("Monitor", 300.00, false));

        System.out.println("--- All Products (forEach Stream) ---");
        // Using forEach with a Stream for simple iteration.
        // Note: forEach is a terminal operation.
        products.stream().forEach(p -> System.out.println(p.getName()));

        System.out.println("\n--- Filtering In-Stock Products (Stream) ---");
        // Stream operations are generally "lazy" and chainable.
        // .filter() is an intermediate operation, producing a new stream.
        // .collect() is a terminal operation, triggering the execution and gathering results.
        List<Product> inStockProducts = products.stream()
            .filter(Product::isInStock) // Method reference: equivalent to p -> p.isInStock()
            .collect(Collectors.toList()); // Collect filtered products into a new List

        System.out.println("In-stock products: " + inStockProducts);

        System.out.println("\n--- Filtering Products by Price (Stream) ---");
        // Find products cheaper than $100 and print their names
        products.stream()
            .filter(p -> p.getPrice() < 100.00) // Filter for price
            .map(Product::getName) // Transform Product objects into their names (String)
            .forEach(name -> System.out.println("Affordable item: " + name));
    }
}

Clean Code Tip: For simple, read-only iteration, use the enhanced for-each loop. If you need to safely remove elements from a collection during iteration, use an Iterator. For complex data transformations, filtering, and aggregation, leverage the Streams API for its declarative syntax and functional approach, which promotes more readable and often more performant code.

Exercise: Reuse your Student class (studentId, name). Create an ArrayList<Student>. Add at least five Student objects, including some whose names start with the letter 'A'. Using Java Streams, filter the list to find all students whose names start with 'A' and print them. Then, use a stream to count how many students are in the entire list.

Solution:

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import java.util.Objects;

// Reusing Student class
class Student {
    String studentId;
    String name;

    public Student(String studentId, String name) {
        this.studentId = studentId;
        this.name = name;
    }

    public String getStudentId() { return studentId; }
    public String getName() { return name; }

    @Override
    public String toString() {
        return "Student [ID: " + studentId + ", Name: " + name + "]";
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Student student = (Student) o;
        return studentId.equals(student.studentId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(studentId);
    }
}

public class StudentStreamsExerciseSolution {
    public static void main(String[] args) {
        List<Student> students = new ArrayList<>();
        students.add(new Student("S001", "Alice Smith"));
        students.add(new Student("S002", "Bob Johnson"));
        students.add(new Student("S003", "Anna Davis"));
        students.add(new Student("S004", "Charlie Brown"));
        students.add(new Student("S005", "Arthur Miller"));
        students.add(new Student("S006", "Diana Prince"));

        System.out.println("--- All Students ---");
        students.forEach(System.out::println); // Using method reference for forEach

        // 1. Filter students whose names start with 'A'
        System.out.println("\n--- Students whose names start with 'A' ---");
        List<Student> studentsStartingWithA = students.stream()
            .filter(s -> s.getName().startsWith("A")) // Filter operation
            .collect(Collectors.toList());            // Terminal operation: collect to a new List

        studentsStartingWithA.forEach(System.out::println);

        // 2. Count how many students are in the entire list
        long totalStudents = students.stream()
                                     .count(); // Terminal operation: count elements

        System.out.println("\nTotal number of students: " + totalStudents);
    }
}

Chapter 18: Wrapper Classes (Boxing/Unboxing)

Quick Theory: Java has two main categories of data types: primitive types (like int, double, boolean, char) and reference types (objects). Primitive types store simple values directly in memory and are efficient, but they lack object-oriented features, meaning they cannot be used in contexts that expect objects, such as in the Collections Framework. For example, you cannot declare an ArrayList<int> because ArrayList can only hold objects.

To bridge this gap, Java provides Wrapper Classes for each primitive type (e.g., Integer for int, Double for double, Boolean for boolean). These wrapper classes encapsulate primitive values within objects. The process of converting a primitive to its corresponding wrapper object is called boxing, and converting a wrapper object back to its primitive value is called unboxing. Since Java 5, these conversions largely happen automatically, a feature known as autoboxing and autounboxing, which greatly simplifies code and allows primitives to seamlessly integrate with the Collections Framework and other object-oriented constructs, enhancing both reusability and flexibility.

Professional Code:

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

// No custom classes needed, as this chapter focuses on primitive wrappers.
// Example 1: ArrayList with Integer (Autoboxing/Autounboxing)
public class WrapperArrayListDemo {
    public static void main(String[] args) {
        // You cannot create an ArrayList of primitive 'int': List<int> numbers = new ArrayList<>(); (Compile error)
        // Instead, use the Integer wrapper class.
        List<Integer> scores = new ArrayList<>();

        System.out.println("--- Adding primitive ints to ArrayList (Autoboxing) ---");
        // When you add an 'int' primitive to 'scores', Java automatically converts it to an 'Integer' object.
        // This is called Autoboxing.
        scores.add(85);  // int 85 is autoboxed to new Integer(85)
        scores.add(92);
        scores.add(78);
        scores.add(95);

        System.out.println("Scores list: " + scores);

        System.out.println("\n--- Retrieving and using values (Autounboxing) ---");
        // When you retrieve an 'Integer' object from 'scores' and assign it to an 'int' primitive,
        // Java automatically converts the 'Integer' object back to an 'int'.
        // This is called Autounboxing.
        int firstScore = scores.get(0); // Integer object is autounboxed to int
        System.out.println("First score (primitive): " + firstScore);

        int sum = 0;
        for (Integer score : scores) { // 'score' is an Integer object, but autounboxed when used in arithmetic
            sum += score;              // 'score' is autounboxed to int before addition
        }
        System.out.println("Total sum of scores: " + sum);

        double average = (double) sum / scores.size();
        System.out.println("Average score: " + String.format("%.2f", average));
    }
}
// Example 2: HashMap with String keys and Double values
public class WrapperHashMapDemo {
    public static void main(String[] args) {
        // Using String as key and Double (wrapper for primitive double) as value.
        Map<String, Double> productPrices = new HashMap<>();

        System.out.println("--- Adding prices to Map (Autoboxing) ---");
        productPrices.put("Laptop", 1200.50); // double 1200.50 autoboxed to new Double(1200.50)
        productPrices.put("Keyboard", 75.25);
        productPrices.put("Mouse", 20.00);

        System.out.println("Product prices: " + productPrices);

        System.out.println("\n--- Retrieving and calculating (Autounboxing) ---");
        // Retrieving a Double object and performing arithmetic, which involves autounboxing.
        double laptopPrice = productPrices.get("Laptop"); // Double object autounboxed to double primitive
        System.out.println("Laptop price: $" + String.format("%.2f", laptopPrice));

        double discount = 0.10; // 10% discount
        // Arithmetic operations on wrapper types implicitly use autounboxing.
        double discountedLaptopPrice = laptopPrice * (1 - discount);
        System.out.println("Discounted Laptop price: $" + String.format("%.2f", discountedLaptopPrice));

        // Adding a new entry with a calculated value
        productPrices.put("Discounted Laptop", discountedLaptopPrice); // discountedLaptopPrice (primitive double) autoboxed back to Double
        System.out.println("\nProduct prices after discount addition: " + productPrices);
    }
}
// Example 3: Manual Boxing and Unboxing (for understanding)
public class ManualBoxingUnboxingDemo {
    public static void main(String[] args) {
        System.out.println("--- Manual Boxing ---");
        int primitiveInt = 100;
        // Manual boxing: creating a new Integer object from a primitive.
        Integer boxedInt = Integer.valueOf(primitiveInt); // Preferred way (can reuse cached instances)
        // Integer boxedInt_old = new Integer(primitiveInt); // Deprecated since Java 9
        System.out.println("Primitive int: " + primitiveInt + ", Boxed Integer: " + boxedInt);

        double primitiveDouble = 3.14;
        Double boxedDouble = Double.valueOf(primitiveDouble);
        System.out.println("Primitive double: " + primitiveDouble + ", Boxed Double: " + boxedDouble);

        System.out.println("\n--- Manual Unboxing ---");
        // Manual unboxing: extracting the primitive value from the wrapper object.
        int unboxedInt = boxedInt.intValue();
        System.out.println("Boxed Integer: " + boxedInt + ", Unboxed int: " + unboxedInt);

        double unboxedDouble = boxedDouble.doubleValue();
        System.out.println("Boxed Double: " + boxedDouble + ", Unboxed double: " + unboxedDouble);

        // While manual boxing/unboxing is shown for educational purposes,
        // it's generally better to let Java handle it automatically with autoboxing/autounboxing
        // for cleaner and more concise code, unless specific performance or object identity
        // considerations dictate otherwise.
    }
}

Clean Code Tip: Use wrapper classes (Integer, Double, Boolean, etc.) when you need to treat primitive values as objects, especially when working with Java Collections (which only store objects) or when a method requires an object type. Rely on autoboxing and autounboxing for convenience and cleaner code, as the compiler handles the conversions implicitly for you. Only perform manual boxing/unboxing if there's a specific requirement for explicit conversion.

Exercise: Create an ArrayList<Double>. Add several primitive double values to it (e.g., 10.5, 20.3, 5.7). Iterate through the list using an enhanced for-each loop, sum up all the Double values (demonstrating autounboxing during arithmetic), and print the total sum and the average.

Solution:

import java.util.ArrayList;
import java.util.List;

public class DoubleWrapperExerciseSolution {
    public static void main(String[] args) {
        List<Double> measurements = new ArrayList<>();

        System.out.println("--- Adding primitive doubles to ArrayList (Autoboxing) ---");
        // Primitive 'double' values are automatically boxed into 'Double' objects
        // when added to the List<Double>.
        measurements.add(10.5);
        measurements.add(20.3);
        measurements.add(5.7);
        measurements.add(15.0);
        measurements.add(8.2);

        System.out.println("Measurements list: " + measurements);

        double totalSum = 0.0;
        System.out.println("\n--- Summing up measurements (Autounboxing) ---");
        for (Double measurement : measurements) {
            // 'measurement' is a 'Double' object. When used in `totalSum += measurement`,
            // it's automatically unboxed to a primitive 'double' before the addition.
            totalSum += measurement;
            System.out.println("Adding: " + measurement + ", Current Sum: " + String.format("%.2f", totalSum));
        }

        System.out.println("\nFinal total sum: " + String.format("%.2f", totalSum));

        // Calculate average
        if (!measurements.isEmpty()) {
            double average = totalSum / measurements.size();
            System.out.println("Average measurement: " + String.format("%.2f", average));
        } else {
            System.out.println("No measurements to calculate average.");
        }
    }
}

Chapter 19: Generics (<T>)

Quick Theory: Generics are a powerful feature in Java that allows you to write classes, interfaces, and methods that operate on types specified as parameters. Instead of writing code that works with a specific type (like String or Integer), you can write code that works with a placeholder type (like T for Type), which is then replaced by an actual type at compile time. This ensures type safety by catching type-mismatch errors at compile time rather than at runtime, preventing ClassCastExceptions.

The primary benefits of generics are increased code reusability and robust type safety. A generic class like Box<T> can hold any type of object, yet the compiler ensures that you only put the correct type into it and retrieve the correct type from it, eliminating the need for explicit casting and the risk associated with it. This leads to cleaner, more readable, and significantly safer code, promoting a higher level of abstraction and scalability in your applications.

Professional Code:

// Example 1: Generic Box class
// This class can hold any type of object. 'T' is a type parameter.
class Box<T> {
    private T content; // The content held by the box can be of any type T.

    public Box(T content) {
        this.content = content;
    }

    public T getContent() {
        return content;
    }

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

    public void displayContentInfo() {
        System.out.println("Box content type: " + content.getClass().getName());
        System.out.println("Box content value: " + content);
    }
}
// Example 2: Generic method
// This method can print an array of any type.
class ArrayUtilities {
    // The <E> before the return type indicates that this is a generic method.
    // 'E' is a type parameter specific to this method.
    public static <E> void printArray(E[] inputArray) {
        System.out.print("Array elements: [");
        for (int i = 0; i < inputArray.length; i++) {
            System.out.print(inputArray[i]);
            if (i < inputArray.length - 1) {
                System.out.print(", ");
            }
        }
        System.out.println("]");
    }
    
    // Another generic method example: Returning the first element of any list.
    public static <T> T getFirstElement(List<T> list) {
        if (list == null || list.isEmpty()) {
            return null; // Or throw an exception
        }
        return list.get(0);
    }
}
// Main method to demonstrate Generics
import java.util.ArrayList;
import java.util.List;

public class GenericsDemo {
    public static void main(String[] args) {
        // --- Generic Box Demo ---
        System.out.println("--- Generic Box Demo ---");

        // Create a Box to hold an Integer.
        Box<Integer> integerBox = new Box<>(123);
        integerBox.displayContentInfo();
        int value = integerBox.getContent(); // No casting needed, type-safe!
        System.out.println("Retrieved from integerBox: " + value);

        // Create a Box to hold a String.
        Box<String> stringBox = new Box<>("Hello Generics!");
        stringBox.displayContentInfo();
        String text = stringBox.getContent(); // No casting needed, type-safe!
        System.out.println("Retrieved from stringBox: " + text);

        // integerBox.setContent("Wrong type"); // Compile-time error! Type safety in action.
        
        // --- Generic Method Demo ---
        System.out.println("\n--- Generic Method Demo ---");

        Integer[] intArray = {1, 2, 3, 4, 5};
        ArrayUtilities.printArray(intArray); // Works with Integer array

        String[] stringArray = {"Apple", "Banana", "Cherry"};
        ArrayUtilities.printArray(stringArray); // Works with String array

        Double[] doubleArray = {1.1, 2.2, 3.3};
        ArrayUtilities.printArray(doubleArray); // Works with Double array
        
        // Using generic method for lists
        List<String> fruits = new ArrayList<>();
        fruits.add("Orange");
        fruits.add("Grape");
        String firstFruit = ArrayUtilities.getFirstElement(fruits);
        System.out.println("First fruit: " + firstFruit);

        List<Integer> numbers = new ArrayList<>();
        numbers.add(42);
        numbers.add(99);
        Integer firstNumber = ArrayUtilities.getFirstElement(numbers);
        System.out.println("First number: " + firstNumber);
    }
}

Clean Code Tip: Always use generics when designing collection classes, utility methods that operate on arbitrary types, or when creating custom data structures. This enforces type safety at compile time, eliminating the need for error-prone runtime casting and making your code more readable, maintainable, and robust against ClassCastExceptions.

Exercise: Create a generic class Pair<K, V> that can hold two values of potentially different types: a key (K) and a value (V). It should have a constructor, getters for both, and a toString() method. In your main method, create instances of Pair for:

  1. Pair<String, Integer> (e.g., "Age", 30)
  2. Pair<Double, String> (e.g., 3.14, "PI") Print both pairs.

Solution:

// Generic class: Pair
class Pair<K, V> {
    private K key;
    private V value;

    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    public K getKey() {
        return key;
    }

    public V getValue() {
        return value;
    }

    @Override
    public String toString() {
        return "Pair [Key: " + key + ", Value: " + value + "]";
    }
}

public class GenericPairExerciseSolution {
    public static void main(String[] args) {
        System.out.println("--- Generic Pair Demo ---");

        // 1. Pair of String and Integer
        Pair<String, Integer> agePair = new Pair<>("Age", 30);
        System.out.println(agePair);
        System.out.println("Key type: " + agePair.getKey().getClass().getName());
        System.out.println("Value type: " + agePair.getValue().getClass().getName());

        System.out.println(); // Spacing

        // 2. Pair of Double and String
        Pair<Double, String> piPair = new Pair<>(3.14, "PI Constant");
        System.out.println(piPair);
        System.out.println("Key type: " + piPair.getKey().getClass().getName());
        System.out.println("Value type: " + piPair.getValue().getClass().getName());
    }
}

Chapter 20: Sorting with Comparator vs Comparable

Quick Theory: Revisiting sorting, the choice between Comparable and Comparator is a crucial design decision for flexibility and reusability. The Comparable interface (defining a compareTo() method) allows a class to specify its "natural ordering." This is the default way objects of that class would be sorted if no other sorting logic is provided. It's an inherent property of the class, making it easy to use with Collections.sort(list).

However, what if you need to sort the same objects in multiple ways (e.g., sort Products by name, then by price, then by category)? This is where Comparator comes in. Comparators are external objects that define a comparison logic separate from the class itself. You can create multiple Comparator instances, each representing a different sorting strategy, and pass them to Collections.sort(list, comparator) or list.sort(comparator). This decouples sorting logic from the class, providing immense flexibility for diverse sorting requirements without modifying the original class.

Professional Code:

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;

// Custom object: Product
class Product implements Comparable<Product> { // Implements Comparable for natural order
    String name;
    double price;
    String category;

    public Product(String name, double price, String category) {
        this.name = name;
        this.price = price;
        this.category = category;
    }

    // Getters for attributes
    public String getName() { return name; }
    public double getPrice() { return price; }
    public String getCategory() { return category; }

    @Override
    public String toString() {
        return "Product [Name: '" + name + "', Price: $" + String.format("%.2f", price) + ", Category: '" + category + "']";
    }

    // --- Comparable: Defines natural ordering (by name) ---
    @Override
    public int compareTo(Product other) {
        // Natural order: sort by product name alphabetically
        return this.name.compareTo(other.name);
    }
}
// Example 1: Sorting by Natural Order (Comparable)
public class ComparableSortingDemo {
    public static void main(String[] args) {
        List<Product> products = new ArrayList<>();
        products.add(new Product("Laptop", 1200.00, "Electronics"));
        products.add(new Product("Keyboard", 75.00, "Electronics"));
        products.add(new Product("Mouse", 25.50, "Electronics"));
        products.add(new Product("Coffee Maker", 99.99, "Home Appliances"));
        products.add(new Product("Monitor", 300.00, "Electronics"));

        System.out.println("--- Unsorted Products ---");
        products.forEach(System.out::println);

        // Sort using the natural order defined by Product's compareTo() (by name).
        Collections.sort(products); // Or products.sort(null); since Java 8

        System.out.println("\n--- Products Sorted by Name (Natural Order - Comparable) ---");
        products.forEach(System.out::println);
    }
}
// Example 2: Sorting by Custom Order (Comparator)
public class ComparatorSortingDemo {
    public static void main(String[] args) {
        List<Product> products = new ArrayList<>();
        products.add(new Product("Laptop", 1200.00, "Electronics"));
        products.add(new Product("Keyboard", 75.00, "Electronics"));
        products.add(new Product("Mouse", 25.50, "Electronics"));
        products.add(new Product("Coffee Maker", 99.99, "Home Appliances"));
        products.add(new Product("Monitor", 300.00, "Electronics"));

        System.out.println("--- Unsorted Products ---");
        products.forEach(System.out::println);

        // --- Custom Sorting Strategy 1: Sort by Price (Ascending) ---
        // Using an anonymous inner class for Comparator (pre-Java 8 style)
        Comparator<Product> priceAscComparator = new Comparator<Product>() {
            @Override
            public int compare(Product p1, Product p2) {
                return Double.compare(p1.getPrice(), p2.getPrice());
            }
        };
        Collections.sort(products, priceAscComparator);

        System.out.println("\n--- Products Sorted by Price (Ascending - Comparator) ---");
        products.forEach(System.out::println);

        // --- Custom Sorting Strategy 2: Sort by Category (Alphabetical) then by Price (Descending) ---
        // Using a Lambda Expression for Comparator (Java 8+ style) for conciseness.
        // Chaining comparators for multi-level sorting (thenComparing).
        Comparator<Product> categoryThenPriceDescComparator = Comparator
            .comparing(Product::getCategory) // Sort by category first
            .thenComparing(Comparator.comparing(Product::getPrice).reversed()); // Then by price, descending

        // Can also be written as:
        // Comparator<Product> categoryThenPriceDescComparator = (p1, p2) -> {
        //     int categoryComparison = p1.getCategory().compareTo(p2.getCategory());
        //     if (categoryComparison != 0) {
        //         return categoryComparison;
        //     }
        //     return Double.compare(p2.getPrice(), p1.getPrice()); // p2 vs p1 for descending
        // };

        products.sort(categoryThenPriceDescComparator); // List.sort() is also available since Java 8

        System.out.println("\n--- Products Sorted by Category then Price (Descending) ---");
        products.forEach(System.out::println);
    }
}

Clean Code Tip: When sorting objects, primarily define a single "natural order" using Comparable if one clearly exists. For all other sorting requirements (multiple criteria, different directions, external classes), create separate Comparators. This design promotes clean code by separating sorting logic from business logic, making your code highly flexible and reusable for various sorting scenarios.

Exercise: Reuse your Student class from previous exercises (with studentId and name). Make Student Comparable by studentId (natural order). Then, create two Comparators: one to sort students by name alphabetically, and another to sort by studentId in reverse order. In your main method, create a list of students, then demonstrate sorting it three ways: by Comparable, by name Comparator, and by studentId reverse Comparator.

Solution:

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;

// Student class implements Comparable for natural ordering by studentId
class Student implements Comparable<Student> {
    String studentId;
    String name;

    public Student(String studentId, String name) {
        this.studentId = studentId;
        this.name = name;
    }

    public String getStudentId() { return studentId; }
    public String getName() { return name; }

    @Override
    public String toString() {
        return "Student [ID: " + studentId + ", Name: " + name + "]";
    }

    // Natural ordering: by studentId
    @Override
    public int compareTo(Student other) {
        return this.studentId.compareTo(other.studentId);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Student student = (Student) o;
        return studentId.equals(student.studentId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(studentId);
    }
}

public class StudentSortingStrategyExerciseSolution {
    public static void main(String[] args) {
        List<Student> students = new ArrayList<>();
        students.add(new Student("S003", "Charlie Brown"));
        students.add(new Student("S001", "Alice Smith"));
        students.add(new Student("S004", "David Lee"));
        students.add(new Student("S002", "Bob Johnson"));

        System.out.println("--- Original List ---");
        students.forEach(System.out::println);

        // 1. Sort by Natural Order (Comparable - by studentId)
        Collections.sort(students);
        System.out.println("\n--- Sorted by Student ID (Natural Order) ---");
        students.forEach(System.out::println);

        // 2. Sort by Name (using a Comparator)
        Comparator<Student> byNameComparator = Comparator.comparing(Student::getName);
        Collections.sort(students, byNameComparator);
        System.out.println("\n--- Sorted by Name (Comparator) ---");
        students.forEach(System.out::println);

        // 3. Sort by Student ID in Reverse Order (using a Comparator)
        // Using thenComparing().reversed()
        Comparator<Student> byIdReverseComparator = Comparator.comparing(Student::getStudentId).reversed();
        Collections.sort(students, byIdReverseComparator);
        System.out.println("\n--- Sorted by Student ID (Reverse Comparator) ---");
        students.forEach(System.out::println);
    }
}

Chapter 21: Deep Copy vs Shallow Copy

Quick Theory: When working with objects, especially complex ones containing references to other objects, understanding deep copy and shallow copy is crucial for maintaining data integrity and avoiding unintended side effects. A shallow copy creates a new object, but instead of copying the actual values of referenced objects, it only copies their references. This means both the original and the copied object will point to the same underlying referenced objects. Modifying a referenced object through one copy will affect the other, which can be a dangerous source of bugs.

A deep copy, conversely, creates a completely independent replica of the original object, including all its nested referenced objects. It recursively copies all objects down the hierarchy, ensuring that no shared references exist between the original and the new copy. This guarantees the copied object is truly isolated from the original, promoting scalability by preventing accidental data corruption. While there's no built-in deepClone() in Java, common strategies involve implementing a "copy constructor" or using serialization techniques.

Professional Code:

// Reusable Address class (a mutable object that will be nested)
class Address {
    String street;
    String city;
    String postalCode;

    public Address(String street, String city, String postalCode) {
        this.street = street;
        this.city = city;
        this.postalCode = postalCode;
    }

    // Copy constructor for Address (essential for deep copy of Person)
    public Address(Address other) {
        this.street = other.street;
        this.city = other.city;
        this.postalCode = other.postalCode;
    }

    // Getters and Setters
    public String getStreet() { return street; }
    public void setStreet(String street) { this.street = street; }
    public String getCity() { return city; }
    public void setCity(String city) { this.city = city; }
    public String getPostalCode() { return postalCode; }
    public void setPostalCode(String postalCode) { this.postalCode = postalCode; }

    @Override
    public String toString() {
        return "Address [Street: '" + street + "', City: '" + city + "', Postal: " + postalCode + "]";
    }
}

// Person class containing an Address object
class Person {
    String name;
    int age;
    Address homeAddress; // This is a reference to another object

    public Person(String name, int age, Address homeAddress) {
        this.name = name;
        this.age = age;
        this.homeAddress = homeAddress;
    }

    // --- Copy Constructor (for Deep Copy) ---
    // This constructor creates a new Person object and a NEW Address object.
    // It calls the copy constructor of Address to ensure the Address is also deeply copied.
    public Person(Person other) {
        this.name = other.name; // String is immutable, so it's effectively deep copied.
        this.age = other.age;   // Primitive, so it's copied by value.
        // CRITICAL for deep copy: Create a NEW Address object.
        this.homeAddress = new Address(other.homeAddress);
    }

    // Getters and Setters
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public int getAge() { return age; }
    public void setAge(int age) { this.age = age; }
    public Address getHomeAddress() { return homeAddress; }
    public void setHomeAddress(Address homeAddress) { this.homeAddress = homeAddress; }

    @Override
    public String toString() {
        return "Person [Name: '" + name + "', Age: " + age + ", Address: " + homeAddress + "]";
    }
}
// Example 1: Demonstrating Shallow Copy (using assignment)
public class ShallowCopyDemo {
    public static void main(String[] args) {
        Address address1 = new Address("123 Main St", "Anytown", "12345");
        Person person1 = new Person("Alice", 30, address1);

        System.out.println("--- Original Person (person1) ---");
        System.out.println("person1: " + person1);
        System.out.println("person1.homeAddress hash: " + System.identityHashCode(person1.getHomeAddress()));

        // --- Shallow Copy (via assignment) ---
        // This does NOT create a new Person object, only a new reference pointing to the SAME object.
        // It's just like objA = objB; for references.
        Person person_ref = person1; // 'person_ref' now points to the same object as 'person1'.

        System.out.println("\n--- Shallow Copy via Assignment (person_ref) ---");
        System.out.println("person_ref: " + person_ref);
        System.out.println("person_ref.homeAddress hash: " + System.identityHashCode(person_ref.getHomeAddress()));
        System.out.println("Are person1 and person_ref the same object? " + (person1 == person_ref));
        System.out.println("Do their addresses point to same object? " + (person1.getHomeAddress() == person_ref.getHomeAddress()));

        // Modify person_ref's address. Since it's a shallow copy (same object reference),
        // person1's address will also change.
        System.out.println("\n--- Modifying person_ref's address ---");
        person_ref.getHomeAddress().setStreet("456 Elm St");

        System.out.println("person1 after person_ref modification: " + person1);
        System.out.println("person_ref after person_ref modification: " + person_ref);
        // Both reflect the change because they share the same Address object.
    }
}
// Example 2: Demonstrating Deep Copy (using a copy constructor)
public class DeepCopyDemo {
    public static void main(String[] args) {
        Address originalAddress = new Address("789 Oak Ave", "Oldville", "98765");
        Person originalPerson = new Person("Bob", 45, originalAddress);

        System.out.println("--- Original Person (originalPerson) ---");
        System.out.println("originalPerson: " + originalPerson);
        System.out.println("originalPerson.homeAddress hash: " + System.identityHashCode(originalPerson.getHomeAddress()));

        // --- Deep Copy (using copy constructor) ---
        // Creates a new Person object AND a new Address object.
        Person copiedPerson = new Person(originalPerson);

        System.out.println("\n--- Deep Copied Person (copiedPerson) ---");
        System.out.println("copiedPerson: " + copiedPerson);
        System.out.println("copiedPerson.homeAddress hash: " + System.identityHashCode(copiedPerson.getHomeAddress()));
        System.out.println("Are originalPerson and copiedPerson the same object? " + (originalPerson == copiedPerson));
        System.out.println("Do their addresses point to same object? " + (originalPerson.getHomeAddress() == copiedPerson.getHomeAddress()));
        // Note the different hash codes for addresses, indicating they are different objects.

        // Modify copiedPerson's address. This will NOT affect originalPerson.
        System.out.println("\n--- Modifying copiedPerson's address ---");
        copiedPerson.getHomeAddress().setStreet("101 Pine Rd");
        copiedPerson.getHomeAddress().setCity("Newville");
        copiedPerson.setName("Robert"); // Modify a primitive/immutable field

        System.out.println("originalPerson after copiedPerson modification: " + originalPerson);
        System.out.println("copiedPerson after copiedPerson modification: " + copiedPerson);
        // Original Person remains unchanged, demonstrating true independence.
    }
}

Clean Code Tip: When passing or returning objects that contain mutable (changeable) references, consider whether a deep copy is necessary. If modifying the copy should not affect the original, implement a deep copy using copy constructors. This prevents unexpected side effects, enhances scalability, and ensures type safety in terms of data integrity. Avoid Object.clone() unless you fully understand its complexities and limitations (it often performs a shallow copy by default).

Exercise: Create a ShoppingCartItem class with productName (String) and quantity (int). Then create a ShoppingCart class that contains an ArrayList<ShoppingCartItem>. Implement a copy constructor for ShoppingCart that performs a deep copy of its items (i.e., creates new ShoppingCartItem objects for the copied list). In your main method, create an original ShoppingCart, add some items, then create a deep copy. Modify an item in the copied cart and verify that the original cart remains unchanged.

Solution:

import java.util.ArrayList;
import java.util.List;

// ShoppingCartItem class
class ShoppingCartItem {
    String productName;
    int quantity;

    public ShoppingCartItem(String productName, int quantity) {
        this.productName = productName;
        this.quantity = quantity;
    }

    // Copy constructor for ShoppingCartItem (for deep copy)
    public ShoppingCartItem(ShoppingCartItem other) {
        this.productName = other.productName; // String is immutable
        this.quantity = other.quantity;       // Primitive
    }

    public String getProductName() { return productName; }
    public void setProductName(String productName) { this.productName = productName; }
    public int getQuantity() { return quantity; }
    public void setQuantity(int quantity) { this.quantity = quantity; }

    @Override
    public String toString() {
        return "Item [Name: '" + productName + "', Qty: " + quantity + "]";
    }
}

// ShoppingCart class
class ShoppingCart {
    String customerName;
    List<ShoppingCartItem> items; // This is the mutable reference list

    public ShoppingCart(String customerName) {
        this.customerName = customerName;
        this.items = new ArrayList<>(); // Initialize an empty list
    }

    // --- Deep Copy Constructor for ShoppingCart ---
    public ShoppingCart(ShoppingCart other) {
        this.customerName = other.customerName; // String is immutable
        this.items = new ArrayList<>(); // CRITICAL: Create a NEW ArrayList for the copy
        for (ShoppingCartItem item : other.items) {
            // CRITICAL: For each item, create a NEW ShoppingCartItem object
            // by calling its copy constructor. This ensures deep copy of list elements.
            this.items.add(new ShoppingCartItem(item));
        }
    }

    public void addItem(ShoppingCartItem item) {
        this.items.add(item);
    }

    public String getCustomerName() { return customerName; }
    public void setCustomerName(String customerName) { this.customerName = customerName; }
    public List<ShoppingCartItem> getItems() { return items; }

    @Override
    public String toString() {
        return "Cart for '" + customerName + "' (Items: " + items.size() + ") -> " + items;
    }
}

public class ShoppingCartDeepCopyExerciseSolution {
    public static void main(String[] args) {
        // Original Cart
        ShoppingCart originalCart = new ShoppingCart("John Doe");
        originalCart.addItem(new ShoppingCartItem("Laptop", 1));
        originalCart.addItem(new ShoppingCartItem("Mouse", 2));

        System.out.println("--- Original Cart ---");
        System.out.println(originalCart);
        System.out.println("Original cart items list hash: " + System.identityHashCode(originalCart.getItems()));
        System.out.println("First item in original cart hash: " + System.identityHashCode(originalCart.getItems().get(0)));

        // Create a Deep Copy
        ShoppingCart copiedCart = new ShoppingCart(originalCart);

        System.out.println("\n--- Deep Copied Cart ---");
        System.out.println(copiedCart);
        System.out.println("Copied cart items list hash: " + System.identityHashCode(copiedCart.getItems()));
        System.out.println("First item in copied cart hash: " + System.identityHashCode(copiedCart.getItems().get(0)));

        System.out.println("\nComparison:");
        System.out.println("Are original and copied cart the same object? " + (originalCart == copiedCart)); // False
        System.out.println("Do their item lists point to same object? " + (originalCart.getItems() == copiedCart.getItems())); // False
        System.out.println("Do their first items point to same object? " + (originalCart.getItems().get(0) == copiedCart.getItems().get(0))); // False

        // --- Modify the Copied Cart ---
        System.out.println("\n--- Modifying Copied Cart ---");
        copiedCart.addItem(new ShoppingCartItem("Keyboard", 1)); // Add new item
        copiedCart.getItems().get(0).setQuantity(2); // Modify quantity of first item

        System.out.println("Original Cart after copied cart modification: " + originalCart);
        System.out.println("Copied Cart after its own modification: " + copiedCart);

        // Verify that the original cart is unchanged
        System.out.println("\nVerification:");
        System.out.println("Original cart still has 2 items: " + (originalCart.getItems().size() == 2));
        System.out.println("Original cart first item quantity is still 1: " + (originalCart.getItems().get(0).getQuantity() == 1));
    }
}

Chapter 22: OOP Exception Handling

Quick Theory: Exception handling is a critical mechanism for building robust and scalable applications. It provides a structured way to deal with unexpected or erroneous situations that occur during program execution, preventing crashes and allowing for graceful recovery or informative error reporting. Instead of relying on if-else cascades that can become unwieldy, exceptions separate the error-handling logic from the regular program flow, improving code readability and maintainability.

In Java, exceptions are objects that represent an exceptional event. You can also define your own custom exception classes by extending Exception (for checked exceptions, which must be declared with throws or handled with try-catch) or RuntimeException (for unchecked exceptions, which don't require explicit handling). Using custom exceptions enhances type safety by allowing you to create specific error types tailored to your application's domain, making error messages clearer and enabling more precise error handling by the caller. The throws keyword in a method signature declares that a method might throw a certain type of checked exception, shifting the responsibility of handling it to the caller.

Professional Code:

// Example 1: Custom Exception Class (Checked Exception)
// This exception is for when an invalid amount is provided.
class InvalidAmountException extends Exception {
    public InvalidAmountException(String message) {
        super(message); // Pass message to the parent Exception constructor.
    }

    public InvalidAmountException(String message, Throwable cause) {
        super(message, cause); // Allows chaining exceptions.
    }
}
// Example 2: Account class with methods that throw custom exceptions
class Account {
    private String accountNumber;
    private double balance;

    public Account(String accountNumber, double initialBalance) throws InvalidAmountException {
        if (initialBalance < 0) {
            throw new InvalidAmountException("Initial balance cannot be negative.");
        }
        this.accountNumber = accountNumber;
        this.balance = initialBalance;
        System.out.println("Account " + accountNumber + " created with balance: $" + String.format("%.2f", balance));
    }

    public String getAccountNumber() {
        return accountNumber;
    }

    public double getBalance() {
        return balance;
    }

    // Method to deposit money, declares it might throw InvalidAmountException
    public void deposit(double amount) throws InvalidAmountException {
        if (amount <= 0) {
            throw new InvalidAmountException("Deposit amount must be positive. Received: " + amount);
        }
        this.balance += amount;
        System.out.println("Deposited $" + String.format("%.2f", amount) + ". New balance: $" + String.format("%.2f", balance));
    }

    // Method to withdraw money, declares it might throw InvalidAmountException
    public void withdraw(double amount) throws InvalidAmountException {
        if (amount <= 0) {
            throw new InvalidAmountException("Withdrawal amount must be positive. Received: " + amount);
        }
        if (this.balance < amount) {
            throw new InvalidAmountException("Insufficient funds. Current balance: $" + String.format("%.2f", balance) + ", Attempted withdrawal: $" + String.format("%.2f", amount));
        }
        this.balance -= amount;
        System.out.println("Withdrew $" + String.format("%.2f", amount) + ". New balance: $" + String.format("%.2f", balance));
    }
}
// Main method to demonstrate exception handling
public class ExceptionHandlingDemo {
    public static void main(String[] args) {
        Account myAccount = null; // Initialize to null

        // --- Scenario 1: Handling exceptions during Account creation ---
        try {
            myAccount = new Account("ACC001", -100.0); // This will throw InvalidAmountException
        } catch (InvalidAmountException e) {
            System.err.println("Error creating account: " + e.getMessage());
        }

        System.out.println(); // Spacing

        // Create a valid account for further operations
        try {
            myAccount = new Account("ACC002", 500.0);
        } catch (InvalidAmountException e) {
            System.err.println("This should not happen for a valid creation: " + e.getMessage());
        }

        // --- Scenario 2: Handling exceptions during deposit/withdrawal operations ---
        if (myAccount != null) { // Ensure account was created successfully
            try {
                myAccount.deposit(200.0);
                myAccount.withdraw(100.0);
                myAccount.deposit(-50.0); // This will throw InvalidAmountException
            } catch (InvalidAmountException e) {
                System.err.println("Transaction error: " + e.getMessage());
            }

            try {
                myAccount.withdraw(1000.0); // This will throw InvalidAmountException (insufficient funds)
            } catch (InvalidAmountException e) {
                System.err.println("Transaction error: " + e.getMessage());
            }
            
            // Catching a more general exception (RuntimeException)
            // try {
            //     int result = 10 / 0; // ArithmeticException is a RuntimeException
            // } catch (RuntimeException e) {
            //     System.err.println("Caught a runtime exception: " + e.getMessage());
            // }

            System.out.println("\nFinal balance for " + myAccount.getAccountNumber() + ": $" + String.format("%.2f", myAccount.getBalance()));
        } else {
            System.out.println("Account not available for transactions.");
        }
    }
}

Clean Code Tip: Create custom exception classes for specific, application-domain-related errors. Extend Exception for checked exceptions (recoverable problems that callers must handle) and RuntimeException for unchecked exceptions (programming errors or unrecoverable situations). This improves type safety by making your error handling more granular and provides clearer communication about what went wrong, leading to more scalable and maintainable code.

Exercise: Create a custom checked exception InvalidAgeException. Create a Person class with name and age. The Person constructor should throw InvalidAgeException if the age is less than 0 or greater than 150. In your main method, attempt to create Person objects with invalid ages and catch the custom exception, printing an informative error message.

Solution:

// Custom Checked Exception
class InvalidAgeException extends Exception {
    public InvalidAgeException(String message) {
        super(message);
    }
}

// Person class with constructor that throws InvalidAgeException
class Person {
    private String name;
    private int age;

    public Person(String name, int age) throws InvalidAgeException {
        if (age < 0 || age > 150) {
            throw new InvalidAgeException("Age " + age + " is invalid. Age must be between 0 and 150.");
        }
        this.name = name;
        this.age = age;
        System.out.println("Person created: " + name + " (Age: " + age + ")");
    }

    public String getName() { return name; }
    public int getAge() { return age; }

    @Override
    public String toString() {
        return "Person [Name: '" + name + "', Age: " + age + "]";
    }
}

public class InvalidAgeExceptionExerciseSolution {
    public static void main(String[] args) {
        System.out.println("--- Attempting to create Person objects ---");

        // Valid age
        try {
            Person p1 = new Person("Alice", 30);
            System.out.println(p1);
        } catch (InvalidAgeException e) {
            System.err.println("Error: " + e.getMessage());
        }

        System.out.println(); // Spacing

        // Invalid age: negative
        try {
            Person p2 = new Person("Bob", -5);
            System.out.println(p2);
        } catch (InvalidAgeException e) {
            System.err.println("Error: " + e.getMessage());
        }

        System.out.println(); // Spacing

        // Invalid age: too high
        try {
            Person p3 = new Person("Charlie", 200);
            System.out.println(p3);
        } catch (InvalidAgeException e) {
            System.err.println("Error: " + e.getMessage());
        }

        System.out.println(); // Spacing

        // Another valid age
        try {
            Person p4 = new Person("Diana", 88);
            System.out.println(p4);
        } catch (InvalidAgeException e) {
            System.err.println("Error: " + e.getMessage());
        }
    }
}

Chapter 23: Introduction to Lambda Expressions

Quick Theory: Lambda expressions, introduced in Java 8, are a concise way to represent anonymous functions (functions without a name). They enable functional programming paradigms in Java, drastically improving code readability and making it more scalable by reducing boilerplate code, especially when working with single-method interfaces (known as functional interfaces). Instead of creating an anonymous inner class with several lines of code to implement an interface, a lambda expression allows you to define that implementation in a single, expressive line.

Lambdas are incredibly powerful for tasks involving collections, event handling, and parallel processing. They simplify operations like filtering, mapping, and iterating over lists by allowing you to pass behavior as an argument directly. This enhances code flexibility by making it easier to compose and reuse small, focused pieces of logic. Together with the Streams API (as seen in Chapter 17), lambda expressions are a cornerstone of modern Java development, making code more declarative and easier to reason about.

Professional Code:

import java.util.ArrayList;
import java.util.Comparator; // For sorting with lambdas
import java.util.List;
import java.util.function.Consumer;   // Functional interface for forEach
import java.util.function.Predicate;  // Functional interface for filter, removeIf
import java.util.stream.Collectors;

// Reusing Product class from Chapter 20 (it implements Comparable)
class Product implements Comparable<Product> {
    String name;
    double price;
    String category;
    boolean inStock;

    public Product(String name, double price, String category, boolean inStock) {
        this.name = name;
        this.price = price;
        this.category = category;
        this.inStock = inStock;
    }

    public String getName() { return name; }
    public double getPrice() { return price; }
    public String getCategory() { return category; }
    public boolean isInStock() { return inStock; }

    @Override
    public String toString() {
        return "Product [Name: '" + name + "', Price: $" + String.format("%.2f", price) + ", Category: '" + category + "', In Stock: " + inStock + "]";
    }

    @Override
    public int compareTo(Product other) {
        return this.name.compareTo(other.name);
    }
}
// Example 1: Basic Lambda for forEach and Comparator
public class LambdaBasicDemo {
    public static void main(String[] args) {
        List<Product> products = new ArrayList<>();
        products.add(new Product("Laptop", 1200.00, "Electronics", true));
        products.add(new Product("Mouse", 25.50, "Electronics", false));
        products.add(new Product("Keyboard", 75.00, "Electronics", true));
        products.add(new Product("Coffee Maker", 99.99, "Home Appliances", true));
        products.add(new Product("Monitor", 300.00, "Electronics", false));

        System.out.println("--- Products List ---");
        // Using lambda for List.forEach() (Consumer functional interface)
        products.forEach(p -> System.out.println("Item: " + p.getName() + " - $" + String.format("%.2f", p.getPrice())));

        System.out.println("\n--- Sorting Products by Price (Lambda Comparator) ---");
        // Using lambda for Collections.sort() (Comparator functional interface)
        Collections.sort(products, (p1, p2) -> Double.compare(p1.getPrice(), p2.getPrice()));
        products.forEach(System.out::println);
    }
}
// Example 2: Filtering with removeIf() and Streams with Lambdas
public class LambdaFilteringDemo {
    public static void main(String[] args) {
        List<Product> products = new ArrayList<>();
        products.add(new Product("Laptop", 1200.00, "Electronics", true));
        products.add(new Product("Mouse", 25.50, "Electronics", false));
        products.add(new Product("Keyboard", 75.00, "Electronics", true));
        products.add(new Product("Coffee Maker", 99.99, "Home Appliances", true));
        products.add(new Product("Monitor", 300.00, "Electronics", false));

        System.out.println("--- Original Products (Size: " + products.size() + ") ---");
        products.forEach(System.out::println);

        // --- Using List.removeIf() with a Lambda (Predicate functional interface) ---
        // removeIf() removes all elements that satisfy the given predicate.
        System.out.println("\n--- Removing Out-of-Stock Products with removeIf() ---");
        boolean changed = products.removeIf(p -> !p.isInStock()); // Remove if not in stock
        System.out.println("List changed: " + changed);
        System.out.println("Products after removeIf (Size: " + products.size() + "): ");
        products.forEach(System.out::println);

        // Reset products for next demo
        products.clear();
        products.add(new Product("Laptop", 1200.00, "Electronics", true));
        products.add(new Product("Mouse", 25.50, "Electronics", false));
        products.add(new Product("Keyboard", 75.00, "Electronics", true));
        products.add(new Product("Coffee Maker", 99.99, "Home Appliances", true));
        products.add(new Product("Monitor", 300.00, "Electronics", false));

        // --- Filtering with Streams and Lambdas ---
        System.out.println("\n--- Filtering Products by Category and Price with Streams ---");
        // Filter products that are "Electronics" AND cost more than $100.
        List<Product> expensiveElectronics = products.stream()
            .filter(p -> p.getCategory().equals("Electronics")) // First filter
            .filter(p -> p.getPrice() > 100.00)                // Second filter
            .collect(Collectors.toList());                     // Collect results

        System.out.println("Expensive Electronics (Stream result):");
        expensiveElectronics.forEach(System.out::println);
    }
}

Clean Code Tip: Embrace lambda expressions for implementing functional interfaces, especially in contexts like iterating with forEach, filtering with removeIf or Streams, and defining custom sorting logic with Comparator. This makes your code more concise, expressive, and easier to understand, reflecting modern Java practices for scalability and flexibility.

Exercise: Reuse your Student class (studentId, name). Create an ArrayList<Student>. Add at least 5 students.

  1. Use List.forEach() with a lambda to print all student names.
  2. Use List.removeIf() with a lambda to remove all students whose studentId ends with an odd number (e.g., "S001", "S003", "S005").
  3. Print the list again to show the removed students.

Solution:

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

// Reusing Student class
class Student {
    String studentId;
    String name;

    public Student(String studentId, String name) {
        this.studentId = studentId;
        this.name = name;
    }

    public String getStudentId() { return studentId; }
    public String getName() { return name; }

    @Override
    public String toString() {
        return "Student [ID: " + studentId + ", Name: " + name + "]";
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Student student = (Student) o;
        return studentId.equals(student.studentId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(studentId);
    }
}

public class LambdaExerciseSolution {
    public static void main(String[] args) {
        List<Student> students = new ArrayList<>();
        students.add(new Student("S001", "Alice Smith"));
        students.add(new Student("S002", "Bob Johnson"));
        students.add(new Student("S003", "Anna Davis"));
        students.add(new Student("S004", "Charlie Brown"));
        students.add(new Student("S005", "Arthur Miller"));
        students.add(new Student("S006", "Diana Prince"));

        System.out.println("--- Initial Student List ---");
        // 1. Print all student names using forEach with a lambda
        students.forEach(s -> System.out.println("Name: " + s.getName() + " (ID: " + s.getStudentId() + ")"));

        // 2. Remove students whose studentId ends with an odd number
        System.out.println("\n--- Removing students with odd-ending IDs ---");
        // The condition `Integer.parseInt(s.getStudentId().substring(s.getStudentId().length() - 1)) % 2 != 0`
        // gets the last digit of the studentId, converts it to an int, and checks if it's odd.
        students.removeIf(s -> {
            String lastDigitStr = s.getStudentId().substring(s.getStudentId().length() - 1);
            int lastDigit = Integer.parseInt(lastDigitStr);
            return lastDigit % 2 != 0;
        });

        System.out.println("\n--- Student List After Removal (Students with even-ending IDs) ---");
        // 3. Print the list again to show the removed students
        students.forEach(System.out::println);
    }
}

Chapter 24: Final Project Logic (Mini System)

Quick Theory: This final chapter synthesizes many OOP concepts into a small, cohesive system. The goal is to demonstrate how inheritance, polymorphism, the Collections Framework, and sorting work together to create a flexible and scalable application. Building such a system from the ground up helps solidify understanding of how individual components contribute to a larger architecture, ensuring type safety and clean design.

A well-designed system, even a small one, typically follows these principles: define a common base (abstract class or interface) for related entities, use specialized subclasses to implement concrete behaviors, store these entities in collections for easy management, and leverage polymorphism to process them uniformly. Sorting mechanisms enhance the user experience by presenting data in a logical order, while exceptions ensure robustness. This structured approach, a common pattern in DAM final projects, emphasizes reusability and maintainability, providing a solid foundation for more complex applications.

Professional Code:

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional; // For safer retrieval from streams
import java.util.stream.Collectors;

// --- 1. Custom Exception for System specific errors ---
class SystemException extends Exception {
    public SystemException(String message) {
        super(message);
    }
}

// --- 2. Base Class (Abstract) for Assets (Inheritance & Polymorphism) ---
abstract class Asset implements Comparable<Asset> { // Asset implements Comparable for natural order
    private String assetId;
    private String name;
    private double acquisitionCost;

    public Asset(String assetId, String name, double acquisitionCost) throws SystemException {
        if (assetId == null || assetId.trim().isEmpty()) {
            throw new SystemException("Asset ID cannot be empty.");
        }
        if (name == null || name.trim().isEmpty()) {
            throw new SystemException("Asset name cannot be empty.");
        }
        if (acquisitionCost <= 0) {
            throw new SystemException("Acquisition cost must be positive.");
        }
        this.assetId = assetId;
        this.name = name;
        this.acquisitionCost = acquisitionCost;
    }

    // Getters for common attributes
    public String getAssetId() { return assetId; }
    public String getName() { return name; }
    public double getAcquisitionCost() { return acquisitionCost; }

    // Abstract method: forces subclasses to define how they calculate depreciation
    public abstract double calculateDepreciation(int years);

    // Concrete method: displays basic asset info
    public void displayBasicInfo() {
        System.out.println("Asset ID: " + assetId + ", Name: " + name + ", Cost: $" + String.format("%.2f", acquisitionCost));
    }

    // Natural order for Asset: by assetId
    @Override
    public int compareTo(Asset other) {
        return this.assetId.compareTo(other.assetId);
    }

    // Crucial for Set/Map keys based on AssetId uniqueness
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Asset asset = (Asset) o;
        return Objects.equals(assetId, asset.assetId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(assetId);
    }
}

// --- 3. Concrete Subclasses (Specialization) ---

class Computer extends Asset {
    private String processor;
    private int ramGB;

    public Computer(String assetId, String name, double acquisitionCost, String processor, int ramGB) throws SystemException {
        super(assetId, name, acquisitionCost);
        if (processor == null || processor.trim().isEmpty()) {
            throw new SystemException("Processor cannot be empty.");
        }
        if (ramGB <= 0) {
            throw new SystemException("RAM must be positive.");
        }
        this.processor = processor;
        this.ramGB = ramGB;
    }

    @Override
    public double calculateDepreciation(int years) {
        // Simple linear depreciation for computers: 20% per year for up to 5 years.
        if (years <= 0) return 0;
        double depreciationRate = 0.20;
        double totalDepreciation = getAcquisitionCost() * Math.min(years, 5) * depreciationRate;
        return Math.min(totalDepreciation, getAcquisitionCost()); // Cannot depreciate more than cost.
    }

    @Override
    public String toString() {
        return String.format("Computer [ID: %s, Name: %s, Cost: $%.2f, Proc: %s, RAM: %dGB]",
                getAssetId(), getName(), getAcquisitionCost(), processor, ramGB);
    }
}

class Vehicle extends Asset {
    private String make;
    private int year;

    public Vehicle(String assetId, String name, double acquisitionCost, String make, int year) throws SystemException {
        super(assetId, name, acquisitionCost);
        if (make == null || make.trim().isEmpty()) {
            throw new SystemException("Vehicle make cannot be empty.");
        }
        if (year <= 1900 || year > java.time.Year.now().getValue()) {
            throw new SystemException("Invalid vehicle year.");
        }
        this.make = make;
        this.year = year;
    }

    @Override
    public double calculateDepreciation(int years) {
        // Simple linear depreciation for vehicles: 15% per year for up to 7 years.
        if (years <= 0) return 0;
        double depreciationRate = 0.15;
        double totalDepreciation = getAcquisitionCost() * Math.min(years, 7) * depreciationRate;
        return Math.min(totalDepreciation, getAcquisitionCost());
    }

    @Override
    public String toString() {
        return String.format("Vehicle [ID: %s, Name: %s, Cost: $%.2f, Make: %s, Year: %d]",
                getAssetId(), getName(), getAcquisitionCost(), make, year);
    }
}

// --- 4. Asset Management System (Collections, Sorting, Lambdas) ---
class AssetManagementSystem {
    private List<Asset> assets; // Use List to store assets (Polymorphism)

    public AssetManagementSystem() {
        this.assets = new ArrayList<>();
    }

    public void addAsset(Asset asset) throws SystemException {
        // Check for duplicate Asset ID before adding
        if (assets.stream().anyMatch(a -> a.getAssetId().equals(asset.getAssetId()))) {
            throw new SystemException("Asset with ID " + asset.getAssetId() + " already exists.");
        }
        this.assets.add(asset);
        System.out.println("Added: " + asset.getName() + " (" + asset.getAssetId() + ")");
    }

    public void removeAsset(String assetId) throws SystemException {
        boolean removed = assets.removeIf(a -> a.getAssetId().equals(assetId)); // Lambda for removeIf
        if (!removed) {
            throw new SystemException("Asset with ID " + assetId + " not found for removal.");
        }
        System.out.println("Removed asset with ID: " + assetId);
    }

    public Optional<Asset> findAssetById(String assetId) {
        // Stream API for efficient search
        return assets.stream()
                     .filter(a -> a.getAssetId().equals(assetId))
                     .findFirst(); // Returns an Optional, preventing NullPointerExceptions
    }

    public List<Asset> getAllAssets() {
        return Collections.unmodifiableList(assets); // Return an unmodifiable list for safety
    }

    public void displayAllAssets() {
        if (assets.isEmpty()) {
            System.out.println("No assets in the system.");
            return;
        }
        System.out.println("\n--- Current Assets (Sorted by ID) ---");
        // Sort by natural order (AssetId)
        Collections.sort(assets);
        assets.forEach(System.out::println); // Lambda for forEach
    }

    public void displayAssetsSortedByCost(boolean ascending) {
        if (assets.isEmpty()) {
            System.out.println("No assets in the system.");
            return;
        }
        System.out.println("\n--- Current Assets (Sorted by Acquisition Cost " + (ascending ? "Asc" : "Desc") + ") ---");
        // Custom sorting using Comparator (Lambda)
        Comparator<Asset> costComparator = Comparator.comparing(Asset::getAcquisitionCost);
        if (!ascending) {
            costComparator = costComparator.reversed();
        }
        assets.sort(costComparator); // List.sort() with Comparator
        assets.forEach(System.out::println);
    }

    public void displayDepreciationReport(int years) {
        if (assets.isEmpty()) {
            System.out.println("No assets to report depreciation.");
            return;
        }
        System.out.println(String.format("\n--- Depreciation Report (after %d years) ---", years));
        assets.forEach(asset -> {
            double currentDepreciation = asset.calculateDepreciation(years); // Polymorphic call
            System.out.println(String.format("Asset ID: %s, Name: %s, Cost: $%.2f, Depreciation: $%.2f",
                    asset.getAssetId(), asset.getName(), asset.getAcquisitionCost(), currentDepreciation));
        });
    }
}
// --- 5. Main Application Logic ---
public class FinalProjectSystemDemo {
    public static void main(String[] args) {
        AssetManagementSystem system = new AssetManagementSystem();

        // Adding Assets
        try {
            system.addAsset(new Computer("COMP001", "Desktop Workstation", 1800.00, "Intel i7", 16));
            system.addAsset(new Vehicle("VEH001", "Company Car A", 25000.00, "Toyota", 2020));
            system.addAsset(new Computer("COMP002", "Laptop Pro", 1100.00, "Ryzen 5", 8));
            system.addAsset(new Vehicle("VEH002", "Delivery Van", 35000.00, "Ford", 2022));
            // system.addAsset(new Computer("COMP001", "Duplicate ID", 500.00, "Intel i3", 4)); // Will throw SystemException
        } catch (SystemException e) {
            System.err.println("Error adding asset: " + e.getMessage());
        } catch (Exception e) { // Catch any other unexpected exceptions
            System.err.println("An unexpected error occurred: " + e.getMessage());
        }

        system.displayAllAssets(); // Sorted by Asset ID

        system.displayAssetsSortedByCost(true); // Sorted by Cost Ascending
        system.displayAssetsSortedByCost(false); // Sorted by Cost Descending

        // Finding an Asset
        String searchId = "VEH001";
        Optional<Asset> foundAsset = system.findAssetById(searchId);
        if (foundAsset.isPresent()) {
            System.out.println("\nFound Asset " + searchId + ": " + foundAsset.get().getName());
            // Downcast safely with instanceof for specific actions if needed
            if (foundAsset.get() instanceof Vehicle vehicle) {
                System.out.println("This is a " + vehicle.getMake() + " from " + vehicle.getYear());
            }
        } else {
            System.out.println("\nAsset with ID " + searchId + " not found.");
        }

        // Depreciation Report (Polymorphism in action)
        system.displayDepreciationReport(3);

        // Removing an Asset
        try {
            system.removeAsset("COMP002");
            system.removeAsset("NONEXISTENT"); // Will throw SystemException
        } catch (SystemException e) {
            System.err.println("Error removing asset: " + e.getMessage());
        }

        system.displayAllAssets(); // Final list
    }
}

Clean Code Tip: Design your system with clear abstractions (abstract classes, interfaces) and leverage polymorphism to process diverse objects uniformly. Use collections (like List) to manage groups of objects, and employ Comparators and Lambdas for flexible sorting and filtering. Implement custom exceptions for domain-specific error handling. This holistic approach ensures your code is scalable, reusable, and type-safe, forming a robust foundation for any project.