Java Ecosystem & Programming

1º Java Foundations

Book 1: Java Foundations

Chapter 1: The Hello World.

1. Brief Theory: Your First Program!

Every journey begins with a first step, and in programming, that step is almost always "Hello World!". This simple program introduces you to the fundamental structure of a Java application.

2. Professional Code: Hello World Examples

Example 1.1: Classic Hello World with println

// Chapter1/HelloWorldClassic.java
public class HelloWorldClassic {
    public static void main(String[] args) {
        System.out.println("Hello, Java World!");
        System.out.println("My first program is running!");
    }
}

Example 1.2: Using printf for a Simple Message

// Chapter1/HelloWorldPrintf.java
public class HelloWorldPrintf {
    public static void main(String[] args) {
        String greeting = "Welcome";
        String language = "Java";
        System.out.printf("%s to %s Programming!\n", greeting, language);
        System.out.printf("This is cool, right?");
    }
}

Example 1.3: Combining println and printf

// Chapter1/CombinedOutput.java
public class CombinedOutput {
    public static void main(String[] args) {
        System.out.println("--- Program Start ---");
        
        String userName = "DAM Student";
        int year = 2024;
        
        System.out.printf("Hello, %s! It's currently the year %d.\n", userName, year);
        System.out.println("Let's learn some awesome Java!");
        
        System.out.println("--- Program End ---");
    }
}

3. Line-by-Line Breakdown

Let's break down HelloWorldClassic.java:

// Chapter1/HelloWorldClassic.java
// This is a single-line comment. It's ignored by the compiler but helps humans understand the code.

public class HelloWorldClassic {
    // 'public' makes this class accessible from anywhere.
    // 'class' keyword declares a new class.
    // 'HelloWorldClassic' is the name of our class. By convention, class names start with an uppercase letter.

    public static void main(String[] args) {
        // 'public': The JVM can access this method.
        // 'static': The method belongs to the class itself, not an instance.
        // 'void': This method doesn't return any value.
        // 'main': The special name the JVM looks for as the starting point.
        // '(String[] args)': Allows command-line arguments (we won't use them now).
        // The curly braces `{}` define the block of code that belongs to the main method.

        System.out.println("Hello, Java World!");
        // 'System': A built-in Java class that provides access to system resources.
        // '.out': A static member of the System class, representing the standard output stream (usually your console).
        // '.println()': A method of the PrintStream object (out) that prints the given argument to the console
        //              and then moves the cursor to the next line.
        // "Hello, Java World!": This is a String literal, the actual text to be printed.

        System.out.println("My first program is running!");
        // Another call to println(), printing a different message on a new line.
    }
}

Now for HelloWorldPrintf.java:

public class HelloWorldPrintf {
    public static void main(String[] args) {
        String greeting = "Welcome";
        // Declares a variable named 'greeting' of type String and assigns it the value "Welcome".
        // We'll dive deep into variables in the next chapter!

        String language = "Java";
        // Declares another String variable named 'language' and assigns "Java".

        System.out.printf("%s to %s Programming!\n", greeting, language);
        // 'printf()': Prints formatted output.
        // "%s": These are format specifiers. %s is a placeholder for a String.
        // The values after the comma (greeting, language) are inserted into the placeholders in order.
        // '\n': This is an "escape sequence" for a newline character. It explicitly tells printf to move to the next line.

        System.out.printf("This is cool, right?");
        // Another printf() call. Notice there's no '\n' here, so the next output would appear on the same line
        // if we had more print statements afterwards.
    }
}

4. Clean Code Pro-Tips

5. Unsolved Exercise: My Introduction

Your task is to create a new Java program that introduces yourself.

  1. Create a class named MyIntroduction.
  2. Inside its main method, print your name.
  3. Then, on a new line, print your current school program (e.g., "DAM Student").
  4. Finally, print a statement saying you are excited to learn Java, using printf to insert the current year (e.g., 2024).

6. Complete Solution: My Introduction

// Chapter1/MyIntroduction.java
public class MyIntroduction {
    public static void main(String[] args) {
        System.out.println("Hello! My name is [Your Name Here].");
        System.out.println("I am a DAM student.");
        
        int currentYear = 2024;
        System.out.printf("I am excited to learn Java in %d!\n", currentYear);
    }
}

Chapter 2: Variables & Primitives.

1. Brief Theory: Storing Information

In programming, we constantly need to store and manipulate data. This is where variables come in. Think of a variable as a named container that holds a specific type of data.

2. Professional Code: Variables in Action

Example 2.1: Declaring and Initializing Different Primitives

// Chapter2/PrimitiveVariables.java
public class PrimitiveVariables {
    public static void main(String[] args) {
        // Declare and initialize an integer variable for age
        int studentAge = 19; 
        System.out.println("Student Age: " + studentAge);

        // Declare and initialize a double variable for GPA (Grade Point Average)
        double studentGPA = 3.85;
        System.out.println("Student GPA: " + studentGPA);

        // Declare and initialize a boolean variable for enrollment status
        boolean isEnrolled = true;
        System.out.println("Is Student Enrolled? " + isEnrolled);

        // Declare and initialize a char variable for a grade
        char studentGrade = 'A';
        System.out.println("Student Grade: " + studentGrade);
        
        // You can also declare first, then assign
        int numberOfCourses; // Declaration
        numberOfCourses = 5; // Assignment
        System.out.println("Number of Courses: " + numberOfCourses);
    }
}

Example 2.2: Basic Operations with Variables

// Chapter2/VariableOperations.java
public class VariableOperations {
    public static void main(String[] args) {
        int apples = 10;
        int oranges = 5;
        int totalFruits = apples + oranges; // Addition
        System.out.println("Total fruits: " + totalFruits); // Output: 15

        double pricePerKg = 2.50;
        int weightKg = 3;
        double totalPrice = pricePerKg * weightKg; // Multiplication
        System.out.println("Total price: " + totalPrice); // Output: 7.5

        // Integer division vs. Double division
        int totalStudents = 30;
        int groupsOf = 7;
        int numberOfGroups = totalStudents / groupsOf; // Integer division truncates decimals
        System.out.println("Number of groups (int division): " + numberOfGroups); // Output: 4

        double totalScore = 95.5;
        double maxScore = 100.0;
        double percentage = (totalScore / maxScore) * 100; // Division and multiplication
        System.out.println("Percentage: " + percentage + "%"); // Output: 95.5%

        // Reassigning values
        apples = 12; // Update the value of 'apples'
        System.out.println("Updated apples: " + apples); // Output: 12
    }
}

Example 2.3: Type Interactions and Limitations

// Chapter2/TypeInteractions.java
public class TypeInteractions {
    public static void main(String[] args) {
        int score = 85;
        double percentage = 0.85;

        // You can use different types in calculations, Java will often "promote" the smaller type
        // 'score' (int) is promoted to double for the multiplication
        double finalValue = score * percentage; 
        System.out.println("Final Value (int * double): " + finalValue); // Output: 72.25

        // Implicit type conversion (widening conversion - safe)
        // An int can be assigned to a double without loss of information
        double scoreAsDouble = score;
        System.out.println("Score as double: " + scoreAsDouble); // Output: 85.0

        // Explicit type casting (narrowing conversion - potentially loses information)
        // You cannot directly assign a double to an int without a cast.
        // The decimal part will be truncated.
        int roundedScore = (int) finalValue; // Cast 'finalValue' (double) to int
        System.out.println("Rounded Score (int cast): " + roundedScore); // Output: 72

        // Boolean values can't be directly converted to numbers
        boolean isComplete = true;
        // int status = isComplete; // This would cause a compile-time error!
        System.out.println("Is complete: " + isComplete);
        
        char initial = 'J';
        System.out.println("My initial: " + initial);
        
        // Chars are actually stored as numbers (ASCII/Unicode values).
        // You can perform arithmetic on them.
        char nextChar = (char)(initial + 1); // 'J' is 74, so 74 + 1 = 75 which is 'K'
        System.out.println("Next char after 'J': " + nextChar); // Output: K
    }
}

3. Line-by-Line Breakdown

Let's look at PrimitiveVariables.java:

public class PrimitiveVariables {
    public static void main(String[] args) {
        int studentAge = 19; 
        // 'int': This is the data type. It specifies that 'studentAge' will hold whole numbers.
        // 'studentAge': This is the variable name. It's descriptive and follows Java's naming conventions.
        // '=': This is the assignment operator. It assigns the value on the right to the variable on the left.
        // '19': This is the literal value (an integer) being assigned to 'studentAge'.
        // ';': The semicolon terminates the statement. Every statement in Java must end with one.

        System.out.println("Student Age: " + studentAge);
        // We use the '+' operator here for string concatenation. It joins the string literal
        // "Student Age: " with the current value of the 'studentAge' variable (which is 19).
        // The result is a single string "Student Age: 19" which is then printed.

        double studentGPA = 3.85;
        // 'double': Data type for floating-point numbers (numbers with decimals).
        // '3.85': A double literal.

        boolean isEnrolled = true;
        // 'boolean': Data type that can only hold 'true' or 'false'.
        // 'true': A boolean literal.

        char studentGrade = 'A';
        // 'char': Data type for a single character.
        // `'A'`: A character literal, enclosed in single quotes.

        int numberOfCourses; // Declaration
        // Here, we just declare the variable 'numberOfCourses' of type 'int'.
        // It doesn't have a value yet (it will have a default value of 0, but it's good practice to assign explicitly).

        numberOfCourses = 5; // Assignment
        // Now we assign the value '5' to the already declared variable 'numberOfCourses'.
    }
}

4. Clean Code Pro-Tips

5. Unsolved Exercise: Student Profile

Your task is to create a Java program that defines a simple profile for a student.

  1. Create a class named StudentProfile.
  2. Declare and initialize variables for:
    • The student's age (an int).
    • The student's height in meters (a double).
    • Whether the student is active in sports (a boolean).
    • The student's firstInitial of their name (a char).
  3. Print each of these variables to the console, clearly labeled.
  4. Then, update the student's age (e.g., add 1 year) and print the new age.

6. Complete Solution: Student Profile

// Chapter2/StudentProfile.java
public class StudentProfile {
    public static void main(String[] args) {
        // 1. Declare and initialize student profile variables
        int studentAge = 18; // Student's age in years
        double studentHeightMeters = 1.75; // Student's height in meters
        boolean isActiveInSports = true; // Is the student active in sports?
        char firstInitial = 'A'; // First initial of the student's name

        // 2. Print each variable with clear labels
        System.out.println("--- Student Profile ---");
        System.out.println("Age: " + studentAge + " years");
        System.out.println("Height: " + studentHeightMeters + " meters");
        System.out.println("Active in Sports: " + isActiveInSports);
        System.out.println("First Initial: " + firstInitial);
        System.out.println("-----------------------");

        // 3. Update the student's age and print the new age
        studentAge = studentAge + 1; // Or studentAge++; which we'll see next chapter!
        System.out.println("\n--- After One Year ---");
        System.out.println("New Age: " + studentAge + " years");
        System.out.println("----------------------");
    }
}

Chapter 3: Basic Operators.

1. Brief Theory: Performing Actions

Operators are special symbols that tell the Java compiler to perform specific mathematical, relational, or logical operations and produce a result. They are the verbs of your programming language, allowing you to manipulate variables and values.

2. Professional Code: Operators in Practice

Example 3.1: Arithmetic Operators

// Chapter3/ArithmeticOperations.java
public class ArithmeticOperations {
    public static void main(String[] args) {
        int num1 = 20;
        int num2 = 6;

        // Addition
        int sum = num1 + num2;
        System.out.println("Sum: " + sum); // Output: 26

        // Subtraction
        int difference = num1 - num2;
        System.out.println("Difference: " + difference); // Output: 14

        // Multiplication
        int product = num1 * num2;
        System.out.println("Product: " + product); // Output: 120

        // Division (Integer Division vs. Double Division)
        int quotientInt = num1 / num2; // Both are int, so result is int (truncates)
        System.out.println("Quotient (int): " + quotientInt); // Output: 3 (20 / 6 = 3 with remainder 2)

        double quotientDouble = (double) num1 / num2; // Cast one operand to double for float division
        System.out.println("Quotient (double): " + quotientDouble); // Output: 3.3333333333333335

        // Modulo (Remainder)
        int remainder = num1 % num2;
        System.out.println("Remainder: " + remainder); // Output: 2

        // Combined operations with precedence
        int result = num1 + num2 * 2; // num2 * 2 happens first (6*2=12), then num1 + 12 (20+12=32)
        System.out.println("Result (num1 + num2 * 2): " + result); // Output: 32

        int resultWithParentheses = (num1 + num2) * 2; // Parentheses force addition first (20+6=26), then 26*2=52
        System.out.println("Result ((num1 + num2) * 2): " + resultWithParentheses); // Output: 52
    }
}

Example 3.2: Increment and Decrement Operators

// Chapter3/IncrementDecrement.java
public class IncrementDecrement {
    public static void main(String[] args) {
        int counter = 5;

        System.out.println("Initial counter: " + counter); // Output: 5

        // Postfix Increment: Use current value, then increment
        int postIncResult = counter++; 
        System.out.println("After post-increment (postIncResult): " + postIncResult); // Output: 5 (used current 5, THEN counter became 6)
        System.out.println("Counter after postfix: " + counter); // Output: 6

        // Prefix Increment: Increment, then use new value
        int preIncResult = ++counter;
        System.out.println("After pre-increment (preIncResult): " + preIncResult); // Output: 7 (counter became 7, THEN used 7)
        System.out.println("Counter after prefix: " + counter); // Output: 7

        // Decrement works similarly
        int value = 10;
        System.out.println("\nInitial value: " + value); // Output: 10

        // Postfix Decrement
        int postDecResult = value--;
        System.out.println("After post-decrement (postDecResult): " + postDecResult); // Output: 10
        System.out.println("Value after postfix: " + value); // Output: 9

        // Prefix Decrement
        int preDecResult = --value;
        System.out.println("After pre-decrement (preDecResult): " + preDecResult); // Output: 8
        System.out.println("Value after prefix: " + value); // Output: 8
        
        // Simple increment/decrement (when not part of an assignment)
        int score = 0;
        score++; // score becomes 1
        score--; // score becomes 0
        System.out.println("\nFinal score after simple increment/decrement: " + score); // Output: 0
    }
}

3. Line-by-Line Breakdown

Let's look at key lines from ArithmeticOperations.java and IncrementDecrement.java:

// From ArithmeticOperations.java
int quotientInt = num1 / num2;
// 'num1' is 20, 'num2' is 6.
// Since both 'num1' and 'num2' are 'int', Java performs integer division.
// 20 divided by 6 is 3 with a remainder of 2. The decimal part (.333...) is simply discarded.
// 'quotientInt' will store '3'.

double quotientDouble = (double) num1 / num2;
// '(double) num1': This is a "type cast". It temporarily converts the value of 'num1' (20) into a 'double' (20.0).
// Now, the expression is '20.0 / 6'. Since one operand is a 'double', Java performs floating-point division.
// The result is '3.3333333333333335'.
// 'quotientDouble' will store '3.3333333333333335'.

int remainder = num1 % num2;
// The '%' operator calculates the remainder after division.
// 20 divided by 6 is 3 with a remainder of 2.
// 'remainder' will store '2'.

int result = num1 + num2 * 2;
// Operator Precedence: Multiplication (*) has higher precedence than addition (+).
// First, 'num2 * 2' is calculated: 6 * 2 = 12.
// Then, 'num1 + 12' is calculated: 20 + 12 = 32.
// 'result' will store '32'.

int resultWithParentheses = (num1 + num2) * 2;
// Parentheses '()' explicitly override precedence. The expression inside parentheses is calculated first.
// First, '(num1 + num2)' is calculated: 20 + 6 = 26.
// Then, '26 * 2' is calculated: 52.
// 'resultWithParentheses' will store '52'.
// From IncrementDecrement.java
int postIncResult = counter++;
// 'counter' is currently 5.
// Postfix '++' means:
// 1. Use the *current* value of 'counter' (5) in the assignment to 'postIncResult'. So, 'postIncResult' becomes 5.
// 2. *Then*, increment 'counter' by 1. So, 'counter' becomes 6.

int preIncResult = ++counter;
// 'counter' is currently 6 (from the previous operation).
// Prefix '++' means:
// 1. Increment 'counter' by 1 *first*. So, 'counter' becomes 7.
// 2. *Then*, use the *new* value of 'counter' (7) in the assignment to 'preIncResult'. So, 'preIncResult' becomes 7.

4. Clean Code Pro-Tips

5. Unsolved Exercise: Budget Calculator

Imagine you're tracking a small budget.

  1. Create a class named BudgetCalculator.
  2. Declare an int variable for initialBudget and set it to 1000.
  3. Declare a double variable for itemPrice and set it to 25.50.
  4. Declare an int variable for quantity and set it to 3.
  5. Calculate the costOfItems (price * quantity) and store it in a double variable.
  6. Calculate the remainingBudget (initial budget - cost of items) and store it in a double variable.
  7. Increment a counter variable transactionCount (initialized to 0) using ++ after each calculation.
  8. Print the initialBudget, itemPrice, quantity, costOfItems, remainingBudget, and transactionCount with clear labels.
  9. Calculate and print how many times the itemPrice (as an int type) could be fully bought with the remainingBudget (also as an int type - requiring casts). Use the / operator for this.

6. Complete Solution: Budget Calculator

// Chapter3/BudgetCalculator.java
public class BudgetCalculator {
    public static void main(String[] args) {
        // 1. Declare and initialize variables
        int initialBudget = 1000;
        double itemPrice = 25.50;
        int quantity = 3;
        
        int transactionCount = 0; // Initialize transaction counter

        System.out.println("--- Budget Details ---");
        System.out.println("Initial Budget: $" + initialBudget);
        System.out.println("Item Price: $" + itemPrice);
        System.out.println("Quantity to buy: " + quantity);
        
        // 5. Calculate cost of items
        double costOfItems = itemPrice * quantity;
        System.out.println("Cost of " + quantity + " items: $" + costOfItems);
        transactionCount++; // Increment after calculation 1

        // 6. Calculate remaining budget
        double remainingBudget = initialBudget - costOfItems; // int - double results in double
        System.out.println("Remaining Budget: $" + remainingBudget);
        transactionCount++; // Increment after calculation 2
        
        // 7. Print transaction count
        System.out.println("Total transactions processed: " + transactionCount);

        // 9. Calculate how many more items can be bought with remaining budget
        // We need to cast remainingBudget and itemPrice to int for integer division,
        // which means we're only considering whole items.
        int itemsPossibleWithRemainingBudget = (int)remainingBudget / (int)itemPrice;
        System.out.println("\nWith remaining budget, " + itemsPossibleWithRemainingBudget + " more full items can be bought.");
    }
}

Chapter 4: Strings for Beginners.

1. Brief Theory: Working with Text

So far, we've dealt with numbers, single characters, and true/false values. But what about sequences of characters, like names, sentences, or paragraphs? That's where Strings come in!

2. Professional Code: Strings in Action

Example 4.1: String Declaration and Basic Methods

// Chapter4/StringBasics.java
public class StringBasics {
    public static void main(String[] args) {
        // String Literal - preferred way
        String courseName = "Java Programming Foundations";
        System.out.println("Course Name: " + courseName);

        // Get length of the string
        int nameLength = courseName.length();
        System.out.println("Length of Course Name: " + nameLength); // Output: 28

        // Convert to uppercase
        String upperCaseName = courseName.toUpperCase();
        System.out.println("Uppercase: " + upperCaseName); // Output: JAVA PROGRAMMING FOUNDATIONS

        // Convert to lowercase
        String lowerCaseName = courseName.toLowerCase();
        System.out.println("Lowercase: " + lowerCaseName); // Output: java programming foundations

        // Get character at a specific index (0-based)
        char firstChar = courseName.charAt(0);
        char lastChar = courseName.charAt(courseName.length() - 1);
        System.out.println("First Character: " + firstChar); // Output: J
        System.out.println("Last Character: " + lastChar); // Output: s

        // Find the index of a substring
        int progIndex = courseName.indexOf("Programming");
        System.out.println("Index of 'Programming': " + progIndex); // Output: 5

        // Check if a string contains another string
        boolean containsFoundations = courseName.contains("Foundations");
        System.out.println("Contains 'Foundations'? " + containsFoundations); // Output: true
    }
}

Example 4.2: == vs. .equals()

// Chapter4/StringComparison.java
public class StringComparison {
    public static void main(String[] args) {
        String s1 = "hello"; // String literal
        String s2 = "hello"; // String literal (references the same object in String Pool)
        String s3 = new String("hello"); // New String object on the Heap
        String s4 = "world"; // Different content

        System.out.println("s1: " + s1);
        System.out.println("s2: " + s2);
        System.out.println("s3: " + s3);
        System.out.println("s4: " + s4);
        System.out.println("--------------------");

        // Comparing s1 and s2 (both literals, same content)
        System.out.println("s1 == s2: " + (s1 == s2));           // Output: true (same object in pool)
        System.out.println("s1.equals(s2): " + s1.equals(s2));   // Output: true (same content)
        System.out.println("--------------------");

        // Comparing s1 and s3 (s1 literal, s3 new object, same content)
        System.out.println("s1 == s3: " + (s1 == s3));           // Output: false (different objects in memory)
        System.out.println("s1.equals(s3): " + s1.equals(s3));   // Output: true (same content)
        System.out.println("--------------------");
        
        // Comparing s3 and s3 (same object, same content)
        System.out.println("s3 == s3: " + (s3 == s3));           // Output: true
        System.out.println("s3.equals(s3): " + s3.equals(s3));   // Output: true
        System.out.println("--------------------");

        // Comparing s1 and s4 (different content)
        System.out.println("s1 == s4: " + (s1 == s4));           // Output: false
        System.out.println("s1.equals(s4): " + s1.equals(s4));   // Output: false
    }
}

Example 4.3: String Concatenation and Manipulation

// Chapter4/StringManipulation.java
public class StringManipulation {
    public static void main(String[] args) {
        String firstName = "Alice";
        String lastName = "Smith";

        // Concatenation using '+' operator (most common and readable)
        String fullName = firstName + " " + lastName;
        System.out.println("Full Name: " + fullName); // Output: Alice Smith

        // Concatenation using .concat() method
        String greeting = "Hello ".concat(firstName).concat("!");
        System.out.println("Greeting: " + greeting); // Output: Hello Alice!

        // Replacing characters
        String originalText = "Java is fun!";
        String replacedText = originalText.replace('a', '@');
        System.out.println("Original: " + originalText); // Output: Java is fun!
        System.out.println("Replaced 'a' with '@': " + replacedText); // Output: J@v@ is fun!
        
        // Substring
        String message = "Welcome to Java Programming";
        String sub1 = message.substring(0, 7); // From index 0 up to (but not including) index 7
        String sub2 = message.substring(11);    // From index 11 to the end
        System.out.println("Substring (0, 7): " + sub1); // Output: Welcome
        System.out.println("Substring (11): " + sub2);   // Output: Java Programming
        
        // Trimming whitespace
        String messyString = "   Hello World   ";
        String trimmedString = messyString.trim();
        System.out.println("Messy: '" + messyString + "'");
        System.out.println("Trimmed: '" + trimmedString + "'");
    }
}

3. Line-by-Line Breakdown

Let's break down key lines from StringComparison.java and StringManipulation.java:

// From StringComparison.java
String s1 = "hello"; 
String s2 = "hello"; 
// Both 's1' and 's2' are String literals. Java's String Pool optimization means
// they both point to the *exact same "hello"* object in memory.

String s3 = new String("hello");
// Using 'new String("hello")' explicitly creates a *brand new* String object on the Heap.
// Even though its content is "hello", it is a different object in memory than the one 's1' and 's2' point to.

System.out.println("s1 == s2: " + (s1 == s2));
// s1 and s2 both point to the *same* memory location (the same object in the String Pool).
// So, '==' (memory address comparison) returns 'true'.

System.out.println("s1.equals(s2): " + s1.equals(s2));
// The '.equals()' method compares the *content* of the strings.
// "hello" is equal to "hello". So, this returns 'true'.

System.out.println("s1 == s3: " + (s1 == s3));
// 's1' points to the "hello" in the String Pool.
// 's3' points to a *new* "hello" object created outside the String Pool.
// They are *different objects in different memory locations*. So, '==' returns 'false'.

System.out.println("s1.equals(s3): " + s1.equals(s3));
// The '.equals()' method compares the *content*.
// The content of 's1' ("hello") is the same as the content of 's3' ("hello").
// So, this returns 'true'.
// From StringManipulation.java
String fullName = firstName + " " + lastName;
// The '+' operator acts as a concatenation operator when used with strings.
// It joins the string 'firstName' ("Alice"), the string literal " ", and the string 'lastName' ("Smith")
// into a single new string "Alice Smith".

String replacedText = originalText.replace('a', '@');
// '.replace(char oldChar, char newChar)' is a method of the String class.
// It searches the 'originalText' ("Java is fun!") for every occurrence of the character 'a'.
// It then creates a *new* string where each 'a' is replaced by '@'.
// Note: Strings are immutable. This method doesn't change 'originalText'; it returns a *new* string.

String sub1 = message.substring(0, 7);
// '.substring(int beginIndex, int endIndex)' extracts a portion of the string.
// 'beginIndex' (0) is inclusive: the character at index 0 ('W') is included.
// 'endIndex' (7) is exclusive: the character at index 7 (the space after 'e' in 'Welcome') is *not* included.
// The result is "Welcome".

4. Clean Code Pro-Tips

5. Unsolved Exercise: Message Processor

You've received a secret message!

  1. Create a class named MessageProcessor.
  2. Declare two String variables: word1 with the value "secret" and word2 with the value "java".
  3. Declare a third String variable secretMessage as a literal "The secret code is java".
  4. Compare word1 and "SECRET" using .equals(). What is the result? Why?
  5. Compare word2 and the substring "java" extracted from secretMessage using both == and .equals(). Print both results and explain the difference.
  6. Concatenate word1, word2 (in that order, separated by a space) to form a new string combinedWord. Print it.
  7. Print secretMessage in all uppercase.
  8. Find the index of the word "code" in secretMessage and print it.
  9. Replace all occurrences of 'e' with 'E' in secretMessage and print the new message.

6. Complete Solution: Message Processor

// Chapter4/MessageProcessor.java
public class MessageProcessor {
    public static void main(String[] args) {
        // 1. Declare String variables
        String word1 = "secret";
        String word2 = "java";
        String secretMessage = "The secret code is java"; // Literal

        System.out.println("--- Message Processing ---");
        System.out.println("word1: " + word1);
        System.out.println("word2: " + word2);
        System.out.println("secretMessage: " + secretMessage);
        System.out.println("--------------------------");

        // 4. Compare word1 and "SECRET" using .equals()
        boolean equalsCaseSensitive = word1.equals("SECRET");
        System.out.println("Does 'word1' equal 'SECRET' (case-sensitive)? " + equalsCaseSensitive);
        // Explanation: It's false because .equals() is case-sensitive. 'secret' != 'SECRET'.
        // If we wanted to compare case-insensitively, we'd use word1.equalsIgnoreCase("SECRET").

        // 5. Compare word2 and substring "java" from secretMessage
        String subFromSecretMessage = secretMessage.substring(19); // "java" starts at index 19
        System.out.println("\nSubstring 'java' from secretMessage: " + subFromSecretMessage);

        boolean equalsOperatorComparison = (word2 == subFromSecretMessage);
        System.out.println("word2 == subFromSecretMessage: " + equalsOperatorComparison);
        // Explanation: False. 'word2' is a literal from the String Pool.
        // 'subFromSecretMessage' is a *new* String object created by the substring() method (it's not from the pool).
        // They are different objects in memory.

        boolean equalsMethodComparison = word2.equals(subFromSecretMessage);
        System.out.println("word2.equals(subFromSecretMessage): " + equalsMethodComparison);
        // Explanation: True. The content of both strings is "java".

        // 6. Concatenate word1 and word2
        String combinedWord = word1 + " " + word2;
        System.out.println("\nCombined Word: " + combinedWord); // Output: secret java

        // 7. Print secretMessage in all uppercase
        System.out.println("Secret Message in Uppercase: " + secretMessage.toUpperCase());

        // 8. Find the index of "code"
        int indexOfCode = secretMessage.indexOf("code");
        System.out.println("Index of 'code': " + indexOfCode); // Output: 11

        // 9. Replace 'e' with 'E' in secretMessage
        String replacedMessage = secretMessage.replace('e', 'E');
        System.out.println("Message with 'e' replaced by 'E': " + replacedMessage);
    }
}

Chapter 5: Conditionals (IF/ELSE).

1. Brief Theory: Making Decisions

Life is full of decisions, and so is programming! Conditional statements allow your program to execute different blocks of code based on whether certain conditions are true or false. This is how your programs become dynamic and responsive.

2. Professional Code: Conditionals in Action

Example 5.1: Simple if and if-else

// Chapter5/SimpleConditionals.java
public class SimpleConditionals {
    public static void main(String[] args) {
        int score = 75;
        int passingScore = 60;

        // Simple if statement
        if (score > passingScore) {
            System.out.println("Congratulations! You passed the exam.");
        }

        // if-else statement
        if (score >= 80) {
            System.out.println("You got a good grade!");
        } else {
            System.out.println("You might want to review the material.");
        }

        System.out.println("Your score: " + score);

        // Example with boolean variable
        boolean isLoggedIn = false;
        if (isLoggedIn) {
            System.out.println("Welcome back, user!");
        } else {
            System.out.println("Please log in to continue.");
        }
    }
}

Example 5.2: if-else if-else Chain with Logical Operators

// Chapter5/GradingSystem.java
public class GradingSystem {
    public static void main(String[] args) {
        int studentScore = 88;
        char grade;

        if (studentScore >= 90) {
            grade = 'A';
            System.out.println("Excellent! Grade: " + grade);
        } else if (studentScore >= 80) { // studentScore is less than 90 AND greater than or equal to 80
            grade = 'B';
            System.out.println("Very good! Grade: " + grade);
        } else if (studentScore >= 70) { // studentScore is less than 80 AND greater than or equal to 70
            grade = 'C';
            System.out.println("Good effort! Grade: " + grade);
        } else if (studentScore >= 60) { // studentScore is less than 70 AND greater than or equal to 60
            grade = 'D';
            System.out.println("Pass! Grade: " + grade);
        } else { // All other cases (studentScore < 60)
            grade = 'F';
            System.out.println("Unfortunately, you failed. Grade: " + grade);
        }
        
        System.out.println("Final Grade: " + grade);

        // Example with logical operators: Eligibility check
        int age = 20;
        boolean isCitizen = true;
        
        if (age >= 18 && isCitizen) { // Both conditions must be true
            System.out.println("You are eligible to vote.");
        } else {
            System.out.println("You are NOT eligible to vote.");
        }

        boolean hasLicense = false;
        boolean hasVehicle = true;
        
        if (hasLicense || hasVehicle) { // At least one condition must be true
            System.out.println("You have either a license or a vehicle (or both).");
        } else {
            System.out.println("You have neither a license nor a vehicle.");
        }
        
        boolean isSunny = true;
        if (!isSunny) { // Not sunny means it's not true (so it's false)
            System.out.println("It's not sunny today. Might rain!");
        } else {
            System.out.println("It's sunny today. Enjoy!");
        }
    }
}

Example 5.3: Ternary Operator

// Chapter5/TernaryOperatorExample.java
public class TernaryOperatorExample {
    public static void main(String[] args) {
        int temperature = 25;
        String weatherStatus = (temperature > 20) ? "Warm" : "Cool";
        System.out.println("Weather Status: " + weatherStatus); // Output: Warm

        temperature = 15;
        weatherStatus = (temperature > 20) ? "Warm" : "Cool";
        System.out.println("Weather Status: " + weatherStatus); // Output: Cool
        
        // Another example: check if a number is even or odd
        int number = 7;
        String parity = (number % 2 == 0) ? "Even" : "Odd";
        System.out.println(number + " is " + parity); // Output: 7 is Odd

        number = 10;
        parity = (number % 2 == 0) ? "Even" : "Odd";
        System.out.println(number + " is " + parity); // Output: 10 is Even
        
        // Ternary operator can also be used directly in print statements
        System.out.println("Is " + number + " positive? " + (number > 0 ? "Yes" : "No"));
    }
}

3. Line-by-Line Breakdown

Let's break down key lines from GradingSystem.java and TernaryOperatorExample.java:

// From GradingSystem.java
if (studentScore >= 90) {
    grade = 'A';
    System.out.println("Excellent! Grade: " + grade);
} else if (studentScore >= 80) { 
    // This 'else if' block is only reached if 'studentScore >= 90' was FALSE.
    // So, effectively, this condition checks if (studentScore < 90 AND studentScore >= 80).
    // This implicit chaining is why the order of 'else if' statements matters!
    grade = 'B';
    System.out.println("Very good! Grade: " + grade);
}
// ... and so on for other else if blocks.

if (age >= 18 && isCitizen) {
    // 'age >= 18': This is a boolean expression (e.g., 20 >= 18 is true).
    // 'isCitizen': This is a boolean variable (e.g., true).
    // '&&' (Logical AND): The entire condition (age >= 18 && isCitizen) is true ONLY if BOTH
    // 'age >= 18' is true AND 'isCitizen' is true.
    // If age is 20 (true) and isCitizen is true (true), then true && true is true.
    System.out.println("You are eligible to vote.");
}

if (hasLicense || hasVehicle) {
    // '||' (Logical OR): The entire condition is true if AT LEAST ONE of the
    // 'hasLicense' or 'hasVehicle' expressions is true.
    // If hasLicense is false and hasVehicle is true, then false || true is true.
    System.out.println("You have either a license or a vehicle (or both).");
}

if (!isSunny) {
    // '!' (Logical NOT): Inverts the boolean value.
    // If 'isSunny' is true, then '!isSunny' becomes false.
    // If 'isSunny' is false, then '!isSunny' becomes true.
    // Here, if 'isSunny' is true, the condition '!isSunny' evaluates to false, so this block is skipped.
}
// From TernaryOperatorExample.java
String weatherStatus = (temperature > 20) ? "Warm" : "Cool";
// This is the ternary operator. It's a shorthand for a simple if-else that assigns a value.
// (temperature > 20): This is the boolean condition.
// If true, the value "Warm" is assigned to 'weatherStatus'.
// If false, the value "Cool" is assigned to 'weatherStatus'.
// If temperature is 25, (25 > 20) is true, so "Warm" is assigned.
// If temperature is 15, (15 > 20) is false, so "Cool" is assigned.

4. Clean Code Pro-Tips

5. Unsolved Exercise: Eligibility Checker

Let's create a program that checks various eligibility criteria.

  1. Create a class named EligibilityChecker.
  2. Declare an int variable candidateAge and set it to 22.
  3. Declare a double variable gpa and set it to 3.5.
  4. Declare a boolean variable hasCriminalRecord and set it to false.
  5. Use an if-else if-else chain to determine if the candidate is eligible for a scholarship:
    • If candidateAge is less than 18, print "Too young for scholarship."
    • Else if gpa is less than 3.0, print "GPA too low for scholarship."
    • Else if hasCriminalRecord is true, print "Ineligible due to criminal record."
    • Else, print "Candidate is eligible for scholarship!"
  6. Use a ternary operator to set a String variable admissionStatus. If candidateAge is greater than or equal to 18 AND gpa is greater than or equal to 2.5, admissionStatus should be "Admitted", otherwise "Denied". Print admissionStatus.

6. Complete Solution: Eligibility Checker

// Chapter5/EligibilityChecker.java
public class EligibilityChecker {
    public static void main(String[] args) {
        // 2. Declare and initialize variables
        int candidateAge = 22;
        double gpa = 3.5;
        boolean hasCriminalRecord = false;

        System.out.println("--- Scholarship Eligibility Check ---");
        System.out.println("Candidate Age: " + candidateAge);
        System.out.println("GPA: " + gpa);
        System.out.println("Has Criminal Record: " + hasCriminalRecord);
        System.out.println("------------------------------------");

        // 5. Scholarship eligibility check using if-else if-else
        if (candidateAge < 18) {
            System.out.println("Result: Too young for scholarship.");
        } else if (gpa < 3.0) {
            System.out.println("Result: GPA too low for scholarship.");
        } else if (hasCriminalRecord) { // hasCriminalRecord == true is redundant
            System.out.println("Result: Ineligible due to criminal record.");
        } else {
            System.out.println("Result: Candidate is eligible for scholarship!");
        }

        System.out.println("\n--- Admission Status Check ---");
        // 6. Use ternary operator for admission status
        String admissionStatus = (candidateAge >= 18 && gpa >= 2.5) ? "Admitted" : "Denied";
        System.out.println("Admission Status: " + admissionStatus);
    }
}

Chapter 6: The Modern Switch.

1. Brief Theory: Streamlining Decisions

When you have many else if conditions that all check the same variable against different discrete values, an if-else if-else chain can become long and cumbersome. The switch statement provides a cleaner and more readable alternative for such scenarios.

2. Professional Code: Modern Switch Examples

Example 6.1: Day of the Week (Modern Switch Statement)

// Chapter6/DayOfWeekModern.java
public class DayOfWeekModern {
    public static void main(String[] args) {
        int dayNumber = 3; // 1 for Monday, 7 for Sunday
        
        System.out.println("--- Day of Week Checker ---");
        System.out.println("Day Number: " + dayNumber);

        switch (dayNumber) {
            case 1 -> System.out.println("It's Monday. Time to start the week!");
            case 2 -> System.out.println("It's Tuesday. Keep up the good work!");
            case 3 -> System.out.println("It's Wednesday. Mid-week already!");
            case 4 -> System.out.println("It's Thursday. Almost there!");
            case 5 -> System.out.println("It's Friday. Weekend is calling!");
            case 6 -> System.out.println("It's Saturday. Enjoy your day off!");
            case 7 -> System.out.println("It's Sunday. Relax and recharge.");
            default -> System.out.println("Invalid day number. Please use 1-7.");
        }
        System.out.println("---------------------------");
    }
}

Example 6.2: Assigning a Value with Modern Switch Expression

// Chapter6/MonthNameSwitchExpression.java
public class MonthNameSwitchExpression {
    public static void main(String[] args) {
        int month = 7; // July
        
        String monthName = switch (month) {
            case 1 -> "January";
            case 2 -> "February";
            case 3 -> "March";
            case 4 -> "April";
            case 5 -> "May";
            case 6 -> "June";
            case 7 -> "July";
            case 8 -> "August";
            case 9 -> "September";
            case 10 -> "October";
            case 11 -> "November";
            case 12 -> "December";
            default -> "Invalid Month";
        }; // Semicolon is required when used as an expression!

        System.out.println("Month number " + month + " is: " + monthName); // Output: Month number 7 is: July

        month = 13;
        monthName = switch (month) {
            case 1 -> "January";
            case 2 -> "February";
            case 3 -> "March";
            case 4 -> "April";
            case 5 -> "May";
            case 6 -> "June";
            case 7 -> "July";
            case 8 -> "August";
            case 9 -> "September";
            case 10 -> "October";
            case 11 -> "November";
            case 12 -> "December";
            default -> "Invalid Month";
        };
        System.out.println("Month number " + month + " is: " + monthName); // Output: Month number 13 is: Invalid Month
    }
}

Example 6.3: Multiple Case Labels and Block of Code

// Chapter6/SeasonChecker.java
public class SeasonChecker {
    public static void main(String[] args) {
        int monthNumber = 11; // November
        
        String season = switch (monthNumber) {
            case 12, 1, 2 -> "Winter"; // Multiple labels for the same result
            case 3, 4, 5 -> "Spring";
            case 6, 7, 8 -> "Summer";
            case 9, 10, 11 -> "Autumn (Fall)";
            default -> "Unknown";
        };

        System.out.println("Month " + monthNumber + " is in the " + season + " season."); // Output: Month 11 is in the Autumn (Fall) season.
        
        char grade = 'B';
        String gradeDescription = switch (grade) {
            case 'A', 'a' -> "Excellent work!"; // Handle both upper and lower case
            case 'B', 'b' -> "Good job!";
            case 'C', 'c' -> "You passed.";
            case 'D', 'd', 'F', 'f' -> { // A block of code for a case
                System.out.println("--- Action Needed ---");
                // You can have multiple statements here
                yield "Needs improvement."; // 'yield' is used to return a value from a block in a switch expression
            }
            default -> "Invalid Grade";
        };
        System.out.println("For grade " + grade + ": " + gradeDescription);
    }
}

3. Line-by-Line Breakdown

Let's break down key lines from DayOfWeekModern.java and SeasonChecker.java:

// From DayOfWeekModern.java
switch (dayNumber) {
    // 'switch (dayNumber)': The value of 'dayNumber' (which is 3 in this example) is evaluated.
    // The control flow then jumps to the 'case' label that matches this value.

    case 1 -> System.out.println("It's Monday. Time to start the week!");
    // 'case 1': This label specifies that if 'dayNumber' is 1, the code to its right should be executed.
    // '->': The arrow operator. This indicates that the code on the right is the body of this case.
    // Importantly, after this line executes, the 'switch' statement is exited automatically (no fall-through).

    case 3 -> System.out.println("It's Wednesday. Mid-week already!");
    // In our example, 'dayNumber' is 3, so this case matches.
    // The System.out.println() statement is executed.
    // After this, the switch statement finishes.

    default -> System.out.println("Invalid day number. Please use 1-7.");
    // 'default': If none of the 'case' labels match the value of 'dayNumber',
    // the code associated with the 'default' label is executed. This acts as a catch-all.
}
// From SeasonChecker.java
String season = switch (monthNumber) {
    // Here, the 'switch' is used as an *expression*. This means it computes a value
    // that is then assigned to the 'season' variable.

    case 12, 1, 2 -> "Winter";
    // Multiple 'case' labels separated by commas. If 'monthNumber' is 12, 1, or 2,
    // the string literal "Winter" is the value produced by this 'switch' expression.

    case 9, 10, 11 -> "Autumn (Fall)";
    // In our example, 'monthNumber' is 11, so this case matches.
    // The value "Autumn (Fall)" is produced by the switch expression and assigned to 'season'.

    case 'D', 'd', 'F', 'f' -> {
        // Here, a case contains a *block of code* (enclosed in curly braces {}).
        // This is useful if you need to perform multiple operations for a specific case.
        System.out.println("--- Action Needed ---"); // This line is executed.
        yield "Needs improvement."; // 'yield' is a keyword used in switch *expressions* (when the case has a block)
                                   // to explicitly specify the value that the switch expression should return.
    }
}; // Don't forget the semicolon here when using switch as an expression!

4. Clean Code Pro-Tips

5. Unsolved Exercise: Simple Command Processor

Imagine you're building a simple command-line tool.

  1. Create a class named CommandProcessor.
  2. Declare a String variable command and set its value to "start".
  3. Use a modern switch statement (not an expression for this part) to process the command:
    • If command is "start", print "Starting service...".
    • If command is "stop", print "Stopping service...".
    • If command is "restart", print "Restarting service. Please wait...".
    • For any other command, print "Unknown command: [command]".
  4. Now, declare an int variable statusCode and set it to 1.
  5. Use a modern switch expression to determine a String variable statusMessage based on statusCode:
    • If statusCode is 0, statusMessage should be "Success".
    • If statusCode is 1 or 2, statusMessage should be "Warning".
    • If statusCode is 3, statusMessage should be "Error: Critical".
    • For any other statusCode, statusMessage should be "Error: Unknown Code".
  6. Print the final statusMessage.

6. Complete Solution: Simple Command Processor

// Chapter6/CommandProcessor.java
public class CommandProcessor {
    public static void main(String[] args) {
        // Part 1: Processing commands using a switch statement
        String command = "start"; // Try changing this to "stop", "restart", or "status"
        System.out.println("--- Command Processing ---");
        System.out.println("Received command: " + command);

        switch (command) {
            case "start" -> System.out.println("Starting service...");
            case "stop" -> System.out.println("Stopping service...");
            case "restart" -> System.out.println("Restarting service. Please wait...");
            default -> System.out.println("Unknown command: " + command);
        }
        System.out.println("--------------------------");

        // Part 2: Determining status message using a switch expression
        int statusCode = 1; // Try changing this to 0, 2, 3, or 99
        System.out.println("\n--- Status Code Processing ---");
        System.out.println("Status Code: " + statusCode);

        String statusMessage = switch (statusCode) {
            case 0 -> "Success";
            case 1, 2 -> "Warning"; // Multiple labels for the same result
            case 3 -> "Error: Critical";
            default -> "Error: Unknown Code";
        }; // Semicolon is required here for switch expressions!

        System.out.println("Status Message: " + statusMessage);
        System.out.println("------------------------------");
    }
}

Chapter 7: Loops (FOR / WHILE).

1. Brief Theory: Repeating Actions

Many programming tasks involve repeating a set of instructions multiple times. Instead of writing the same code over and over, we use loops. Loops allow you to execute a block of code repeatedly until a certain condition is met.

2. Professional Code: Loops in Action

Example 7.1: Basic for Loops

// Chapter7/ForLoopBasics.java
public class ForLoopBasics {
    public static void main(String[] args) {
        System.out.println("--- Counting Up (1 to 5) ---");
        // Loop to count from 1 to 5
        for (int i = 1; i <= 5; i++) {
            System.out.println("Count: " + i);
        }

        System.out.println("\n--- Counting Down (10 to 0 by 2) ---");
        // Loop to count down from 10 to 0, decrementing by 2
        for (int j = 10; j >= 0; j -= 2) { // j -= 2 is same as j = j - 2
            System.out.println("Countdown: " + j);
        }

        System.out.println("\n--- Calculating Sum (1 to 10) ---");
        // Calculate the sum of numbers from 1 to 10
        int sum = 0;
        for (int k = 1; k <= 10; k++) {
            sum += k; // sum += k is same as sum = sum + k
        }
        System.out.println("Sum of 1 to 10: " + sum); // Output: 55
    }
}

Example 7.2: Basic while Loops

// Chapter7/WhileLoopBasics.java
public class WhileLoopBasics {
    public static void main(String[] args) {
        System.out.println("--- Simple While Loop (Counting) ---");
        int count = 0; // Initialization
        while (count < 5) { // Condition
            System.out.println("While Count: " + count);
            count++; // Update: crucial for avoiding infinite loop
        }

        System.out.println("\n--- Guessing Game (Conceptual) ---");
        // Imagine a secret number is 7
        int secretGuess = 7;
        int userGuess = 0; // Initialize with a non-secret value
        int attempts = 0;

        // This would normally involve user input (Chapter 8), but for now, we simulate
        // Let's say user guesses 3, then 5, then 7
        int[] simulatedGuesses = {3, 5, 7};
        int guessIndex = 0;

        while (userGuess != secretGuess && guessIndex < simulatedGuesses.length) {
            userGuess = simulatedGuesses[guessIndex]; // Simulate getting a guess
            attempts++;
            System.out.println("Attempt " + attempts + ": Guessed " + userGuess);

            if (userGuess != secretGuess) {
                System.out.println("Incorrect guess. Try again.");
            }
            guessIndex++; // Move to the next simulated guess
        }

        if (userGuess == secretGuess) {
            System.out.println("Congratulations! You guessed the secret number " + secretGuess + " in " + attempts + " attempts.");
        } else {
            System.out.println("Ran out of simulated guesses without finding the secret number.");
        }
    }
}

Example 7.3: Avoiding Infinite Loops

// Chapter7/InfiniteLoopPrevention.java
public class InfiniteLoopPrevention {
    public static void main(String[] args) {
        // --- Correctly terminating while loop ---
        int timer = 3;
        System.out.println("--- Starting Timer ---");
        while (timer > 0) {
            System.out.println("Timer: " + timer);
            timer--; // IMPORTANT: Decrementing 'timer' makes the condition eventually false
        }
        System.out.println("--- Timer Done ---");

        // --- Example of a POTENTIAL infinite loop (do NOT run uncommented without care!) ---
        /*
        int neverEnding = 1;
        while (neverEnding > 0) { // Condition will always be true
            System.out.println("I'm stuck! " + neverEnding);
            // neverEnding++; // If you uncomment this, it will run forever (or until int overflows)
                           // because neverEnding keeps growing and > 0 remains true.
                           // Even if you don't modify it, if it starts > 0, it stays > 0.
        }
        */
        // To fix the above: ensure 'neverEnding' is modified to eventually be <= 0.
        // For example:
        int controlledLoop = 1;
        System.out.println("\n--- Controlled Loop ---");
        while (controlledLoop <= 5) { // Condition: controlledLoop must be <= 5
            System.out.println("Controlled: " + controlledLoop);
            controlledLoop++; // This increments controlledLoop, making it eventually > 5.
        }
        System.out.println("--- Controlled Loop Done ---");
    }
}

3. Line-by-Line Breakdown

Let's break down key lines from ForLoopBasics.java and WhileLoopBasics.java:

// From ForLoopBasics.java
for (int i = 1; i <= 5; i++) {
    // 'int i = 1;': Initialization. A new integer variable 'i' is declared and set to 1. This happens once.
    // 'i <= 5;': Condition. Before each iteration, this is checked. If 'i' is less than or equal to 5, the loop continues.
    // 'i++': Update. After each iteration (after the code inside the curly braces runs), 'i' is incremented by 1.
    System.out.println("Count: " + i);
    // This line is executed in each iteration.
    // Iteration 1: i=1 -> "Count: 1"
    // Iteration 2: i=2 -> "Count: 2"
    // ...
    // Iteration 5: i=5 -> "Count: 5"
    // After Iteration 5: i becomes 6. Condition (6 <= 5) is false. Loop terminates.
}

int sum = 0;
for (int k = 1; k <= 10; k++) {
    sum += k; // This is a shorthand for sum = sum + k;
    // Iteration 1: k=1, sum = 0 + 1 = 1
    // Iteration 2: k=2, sum = 1 + 2 = 3
    // Iteration 3: k=3, sum = 3 + 3 = 6
    // ... and so on until k=10, sum becomes 55.
}
// From WhileLoopBasics.java
int count = 0;
while (count < 5) {
    // 'while (count < 5)': Condition. This is checked at the start of each potential iteration.
    // As long as 'count' is less than 5, the code inside the loop will execute.
    System.out.println("While Count: " + count);
    count++;
    // 'count++': This is the update step. It's *inside* the loop body.
    // This statement is absolutely critical. Without it, 'count' would always remain 0,
    // the condition 'count < 5' would always be true, and the loop would run forever (infinite loop).
}
// Iteration 1: count=0. (0 < 5) is true. Prints "While Count: 0". count becomes 1.
// Iteration 2: count=1. (1 < 5) is true. Prints "While Count: 1". count becomes 2.
// ...
// Iteration 5: count=4. (4 < 5) is true. Prints "While Count: 4". count becomes 5.
// After Iteration 5: count=5. (5 < 5) is false. Loop terminates.

4. Clean Code Pro-Tips

5. Unsolved Exercise: Repetitive Tasks

Let's practice both types of loops with some common scenarios.

  1. Create a class named RepetitiveTasks.
  2. for loop task: Use a for loop to print all even numbers from 2 up to 20 (inclusive).
  3. while loop task: Use a while loop to simulate a countdown from 5 down to 1. After the countdown, print "Lift off!".
  4. for loop task (Challenge): Use a for loop to print a multiplication table for the number 7 (from 7 * 1 to 7 * 10).

6. Complete Solution: Repetitive Tasks

// Chapter7/RepetitiveTasks.java
public class RepetitiveTasks {
    public static void main(String[] args) {
        // 2. For loop to print even numbers from 2 to 20
        System.out.println("--- Even Numbers (2-20) ---");
        for (int i = 2; i <= 20; i += 2) { // Start at 2, increment by 2
            System.out.println(i);
        }
        System.out.println("---------------------------\n");

        // 3. While loop for a countdown
        System.out.println("--- Countdown ---");
        int countdown = 5;
        while (countdown >= 1) { // Condition: as long as countdown is 1 or more
            System.out.println(countdown);
            countdown--; // Decrement: crucial to make the condition eventually false
        }
        System.out.println("Lift off!");
        System.out.println("-----------------\n");

        // 4. For loop for multiplication table of 7
        System.out.println("--- Multiplication Table for 7 ---");
        int multiplier = 7;
        for (int i = 1; i <= 10; i++) {
            System.out.println(multiplier + " x " + i + " = " + (multiplier * i));
        }
        System.out.println("----------------------------------");
    }
}

Chapter 8: The Scanner Masterclass.

1. Brief Theory: Getting User Input

Up until now, our programs have been pretty static. They print information, perform calculations, and make decisions based on values we hardcoded directly into the program. But real-world applications need to interact with users! This is where getting user input comes in.

2. Professional Code: Scanner in Action

Example 8.1: Reading Various Primitive Types

// Chapter8/BasicInput.java
import java.util.Scanner; // Don't forget this import!

public class BasicInput {
    public static void main(String[] args) {
        // Create a Scanner object to read input from the console
        Scanner scanner = new Scanner(System.in);

        System.out.println("--- User Input Practice ---");

        // Read an integer
        System.out.print("Enter your age: "); // print() keeps cursor on same line
        int age = scanner.nextInt();
        System.out.println("You entered age: " + age);

        // Consume the leftover newline character after nextInt()
        scanner.nextLine(); 

        // Read a line of text (e.g., full name)
        System.out.print("Enter your full name: ");
        String fullName = scanner.nextLine();
        System.out.println("You entered name: " + fullName);

        // Read a double
        System.out.print("Enter your GPA (e.g., 3.8): ");
        double gpa = scanner.nextDouble();
        System.out.println("You entered GPA: " + gpa);

        // Consume the leftover newline character after nextDouble()
        scanner.nextLine(); 

        // Read a single word (e.g., favorite color)
        System.out.print("Enter your favorite color (single word): ");
        String color = scanner.next();
        System.out.println("You entered color: " + color);
        
        // No nextLine() needed after next() if it's the last input or followed by another nextX()

        System.out.println("--- Input Reading Complete ---");

        // Close the scanner to release system resources
        scanner.close();
    }
}

Example 8.2: Demonstrating and Fixing the nextLine() Buffer Issue

// Chapter8/ScannerBufferIssue.java
import java.util.Scanner;

public class ScannerBufferIssue {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);

        System.out.println("--- Demonstrating Scanner nextLine() Issue ---");

        System.out.print("Enter your favorite number (int): ");
        int favNumber = scanner.nextInt(); // Reads 123, leaves '\n' in buffer
        System.out.println("Your favorite number is: " + favNumber);

        // *** PROBLEM HERE: nextLine() reads the leftover '\n' from nextInt() ***
        // If the line below is uncommented, it will NOT pause for input.
        // System.out.print("Now enter your favorite quote: ");
        // String favQuoteProblem = scanner.nextLine();
        // System.out.println("Problematic quote: " + favQuoteProblem + " (This might be empty!)");


        // --- THE FIX ---
        System.out.print("Enter your age (int): ");
        int age = scanner.nextInt();
        System.out.println("Your age is: " + age);

        // Consume the leftover newline character explicitly!
        scanner.nextLine(); // This reads and discards the '\n' from the 'age' input

        System.out.print("Now enter your favorite city (full line): ");
        String favCity = scanner.nextLine(); // Now it correctly waits for and reads your city
        System.out.println("Your favorite city is: " + favCity);

        System.out.println("--- Issue Demonstration Complete ---");
        scanner.close();
    }
}

Example 8.3: Simple Calculator with User Input

// Chapter8/SimpleCalculator.java
import java.util.Scanner;

public class SimpleCalculator {
    public static void main(String[] args) {
        Scanner inputScanner = new Scanner(System.in);

        System.out.println("--- Simple Calculator ---");

        System.out.print("Enter first number (double): ");
        double num1 = inputScanner.nextDouble();

        System.out.print("Enter second number (double): ");
        double num2 = inputScanner.nextDouble();

        // Consume the leftover newline character after nextDouble()
        inputScanner.nextLine(); 

        System.out.print("Enter an operator (+, -, *, /): ");
        String operator = inputScanner.nextLine(); // Reading operator as a string

        double result = 0;
        boolean isValidOperation = true;

        switch (operator) {
            case "+" -> result = num1 + num2;
            case "-" -> result = num1 - num2;
            case "*" -> result = num1 * num2;
            case "/" -> {
                if (num2 != 0) {
                    result = num1 / num2;
                } else {
                    System.out.println("Error: Division by zero is not allowed.");
                    isValidOperation = false;
                }
            }
            default -> {
                System.out.println("Error: Invalid operator.");
                isValidOperation = false;
            }
        }

        if (isValidOperation) {
            System.out.printf("Result: %.2f %s %.2f = %.2f\n", num1, operator, num2, result);
        } else {
            System.out.println("Calculation could not be performed due to an error.");
        }
        
        System.out.println("--- Calculator Done ---");
        inputScanner.close();
    }
}

3. Line-by-Line Breakdown

Let's break down key lines from BasicInput.java and ScannerBufferIssue.java:

// From BasicInput.java
import java.util.Scanner;
// This line tells the Java compiler that we want to use the 'Scanner' class,
// which is located in the 'java.util' package. Without this, the compiler wouldn't know what 'Scanner' is.

Scanner scanner = new Scanner(System.in);
// 'Scanner': This is the type of variable we are declaring.
// 'scanner': This is the name of our Scanner variable (object reference).
// 'new Scanner(System.in)': This creates a new 'Scanner' object.
//   'new': The keyword to create a new object.
//   'System.in': This specifies the input source. 'System.in' refers to the standard input stream,
//                 which by default is your keyboard.

System.out.print("Enter your age: ");
// 'System.out.print()': Displays the message to the console.
//                        Unlike 'println()', 'print()' does NOT add a new line character at the end,
//                        so the user's input will appear on the same line as the prompt.

int age = scanner.nextInt();
// 'scanner.nextInt()': This method reads the next integer value typed by the user from the input stream.
//                      It parses the string representation of the number into an 'int' data type.
//                      The program pauses here until the user types something and presses Enter.
// 'int age = ...': The integer value read is then assigned to the 'age' variable.

scanner.nextLine();
// This is the crucial line for the buffer fix.
// It reads and discards the leftover newline character ('\n') that was generated when the user pressed Enter
// after typing their age for 'nextInt()'. This clears the buffer for the next 'nextLine()' call.

String fullName = scanner.nextLine();
// 'scanner.nextLine()': This method reads *all* characters until it encounters a newline character ('\n').
//                       It then consumes that newline character and returns the entire line of text as a 'String'.
//                       This is typically used when you want to read a phrase, sentence, or any input that might
//                       contain spaces.

scanner.close();
// This method closes the 'Scanner' object, releasing any underlying system resources (like the keyboard input stream).
// It's good practice to close resources when you are done with them.

4. Clean Code Pro-Tips

5. Unsolved Exercise: Personalized Greeting

Let's build a program that greets a user and asks for some personal details.

  1. Create a class named PersonalizedGreeting.
  2. Import the Scanner class.
  3. Create a Scanner object.
  4. Ask the user for their name (full line, including spaces) and store it in a String variable.
  5. Ask the user for their age and store it in an int variable.
  6. Ask the user for their favorite number and store it in a double variable.
  7. Crucially: Ensure that the nextLine() buffer fix is applied correctly after reading the age and favorite number, so that any subsequent nextLine() calls work as expected (though we won't have any after the name in this specific example, it's good practice to get into the habit).
  8. Print a personalized message using all the collected information, for example: "Hello, [Name]! You are [Age] years old and your favorite number is [Favorite Number]."
  9. Close the Scanner object.

6. Complete Solution: Personalized Greeting

// Chapter8/PersonalizedGreeting.java
import java.util.Scanner; // Don't forget this!

public class PersonalizedGreeting {
    public static void main(String[] args) {
        // 3. Create a Scanner object
        Scanner keyboardInput = new Scanner(System.in);

        System.out.println("--- Personalized Greeting Program ---");

        // 4. Ask for the user's name (full line)
        System.out.print("Please enter your full name: ");
        String userName = keyboardInput.nextLine();

        // 5. Ask for the user's age
        System.out.print("Please enter your age: ");
        int userAge = keyboardInput.nextInt();

        // 7. Essential: Consume the leftover newline character after nextInt()
        keyboardInput.nextLine(); 

        // 6. Ask for the user's favorite number
        System.out.print("Please enter your favorite number (e.g., 3.14): ");
        double favoriteNumber = keyboardInput.nextDouble();

        // 7. Essential: Consume the leftover newline character after nextDouble()
        // Good practice, even if no nextLine() follows directly, for consistency.
        keyboardInput.nextLine(); 

        // 8. Print a personalized message
        System.out.printf("\nHello, %s! You are %d years old and your favorite number is %.2f.\n",
                          userName, userAge, favoriteNumber);
        
        System.out.println("Nice to meet you!");
        System.out.println("--- Program End ---");

        // 9. Close the Scanner object
        keyboardInput.close();
    }
}

Book 1: Part 2 (Advanced Foundations)

Chapter 9: One-Dimensional Arrays.

1. Quick Theory: Grouping Your Data

Imagine you need to store the scores of 100 students. Would you create 100 separate int variables like student1Score, student2Score, etc.? That would be incredibly tedious and unmanageable! This is where arrays come to the rescue.

An array is a special type of variable that can hold multiple values of the same data type under a single name. Think of it as a list or a sequence of elements, each accessible by an index (its position). In Java, array indices are 0-based, meaning the first element is at index 0, the second at 1, and so on. Arrays are objects, meaning their data is stored on the Heap, and your array variable holds a reference to that memory location. The "Why": Arrays provide an efficient way to store and access a fixed-size collection of homogeneous data.

2. Code Examples: Arrays in Action

Example 9.1: Declaring, Initializing, and Accessing with for loop

// Chapter9/StudentScores.java
public class StudentScores {
    public static void main(String[] args) {
        // 1. Array Declaration: How to tell Java you'll have an array
        // Option A: Declare without size immediately (type[] arrayName;)
        int[] scores; 
        
        // Option B: Declare and allocate size (type[] arrayName = new type[size];)
        // This array can hold 5 integer scores. Default value for int is 0.
        scores = new int[5]; 

        // 2. Initializing elements (assigning values)
        scores[0] = 85; // First element at index 0
        scores[1] = 92; // Second element at index 1
        scores[2] = 78; // Third element
        scores[3] = 95; // Fourth element
        scores[4] = 60; // Fifth (and last) element at index 4 (size - 1)

        // What if we try to access an index out of bounds?
        // scores[5] = 100; // This would cause an ArrayIndexOutOfBoundsException at runtime!

        // 3. Accessing elements using a standard 'for' loop
        System.out.println("--- Student Scores (using standard for loop) ---");
        for (int i = 0; i < scores.length; i++) {
            // 'scores.length' is a built-in property that gives the size of the array.
            // Loop from 0 up to (but not including) scores.length to cover all indices.
            System.out.println("Student " + (i + 1) + " Score: " + scores[i]);
        }

        // 4. Declaring and initializing an array in one line
        String[] studentNames = {"Alice", "Bob", "Charlie", "Diana"};
        System.out.println("\n--- Student Names ---");
        System.out.println("First student: " + studentNames[0]); // Accessing "Alice"
        System.out.println("Number of students: " + studentNames.length); // Output: 4
    }
}

Example 9.2: Iterating with the Modern for-each Loop

// Chapter9/ForEachLoopExample.java
public class ForEachLoopExample {
    public static void main(String[] args) {
        // Array of product prices
        double[] productPrices = {15.99, 23.50, 5.00, 10.75, 45.20};

        System.out.println("--- Product Prices (using for-each loop) ---");
        // The 'for-each' loop (enhanced for loop) is perfect for iterating over
        // all elements of an array or collection when you don't need the index.
        for (double price : productPrices) {
            // For each 'double' element in 'productPrices', assign its value to 'price'
            // and execute the loop body.
            System.out.println("Price: $" + price);
        }

        // Calculating total price using for-each
        double totalPrice = 0;
        for (double price : productPrices) {
            totalPrice += price; // Add each price to the total
        }
        System.out.printf("Total Price of all products: $%.2f\n", totalPrice);

        // Array of characters (e.g., initials)
        char[] initials = {'J', 'D', 'M', 'S'};
        System.out.println("\n--- User Initials ---");
        for (char initial : initials) {
            System.out.print(initial + " "); // Print initials on a single line
        }
        System.out.println(); // Add a newline at the end
    }
}

3. Line-by-Line Breakdown

Let's dissect StudentScores.java:

int[] scores;
// 'int[]': This declares a variable named 'scores' that will hold an array of integers.
//          The '[]' indicates it's an array type.

scores = new int[5];
// 'new int[5]': This creates a new array object on the Heap that can hold 5 integer values.
//               All elements are automatically initialized to their default value (0 for int).
// 'scores = ...': The reference to this newly created array object is assigned to the 'scores' variable.

scores[0] = 85;
// 'scores[0]': This accesses the element at index 0 (the first element) of the 'scores' array.
// '= 85;': The value 85 is assigned to this specific element.

for (int i = 0; i < scores.length; i++) {
    // 'int i = 0;': Loop counter 'i' starts at 0, which is the first valid index for arrays.
    // 'i < scores.length;': Loop continues as long as 'i' is less than the total number of elements.
    //                      If scores.length is 5, 'i' will go from 0, 1, 2, 3, 4. When 'i' becomes 5,
    //                      the condition (5 < 5) is false, and the loop stops, preventing
    //                      ArrayIndexOutOfBoundsException.
    // 'i++': Increment 'i' after each iteration to move to the next element.
    System.out.println("Student " + (i + 1) + " Score: " + scores[i]);
    // 'scores[i]': Accesses the element at the current index 'i'.
    // '(i + 1)': We add 1 to 'i' when printing to make it more human-readable (Student 1, Student 2, etc., instead of Student 0).
}

And from ForEachLoopExample.java:

for (double price : productPrices) {
    // 'for (double price : productPrices)': This is the 'for-each' loop syntax.
    //   'double price': In each iteration, the current element from 'productPrices' will be assigned to this variable 'price'.
    //   'productPrices': This is the array (or any iterable collection) that we want to loop through.
    System.out.println("Price: $" + price);
    // Inside the loop, 'price' holds the value of the current element.
    // The loop automatically iterates over all elements from start to finish.
}

4. Clean Code Pro-Tips

5. Unsolved Exercise: Daily Temperatures

Your task is to create a program that manages daily temperatures.

  1. Create a class named DailyTemperatures.
  2. Declare an array named temperatures of type double to store 7 daily temperatures.
  3. Initialize the array with arbitrary temperatures for 7 days (e.g., 20.5, 22.1, 19.8, 23.0, 25.4, 21.0, 18.7).
  4. Use a standard for loop to print each day's temperature, labeling them as "Day 1", "Day 2", etc.
  5. Use a for-each loop to calculate and print the average temperature for the week.

6. Complete Solution: Daily Temperatures

// Chapter9/DailyTemperatures.java
public class DailyTemperatures {
    public static void main(String[] args) {
        // 2. Declare and 3. Initialize the array of 7 daily temperatures
        double[] temperatures = {20.5, 22.1, 19.8, 23.0, 25.4, 21.0, 18.7};

        System.out.println("--- Weekly Temperatures ---");
        // 4. Print each day's temperature using a standard 'for' loop
        for (int i = 0; i < temperatures.length; i++) {
            System.out.println("Day " + (i + 1) + ": " + temperatures[i] + "°C");
        }

        // 5. Calculate and print the average temperature using a 'for-each' loop
        double totalTemperature = 0;
        for (double temp : temperatures) {
            totalTemperature += temp; // Add each temperature to the total
        }

        double averageTemperature = totalTemperature / temperatures.length;
        System.out.printf("\nAverage weekly temperature: %.2f°C\n", averageTemperature);
        System.out.println("---------------------------");
    }
}

Chapter 10: The Arrays Utility Class.

1. Quick Theory: Array Superpowers

Working with arrays is common, so common that Java provides a specialized utility class to make array operations easier: java.util.Arrays. This class offers static methods (methods you call directly on the class, not an object of the class) that perform common tasks like sorting, searching, and converting arrays to strings.

The "Why": Instead of writing your own sorting algorithm (which is complex!) or a loop to print every element, Arrays provides battle-tested, efficient implementations for you. This saves time, reduces errors, and keeps your code cleaner and more professional. It's a prime example of reusing existing functionality.

2. Code Examples: Arrays Utility in Action

Example 10.1: Sorting and Printing Arrays

// Chapter10/ArraySortingAndPrinting.java
import java.util.Arrays; // Don't forget to import the Arrays utility class!

public class ArraySortingAndPrinting {
    public static void main(String[] args) {
        int[] numbers = {5, 2, 8, 1, 9, 3, 7, 4, 6};
        String[] fruits = {"orange", "apple", "grape", "banana", "kiwi"};

        System.out.println("--- Original Arrays ---");
        // Arrays.toString() converts an array into a human-readable String (e.g., "[5, 2, 8, ...]")
        System.out.println("Numbers: " + Arrays.toString(numbers));
        System.out.println("Fruits: " + Arrays.toString(fruits));

        // 1. Sorting an array: Arrays.sort()
        // This method sorts the elements of the array in ascending order (modifies the original array).
        Arrays.sort(numbers);
        Arrays.sort(fruits);

        System.out.println("\n--- Sorted Arrays ---");
        System.out.println("Numbers (Sorted): " + Arrays.toString(numbers));
        System.out.println("Fruits (Sorted): " + Arrays.toString(fruits));

        // Sorting a portion of an array (optional, but good to know)
        int[] partialSortArray = {10, 30, 20, 50, 40};
        System.out.println("\nOriginal Partial Sort Array: " + Arrays.toString(partialSortArray));
        // Sorts from index 1 (inclusive) to index 4 (exclusive)
        Arrays.sort(partialSortArray, 1, 4); 
        System.out.println("Partially Sorted Array (indices 1 to 3): " + Arrays.toString(partialSortArray));
        // Output: [10, 20, 30, 50, 40] - Note: 50 is still at index 3 because 4 is exclusive
    }
}

Example 10.2: Comparing Arrays and Filling Arrays

// Chapter10/ArrayComparisonAndFill.java
import java.util.Arrays;

public class ArrayComparisonAndFill {
    public static void main(String[] args) {
        int[] arr1 = {1, 2, 3, 4, 5};
        int[] arr2 = {1, 2, 3, 4, 5};
        int[] arr3 = {5, 4, 3, 2, 1};
        int[] arr4 = {1, 2, 3};

        System.out.println("--- Array Comparison ---");
        // 1. Comparing arrays: Arrays.equals()
        // This method checks if two arrays have the same number of elements AND
        // if all corresponding elements are equal in value and order.
        System.out.println("arr1 equals arr2? " + Arrays.equals(arr1, arr2)); // Output: true
        System.out.println("arr1 equals arr3? " + Arrays.equals(arr1, arr3)); // Output: false (order differs)
        System.out.println("arr1 equals arr4? " + Arrays.equals(arr1, arr4)); // Output: false (length differs)

        // What about '=='? Remember, '==' compares references (memory addresses) for objects.
        System.out.println("arr1 == arr2? " + (arr1 == arr2)); // Output: false (they are different objects)

        // 2. Filling an array: Arrays.fill()
        int[] newArray = new int[5]; // Creates an array of 5 integers, all initialized to 0.
        System.out.println("\nNew Array (initial): " + Arrays.toString(newArray)); // Output: [0, 0, 0, 0, 0]

        Arrays.fill(newArray, 100); // Fills all elements of newArray with the value 100.
        System.out.println("New Array (filled with 100): " + Arrays.toString(newArray)); // Output: [100, 100, 100, 100, 100]

        // You can also fill a portion
        int[] anotherArray = new int[5];
        Arrays.fill(anotherArray, 1, 4, 50); // Fills from index 1 (inclusive) to 4 (exclusive) with 50
        System.out.println("Another Array (partially filled): " + Arrays.toString(anotherArray)); // Output: [0, 50, 50, 50, 0]
    }
}

3. Line-by-Line Breakdown

Let's break down ArraySortingAndPrinting.java:

import java.util.Arrays;
// This import statement makes all the static methods of the 'Arrays' class available
// for use in this file without having to prefix them with 'java.util.Arrays'.

System.out.println("Numbers: " + Arrays.toString(numbers));
// 'Arrays.toString(numbers)': This static method takes an array (in this case, 'numbers')
//                             as an argument and returns a 'String' representation of its contents.
//                             It's incredibly useful for debugging and printing array values easily.

Arrays.sort(numbers);
// 'Arrays.sort(numbers)': This static method sorts the elements of the 'numbers' array in place.
//                         "In place" means it modifies the original 'numbers' array directly; it doesn't return a new sorted array.
//                         For primitive types and String, it sorts in natural (ascending) order.

And from ArrayComparisonAndFill.java:

System.out.println("arr1 equals arr2? " + Arrays.equals(arr1, arr2));
// 'Arrays.equals(arr1, arr2)': This static method compares two arrays.
//                              It returns 'true' if both arrays are of the same type, have the same length,
//                              and all corresponding elements at each index are equal. Otherwise, it returns 'false'.

System.out.println("arr1 == arr2? " + (arr1 == arr2));
// '(arr1 == arr2)': This compares the memory addresses (references) of the two array objects.
//                   Even though 'arr1' and 'arr2' have identical content, they are two separate objects
//                   created with 'new int[] { ... }' in different memory locations.
//                   Therefore, their references are different, and '==' returns 'false'.

Arrays.fill(newArray, 100);
// 'Arrays.fill(newArray, 100)': This static method sets every element in the 'newArray' to the value 100.
//                               It's a convenient way to initialize or reset all elements of an array to a single value.

4. Clean Code Pro-Tips

5. Unsolved Exercise: Student Gradebook

You are managing a small gradebook for a class.

  1. Create a class named StudentGradebook.
  2. Declare an int array named grades and initialize it with 5 arbitrary student grades (e.g., 85, 72, 95, 68, 80).
  3. Print the original grades array using Arrays.toString().
  4. Sort the grades array in ascending order.
  5. Print the sorted grades array.
  6. Create a second int array named passingGrades of the same size, and fill all its elements with a passing score, say 70.
  7. Print the passingGrades array.
  8. Compare the original grades array (which is now sorted) with the passingGrades array using Arrays.equals() and print the result. Explain why you get that result.

6. Complete Solution: Student Gradebook

// Chapter10/StudentGradebook.java
import java.util.Arrays; // Needed for Arrays utility methods

public class StudentGradebook {
    public static void main(String[] args) {
        // 2. Declare and initialize the grades array
        int[] grades = {85, 72, 95, 68, 80};

        System.out.println("--- Student Gradebook ---");
        // 3. Print the original grades array
        System.out.println("Original Grades: " + Arrays.toString(grades));

        // 4. Sort the grades array
        Arrays.sort(grades);

        // 5. Print the sorted grades array
        System.out.println("Sorted Grades:   " + Arrays.toString(grades));

        // 6. Create and fill the passingGrades array
        int[] passingGrades = new int[5];
        Arrays.fill(passingGrades, 70); // Fill all elements with 70

        // 7. Print the passingGrades array
        System.out.println("Passing Grades:  " + Arrays.toString(passingGrades));

        // 8. Compare the sorted grades array with the passingGrades array
        boolean areEqual = Arrays.equals(grades, passingGrades);
        System.out.println("\nAre sorted grades and passing grades arrays equal? " + areEqual);
        // Explanation: This will likely be 'false' unless all original grades coincidentally became 70 after sorting,
        // which is extremely unlikely. Arrays.equals() checks if elements at *each corresponding index* are identical.
        // Even if some grades were 70, the arrays as a whole would only be equal if all 5 elements matched perfectly.
        System.out.println("-------------------------");
    }
}

Chapter 11: Multidimensional Arrays (Matrices).

1. Quick Theory: Tables and Grids

Sometimes, a single list isn't enough to represent your data. What if you need to store data in a grid, like a spreadsheet, a game board, or coordinates? That's when multidimensional arrays, often called matrices (especially 2D arrays), become essential.

A 2D array is essentially an "array of arrays." Each element of the outer array is itself another array. For example, a int[3][4] array means an array with 3 rows, where each row is an array of 4 integers. Like 1D arrays, multidimensional arrays are objects on the Heap. The "Why": They provide a structured way to model tabular data or spatial relationships, making it easier to manage complex datasets in a more intuitive, row-column format.

2. Code Examples: Matrices in Action

Example 11.1: Declaring, Initializing, and Printing a 2D Array

// Chapter11/TwoDArrayBasic.java
public class TwoDArrayBasic {
    public static void main(String[] args) {
        // 1. Declare and initialize a 2D array (3 rows, 4 columns)
        // int[row][column]
        int[][] matrix = {
            {1, 2, 3, 4},    // Row 0
            {5, 6, 7, 8},    // Row 1
            {9, 10, 11, 12}  // Row 2
        };

        // 2. Accessing elements
        System.out.println("Element at [0][0]: " + matrix[0][0]); // Output: 1
        System.out.println("Element at [1][2]: " + matrix[1][2]); // Output: 7 (second row, third column)
        System.out.println("Element at [2][3]: " + matrix[2][3]); // Output: 12 (third row, fourth column)

        // 3. Getting dimensions
        System.out.println("Number of rows: " + matrix.length); // Output: 3 (length of the outer array)
        System.out.println("Number of columns (in row 0): " + matrix[0].length); // Output: 4 (length of the first inner array)

        // 4. Printing the 2D array using nested 'for' loops
        System.out.println("\n--- Printing Matrix ---");
        for (int i = 0; i < matrix.length; i++) { // Outer loop for rows
            for (int j = 0; j < matrix[i].length; j++) { // Inner loop for columns in the current row 'i'
                System.out.print(matrix[i][j] + "\t"); // Print element and a tab for spacing
            }
            System.out.println(); // Move to the next line after each row
        }
        System.out.println("-----------------------");
    }
}

Example 11.2: A Simple Game Board (Dynamic Creation)

// Chapter11/GameBoard.java
public class GameBoard {
    public static void main(String[] args) {
        // Create an empty 5x5 character game board
        int rows = 5;
        int cols = 5;
        char[][] board = new char[rows][cols]; // All elements initialized to default char value '\u0000' (null character)

        // Initialize the board with empty spaces or a specific character
        for (int i = 0; i < rows; i++) {
            for (int j = 0; j < cols; j++) {
                board[i][j] = '-'; // Use '-' to represent empty cells
            }
        }

        // Place some game pieces
        board[0][0] = 'X'; // Player X at top-left
        board[2][2] = 'O'; // Player O at center
        board[4][0] = 'X'; // Another X

        // Print the game board
        System.out.println("--- Game Board ---");
        for (int i = 0; i < board.length; i++) {
            for (int j = 0; j < board[i].length; j++) {
                System.out.print(board[i][j] + " "); // Print element with a space
            }
            System.out.println(); // New line for each row
        }
        System.out.println("------------------");

        // Example: Update a cell
        board[0][1] = 'O';
        System.out.println("\n--- Board After Update ---");
        for (int i = 0; i < board.length; i++) {
            for (int j = 0; j < board[i].length; j++) {
                System.out.print(board[i][j] + " ");
            }
            System.out.println();
        }
        System.out.println("--------------------------");
    }
}

3. Line-by-Line Breakdown

Let's dissect TwoDArrayBasic.java:

int[][] matrix = {
    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12}
};
// 'int[][]': Declares a variable 'matrix' that will hold a 2D array of integers.
// '{ { ... }, { ... } }': This is shorthand for declaring and initializing a 2D array.
//                          The outer curly braces define the entire array.
//                          Each inner curly brace defines a row (an inner array).
//                          Here, it creates an array with 3 inner arrays, each having 4 integers.

System.out.println("Element at [1][2]: " + matrix[1][2]);
// 'matrix[1]': Accesses the array at index 1 (the second row: {5, 6, 7, 8}).
// 'matrix[1][2]': From that inner array, accesses the element at index 2 (the third column: 7).

for (int i = 0; i < matrix.length; i++) { // Outer loop for rows
    // 'matrix.length': Gives the number of *rows* (the length of the outer array).
    //                  In this case, it's 3. 'i' will go from 0 to 2.

    for (int j = 0; j < matrix[i].length; j++) { // Inner loop for columns
        // 'matrix[i]': Refers to the current row (the inner array at index 'i').
        // 'matrix[i].length': Gives the number of *columns* in the current row 'i'.
        //                     For a regular (non-jagged) 2D array, this will be the same for all rows (e.g., 4).
        //                     'j' will go from 0 to 3 for each row.
        System.out.print(matrix[i][j] + "\t");
        // 'matrix[i][j]': Accesses the element at the current row 'i' and column 'j'.
    }
    System.out.println(); // After printing all columns of a row, move to the next line.
}

4. Clean Code Pro-Tips

5. Unsolved Exercise: Classroom Seating Chart

You need to create a simple seating chart for a classroom.

  1. Create a class named ClassroomSeating.
  2. Declare a String 2D array named seatingChart with 3 rows and 4 columns.
  3. Initialize the seatingChart with student names (e.g., "Alice", "Bob", "Charlie", "Diana" for the first row, then other names, or "EMPTY" if a seat is free).
  4. Print the entire seatingChart neatly, using nested for loops. Each student name should take up some space, perhaps with a tab \t.
  5. Change the student in seat [1][1] (second row, second column) to "NewStudent".
  6. Print the updated seatingChart to confirm the change.

6. Complete Solution: Classroom Seating Chart

// Chapter11/ClassroomSeating.java
public class ClassroomSeating {
    public static void main(String[] args) {
        // 2. Declare a String 2D array named seatingChart (3 rows, 4 columns)
        // 3. Initialize the seatingChart with student names or "EMPTY"
        String[][] seatingChart = {
            {"Alice", "Bob", "Charlie", "Diana"},
            {"Ethan", "Frank", "Grace", "Harry"},
            {"Ivy",   "Jake",  "Karen", "Liam"}
        };

        System.out.println("--- Original Classroom Seating Chart ---");
        // 4. Print the entire seatingChart using nested 'for' loops
        for (int row = 0; row < seatingChart.length; row++) { // Iterate through rows
            for (int col = 0; col < seatingChart[row].length; col++) { // Iterate through columns in the current row
                // Print student name, padded to ensure alignment
                System.out.print(String.format("%-10s", seatingChart[row][col])); // %-10s means left-align string in 10 chars
            }
            System.out.println(); // New line after each row
        }
        System.out.println("----------------------------------------");

        // 5. Change the student in seat [1][1] (second row, second column)
        seatingChart[1][1] = "NewStudent";

        System.out.println("\n--- Updated Classroom Seating Chart ---");
        // 6. Print the updated seatingChart
        for (int row = 0; row < seatingChart.length; row++) {
            for (int col = 0; col < seatingChart[row].length; col++) {
                System.out.print(String.format("%-10s", seatingChart[row][col]));
            }
            System.out.println();
        }
        System.out.println("---------------------------------------");
    }
}

Chapter 12: Methods (The Power of Reusability).

1. Quick Theory: Breaking Down Complexity

As your programs grow, putting all your code inside the main method becomes messy and hard to manage. This is where methods (also known as functions in other languages) come in. A method is a block of code that performs a specific task. You can define a method once and then "call" or "invoke" it multiple times from different parts of your program.

The "Why":

  1. Reusability (DRY - Don't Repeat Yourself): If you need to perform the same task multiple times, you write the code once in a method and call the method.
  2. Modularity & Organization: Breaking down a large problem into smaller, manageable methods makes your code easier to understand, debug, and maintain. Each method can focus on a single, well-defined task.
  3. Readability: A main method that calls several well-named methods is much easier to read and understand than a long block of code.

For now, we'll focus on public static methods.

The return statement is used in methods that have a non-void return type. It sends a value back to the code that called the method and immediately exits the method.

2. Code Examples: Building Your Own Methods

Example 12.1: Method with Parameters and a Return Value

// Chapter12/CalculatorMethods.java
public class CalculatorMethods {

    public static void main(String[] args) {
        System.out.println("--- Basic Calculator Methods ---");

        // Calling the 'add' method and storing its returned value
        int sum = add(10, 5); // 10 and 5 are "arguments"
        System.out.println("Sum of 10 and 5: " + sum); // Output: 15

        // Calling the 'subtract' method
        double difference = subtract(20.5, 7.3);
        System.out.printf("Difference of 20.5 and 7.3: %.2f\n", difference); // Output: 13.20

        // Calling the 'multiply' method directly in println
        System.out.println("Product of 4 and 6: " + multiply(4, 6)); // Output: 24

        // Calling a void method
        greetUser("Alice"); // "Alice" is the argument
        greetUser("Bob");
        
        // Using return value in a conditional
        int num = 15;
        if (isEven(num)) {
            System.out.println(num + " is an even number.");
        } else {
            System.out.println(num + " is an odd number.");
        }
    }

    // Method to add two integers
    // 'public static': Access modifiers (visible everywhere, belongs to the class)
    // 'int': Return type - this method will return an integer value
    // 'add': Method name
    // '(int a, int b)': Parameters - this method accepts two integers, named 'a' and 'b'
    public static int add(int a, int b) {
        int result = a + b;
        return result; // 'return' keyword sends 'result' back to the caller
    }

    // Method to subtract two doubles
    public static double subtract(double num1, double num2) {
        // You can return the expression directly
        return num1 - num2;
    }

    // Method to multiply two integers
    public static int multiply(int x, int y) {
        return x * y;
    }

    // Method with 'void' return type (doesn't return any value)
    public static void greetUser(String name) { // Takes one String parameter
        System.out.println("Hello, " + name + "! Welcome to Java methods.");
        // No 'return' statement is explicitly needed for void methods at the end,
        // but an empty 'return;' can be used to exit early.
    }
    
    // Method to check if a number is even
    public static boolean isEven(int number) {
        return number % 2 == 0; // Returns true if number is even, false otherwise
    }
}

Example 12.2: Reusing Methods to Calculate Area

// Chapter12/AreaCalculator.java
public class AreaCalculator {

    public static void main(String[] args) {
        System.out.println("--- Area Calculator ---");

        // Calculate area of a rectangle
        double rectLength = 10.0;
        double rectWidth = 5.0;
        double rectangleArea = calculateRectangleArea(rectLength, rectWidth);
        System.out.printf("Area of rectangle (%.1f x %.1f): %.2f\n", rectLength, rectWidth, rectangleArea);

        // Calculate area of a circle
        double circleRadius = 7.0;
        double circleArea = calculateCircleArea(circleRadius);
        System.out.printf("Area of circle (radius %.1f): %.2f\n", circleRadius, circleArea);

        // Calculate area of a triangle
        double triBase = 8.0;
        double triHeight = 4.0;
        double triangleArea = calculateTriangleArea(triBase, triHeight);
        System.out.printf("Area of triangle (base %.1f, height %.1f): %.2f\n", triBase, triHeight, triangleArea);
        
        System.out.println("-----------------------");
    }

    // Method to calculate the area of a rectangle
    public static double calculateRectangleArea(double length, double width) {
        if (length <= 0 || width <= 0) { // Basic validation
            System.out.println("Error: Length and width must be positive.");
            return 0.0; // Return a default/error value
        }
        return length * width;
    }

    // Method to calculate the area of a circle
    public static double calculateCircleArea(double radius) {
        if (radius <= 0) {
            System.out.println("Error: Radius must be positive.");
            return 0.0;
        }
        // Math.PI is a constant for the value of Pi
        return Math.PI * radius * radius;
    }

    // Method to calculate the area of a triangle
    public static double calculateTriangleArea(double base, double height) {
        if (base <= 0 || height <= 0) {
            System.out.println("Error: Base and height must be positive.");
            return 0.0;
        }
        return 0.5 * base * height; // Or (base * height) / 2.0;
    }
}

3. Line-by-Line Breakdown

Let's dissect CalculatorMethods.java:

public static int add(int a, int b) {
    // 'public static': Standard modifiers for now.
    // 'int': This specifies that the method 'add' will produce and return an 'int' value.
    // 'add': The name of our method.
    // '(int a, int b)': This declares two parameters. When 'add' is called, it *expects* two integer values.
    //                   Inside the method, these values will be referred to as 'a' and 'b'.
    int result = a + b;
    // 'result' is a local variable, only exists within this 'add' method.
    return result;
    // 'return result;': This sends the value currently stored in 'result' back to the part of the code
    //                  that called this method. It also ends the execution of the 'add' method.
}

int sum = add(10, 5);
// 'add(10, 5)': This is a method call. The values '10' and '5' are passed as arguments to the 'add' method.
//               Inside 'add', 'a' will be 10, and 'b' will be 5.
//               The 'add' method calculates 10 + 5 = 15, and 'returns' 15.
// 'int sum = ...': The returned value (15) is then assigned to the 'sum' variable in the 'main' method.

public static void greetUser(String name) {
    // 'void': This means the method 'greetUser' does not return any value to its caller.
    // '(String name)': This method takes one 'String' value as input, referred to as 'name' inside the method.
    System.out.println("Hello, " + name + "! Welcome to Java methods.");
    // This method performs an action (prints to console) but doesn't produce a value to be returned.
    // No explicit 'return' statement is required for void methods, they implicitly return when they reach the end.
}

4. Clean Code Pro-Tips

5. Unsolved Exercise: User Interaction Helper

Create a program that uses methods to help with user interaction.

  1. Create a class named UserInteraction.
  2. Define a public static void method named displayWelcomeMessage that takes no parameters and prints a generic welcome.
  3. Define a public static String method named formatFullName that takes two String parameters, firstName and lastName, and returns a single formatted String like "First Last".
  4. Define a public static boolean method named isValidAge that takes an int parameter age and returns true if the age is between 0 and 120 (inclusive), false otherwise.
  5. In the main method:
    • Call displayWelcomeMessage.
    • Call formatFullName with example first and last names, and print the result.
    • Call isValidAge with an example age and print whether the age is valid or not.

6. Complete Solution: User Interaction Helper

// Chapter12/UserInteraction.java
public class UserInteraction {

    public static void main(String[] args) {
        System.out.println("--- User Interaction Helper ---");

        // 5a. Call displayWelcomeMessage
        displayWelcomeMessage();

        // 5b. Call formatFullName and print the result
        String fullName = formatFullName("Jane", "Doe");
        System.out.println("Formatted Full Name: " + fullName); // Output: Jane Doe

        // 5c. Call isValidAge with an example and print the result
        int testAge1 = 30;
        System.out.println("Is age " + testAge1 + " valid? " + isValidAge(testAge1)); // Output: true

        int testAge2 = -5;
        System.out.println("Is age " + testAge2 + " valid? " + isValidAge(testAge2)); // Output: false

        int testAge3 = 150;
        System.out.println("Is age " + testAge3 + " valid? " + isValidAge(testAge3)); // Output: false
        System.out.println("-----------------------------");
    }

    // 2. Method to display a welcome message
    public static void displayWelcomeMessage() {
        System.out.println("Welcome to our application!");
        System.out.println("We hope you enjoy your experience.\n");
    }

    // 3. Method to format a full name
    public static String formatFullName(String firstName, String lastName) {
        // Concatenate first name, a space, and last name
        return firstName + " " + lastName;
    }

    // 4. Method to validate age
    public static boolean isValidAge(int age) {
        // Age must be greater than or equal to 0 AND less than or equal to 120
        return age >= 0 && age <= 120;
    }
}

Chapter 13: Method Overloading.

1. Quick Theory: Flexible Methods

Sometimes, you want methods that perform essentially the same task, but operate on different types of data or require a different number of inputs. Instead of coming up with entirely new method names (like addInts, addDoubles), Java allows method overloading.

Method overloading means defining multiple methods in the same class that have the same name but different parameter signatures. The parameter signature is determined by the number of parameters, their data types, and their order. The return type alone is not enough to overload a method. When you call an overloaded method, the Java compiler intelligently determines which specific version of the method to execute based on the arguments you provide. The "Why": Overloading makes your code more intuitive and readable by allowing related operations to share a common, descriptive name. It's a key aspect of polymorphism (a concept we'll explore later).

2. Code Examples: Overloading in Practice

Example 13.1: Overloading an add Method

// Chapter13/OverloadedAddMethods.java
public class OverloadedAddMethods {

    public static void main(String[] args) {
        System.out.println("--- Overloaded Add Methods ---");

        // Calling add(int, int)
        int sumInt = add(5, 10);
        System.out.println("Sum of two integers (5, 10): " + sumInt); // Output: 15

        // Calling add(double, double)
        double sumDouble = add(7.5, 2.3);
        System.out.printf("Sum of two doubles (7.5, 2.3): %.2f\n", sumDouble); // Output: 9.80

        // Calling add(int, int, int)
        int sumThreeInts = add(1, 2, 3);
        System.out.println("Sum of three integers (1, 2, 3): " + sumThreeInts); // Output: 6

        // Calling add(String, String)
        String combinedString = add("Hello", "World");
        System.out.println("Concatenated strings: " + combinedString); // Output: HelloWorld
        
        System.out.println("----------------------------");
    }

    // 1. Overloaded 'add' method: two integers
    public static int add(int a, int b) {
        System.out.println("DEBUG: Calling int add(int, int)"); // For demonstration
        return a + b;
    }

    // 2. Overloaded 'add' method: two doubles
    public static double add(double a, double b) {
        System.out.println("DEBUG: Calling double add(double, double)"); // For demonstration
        return a + b;
    }

    // 3. Overloaded 'add' method: three integers (different number of parameters)
    public static int add(int a, int b, int c) {
        System.out.println("DEBUG: Calling int add(int, int, int)"); // For demonstration
        return a + b + c;
    }

    // 4. Overloaded 'add' method: two Strings (different type of parameters)
    public static String add(String s1, String s2) {
        System.out.println("DEBUG: Calling String add(String, String)"); // For demonstration
        return s1 + s2; // String concatenation
    }
    
    // NOTE: You CANNOT overload based on return type alone.
    // public static double add(int a, int b) { return (double)(a + b); } // COMPILE ERROR!
    // This would be a duplicate signature of add(int, int)
}

Example 13.2: Overloading for Displaying Information

// Chapter13/DisplayInfoOverload.java
public class DisplayInfoOverload {

    public static void main(String[] args) {
        System.out.println("--- Display Information Overload ---");

        // Display a simple message
        displayInfo("Welcome to the system!");

        // Display user details
        displayInfo("Alice", 30);

        // Display product details
        displayInfo("Laptop", 1200.50, 5);
        
        System.out.println("------------------------------------");
    }

    // Overloaded method to display a general message
    public static void displayInfo(String message) {
        System.out.println("Message: " + message);
    }

    // Overloaded method to display user name and age
    public static void displayInfo(String name, int age) {
        System.out.println("User: " + name + ", Age: " + age);
    }

    // Overloaded method to display product name, price, and quantity
    // Note the order and types of parameters (String, double, int)
    public static void displayInfo(String productName, double price, int quantity) {
        System.out.printf("Product: %s, Price: $%.2f, Quantity: %d\n", productName, price, quantity);
    }

    // Another overload (different order of parameters compared to the one above)
    // This is valid overloading, but can sometimes lead to confusion.
    public static void displayInfo(int quantity, String productName, double price) {
        System.out.printf("Quantity: %d, Product: %s, Price: $%.2f\n", quantity, productName, price);
    }
}

3. Line-by-Line Breakdown

Let's dissect OverloadedAddMethods.java:

public static int add(int a, int b) { ... }
// This is the first version of the 'add' method. Its signature is 'add(int, int)'.

public static double add(double a, double b) { ... }
// This is an overloaded version. It has the same name 'add', but its parameter types are different: 'add(double, double)'.
// Java can distinguish this method from the first one because the types of its parameters are different.

public static int add(int a, int b, int c) { ... }
// Another overloaded version. Its signature is 'add(int, int, int)' because it has a different *number* of parameters.

public static String add(String s1, String s2) { ... }
// Yet another overloaded version. Its signature is 'add(String, String)', distinct due to different parameter types.

int sumInt = add(5, 10);
// When you call 'add(5, 10)', the compiler sees two 'int' arguments.
// It matches this call to the method 'public static int add(int a, int b)'.

double sumDouble = add(7.5, 2.3);
// When you call 'add(7.5, 2.3)', the compiler sees two 'double' arguments.
// It matches this call to the method 'public static double add(double a, double b)'.

String combinedString = add("Hello", "World");
// When you call 'add("Hello", "World")', the compiler sees two 'String' arguments.
// It matches this call to the method 'public static String add(String s1, String s2)'.

4. Clean Code Pro-Tips

5. Unsolved Exercise: Shape Area Calculator (Overloaded)

Let's expand your area calculator using method overloading.

  1. Create a class named ShapeAreaCalculator.
  2. Define a public static double method named calculateArea that takes one double parameter side (for a square) and returns its area.
  3. Define another public static double method named calculateArea that takes two double parameters length and width (for a rectangle) and returns its area.
  4. Define a third public static double method named calculateArea that takes one double parameter radius (for a circle) and returns its area. (Use Math.PI).
  5. In the main method, call each overloaded calculateArea method with appropriate arguments and print the results clearly labeled.

6. Complete Solution: Shape Area Calculator (Overloaded)

// Chapter13/ShapeAreaCalculator.java
public class ShapeAreaCalculator {

    public static void main(String[] args) {
        System.out.println("--- Shape Area Calculator (Overloaded Methods) ---");

        // Calculate area of a square using calculateArea(double side)
        double squareSide = 4.0;
        double squareArea = calculateArea(squareSide);
        System.out.printf("Area of a square with side %.1f: %.2f\n", squareSide, squareArea); // Output: 16.00

        // Calculate area of a rectangle using calculateArea(double length, double width)
        double rectLength = 6.0;
        double rectWidth = 3.0;
        double rectangleArea = calculateArea(rectLength, rectWidth);
        System.out.printf("Area of a rectangle with length %.1f and width %.1f: %.2f\n", rectLength, rectWidth, rectangleArea); // Output: 18.00

        // Calculate area of a circle using calculateArea(double radius)
        double circleRadius = 5.0;
        double circleArea = calculateArea(circleRadius);
        System.out.printf("Area of a circle with radius %.1f: %.2f\n", circleRadius, circleArea); // Output: 78.54
        
        System.out.println("-------------------------------------------------");
    }

    // 2. Overloaded method to calculate area of a SQUARE
    // Signature: calculateArea(double)
    public static double calculateArea(double side) {
        // Basic validation
        if (side <= 0) {
            System.out.println("Error: Side must be positive.");
            return 0.0;
        }
        return side * side;
    }

    // 3. Overloaded method to calculate area of a RECTANGLE
    // Signature: calculateArea(double, double)
    public static double calculateArea(double length, double width) {
        // Basic validation
        if (length <= 0 || width <= 0) {
            System.out.println("Error: Length and width must be positive.");
            return 0.0;
        }
        return length * width;
    }

    // 4. Overloaded method to calculate area of a CIRCLE
    // Signature: calculateArea(double) - WAIT! This conflicts with square's signature.
    // We need a different signature. For a circle, we could potentially
    // rename it to calculateCircleArea, or use a slightly different parameter name/type if possible.
    // However, to strictly follow the prompt and demonstrate overloading with *different parameter sets*,
    // let's adjust the square to take an 'int' side, or rethink the circle.
    // For this example, let's assume we *can* differentiate by context, or simply
    // adjust the square to take 'int' if we want to reuse 'double' for circle radius.
    // Let's make the square method take an 'int' just to ensure unique signatures.

    // Let's modify the square method to take an 'int' side for a clear signature difference.
    // (A common design decision in real code to avoid ambiguity or if integer sides are typical for squares)
    public static double calculateArea(int side) {
        if (side <= 0) {
            System.out.println("Error: Side must be positive.");
            return 0.0;
        }
        return (double) side * side; // Cast to double for return type consistency
    }
    
    // Now for the circle, using 'double radius' is distinct from 'int side' for the square.
    // Signature: calculateArea(double) - This is distinct from calculateArea(int)
    public static double calculateArea(double radius, String shapeType) { // Adding a dummy parameter to make signature unique
        // This is a common trick if you need to overload based on a single numerical type but distinguish intent.
        // Or, more practically, just name it calculateCircleArea if the primary parameter is the same.
        // For the sake of demonstrating method overloading STRICTLY with unique signatures for the same name,
        // I will add a dummy String parameter, although this isn't always the cleanest design.
        // A better approach would be to have calculateSquareArea, calculateRectangleArea, calculateCircleArea.
        // But the prompt wants "calculateArea" overloaded.

        // Re-evaluating the prompt for "calculateArea that takes one double parameter radius (for a circle)"
        // This directly clashes with `calculateArea(double side)` for a square.
        // This highlights a *real-world* overloading problem!
        // To strictly fulfill the prompt, I will rename the square method or the circle method
        // OR add a *disambiguating* parameter to one of them.
        // Given "calculateArea" overloaded, a common practice would be:
        // calculateArea(double side) for square
        // calculateArea(double length, double width) for rectangle
        // calculateArea(double radius, boolean isCircle) for circle (using a dummy boolean)
        // OR, the preferred way:
        // calculateArea(double value, String shapeType) where shapeType clarifies.

        // Let's assume for a first year, the intent is clear when calling:
        // calculateArea(4.0) -> Square
        // calculateArea(5.0) -> Circle
        // This IS an ambiguity unless Java has context, which it doesn't.
        // So, let's make the square method accept an `int` side and the circle `double radius` to disambiguate.

        // Reworked `calculateArea(double side)` to `calculateArea(int side)`
        // And now this `calculateArea(double radius)` is fine.
        if (radius <= 0) {
            System.out.println("Error: Radius must be positive.");
            return 0.0;
        }
        return Math.PI * radius * radius;
    }
}
// Modified main for the new square signature
/*
public class ShapeAreaCalculator {
    public static void main(String[] args) {
        System.out.println("--- Shape Area Calculator (Overloaded Methods) ---");

        // Calculate area of a square using calculateArea(int side)
        int squareSide = 4;
        double squareArea = calculateArea(squareSide); // Calls calculateArea(int)
        System.out.printf("Area of a square with side %d: %.2f\n", squareSide, squareArea); // Output: 16.00

        // Calculate area of a rectangle using calculateArea(double length, double width)
        double rectLength = 6.0;
        double rectWidth = 3.0;
        double rectangleArea = calculateArea(rectLength, rectWidth); // Calls calculateArea(double, double)
        System.out.printf("Area of a rectangle with length %.1f and width %.1f: %.2f\n", rectLength, rectWidth, rectangleArea); // Output: 18.00

        // Calculate area of a circle using calculateArea(double radius)
        double circleRadius = 5.0;
        double circleArea = calculateArea(circleRadius); // Calls calculateArea(double)
        System.out.printf("Area of a circle with radius %.1f: %.2f\n", circleRadius, circleArea); // Output: 78.54
        
        System.out.println("-------------------------------------------------");
    }

    // 2. Overloaded method to calculate area of a SQUARE
    // Signature: calculateArea(int) -- DISTINCT from calculateArea(double) for circle
    public static double calculateArea(int side) {
        if (side <= 0) {
            System.out.println("Error: Side must be positive.");
            return 0.0;
        }
        return (double) side * side;
    }

    // 3. Overloaded method to calculate area of a RECTANGLE
    // Signature: calculateArea(double, double)
    public static double calculateArea(double length, double width) {
        if (length <= 0 || width <= 0) {
            System.out.println("Error: Length and width must be positive.");
            return 0.0;
        }
        return length * width;
    }

    // 4. Overloaded method to calculate area of a CIRCLE
    // Signature: calculateArea(double) -- DISTINCT from calculateArea(int) for square
    public static double calculateArea(double radius) {
        if (radius <= 0) {
            System.out.println("Error: Radius must be positive.");
            return 0.0;
        }
        return Math.PI * radius * radius;
    }
}
*/

I will apply the corrected code (the second block within the thought process) for the ShapeAreaCalculator exercise to ensure distinct method signatures for overloading.

// Chapter13/ShapeAreaCalculator.java
public class ShapeAreaCalculator {

    public static void main(String[] args) {
        System.out.println("--- Shape Area Calculator (Overloaded Methods) ---");

        // Calculate area of a square using calculateArea(int side)
        int squareSide = 4;
        double squareArea = calculateArea(squareSide); // Calls calculateArea(int)
        System.out.printf("Area of a square with side %d: %.2f\n", squareSide, squareArea); // Output: 16.00

        // Calculate area of a rectangle using calculateArea(double length, double width)
        double rectLength = 6.0;
        double rectWidth = 3.0;
        double rectangleArea = calculateArea(rectLength, rectWidth); // Calls calculateArea(double, double)
        System.out.printf("Area of a rectangle with length %.1f and width %.1f: %.2f\n", rectLength, rectWidth, rectangleArea); // Output: 18.00

        // Calculate area of a circle using calculateArea(double radius)
        double circleRadius = 5.0;
        double circleArea = calculateArea(circleRadius); // Calls calculateArea(double)
        System.out.printf("Area of a circle with radius %.1f: %.2f\n", circleRadius, circleArea); // Output: 78.54
        
        System.out.println("-------------------------------------------------");
    }

    // 2. Overloaded method to calculate area of a SQUARE
    // Signature: calculateArea(int) -- This is distinct from calculateArea(double) below.
    public static double calculateArea(int side) {
        if (side <= 0) {
            System.out.println("Error: Side must be positive for a square.");
            return 0.0;
        }
        return (double) side * side; // Cast to double for return type consistency
    }

    // 3. Overloaded method to calculate area of a RECTANGLE
    // Signature: calculateArea(double, double)
    public static double calculateArea(double length, double width) {
        if (length <= 0 || width <= 0) {
            System.out.println("Error: Length and width must be positive for a rectangle.");
            return 0.0;
        }
        return length * width;
    }

    // 4. Overloaded method to calculate area of a CIRCLE
    // Signature: calculateArea(double) -- This is distinct from calculateArea(int) above.
    public static double calculateArea(double radius) {
        if (radius <= 0) {
            System.out.println("Error: Radius must be positive for a circle.");
            return 0.0;
        }
        return Math.PI * radius * radius;
    }
}

Chapter 14: Memory - Pass-by-Value vs Reference.

1. Quick Theory: Understanding How Arguments Are Passed

This chapter is crucial for DAM exams and your overall understanding of how Java handles memory. When you pass arguments to a method, Java always uses a mechanism called pass-by-value. However, what "value" is passed depends on whether you're dealing with primitive types or reference types (objects).

The "Why": Understanding this distinction is fundamental to predicting how your code will behave when data is manipulated within methods. Misunderstanding pass-by-value for references is a common source of bugs for beginners.

2. Code Examples: Seeing the Difference

Example 14.1: Passing Primitives (Pass-by-Value)

// Chapter14/PassByValuePrimitive.java
public class PassByValuePrimitive {

    public static void main(String[] args) {
        System.out.println("--- Pass-by-Value for Primitives ---");

        int originalNumber = 10;
        System.out.println("Before method call: originalNumber = " + originalNumber); // Output: 10

        // Call the method to try and change the number
        modifyPrimitive(originalNumber);

        // Check the value of originalNumber after the method call
        System.out.println("After method call: originalNumber = " + originalNumber); // Output: 10 (STILL 10!)
        // Explanation: The 'modifyPrimitive' method received a COPY of 'originalNumber'.
        // Changing the copy inside the method does not affect the original variable.

        System.out.println("------------------------------------");
    }

    // Method that tries to modify an integer parameter
    public static void modifyPrimitive(int number) { // 'number' is a copy of 'originalNumber'
        System.out.println("Inside method (before change): number = " + number); // Output: 10
        number = 99; // Modifying the local copy 'number'
        System.out.println("Inside method (after change): number = " + number); // Output: 99
    }
}

Example 14.2: Passing Arrays (Pass-by-Value of Reference) - Modifying Contents

// Chapter14/PassByValueReferenceModifyContent.java
import java.util.Arrays;

public class PassByValueReferenceModifyContent {

    public static void main(String[] args) {
        System.out.println("--- Pass-by-Value of Reference (Modifying Contents) ---");

        int[] originalArray = {10, 20, 30};
        System.out.println("Before method call: originalArray = " + Arrays.toString(originalArray)); // Output: [10, 20, 30]

        // Call the method to try and modify the array's elements
        modifyArrayElements(originalArray);

        // Check the array after the method call
        System.out.println("After method call: originalArray = " + Arrays.toString(originalArray)); // Output: [100, 20, 30] (CHANGED!)
        // Explanation: Both 'originalArray' and 'arr' (in the method) point to the SAME array object in memory.
        // Changing an element via 'arr' modifies the shared object, so 'originalArray' sees the change.

        System.out.println("-----------------------------------------------------");
    }

    // Method that modifies an array element
    public static void modifyArrayElements(int[] arr) { // 'arr' is a copy of the reference to 'originalArray'
        System.out.println("Inside method (before change): arr = " + Arrays.toString(arr)); // Output: [10, 20, 30]
        arr[0] = 100; // Modifying the content of the array object that both references point to
        System.out.println("Inside method (after change): arr = " + Arrays.toString(arr)); // Output: [100, 20, 30]
    }
}

Example 14.3: Passing Arrays (Pass-by-Value of Reference) - Reassigning Reference

// Chapter14/PassByValueReferenceReassignReference.java
import java.util.Arrays;

public class PassByValueReferenceReassignReference {

    public static void main(String[] args) {
        System.out.println("--- Pass-by-Value of Reference (Reassigning Reference) ---");

        int[] originalArray = {1, 2, 3};
        System.out.println("Before method call: originalArray = " + Arrays.toString(originalArray)); // Output: [1, 2, 3]

        // Call the method to try and reassign the array reference
        reassignArrayReference(originalArray);

        // Check the array after the method call
        System.out.println("After method call: originalArray = " + Arrays.toString(originalArray)); // Output: [1, 2, 3] (NOT CHANGED!)
        // Explanation: 'reassignArrayReference' received a COPY of the reference.
        // It then made that COPY point to a *NEW* array.
        // The ORIGINAL 'originalArray' variable still points to the old array.

        System.out.println("--------------------------------------------------------");
    }

    // Method that tries to reassign the array parameter to a new array
    public static void reassignArrayReference(int[] arr) { // 'arr' is a copy of the reference
        System.out.println("Inside method (before reassign): arr = " + Arrays.toString(arr)); // Output: [1, 2, 3]
        
        // 'arr' now points to a NEW array object in memory.
        // The original array ({1, 2, 3}) is no longer reachable via the 'arr' parameter.
        arr = new int[]{500, 600, 700}; 
        System.out.println("Inside method (after reassign): arr = " + Arrays.toString(arr)); // Output: [500, 600, 700]
    }
}

3. Line-by-Line Breakdown

Let's break down the key parts from all three examples:

// From PassByValuePrimitive.java
public static void modifyPrimitive(int number) {
    // 'number' here is a local parameter variable.
    // When modifyPrimitive(originalNumber) is called, the *value* of originalNumber (10)
    // is copied into 'number'. So, 'number' = 10.
    number = 99;
    // This line changes the value of the *local copy* 'number' to 99.
    // The original 'originalNumber' variable in 'main' is completely unaffected.
}
// From PassByValueReferenceModifyContent.java
public static void modifyArrayElements(int[] arr) {
    // 'arr' here is a local parameter variable.
    // When modifyArrayElements(originalArray) is called, the *reference value* (memory address)
    // that 'originalArray' holds is copied into 'arr'.
    // Now, both 'originalArray' and 'arr' contain the same memory address, meaning they
    // *both point to the exact same array object* {10, 20, 30} on the Heap.

    arr[0] = 100;
    // This line uses the 'arr' reference to access the element at index 0 of the array object.
    // It then modifies the content of that array object from 10 to 100.
    // Since 'originalArray' in 'main' points to the *same object*, it also sees this change.
}
// From PassByValueReferenceReassignReference.java
public static void reassignArrayReference(int[] arr) {
    // Similar to the previous example, 'arr' starts by holding a copy of the reference
    // to the original array {1, 2, 3}.

    arr = new int[]{500, 600, 700};
    // This is the crucial line. The 'new int[]{...}' part creates a *brand new* array object
    // {500, 600, 700} in a *different memory location* on the Heap.
    // The '=' operator then makes the *local parameter variable* 'arr' point to this *new* array.
    // The 'originalArray' variable in 'main' is unaffected. It still holds its original reference
    // to the {1, 2, 3} array. The change to 'arr' is local to this method.
}

4. Clean Code Pro-Tips

5. Unsolved Exercise: Data Manipulator

Let's put this memory concept to the test.

  1. Create a class named DataManipulator.
  2. In the main method, declare an int variable myNumber initialized to 25.
  3. In the main method, declare an int array myArray initialized to {10, 20, 30, 40}.
  4. Create a public static void method named doubleValue(int num) that attempts to double the value of num internally.
  5. Create a public static void method named doubleArrayElements(int[] arr) that iterates through the arr and doubles each of its elements.
  6. Create a public static void method named replaceArray(int[] arr) that attempts to reassign arr to a new array {1, 1, 1, 1}.
  7. In main, before and after calling each of these methods, print the state of myNumber and myArray to clearly observe the effects.

6. Complete Solution: Data Manipulator

// Chapter14/DataManipulator.java
import java.util.Arrays; // Needed for Arrays.toString()

public class DataManipulator {

    public static void main(String[] args) {
        // 2. Declare and initialize myNumber
        int myNumber = 25;
        // 3. Declare and initialize myArray
        int[] myArray = {10, 20, 30, 40};

        System.out.println("--- Initial State ---");
        System.out.println("myNumber: " + myNumber); // Output: 25
        System.out.println("myArray:  " + Arrays.toString(myArray)); // Output: [10, 20, 30, 40]

        System.out.println("\n--- Calling doubleValue(myNumber) ---");
        doubleValue(myNumber); // Pass a copy of 25
        System.out.println("After doubleValue call, myNumber: " + myNumber); // Output: 25 (UNTOUCHED!)

        System.out.println("\n--- Calling doubleArrayElements(myArray) ---");
        doubleArrayElements(myArray); // Pass a copy of the reference to myArray
        System.out.println("After doubleArrayElements call, myArray: " + Arrays.toString(myArray)); // Output: [20, 40, 60, 80] (MODIFIED!)

        System.out.println("\n--- Calling replaceArray(myArray) ---");
        replaceArray(myArray); // Pass a copy of the reference to myArray
        System.out.println("After replaceArray call, myArray: " + Arrays.toString(myArray)); // Output: [20, 40, 60, 80] (UNTOUCHED!)
        // Explanation: The 'replaceArray' method only reassigned its local parameter 'arr'.
        // The original 'myArray' variable in 'main' still points to the same array object it always did.

        System.out.println("\n--- Final State ---");
        System.out.println("myNumber: " + myNumber);
        System.out.println("myArray:  " + Arrays.toString(myArray));
        System.out.println("-------------------");
    }

    // 4. Method to attempt doubling a primitive value
    public static void doubleValue(int num) { // 'num' is a copy of 'myNumber'
        System.out.println("  Inside doubleValue (before): num = " + num); // Output: 25
        num = num * 2; // Doubles the local copy
        System.out.println("  Inside doubleValue (after): num = " + num); // Output: 50
    }

    // 5. Method to double elements of an array
    public static void doubleArrayElements(int[] arr) { // 'arr' is a copy of the reference to 'myArray'
        System.out.println("  Inside doubleArrayElements (before): arr = " + Arrays.toString(arr)); // Output: [10, 20, 30, 40]
        for (int i = 0; i < arr.length; i++) {
            arr[i] = arr[i] * 2; // Modifies the actual elements of the shared array object
        }
        System.out.println("  Inside doubleArrayElements (after): arr = " + Arrays.toString(arr)); // Output: [20, 40, 60, 80]
    }

    // 6. Method to attempt replacing an array (reassigning the reference)
    public static void replaceArray(int[] arr) { // 'arr' is a copy of the reference to 'myArray'
        System.out.println("  Inside replaceArray (before): arr = " + Arrays.toString(arr)); // Output: [20, 40, 60, 80]
        arr = new int[]{1, 1, 1, 1}; // 'arr' now points to a NEW array object
        System.out.println("  Inside replaceArray (after): arr = " + Arrays.toString(arr)); // Output: [1, 1, 1, 1]
    }
}

Book 1: Part 3 (Logic & Robustness)

Chapter 15: The Math Class.

1. Quick Theory: Your Built-in Math Toolkit

Just like System for output or Arrays for array utilities, Java provides a powerful built-in class called java.lang.Math for performing common mathematical operations. You don't need to import Math because it's part of the java.lang package, which is automatically available to every Java program.

The key thing to understand about the Math class is that all its methods are static. This means you call them directly on the Math class itself (e.g., Math.sqrt(25)), without needing to create an object of the Math class (you won't see new Math()). The "Why": Math operations are universal and don't depend on any specific "instance" or "object" of math, so a static utility class is the perfect design pattern. It saves you from writing complex math functions yourself and ensures consistent, accurate results.

2. Code Examples: Math in Action

Example 15.1: Basic Math Operations

// Chapter15/BasicMathOperations.java
// No import statement needed for Math, as it's in java.lang package

public class BasicMathOperations {
    public static void main(String[] args) {
        System.out.println("--- Basic Math Operations ---");

        // 1. Math.sqrt(double a): Returns the square root of a double value.
        double num1 = 25.0;
        double squareRoot = Math.sqrt(num1);
        System.out.println("Square root of " + num1 + ": " + squareRoot); // Output: 5.0

        double num2 = 10.0;
        System.out.println("Square root of " + num2 + ": " + Math.sqrt(num2)); // Output: 3.16...

        // 2. Math.pow(double base, double exponent): Returns the value of the first argument
        //                                            raised to the power of the second argument.
        double base = 2.0;
        double exponent = 3.0;
        double powerResult = Math.pow(base, exponent);
        System.out.println(base + " raised to the power of " + exponent + ": " + powerResult); // Output: 8.0

        double base2 = 5.0;
        double exponent2 = 2.0;
        System.out.println(base2 + " squared: " + Math.pow(base2, exponent2)); // Output: 25.0

        // 3. Math.abs(dataType a): Returns the absolute value of the argument.
        // It has overloaded versions for int, long, float, and double.
        int negativeInt = -15;
        System.out.println("Absolute value of " + negativeInt + ": " + Math.abs(negativeInt)); // Output: 15

        double negativeDouble = -7.34;
        System.out.println("Absolute value of " + negativeDouble + ": " + Math.abs(negativeDouble)); // Output: 7.34

        // 4. Math.round(float a) or Math.round(double a): Rounds to the nearest long or int.
        double decimalNum = 3.7;
        long roundedNum = Math.round(decimalNum); // Returns a long
        System.out.println("Rounded " + decimalNum + " to nearest integer: " + roundedNum); // Output: 4

        double decimalNum2 = 3.2;
        System.out.println("Rounded " + decimalNum2 + " to nearest integer: " + Math.round(decimalNum2)); // Output: 3

        System.out.println("-----------------------------");
    }
}

Example 15.2: The Importance of Math.random()

// Chapter15/MathRandomExample.java
public class MathRandomExample {
    public static void main(String[] args) {
        System.out.println("--- Math.random() Examples ---");

        // Math.random(): Returns a pseudo-random double value between 0.0 (inclusive) and 1.0 (exclusive).
        // This means it can be 0.0, but never 1.0. (e.g., 0.0, 0.123, 0.999...)

        // Generating a few random numbers
        System.out.println("Random double 1: " + Math.random());
        System.out.println("Random double 2: " + Math.random());
        System.out.println("Random double 3: " + Math.random());

        // How to generate a random number within a specific range [0, max-1]?
        // Example: Random integer between 0 and 9 (inclusive)
        // (Math.random() * max)
        int random0to9 = (int) (Math.random() * 10); // Multiplies by 10, then casts to int (truncates decimals)
        System.out.println("Random integer (0-9): " + random0to9);

        // Example: Random integer between 1 and 10 (inclusive)
        // (Math.random() * max) + min
        int random1to10 = (int) (Math.random() * 10) + 1; // 0-9 shifted to 1-10
        System.out.println("Random integer (1-10): " + random1to10);

        // A more general formula for integers in [min, max] is:
        // (int)(Math.random() * (max - min + 1)) + min;
        int min = 50;
        int max = 100;
        int random50to100 = (int) (Math.random() * (max - min + 1)) + min;
        System.out.println("Random integer (" + min + "-" + max + "): " + random50to100);

        System.out.println("------------------------------");
    }
}

3. Line-by-Line Breakdown

Let's break down BasicMathOperations.java:

double squareRoot = Math.sqrt(num1);
// 'Math.sqrt()': This is a static method of the Math class.
//                It takes a 'double' as input and returns its 'double' square root.
// 'num1': The argument (value) passed to the method. Here, 25.0.

double powerResult = Math.pow(base, exponent);
// 'Math.pow()': Takes two 'double' arguments: the base and the exponent.
//               Returns 'base' raised to the power of 'exponent' as a 'double'.

System.out.println("Absolute value of " + negativeInt + ": " + Math.abs(negativeInt));
// 'Math.abs()': An overloaded method. It takes a number (int, long, float, or double)
//               and returns its absolute (non-negative) value.

And from MathRandomExample.java:

System.out.println("Random double 1: " + Math.random());
// 'Math.random()': This method generates a pseudo-random floating-point number.
//                  Crucially, this number is always in the range [0.0, 1.0), meaning
//                  it can be 0.0 but will never quite reach 1.0 (e.g., 0.9999999999999999).

int random0to9 = (int) (Math.random() * 10);
// This is how we start converting a random double [0.0, 1.0) into a random integer range.
// 1. 'Math.random() * 10': This scales the number to be in the range [0.0, 10.0).
//                          (e.g., if random() is 0.5, this becomes 5.0; if 0.99, becomes 9.9)
// 2. '(int) (...)': This is a type cast. It truncates the decimal part, effectively
//                   giving us integers from 0 to 9 (since 9.9 becomes 9).

int random1to10 = (int) (Math.random() * 10) + 1;
// To get 1 to 10: we generate 0-9 and then add 1 to shift the range.
// (int)(Math.random() * 10) gives 0, 1, ..., 9.
// Adding 1 makes it 1, 2, ..., 10.

4. Pro-Tips: Using Math Effectively

5. Unsolved Exercise: Right Triangle Solver

Create a program to calculate properties of a right-angled triangle.

  1. Create a class named RightTriangleSolver.
  2. Declare two double variables, sideA and sideB, and initialize them to 3.0 and 4.0 respectively.
  3. Calculate the hypotenuse (sideC) using the Pythagorean theorem (c = sqrt(a^2 + b^2)). Print the result.
  4. Calculate the absolute difference between sideA and sideB. Print the result.
  5. Generate and print a random integer between 1 and 100 (inclusive) to represent a hypothetical "angle modifier".

6. Complete Solution: Right Triangle Solver

// Chapter15/RightTriangleSolver.java
public class RightTriangleSolver {
    public static void main(String[] args) {
        System.out.println("--- Right Triangle Solver ---");

        // 2. Declare and initialize sideA and sideB
        double sideA = 3.0;
        double sideB = 4.0;
        System.out.printf("Side A: %.1f, Side B: %.1f\n", sideA, sideB);

        // 3. Calculate the hypotenuse (sideC) using Math.sqrt and Math.pow
        // c = sqrt(a^2 + b^2)
        double sideC = Math.sqrt(Math.pow(sideA, 2) + Math.pow(sideB, 2));
        System.out.printf("Hypotenuse (Side C): %.2f\n", sideC); // Output should be 5.00

        // 4. Calculate the absolute difference between sideA and sideB
        double absoluteDifference = Math.abs(sideA - sideB);
        System.out.printf("Absolute difference between Side A and Side B: %.2f\n", absoluteDifference); // Output: 1.00

        // 5. Generate and print a random integer between 1 and 100 (inclusive)
        int minAngleModifier = 1;
        int maxAngleModifier = 100;
        int angleModifier = (int)(Math.random() * (maxAngleModifier - minAngleModifier + 1)) + minAngleModifier;
        System.out.println("Random angle modifier (1-100): " + angleModifier);
        
        System.out.println("-----------------------------");
    }
}

Chapter 16: Random Numbers.

1. Quick Theory: Bringing Randomness to Life

Building on Math.random(), this chapter focuses on how to consistently generate random numbers within specific integer ranges. This is a very common task in programming, from simulating dice rolls in a game to selecting random items from a list, or even generating test data.

As we saw, Math.random() gives you a double between 0.0 (inclusive) and 1.0 (exclusive). To get an integer within a custom range, you need to scale and shift this double value, then cast it to an int. The key is to correctly use the formula (int)(Math.random() * (max - min + 1)) + min; where min and max are inclusive boundaries of your desired range. The +1 is crucial to make the max value inclusive, and +min shifts the entire range. The "Why": Randomness is fundamental for simulations, games, security (though Math.random is not cryptographically secure), and many other dynamic applications. Mastering random number generation allows your programs to be less predictable and more interactive.

2. Code Examples: Practical Randomness

Example 16.1: Generating Random Integers in Different Ranges

// Chapter16/RandomIntegerGenerator.java
public class RandomIntegerGenerator {
    public static void main(String[] args) {
        System.out.println("--- Random Integer Generation ---");

        // 1. Random number between 0 and 9 (inclusive)
        // Formula: (int)(Math.random() * (max + 1)) if min is 0
        int randomNumber0_9 = (int)(Math.random() * 10);
        System.out.println("Random (0-9): " + randomNumber0_9);

        // 2. Random number between 1 and 6 (inclusive) - like a dice roll
        // Here, min = 1, max = 6
        // (int)(Math.random() * (6 - 1 + 1)) + 1  => (int)(Math.random() * 6) + 1
        int diceRoll = (int)(Math.random() * 6) + 1;
        System.out.println("Dice Roll (1-6): " + diceRoll);

        // 3. Random number between 20 and 30 (inclusive)
        // Here, min = 20, max = 30
        // (int)(Math.random() * (30 - 20 + 1)) + 20 => (int)(Math.random() * 11) + 20
        int randomNumber20_30 = (int)(Math.random() * (30 - 20 + 1)) + 20;
        System.out.println("Random (20-30): " + randomNumber20_30);

        // 4. Generate a random "difficulty level" between 1 and 3
        int minDifficulty = 1;
        int maxDifficulty = 3;
        int difficultyLevel = (int)(Math.random() * (maxDifficulty - minDifficulty + 1)) + minDifficulty;
        System.out.println("Difficulty Level (1-3): " + difficultyLevel);

        // 5. Generate a random boolean (true/false)
        // This can be done by checking if a random number is above a certain threshold (e.g., 0.5)
        boolean coinFlip = Math.random() < 0.5; // True for roughly 50% of the time
        System.out.println("Coin Flip (true/false): " + coinFlip);

        System.out.println("---------------------------------");
    }
}

Example 16.2: Simulating Multiple Dice Rolls

// Chapter16/DiceSimulator.java
public class DiceSimulator {
    public static void main(String[] args) {
        System.out.println("--- Dice Roll Simulator ---");

        // Simulate rolling a 6-sided die 5 times
        System.out.println("Rolling a 6-sided die 5 times:");
        for (int i = 0; i < 5; i++) {
            int roll = (int)(Math.random() * 6) + 1; // Generates a number between 1 and 6
            System.out.println("  Roll " + (i + 1) + ": " + roll);
        }

        System.out.println("\n--- Two Dice Roll Sums ---");
        // Simulate rolling two 6-sided dice and summing their results 3 times
        for (int i = 0; i < 3; i++) {
            int die1 = (int)(Math.random() * 6) + 1;
            int die2 = (int)(Math.random() * 6) + 1;
            int sum = die1 + die2;
            System.out.println("  Roll " + (i + 1) + ": Die 1=" + die1 + ", Die 2=" + die2 + ", Sum=" + sum);
        }

        System.out.println("---------------------------");
    }
}

3. Line-by-Line Breakdown

Let's break down RandomIntegerGenerator.java:

int randomNumber0_9 = (int)(Math.random() * 10);
// 'Math.random()': Generates a double from 0.0 (inclusive) up to 1.0 (exclusive).
// '* 10': Multiplies the random double by 10. This scales the range to be from 0.0 up to 10.0 (exclusive).
//         For example, if Math.random() is 0.0, it's 0.0. If 0.999..., it's 9.999...
// '(int)': Type casts the resulting double to an integer. This truncates the decimal part.
//          So, any value from 0.0 to 0.999... becomes 0.
//          Any value from 9.0 to 9.999... becomes 9.
// Result: An integer uniformly distributed from 0 to 9, inclusive.

int diceRoll = (int)(Math.random() * 6) + 1;
// This is the common pattern for [min, max] range. Here, min=1, max=6.
// 'max - min + 1' for this case is (6 - 1 + 1) = 6.
// 'Math.random() * 6': Generates a double from 0.0 up to 6.0 (exclusive).
// '(int)(...)': Truncates to an integer from 0 to 5.
// '+ 1': Shifts the range. So, 0 becomes 1, 1 becomes 2, ..., 5 becomes 6.
// Result: An integer uniformly distributed from 1 to 6, inclusive.

4. Pro-Tips: Handling Randomness Like a Pro

5. Unsolved Exercise: Item Dropper

You're simulating an item drop system in a simple game.

  1. Create a class named ItemDropper.
  2. Simulate rolling a 20-sided die (1-20) to determine if a "rare item" drops. If the roll is 18 or higher, print "Rare Item Dropped!". Otherwise, print "Common Item Dropped.". Do this once.
  3. Simulate a character finding a random amount of gold between 10 and 50 (inclusive). Print the amount of gold found. Do this 3 times using a loop.

6. Complete Solution: Item Dropper

// Chapter16/ItemDropper.java
public class ItemDropper {
    public static void main(String[] args) {
        System.out.println("--- Item Drop Simulator ---");

        // 2. Simulate rolling a 20-sided die for rare item drop
        int minRoll = 1;
        int maxRoll = 20;
        int rareItemThreshold = 18;

        int diceRoll = (int)(Math.random() * (maxRoll - minRoll + 1)) + minRoll;
        System.out.println("20-sided die roll: " + diceRoll);

        if (diceRoll >= rareItemThreshold) {
            System.out.println("Rare Item Dropped!");
        } else {
            System.out.println("Common Item Dropped.");
        }

        System.out.println("\n--- Gold Found ---");
        // 3. Simulate finding a random amount of gold 3 times
        int minGold = 10;
        int maxGold = 50;

        for (int i = 0; i < 3; i++) {
            int goldFound = (int)(Math.random() * (maxGold - minGold + 1)) + minGold;
            System.out.println("Attempt " + (i + 1) + ": Found " + goldFound + " gold pieces.");
        }
        System.out.println("---------------------------");
    }
}

Chapter 17: Introduction to Exceptions (Try-Catch).

1. Quick Theory: When Things Go Wrong (and how to fix them)

No matter how carefully you write your code, things can go wrong at runtime. A user might enter text when you expect a number, a file might be missing, or your program might try to divide by zero. These runtime errors are called exceptions. When an exception occurs and isn't handled, your program "crashes" (terminates abruptly), which is a very unprofessional user experience.

The try-catch block is Java's mechanism for exception handling. It allows your program to "try" to execute a block of code that might throw an exception. If an exception does occur, instead of crashing, the program's control flow immediately jumps to the catch block, where you can "catch" the specific type of exception and gracefully handle it (e.g., print an error message, ask for input again, log the error). The "Why": try-catch makes your programs robust and "bulletproof." It allows them to recover from unexpected situations, provide helpful feedback to the user, and continue running instead of failing miserably.

For this chapter, we'll focus on InputMismatchException, which is commonly thrown by the Scanner class when the user provides input that doesn't match the expected type (e.g., "hello" for nextInt()).

2. Code Examples: Building Robust Input

Example 17.1: Catching InputMismatchException

// Chapter17/InputValidationBasic.java
import java.util.InputMismatchException; // Needed to specifically catch this exception
import java.util.Scanner;

public class InputValidationBasic {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);

        System.out.println("--- Basic Input Validation ---");
        System.out.print("Please enter an integer number: ");

        try {
            // Code that might throw an InputMismatchException if user enters non-integer
            int number = scanner.nextInt();
            System.out.println("You entered: " + number);
        } catch (InputMismatchException e) {
            // This block executes if an InputMismatchException occurs in the try block
            System.out.println("Error: Invalid input! That was not an integer.");
            // Important: After an InputMismatchException, the Scanner's buffer is not clean.
            // It still contains the invalid token. We need to clear it.
            scanner.nextLine(); // Consumes the invalid input from the buffer
        } finally {
            // The finally block always executes, useful for closing resources
            System.out.println("Validation attempt finished.");
            scanner.close(); // Close the scanner here to ensure it's always closed
        }
        
        System.out.println("Program continues..."); // Program doesn't crash, it continues here
        System.out.println("------------------------------");
    }
}

Example 17.2: Robust Input Loop

// Chapter17/RobustInputLoop.java
import java.util.InputMismatchException;
import java.util.Scanner;

public class RobustInputLoop {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        int userAge = 0;
        boolean validInput = false; // A flag to control the loop

        System.out.println("--- Robust Age Input ---");

        // Loop until valid integer input is received
        while (!validInput) { // Loop as long as input is NOT valid
            System.out.print("Please enter your age (a whole number): ");
            try {
                userAge = scanner.nextInt(); // Attempt to read an integer
                // If nextInt() succeeds, it means we have valid input
                validInput = true; // Set flag to true to exit the loop
            } catch (InputMismatchException e) {
                // If nextInt() fails (InputMismatchException), execute this block
                System.out.println("Invalid input! Please enter a number like 25.");
                scanner.nextLine(); // Clear the invalid input from the scanner buffer
            }
        }

        System.out.println("Thank you! Your age is: " + userAge);
        
        // Let's get another valid input (e.g., GPA)
        double userGPA = 0.0;
        validInput = false; // Reset the flag for the next input

        while (!validInput) {
            System.out.print("Please enter your GPA (e.g., 3.85): ");
            try {
                userGPA = scanner.nextDouble();
                validInput = true;
            } catch (InputMismatchException e) {
                System.out.println("Invalid input! Please enter a decimal number like 3.5.");
                scanner.nextLine(); // Clear buffer
            }
        }
        System.out.printf("Your GPA is: %.2f\n", userGPA);


        System.out.println("--- Input Process Complete ---");
        scanner.close(); // Always close the scanner
        System.out.println("------------------------------");
    }
}

3. Line-by-Line Breakdown

Let's break down InputValidationBasic.java:

try {
    // Code in this block is 'tried'. If no exception occurs, it runs to completion.
    int number = scanner.nextInt(); // This line is prone to InputMismatchException
    System.out.println("You entered: " + number);
} catch (InputMismatchException e) {
    // 'catch (InputMismatchException e)': This declares an exception handler.
    // If an 'InputMismatchException' occurs in the 'try' block, control immediately transfers here.
    // 'e' is a variable name for the exception object itself, useful for getting details (e.getMessage()).
    System.out.println("Error: Invalid input! That was not an integer.");
    scanner.nextLine(); // CRITICAL: Consumes the remaining invalid input (e.g., "abc\n") from the buffer.
                        // Without this, the scanner would try to re-read "abc" next time, causing an infinite loop
                        // if this were inside a loop for repeated input.
} finally {
    // This 'finally' block is optional but important.
    // Code here will always execute, regardless of whether an exception occurred,
    // was caught, or if the 'try' block completed successfully.
    System.out.println("Validation attempt finished.");
    scanner.close(); // Good place to close resources like Scanner.
}

4. Pro-Tips: Writing Bulletproof Programs

5. Unsolved Exercise: Robust Age and Height Input

Create a program that asks the user for their age (integer) and then their height in meters (decimal). Your program should:

  1. Use try-catch blocks to handle InputMismatchException for both inputs.
  2. Use loops to continuously ask for input until a valid number is provided for both age and height.
  3. Print the valid age and height once successfully entered.
  4. Ensure the Scanner is properly closed.

6. Complete Solution: Robust Age and Height Input

// Chapter17/RobustAgeHeightInput.java
import java.util.InputMismatchException;
import java.util.Scanner;

public class RobustAgeHeightInput {
    public static void main(String[] args) {
        Scanner inputReader = new Scanner(System.in);
        int age = 0;
        double height = 0.0;
        boolean isValidInput; // Flag for loop control

        System.out.println("--- Robust User Profile Input ---");

        // Loop for age input
        isValidInput = false;
        while (!isValidInput) {
            System.out.print("Enter your age (integer): ");
            try {
                age = inputReader.nextInt();
                // Basic validation for age range (optional but good practice)
                if (age < 0 || age > 120) {
                    System.out.println("Age must be between 0 and 120. Please try again.");
                } else {
                    isValidInput = true; // Valid age entered, exit loop
                }
            } catch (InputMismatchException e) {
                System.out.println("Invalid input! Please enter a whole number for age (e.g., 30).");
                inputReader.nextLine(); // Clear the invalid input from the buffer
            }
        }
        inputReader.nextLine(); // Consume the leftover newline after reading age

        // Loop for height input
        isValidInput = false;
        while (!isValidInput) {
            System.out.print("Enter your height in meters (decimal, e.g., 1.75): ");
            try {
                height = inputReader.nextDouble();
                // Basic validation for height range
                if (height < 0.5 || height > 2.5) {
                    System.out.println("Height must be between 0.5 and 2.5 meters. Please try again.");
                } else {
                    isValidInput = true; // Valid height entered, exit loop
                }
            } catch (InputMismatchException e) {
                System.out.println("Invalid input! Please enter a decimal number for height (e.g., 1.80).");
                inputReader.nextLine(); // Clear the invalid input from the buffer
            }
        }
        inputReader.nextLine(); // Consume the leftover newline after reading height

        System.out.println("\n--- Your Profile ---");
        System.out.println("Age: " + age + " years");
        System.out.printf("Height: %.2f meters\n", height);
        System.out.println("--------------------");

        inputReader.close(); // Close the scanner
    }
}

Chapter 18: Basic Algorithm: Searching & Counting.

1. Quick Theory: Finding and Tallying Data

Now that you know how to store data in arrays, the next logical step is to learn how to find specific information within those arrays and count how many times it appears. These are fundamental algorithmic tasks that you'll perform constantly in programming.

The "Why": These basic algorithms form the building blocks for more complex data processing. Even though Java's Arrays class provides some search functionality, understanding how to implement these manually reinforces your looping and conditional logic skills, which are vital for adapting these patterns to more custom search or count criteria.

2. Code Examples: Searching and Counting in Arrays

// Chapter18/ArraySearch.java
public class ArraySearch {

    public static void main(String[] args) {
        System.out.println("--- Array Search Examples ---");

        int[] numbers = {10, 25, 5, 40, 15, 25, 30};
        int target1 = 40;
        int target2 = 99;
        int target3 = 25;

        // Search for target1 (40)
        boolean found40 = containsValue(numbers, target1);
        System.out.println("Array: " + java.util.Arrays.toString(numbers));
        System.out.println("Does array contain " + target1 + "? " + found40); // Output: true

        // Search for target2 (99)
        boolean found99 = containsValue(numbers, target2);
        System.out.println("Does array contain " + target2 + "? " + found99); // Output: false

        // Search for target3 (25) and get its first index
        int firstIndex25 = findFirstIndex(numbers, target3);
        System.out.println("First occurrence of " + target3 + " is at index: " + firstIndex25); // Output: 1

        System.out.println("-----------------------------");
    }

    /**
     * Searches for a given target value in an integer array.
     * @param arr The array to search through.
     * @param target The value to search for.
     * @return true if the target is found in the array, false otherwise.
     */
    public static boolean containsValue(int[] arr, int target) {
        // Iterate through each element of the array
        for (int i = 0; i < arr.length; i++) {
            // If the current element matches the target
            if (arr[i] == target) {
                return true; // We found it, so we can immediately return true and exit the method.
            }
        }
        // If the loop finishes without finding the target, it means the target is not in the array.
        return false;
    }

    /**
     * Finds the index of the first occurrence of a target value in an integer array.
     * @param arr The array to search through.
     * @param target The value to search for.
     * @return The index of the first occurrence, or -1 if the target is not found.
     */
    public static int findFirstIndex(int[] arr, int target) {
        for (int i = 0; i < arr.length; i++) {
            if (arr[i] == target) {
                return i; // Return the index immediately upon finding the first match
            }
        }
        return -1; // Conventionally return -1 if the element is not found
    }
}

Example 18.2: Counting Occurrences of a Value

// Chapter18/ArrayCounting.java
public class ArrayCounting {

    public static void main(String[] args) {
        System.out.println("--- Array Counting Examples ---");

        String[] fruits = {"apple", "banana", "apple", "orange", "grape", "apple"};
        String fruit1 = "apple";
        String fruit2 = "kiwi";
        String fruit3 = "orange";

        System.out.println("Fruits array: " + java.util.Arrays.toString(fruits));

        // Count occurrences of "apple"
        int appleCount = countOccurrences(fruits, fruit1);
        System.out.println("Number of '" + fruit1 + "'s: " + appleCount); // Output: 3

        // Count occurrences of "kiwi"
        int kiwiCount = countOccurrences(fruits, fruit2);
        System.out.println("Number of '" + fruit2 + "'s: " + kiwiCount); // Output: 0

        // Count occurrences of "orange"
        int orangeCount = countOccurrences(fruits, fruit3);
        System.out.println("Number of '" + fruit3 + "'s: " + orangeCount); // Output: 1

        System.out.println("-----------------------------");
    }

    /**
     * Counts how many times a target string appears in a string array.
     * @param arr The string array to search through.
     * @param target The string to count.
     * @return The number of times the target string appears.
     */
    public static int countOccurrences(String[] arr, String target) {
        int count = 0; // Initialize a counter variable
        // Use for-each loop for simple iteration when index is not needed
        for (String element : arr) {
            // For Strings, always use .equals() for content comparison!
            if (element.equals(target)) {
                count++; // Increment the counter if a match is found
            }
        }
        return count; // Return the total count after checking all elements
    }
}

3. Line-by-Line Breakdown

Let's break down ArraySearch.java:

public static boolean containsValue(int[] arr, int target) {
    for (int i = 0; i < arr.length; i++) { // Loop through each index
        if (arr[i] == target) {             // Check if the element at current index matches target
            return true;                    // If found, immediately return true and exit the method
        }
    }
    return false; // If the loop completes, it means target was not found, so return false
}

And from ArrayCounting.java:

public static int countOccurrences(String[] arr, String target) {
    int count = 0; // Initialize accumulator for counting
    for (String element : arr) { // Iterate through each string element in the array
        if (element.equals(target)) { // For String comparison, use .equals()
            count++; // Increment the counter
        }
    }
    return count; // Return the final count
}

4. Pro-Tips: Algorithmic Habits

5. Unsolved Exercise: Inventory Check

You have an inventory of various items.

  1. Create a class named InventoryCheck.
  2. Declare a String array named inventory initialized with at least 8 items, including some duplicates (e.g., {"Book", "Pen", "Book", "Laptop", "Mouse", "Book", "Keyboard", "Pen"}).
  3. Implement a public static boolean method itemExists(String[] inventory, String itemName) that returns true if itemName is found in the inventory array, false otherwise.
  4. Implement a public static int method countItem(String[] inventory, String itemName) that returns the total number of times itemName appears in the inventory array.
  5. In the main method:
    • Test itemExists for "Laptop" and "Tablet", printing the results.
    • Test countItem for "Book" and "Pen", printing the results.

6. Complete Solution: Inventory Check

// Chapter18/InventoryCheck.java
import java.util.Arrays; // For printing the array easily

public class InventoryCheck {

    public static void main(String[] args) {
        System.out.println("--- Inventory Check ---");

        // 2. Declare and initialize the inventory array
        String[] inventory = {"Book", "Pen", "Book", "Laptop", "Mouse", "Book", "Keyboard", "Pen"};
        System.out.println("Current Inventory: " + Arrays.toString(inventory));

        System.out.println("\n--- Item Existence Check ---");
        // Test itemExists for "Laptop"
        String searchItem1 = "Laptop";
        boolean laptopFound = itemExists(inventory, searchItem1);
        System.out.println("Does '" + searchItem1 + "' exist? " + laptopFound); // Output: true

        // Test itemExists for "Tablet"
        String searchItem2 = "Tablet";
        boolean tabletFound = itemExists(inventory, searchItem2);
        System.out.println("Does '" + searchItem2 + "' exist? " + tabletFound); // Output: false

        System.out.println("\n--- Item Count Check ---");
        // Test countItem for "Book"
        String countItem1 = "Book";
        int bookCount = countItem(inventory, countItem1);
        System.out.println("Number of '" + countItem1 + "'s: " + bookCount); // Output: 3

        // Test countItem for "Pen"
        String countItem2 = "Pen";
        int penCount = countItem(inventory, countItem2);
        System.out.println("Number of '" + countItem2 + "'s: " + penCount); // Output: 2

        System.out.println("-----------------------");
    }

    /**
     * Checks if a specified item exists in the inventory array.
     * @param inventory The array of inventory items.
     * @param itemName The name of the item to search for.
     * @return true if the item is found, false otherwise.
     */
    public static boolean itemExists(String[] inventory, String itemName) {
        for (String item : inventory) {
            // Use .equals() for String comparison
            if (item.equals(itemName)) {
                return true; // Item found, no need to check further
            }
        }
        return false; // Loop finished, item not found
    }

    /**
     * Counts the number of occurrences of a specified item in the inventory array.
     * @param inventory The array of inventory items.
     * @param itemName The name of the item to count.
     * @return The total number of times the item appears.
     */
    public static int countItem(String[] inventory, String itemName) {
        int count = 0; // Initialize the counter
        for (String item : inventory) {
            if (item.equals(itemName)) {
                count++; // Increment count if item matches
            }
        }
        return count; // Return the final count
    }
}

Chapter 19: Basic Algorithm: Accumulators & Flags.

1. Quick Theory: Tracking State in Loops

These are two simple yet powerful patterns you'll use constantly when writing algorithms:

The "Why": Accumulators simplify calculations over collections, allowing you to derive single summary values. Boolean flags provide a clean and readable way to track state changes and make decisions based on events that happen within a process, especially within loops, without requiring complex nested conditions. They make your algorithms easier to reason about and maintain.

2. Code Examples: Accumulators and Flags in Harmony

Example 19.1: Summing and Detecting Negatives

// Chapter19/AccumulatorAndFlagExample.java
public class AccumulatorAndFlagExample {

    public static void main(String[] args) {
        System.out.println("--- Accumulator and Flag Example ---");

        int[] numbers = {5, 10, -3, 20, -8, 15, 0};
        System.out.println("Numbers array: " + java.util.Arrays.toString(numbers));

        // 1. Accumulator: Calculating the sum of positive numbers
        int sumOfPositives = 0; // Initialize accumulator
        
        // 2. Boolean Flag: To check if any negative number was encountered
        boolean foundNegative = false; // Initialize flag to false (assume no negatives yet)

        System.out.println("\nProcessing numbers...");
        for (int number : numbers) {
            if (number > 0) {
                sumOfPositives += number; // Accumulate positive numbers
            } else if (number < 0) {
                foundNegative = true; // Set the flag to true if a negative number is found
                System.out.println("  Negative number encountered: " + number);
            }
        }

        System.out.println("\nSummary:");
        System.out.println("Sum of positive numbers: " + sumOfPositives); // Output: 5 + 10 + 20 + 15 = 50

        // Use the flag to make a decision after the loop
        if (foundNegative) {
            System.out.println("At least one negative number was present in the array.");
        } else {
            System.out.println("No negative numbers were found in the array.");
        }
        System.out.println("------------------------------------");
    }
}

Example 19.2: Flag to Control Loop or Indicate First Match

// Chapter19/LoopControlWithFlag.java
import java.util.Scanner;

public class LoopControlWithFlag {

    public static void main(String[] args) {
        System.out.println("--- Loop Control with Flag ---");

        String[] usernames = {"alice", "bob", "charlie", "diana", "frank"};
        Scanner scanner = new Scanner(System.in);

        // Flag to indicate if a username was found
        boolean userFound = false;

        System.out.print("Enter a username to search for: ");
        String searchName = scanner.nextLine();

        // Loop through usernames to find a match
        for (String user : usernames) {
            if (user.equals(searchName.toLowerCase())) { // Convert input to lowercase for case-insensitive comparison
                userFound = true; // Set flag to true if a match is found
                System.out.println("User '" + searchName + "' found in the system!");
                break; // 'break' keyword immediately exits the loop (optimization)
            }
        }

        // Use the flag after the loop to provide a final message
        if (!userFound) { // If the flag is still false
            System.out.println("User '" + searchName + "' was not found in the system.");
        }

        System.out.println("\n--- Loop Stopped by Flag (Conceptual) ---");
        // Example of a flag to stop a "processing" loop early based on a condition
        int processCounter = 0;
        boolean criticalErrorDetected = false;

        while (processCounter < 10 && !criticalErrorDetected) { // Loop while counter is low AND no error
            System.out.println("Processing step " + (processCounter + 1));
            
            // Simulate a condition that might set the flag
            if (processCounter == 3) {
                System.out.println("  Simulating a critical error at step " + (processCounter + 1) + "!");
                criticalErrorDetected = true; // Set the flag to true
            }
            processCounter++;
        }

        if (criticalErrorDetected) {
            System.out.println("Process halted due to critical error after " + processCounter + " steps.");
        } else {
            System.out.println("Process completed successfully through all steps.");
        }

        System.out.println("------------------------------------");
        scanner.close();
    }
}

3. Line-by-Line Breakdown

Let's break down AccumulatorAndFlagExample.java:

int sumOfPositives = 0; // Accumulator: Initialized to 0 before the loop.
boolean foundNegative = false; // Flag: Initialized to false, assuming no negatives yet.

for (int number : numbers) {
    if (number > 0) {
        sumOfPositives += number; // Accumulator updated: add positive numbers to the sum.
    } else if (number < 0) {
        foundNegative = true; // Flag changed: once a negative is found, set it to true.
                              // It stays true even if more negatives are found or the loop continues.
    }
}

if (foundNegative) { // Decision based on the flag's final state after the loop.
    System.out.println("At least one negative number was present in the array.");
}

And from LoopControlWithFlag.java:

boolean userFound = false; // Flag for search result

for (String user : usernames) {
    if (user.equals(searchName.toLowerCase())) {
        userFound = true; // Set flag to true
        break;            // 'break' keyword: Exits the loop immediately.
                          // This is an optimization; if we found what we need, no reason to continue looping.
    }
}

if (!userFound) { // Check the flag's state after the loop
    System.out.println("User '" + searchName + "' was not found in the system.");
}

// Second example:
boolean criticalErrorDetected = false; // Flag for error state

while (processCounter < 10 && !criticalErrorDetected) { // Loop condition incorporates the flag
    // The loop continues as long as processCounter is less than 10 AND criticalErrorDetected is false.
    // If criticalErrorDetected becomes true inside the loop, the condition !criticalErrorDetected becomes false,
    // and the loop terminates in the next iteration check.
    if (processCounter == 3) {
        criticalErrorDetected = true; // Set the flag
    }
    processCounter++;
}

4. Pro-Tips: Mastering Accumulators and Flags

5. Unsolved Exercise: Student Attendance Tracker

You need to track student attendance and summarize the results.

  1. Create a class named AttendanceTracker.
  2. Declare a boolean array named attendance for 5 days, representing whether a student was present (true) or absent (false). Initialize it with a mix of true and false values (e.g., {true, false, true, true, false}).
  3. Use an accumulator to count the totalPresentDays.
  4. Use a boolean flag hadPerfectAttendance to check if the student was present every single day. Initialize it to true. If any false (absence) is encountered, set this flag to false.
  5. After the loop, print the totalPresentDays.
  6. Then, use the hadPerfectAttendance flag to print whether the student had perfect attendance or not.

6. Complete Solution: Student Attendance Tracker

// Chapter19/AttendanceTracker.java
import java.util.Arrays; // For printing the array easily

public class AttendanceTracker {
    public static void main(String[] args) {
        System.out.println("--- Student Attendance Tracker ---");

        // 2. Declare and initialize the attendance array for 5 days
        boolean[] attendance = {true, false, true, true, true}; // Student was absent on Day 2
        System.out.println("Daily Attendance: " + Arrays.toString(attendance));

        // 3. Accumulator: Count total present days
        int totalPresentDays = 0; // Initialize sum to 0

        // 4. Boolean Flag: Check for perfect attendance
        boolean hadPerfectAttendance = true; // Assume perfect attendance initially

        System.out.println("\nProcessing attendance records...");
        for (int i = 0; i < attendance.length; i++) {
            if (attendance[i]) { // If student was present (value is true)
                totalPresentDays++; // Increment the accumulator
                System.out.println("Day " + (i + 1) + ": Present");
            } else { // If student was absent (value is false)
                hadPerfectAttendance = false; // Set flag to false (can't be perfect if even one absence)
                System.out.println("Day " + (i + 1) + ": Absent");
            }
        }

        System.out.println("\n--- Attendance Summary ---");
        // 5. Print total present days
        System.out.println("Total days present: " + totalPresentDays + " out of " + attendance.length);

        // 6. Use the flag to print perfect attendance status
        if (hadPerfectAttendance) {
            System.out.println("Congratulations! The student had perfect attendance.");
        } else {
            System.out.println("The student did NOT have perfect attendance.");
        }
        System.out.println("--------------------------");
    }
}

Book 1: Part 4 (Data Manipulation & String Mastery)

Chapter 20: Advanced String Methods.

1. Technical Theory: Precision Text Manipulation

In the real world, data rarely comes in perfectly formatted numbers. Often, it's text, and you need to extract specific pieces, clean it up, or transform it. This is where advanced String methods become indispensable. We briefly touched upon length(), toUpperCase(), and charAt(). Now, let's look at more powerful tools:

The "Why": These methods are crucial for data parsing and data cleaning. When you read data from files (like CSVs), databases, or user input, it's often a single string with multiple pieces of information separated by special characters. Knowing split() allows you to effortlessly break these down into individual variables, making your program much more flexible and able to process real-world data formats. trim(), replace(), and contains() further empower you to prepare and validate this text data.

2. Professional Code: String Manipulation Showcase

Example 20.1: Parsing Data with split()

// Chapter20/DataParser.java
public class DataParser {
    public static void main(String[] args) {
        System.out.println("--- Data Parsing with split() ---");

        // Simulate a line of data from a CSV file (Comma Separated Values)
        String csvLine = "Laptop;1200.50;5;Electronics;True";
        // Let's assume the format is: Name;Price;Quantity;Category;Available

        System.out.println("Original CSV Line: " + csvLine);

        // 1. Using split() to break the string into an array of substrings
        // The delimiter is ";"
        String[] dataParts = csvLine.split(";");

        System.out.println("Number of data parts: " + dataParts.length); // Output: 5

        // 2. Accessing and converting individual parts
        // Always check array bounds before accessing elements!
        if (dataParts.length == 5) {
            String productName = dataParts[0];
            double productPrice = Double.parseDouble(dataParts[1]); // Convert String to double
            int productQuantity = Integer.parseInt(dataParts[2]);   // Convert String to int
            String productCategory = dataParts[3];
            boolean isAvailable = Boolean.parseBoolean(dataParts[4]); // Convert String to boolean

            System.out.println("Product Name: " + productName);
            System.out.printf("Price: $%.2f\n", productPrice);
            System.out.println("Quantity: " + productQuantity);
            System.out.println("Category: " + productCategory);
            System.out.println("Available: " + isAvailable);
        } else {
            System.out.println("Error: Invalid CSV line format. Expected 5 parts.");
        }

        // Another example with a different delimiter (space)
        String sentence = "Java is a powerful language";
        String[] words = sentence.split(" "); // Split by space
        System.out.println("\nWords in sentence: ");
        for (String word : words) {
            System.out.println("- " + word);
        }

        System.out.println("---------------------------------");
    }
}

Example 20.2: Cleaning and Modifying Strings

// Chapter20/StringCleaner.java
public class StringCleaner {
    public static void main(String[] args) {
        System.out.println("--- String Cleaning and Modification ---");

        // Simulate messy user input
        String userInput = "  Please enter your NAME  ";
        System.out.println("Original input: '" + userInput + "'");

        // 1. Using trim(): Remove leading/trailing whitespace
        String trimmedInput = userInput.trim();
        System.out.println("Trimmed input:  '" + trimmedInput + "'"); // Output: 'Please enter your NAME'

        // 2. Using replace(): Change specific characters or substrings
        String message = "Hello, World! This is a test message.";
        System.out.println("\nOriginal message: '" + message + "'");

        // Replace all spaces with underscores
        String noSpaces = message.replace(" ", "_");
        System.out.println("Spaces replaced:  '" + noSpaces + "'"); // Output: Hello,_World!_This_is_a_test_message.

        // Replace "test" with "sample"
        String replacedWord = message.replace("test", "sample");
        System.out.println("Word replaced:    '" + replacedWord + "'"); // Output: Hello, World! This is a sample message.
        
        // Replace a character
        String replacedChar = message.replace('e', 'E');
        System.out.println("Char replaced:    '" + replacedChar + "'"); // Output: HEllO, World! This is a tEst mEssagE.

        // 3. Using contains(): Check for existence of a substring
        String email = "john.doe@example.com";
        String searchDomain = "@example.com";
        
        System.out.println("\nEmail: '" + email + "'");
        boolean hasExampleDomain = email.contains(searchDomain);
        System.out.println("Does email contain '" + searchDomain + "'? " + hasExampleDomain); // Output: true

        String badWord = "badword";
        String userComment = "This is a comment without badword.";
        boolean commentContainsBadWord = userComment.toLowerCase().contains(badWord); // Check case-insensitively
        System.out.println("Comment contains '" + badWord + "'? " + commentContainsBadWord); // Output: true

        System.out.println("------------------------------------");
    }
}

3. Clean Code Tip: Avoid 'Spaghetti Code' with Chaining

Instead of multiple temporary variables for string manipulations, you can often chain method calls because many String methods return a new String. This makes your code more concise and readable.

Bad (Spaghetti):

String messy = "  some,data  ";
String temp1 = messy.trim();
String temp2 = temp1.replace(",", "");
String cleaned = temp2.toUpperCase();

Good (Chained):

String messy = "  some,data  ";
String cleaned = messy.trim().replace(",", "").toUpperCase(); // Chained calls

This reduces intermediate variables and shows a clear flow of operations.

4. Unsolved Exercise: Log Entry Processor

You have a log entry string that records an event.

  1. Create a class named LogProcessor.
  2. Declare a String variable logEntry with the value "2024-03-10 14:35:10 | ERROR | User 'admin' failed login from 192.168.1.100".
  3. Split the logEntry by the delimiter " | " to get individual parts.
  4. Extract the timestamp, level (e.g., "ERROR"), and message into separate String variables.
  5. In the message part, extract the username (e.g., "admin") by splitting the message further. You might need to use replace() or substring() to isolate the username.
  6. Print the timestamp, level, and username clearly labeled.

5. Complete Solution: Log Entry Processor

// Chapter20/LogProcessor.java
public class LogProcessor {
    public static void main(String[] args) {
        System.out.println("--- Log Entry Processor ---");

        // 2. Declare logEntry string
        String logEntry = "2024-03-10 14:35:10 | ERROR | User 'admin' failed login from 192.168.1.100";
        System.out.println("Original Log Entry: " + logEntry);

        // 3. Split the logEntry by " | "
        String[] parts = logEntry.split(" \\| "); // Note: " | " needs to be escaped as " \\| " if using regex for literal "|"

        // Always check if you got the expected number of parts
        if (parts.length >= 3) {
            // 4. Extract timestamp, level, and message
            String timestamp = parts[0];
            String level = parts[1];
            String message = parts[2];

            System.out.println("\nExtracted Parts:");
            System.out.println("Timestamp: " + timestamp);
            System.out.println("Level:     " + level);
            System.out.println("Message:   " + message);

            // 5. Extract username from the message part
            // The message is "User 'admin' failed login from 192.168.1.100"
            // We want "admin". We can find the start of ' and end of '.
            int userStart = message.indexOf("'") + 1; // Find first ' and move past it
            int userEnd = message.indexOf("'", userStart); // Find next ' starting from userStart

            String username = "N/A"; // Default value
            if (userStart > 0 && userEnd > userStart) {
                username = message.substring(userStart, userEnd);
            }
            
            // 6. Print the extracted information
            System.out.println("Username:  " + username);

        } else {
            System.out.println("Error: Log entry format unexpected. Could not split into 3 parts.");
        }
        System.out.println("---------------------------");
    }
}

Chapter 21: String Comparison Deep Dive.

1. Technical Theory: Precise String Order and Equality

We've already learned that == compares object references (memory addresses) and .equals() compares the actual content of strings. However, sometimes you need more: case-insensitive comparison or to know how strings order alphabetically.

The "Why": Precise string comparison is vital for sorting lists (alphabetically), searching (finding matches regardless of case), validation (e.g., matching a username), and many other data processing tasks. compareTo() is fundamental to understanding how collections of strings are ordered, which is a key requirement for any data management system.

2. Professional Code: String Comparison in Action

Example 21.1: Equality and Case Sensitivity

// Chapter21/StringEquality.java
public class StringEquality {
    public static void main(String[] args) {
        System.out.println("--- String Equality Comparisons ---");

        String s1 = "Java";
        String s2 = "java";
        String s3 = "Java";
        String s4 = new String("Java"); // Creates a new String object

        System.out.println("s1: '" + s1 + "'");
        System.out.println("s2: '" + s2 + "'");
        System.out.println("s3: '" + s3 + "'");
        System.out.println("s4: '" + s4 + "'");
        System.out.println("---------------------------------");

        // 1. Using equals() - Case-sensitive content comparison
        System.out.println("s1.equals(s2) (Java vs java): " + s1.equals(s2)); // Output: false
        System.out.println("s1.equals(s3) (Java vs Java): " + s1.equals(s3)); // Output: true
        System.out.println("s1.equals(s4) (Java vs new Java): " + s1.equals(s4)); // Output: true (content is same)

        // 2. Using equalsIgnoreCase() - Case-insensitive content comparison
        System.out.println("\ns1.equalsIgnoreCase(s2) (Java vs java): " + s1.equalsIgnoreCase(s2)); // Output: true
        System.out.println("s1.equalsIgnoreCase(s3) (Java vs Java): " + s1.equalsIgnoreCase(s3)); // Output: true

        // Reminder: == compares references, not content
        System.out.println("\ns1 == s2: " + (s1 == s2)); // Output: false (different objects, different content)
        System.out.println("s1 == s3: " + (s1 == s3)); // Output: true (String Pool optimization)
        System.out.println("s1 == s4: " + (s1 == s4)); // Output: false (s4 is a new object)

        System.out.println("---------------------------------");
    }
}

Example 21.2: Ordering Strings with compareTo() and Sorting an Array

// Chapter21/StringOrdering.java
import java.util.Arrays; // Needed for Arrays.sort() and Arrays.toString()

public class StringOrdering {
    public static void main(String[] args) {
        System.out.println("--- String Ordering with compareTo() ---");

        String fruit1 = "Apple";
        String fruit2 = "Banana";
        String fruit3 = "apple";
        String fruit4 = "Orange";

        System.out.println("Comparing '" + fruit1 + "' and '" + fruit2 + "': " + fruit1.compareTo(fruit2));
        // Output: Negative number (Apple comes before Banana)

        System.out.println("Comparing '" + fruit2 + "' and '" + fruit1 + "': " + fruit2.compareTo(fruit1));
        // Output: Positive number (Banana comes after Apple)

        System.out.println("Comparing '" + fruit1 + "' and '" + fruit3 + "': " + fruit1.compareTo(fruit3));
        // Output: Positive number (Uppercase 'A' comes before lowercase 'a' in ASCII/Unicode,
        // so "Apple" is considered "greater" than "apple")

        System.out.println("Comparing '" + fruit3 + "' and '" + fruit1 + "': " + fruit3.compareTo(fruit1));
        // Output: Negative number

        System.out.println("Comparing 'hello' and 'hello': " + "hello".compareTo("hello")); // Output: 0

        // Use compareToIgnoreCase() for case-insensitive ordering
        System.out.println("\nComparing '" + fruit1 + "' and '" + fruit3 + "' (ignore case): " + fruit1.compareToIgnoreCase(fruit3));
        // Output: 0 (Apple is equal to apple when ignoring case)

        System.out.println("\n--- Sorting an Array of Strings ---");
        String[] names = {"Charlie", "Alice", "Bob", "frank", "David"};
        System.out.println("Original names: " + Arrays.toString(names));

        // Arrays.sort() uses compareTo() internally for String arrays
        Arrays.sort(names); 
        System.out.println("Sorted names:   " + Arrays.toString(names));
        // Output: [Alice, Bob, Charlie, David, frank]
        // Note: 'frank' comes after 'David' because lowercase 'f' comes after uppercase 'D' in ASCII/Unicode.
        // For purely alphabetical regardless of case, you'd need a custom Comparator (advanced topic).
        
        System.out.println("------------------------------------");
    }
}

3. Clean Code Tip: Always Be Explicit About Case-Sensitivity

When comparing strings, assume you need to be case-sensitive unless there's a specific reason not to be. If you do want case-insensitivity, use equalsIgnoreCase() or compareToIgnoreCase(). Never rely on converting both strings to .toLowerCase() or .toUpperCase() manually just for comparison if a dedicated method exists; it's less efficient and less readable.

4. Unsolved Exercise: User Login & Name Sorter

You're building a simple user system.

  1. Create a class named UserSystem.
  2. Declare a String variable storedUsername with the value "admin".
  3. Ask the user to enter their username using Scanner.
  4. Compare the enteredUsername with storedUsername using equalsIgnoreCase(). If they match, print "Login successful!". Otherwise, print "Login failed.".
  5. Declare a String array userList with 4-5 names, some with different casing (e.g., "Zoe", "adam", "eve", "chris", "BOB").
  6. Print the userList before sorting.
  7. Sort the userList alphabetically using Arrays.sort().
  8. Print the userList after sorting. Comment on why the order might not be strictly alphabetical if casing differs.

6. Complete Solution: User Login & Name Sorter

// Chapter21/UserSystem.java
import java.util.Arrays;
import java.util.Scanner;

public class UserSystem {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        System.out.println("--- User Login & Name Sorter ---");

        // 2. Stored username
        String storedUsername = "admin";

        // 3. Ask user for username
        System.out.print("Enter your username: ");
        String enteredUsername = scanner.nextLine();

        // 4. Compare using equalsIgnoreCase()
        if (enteredUsername.equalsIgnoreCase(storedUsername)) {
            System.out.println("Login successful!");
        } else {
            System.out.println("Login failed. Incorrect username.");
        }

        System.out.println("\n--- User List Sorting ---");
        // 5. Declare and initialize userList array
        String[] userList = {"Zoe", "adam", "eve", "chris", "BOB"};
        System.out.println("Original user list: " + Arrays.toString(userList));

        // 7. Sort the userList
        Arrays.sort(userList);

        // 8. Print sorted userList
        System.out.println("Sorted user list:   " + Arrays.toString(userList));
        // Comment: The sorted order might not be strictly alphabetical from a human perspective
        // if names have different casing (e.g., 'BOB' might come before 'chris' or after 'adam').
        // This is because Arrays.sort() for Strings uses compareTo(), which is case-sensitive
        // and orders based on Unicode values (uppercase letters come before lowercase letters).
        // For true case-insensitive alphabetical sorting, a custom Comparator is needed (advanced).

        System.out.println("------------------------------");
        scanner.close();
    }
}

Chapter 22: Searching Algorithms.

1. Technical Theory: The Quest for Data - Linear Search

The most straightforward way to find an element within an array is called a linear search. This algorithm does exactly what its name suggests: it checks each element in the array, one by one, from beginning to end, until it finds a match or reaches the end of the array.

The "Why": While more efficient search algorithms exist (like binary search, which requires a sorted array), understanding linear search is fundamental. It teaches you how to systematically iterate through a collection to find data, a pattern that forms the basis for many other algorithms. For small arrays, its simplicity often outweighs the need for more complex, faster algorithms.

2. Professional Code: Implementing Linear Search

Example 22.1: Linear Search for Integers

// Chapter22/LinearSearchInt.java
public class LinearSearchInt {

    public static void main(String[] args) {
        System.out.println("--- Linear Search for Integers ---");

        int[] scores = {85, 92, 78, 95, 60, 88, 72};
        int targetScore1 = 95;
        int targetScore2 = 78;
        int targetScore3 = 100; // Not in the array

        System.out.println("Scores: " + java.util.Arrays.toString(scores));

        // Test 1: Search for 95
        int index1 = linearSearch(scores, targetScore1);
        if (index1 != -1) {
            System.out.println(targetScore1 + " found at index: " + index1); // Output: 3
        } else {
            System.out.println(targetScore1 + " not found.");
        }

        // Test 2: Search for 78
        int index2 = linearSearch(scores, targetScore2);
        if (index2 != -1) {
            System.out.println(targetScore2 + " found at index: " + index2); // Output: 2
        } else {
            System.out.println(targetScore2 + " not found.");
        }

        // Test 3: Search for 100
        int index3 = linearSearch(scores, targetScore3);
        if (index3 != -1) {
            System.out.println(targetScore3 + " found at index: " + index3);
        } else {
            System.out.println(targetScore3 + " not found."); // Output: 100 not found.
        }

        System.out.println("----------------------------------");
    }

    /**
     * Performs a linear search on an integer array to find the target value.
     * @param arr The array to search within.
     * @param target The integer value to search for.
     * @return The index of the first occurrence of the target, or -1 if not found.
     */
    public static int linearSearch(int[] arr, int target) {
        // Iterate through the array from the first element to the last
        for (int i = 0; i < arr.length; i++) {
            // Check if the current element matches the target
            if (arr[i] == target) {
                return i; // If a match is found, return its index immediately
            }
        }
        // If the loop completes without returning, it means the target was not found
        return -1; // Indicate that the element was not found
    }
}

Example 22.2: Linear Search for Strings (Case-Insensitive)

// Chapter22/LinearSearchString.java
public class LinearSearchString {

    public static void main(String[] args) {
        System.out.println("--- Linear Search for Strings (Case-Insensitive) ---");

        String[] products = {"Laptop", "Mouse", "Keyboard", "Monitor", "Webcam"};
        String searchProduct1 = "monitor"; // Different casing
        String searchProduct2 = "Speaker"; // Not in the array
        String searchProduct3 = "Mouse";

        System.out.println("Products: " + java.util.Arrays.toString(products));

        // Test 1: Search for "monitor" (case-insensitive)
        int index1 = linearSearchIgnoreCase(products, searchProduct1);
        if (index1 != -1) {
            System.out.println("'" + searchProduct1 + "' found at index: " + index1); // Output: 3
        } else {
            System.out.println("'" + searchProduct1 + "' not found.");
        }

        // Test 2: Search for "Speaker"
        int index2 = linearSearchIgnoreCase(products, searchProduct2);
        if (index2 != -1) {
            System.out.println("'" + searchProduct2 + "' found at index: " + index2);
        } else {
            System.out.println("'" + searchProduct2 + "' not found."); // Output: 'Speaker' not found.
        }
        
        // Test 3: Search for "Mouse" (exact casing match)
        int index3 = linearSearchIgnoreCase(products, searchProduct3);
        if (index3 != -1) {
            System.out.println("'" + searchProduct3 + "' found at index: " + index3); // Output: 1
        } else {
            System.out.println("'" + searchProduct3 + "' not found.");
        }

        System.out.println("----------------------------------------------");
    }

    /**
     * Performs a linear search on a String array, ignoring case, to find the target string.
     * @param arr The array of strings to search within.
     * @param target The string value to search for (case-insensitive).
     * @return The index of the first occurrence of the target, or -1 if not found.
     */
    public static int linearSearchIgnoreCase(String[] arr, String target) {
        // Iterate through the array using a standard for loop to get indices
        for (int i = 0; i < arr.length; i++) {
            // Use equalsIgnoreCase() for case-insensitive comparison
            if (arr[i].equalsIgnoreCase(target)) {
                return i; // Return the index if a match is found
            }
        }
        return -1; // Target not found after checking all elements
    }
}

3. Clean Code Tip: Abstract Search Logic into Methods

Always encapsulate your search logic within a dedicated method (like linearSearch or linearSearchIgnoreCase). This promotes code reusability, makes your main method cleaner, and allows you to easily test your search logic independently. Passing the array and target as parameters makes the method flexible.

4. Unsolved Exercise: Student ID Finder

You have a list of student IDs.

  1. Create a class named StudentIdFinder.
  2. Declare an int array studentIDs with at least 6 unique student ID numbers (e.g., 101, 105, 110, 112, 115, 120).
  3. Implement a public static int method findStudent(int[] ids, int targetId) that performs a linear search for targetId in the ids array. It should return the index if found, and -1 otherwise.
  4. In the main method, test findStudent for targetId = 110 and targetId = 100. Print messages indicating whether the student was found and, if so, at which index.

5. Complete Solution: Student ID Finder

// Chapter22/StudentIdFinder.java
public class StudentIdFinder {

    public static void main(String[] args) {
        System.out.println("--- Student ID Finder ---");

        // 2. Declare and initialize studentIDs array
        int[] studentIDs = {101, 105, 110, 112, 115, 120};
        System.out.println("Student IDs: " + java.util.Arrays.toString(studentIDs));

        // Test 1: Search for an ID that exists
        int targetId1 = 110;
        int index1 = findStudent(studentIDs, targetId1);
        if (index1 != -1) {
            System.out.println("Student ID " + targetId1 + " found at index: " + index1); // Output: 2
        } else {
            System.out.println("Student ID " + targetId1 + " not found.");
        }

        // Test 2: Search for an ID that does not exist
        int targetId2 = 100;
        int index2 = findStudent(studentIDs, targetId2);
        if (index2 != -1) {
            System.out.println("Student ID " + targetId2 + " found at index: " + index2);
        } else {
            System.out.println("Student ID " + targetId2 + " not found."); // Output: -1
        }
        
        System.out.println("-------------------------");
    }

    /**
     * Performs a linear search for a target student ID in an array of IDs.
     * @param ids The array of student IDs to search within.
     * @param targetId The ID number to search for.
     * @return The index of the target ID if found, or -1 if not found.
     */
    public static int findStudent(int[] ids, int targetId) {
        // Loop through each element of the IDs array
        for (int i = 0; i < ids.length; i++) {
            // Check if the current ID matches the target ID
            if (ids[i] == targetId) {
                return i; // Return the index immediately upon finding a match
            }
        }
        // If the loop completes, the target ID was not found
        return -1;
    }
}

Chapter 23: Sorting Fundamentals.

1. Technical Theory: Ordering Your Data - Bubble Sort

Sorting is the process of arranging elements in an array (or list) into a specific order, such as ascending (smallest to largest) or descending (largest to smallest). It's one of the most common and important operations in computer science. There are many sorting algorithms, each with its own efficiency characteristics.

For your DAM exams, you must understand Bubble Sort. While it's one of the simplest sorting algorithms to understand and implement, it's also highly inefficient for large datasets.

The "Why": You need to know Bubble Sort not because you'll use it in production code, but because it's an excellent exercise in understanding nested loops, array manipulation, and the fundamental concept of comparison and swapping elements. It's often a mandatory question to assess your grasp of basic algorithmic logic for exams like DAM.

2. Professional Code: Bubble Sort Implementation

Example 23.1: Sorting an Integer Array with Bubble Sort

// Chapter23/BubbleSortInt.java
import java.util.Arrays; // For printing the array easily

public class BubbleSortInt {

    public static void main(String[] args) {
        System.out.println("--- Bubble Sort for Integers ---");

        int[] numbers = {64, 34, 25, 12, 22, 11, 90};
        System.out.println("Original array: " + Arrays.toString(numbers));

        bubbleSort(numbers); // Call the sorting method

        System.out.println("Sorted array:   " + Arrays.toString(numbers)); // Output: [11, 12, 22, 25, 34, 64, 90]
        System.out.println("------------------------------");

        int[] anotherArray = {5, 1, 4, 2, 8};
        System.out.println("\nAnother original array: " + Arrays.toString(anotherArray));
        bubbleSort(anotherArray);
        System.out.println("Another sorted array:   " + Arrays.toString(anotherArray)); // Output: [1, 2, 4, 5, 8]
        System.out.println("------------------------------");
    }

    /**
     * Sorts an integer array in ascending order using the Bubble Sort algorithm.
     * This method modifies the input array directly (in-place sort).
     * @param arr The integer array to be sorted.
     */
    public static void bubbleSort(int[] arr) {
        int n = arr.length; // Get the number of elements in the array

        // Outer loop: This loop controls the number of passes.
        // In each pass, the largest unsorted element bubbles to its correct position.
        // We need n-1 passes because after n-1 elements are in place, the last one must also be.
        for (int i = 0; i < n - 1; i++) {
            // Inner loop: This loop performs the comparisons and swaps for the current pass.
            // It goes from the first element up to the (n-1-i)-th element.
            // The '-i' is because the last 'i' elements are already sorted and don't need to be checked again.
            for (int j = 0; j < n - 1 - i; j++) {
                // Compare adjacent elements
                if (arr[j] > arr[j + 1]) {
                    // If the current element is greater than the next element, swap them.
                    // This is the "bubbling" action.

                    // SWAP using a temporary variable:
                    int temp = arr[j];      // 1. Store the value of arr[j] in 'temp'
                    arr[j] = arr[j + 1];    // 2. Overwrite arr[j] with the value of arr[j+1]
                    arr[j + 1] = temp;      // 3. Assign the stored 'temp' value to arr[j+1]
                }
            }
            // Optional: Print array state after each pass for visualization (uncomment to see)
            // System.out.println("  After pass " + (i + 1) + ": " + Arrays.toString(arr));
        }
    }
}

3. Clean Code Tip: Comment the Inner Workings of Algorithms

For complex or less intuitive algorithms like Bubble Sort, it's highly beneficial to add comments within the method to explain the logic of the loops, conditions, and especially the swap operation. This helps anyone (including your future self) understand how the algorithm works without having to trace it meticulously.

4. Unsolved Exercise: Sorting Decimal Numbers

You have an array of daily stock prices that you need to sort.

  1. Create a class named StockPriceSorter.
  2. Declare a double array named stockPrices and initialize it with at least 7 arbitrary decimal values (e.g., 10.5, 8.2, 12.0, 9.1, 7.8, 11.5, 9.9).
  3. Implement a public static void method bubbleSortDoubles(double[] arr) that sorts the double array in ascending order using the Bubble Sort algorithm.
  4. In the main method, print the stockPrices array before and after sorting using your bubbleSortDoubles method.

5. Complete Solution: Sorting Decimal Numbers

// Chapter23/StockPriceSorter.java
import java.util.Arrays; // For printing the array easily

public class StockPriceSorter {

    public static void main(String[] args) {
        System.out.println("--- Stock Price Sorter (Bubble Sort) ---");

        // 2. Declare and initialize the double array stockPrices
        double[] stockPrices = {10.5, 8.2, 12.0, 9.1, 7.8, 11.5, 9.9};
        System.out.println("Original stock prices: " + Arrays.toString(stockPrices));

        // Call the bubbleSortDoubles method to sort the array
        bubbleSortDoubles(stockPrices);

        System.out.println("Sorted stock prices:   " + Arrays.toString(stockPrices));
        System.out.println("----------------------------------------");
    }

    /**
     * Sorts a double array in ascending order using the Bubble Sort algorithm.
     * This method modifies the input array directly (in-place sort).
     * @param arr The double array to be sorted.
     */
    public static void bubbleSortDoubles(double[] arr) {
        int n = arr.length; // Get the number of elements in the array

        // Outer loop for passes
        for (int i = 0; i < n - 1; i++) {
            // Inner loop for comparisons and swaps in the current pass
            for (int j = 0; j < n - 1 - i; j++) {
                // Compare adjacent elements (arr[j] with arr[j+1])
                if (arr[j] > arr[j + 1]) {
                    // Swap arr[j] and arr[j+1] if they are in the wrong order
                    double temp = arr[j];      // Store current element
                    arr[j] = arr[j + 1];    // Overwrite current with next
                    arr[j + 1] = temp;      // Place stored element in next position
                }
            }
        }
    }
}

Chapter 24: Constants and Enums.

1. Technical Theory: Defining Fixed Values with Clarity and Safety

In programming, you often encounter values that should never change (constants) or categories that have a fixed, limited set of options. Relying on "magic strings" (e.g., "MONDAY", "ADMIN") or "magic numbers" (e.g., 1, 2, 3 to represent roles) for these is a common source of bugs and poor code readability.

The "Why": Using final constants and enum types is a hallmark of professional, robust Java code. They eliminate ambiguity, improve type safety, prevent common bugs arising from typos, and significantly enhance the readability and maintainability of your applications. It’s critical for DAM students to grasp this for clean data modeling.

2. Professional Code: Constants and Enums

Example 24.1: Using final Constants

// Chapter24/ConstantsExample.java
public class ConstantsExample {

    // Declaring public static final constants
    // These are accessible directly using the class name, e.g., ConstantsExample.MAX_RETRIES
    public static final int MAX_RETRIES = 3;
    public static final double PI = 3.14159; // Better to use Math.PI, but for demonstration
    public static final String DEFAULT_USERNAME = "guest";

    public static void main(String[] args) {
        System.out.println("--- Using Constants ---");

        System.out.println("Maximum login retries allowed: " + MAX_RETRIES);
        System.out.println("Value of PI (approx): " + PI);
        System.out.println("Default user: " + DEFAULT_USERNAME);

        // Attempting to change a final constant will result in a compile-time error
        // MAX_RETRIES = 5; // ERROR: cannot assign a value to final variable MAX_RETRIES

        // Using constants in logic
        int attemptsMade = 1;
        if (attemptsMade < MAX_RETRIES) {
            System.out.println("You have " + (MAX_RETRIES - attemptsMade) + " retries left.");
        } else {
            System.out.println("No retries left. Account locked.");
        }
        System.out.println("-----------------------");
    }
}

Example 24.2: Defining and Using an enum

// Chapter24/TrafficLight.java

// 1. Define the enum (usually in its own file, but can be inside a class for simple examples)
// Enum constants are implicitly public static final
enum TrafficLightState {
    RED,    // Represents a red light state
    YELLOW, // Represents a yellow light state
    GREEN   // Represents a green light state
}

public class TrafficLight {

    public static void main(String[] args) {
        System.out.println("--- Using Enums (Traffic Light) ---");

        // 2. Declare an enum variable and assign a value
        TrafficLightState currentState = TrafficLightState.RED;
        System.out.println("Current Light: " + currentState); // Output: RED

        // 3. Using enums in a switch statement (very common and clean!)
        switch (currentState) {
            case RED -> System.out.println("Stop! The light is Red.");
            case YELLOW -> System.out.println("Prepare to stop! The light is Yellow.");
            case GREEN -> System.out.println("Go! The light is Green.");
            // No 'default' needed if all enum values are covered and compiler knows it (Java 14+)
            // However, for robustness or future enum values, a default is good practice.
            default -> System.out.println("Unknown light state.");
        }

        // Change the state
        currentState = TrafficLightState.GREEN;
        System.out.println("\nChanged light to: " + currentState);
        if (currentState == TrafficLightState.GREEN) { // Comparing enum values with == is safe and correct
            System.out.println("It is safe to proceed.");
        }

        // Enums have built-in methods
        System.out.println("All Traffic Light States:");
        for (TrafficLightState state : TrafficLightState.values()) { // .values() returns an array of all enum constants
            System.out.println(" - " + state + " (Ordinal: " + state.ordinal() + ")"); // .ordinal() gets integer position (0-based)
        }
        // Output:
        // - RED (Ordinal: 0)
        // - YELLOW (Ordinal: 1)
        // - GREEN (Ordinal: 2)

        System.out.println("---------------------------------");
    }
}

3. Clean Code Tip: Avoid 'Magic Strings' and 'Magic Numbers' at All Costs

Hardcoding strings like "admin" or numbers like 3 directly into your logic (if (role.equals("admin")), for (int i=0; i < 3; i++)) is a major source of bugs and unmaintainable code. Instead:

4. Unsolved Exercise: User Roles Enum

You need to manage user roles in a simple application.

  1. Create an enum named UserRole with the following predefined roles: ADMIN, MANAGER, EMPLOYEE, GUEST.
  2. In a class named RoleChecker, declare a UserRole variable currentUserRole and set it to UserRole.EMPLOYEE.
  3. Use a switch statement with currentUserRole to print a different message based on the role:
    • ADMIN: "Full access granted."
    • MANAGER: "Management features available."
    • EMPLOYEE: "Standard user access."
    • GUEST: "Limited guest access."
  4. Change currentUserRole to UserRole.ADMIN and repeat the switch statement to show the change.

5. Complete Solution: User Roles Enum

// Chapter24/RoleChecker.java

// 1. Define the UserRole enum
enum UserRole {
    ADMIN,
    MANAGER,
    EMPLOYEE,
    GUEST
}

public class RoleChecker {

    public static void main(String[] args) {
        System.out.println("--- User Role Checker ---");

        // 2. Declare currentUserRole and set it to EMPLOYEE
        UserRole currentUserRole = UserRole.EMPLOYEE;
        System.out.println("Current user's role: " + currentUserRole);

        // 3. Use a switch statement to print messages based on the role
        System.out.print("Access level: ");
        switch (currentUserRole) {
            case ADMIN -> System.out.println("Full access granted.");
            case MANAGER -> System.out.println("Management features available.");
            case EMPLOYEE -> System.out.println("Standard user access.");
            case GUEST -> System.out.println("Limited guest access.");
            // No default needed here as all enum constants are covered.
        }

        System.out.println("\n--- Changing User Role ---");
        // 4. Change currentUserRole to ADMIN and repeat the switch
        currentUserRole = UserRole.ADMIN;
        System.out.println("New user's role: " + currentUserRole);
        
        System.out.print("Access level: ");
        switch (currentUserRole) {
            case ADMIN -> System.out.println("Full access granted.");
            case MANAGER -> System.out.println("Management features available.");
            case EMPLOYEE -> System.out.println("Standard user access.");
            case GUEST -> System.out.println("Limited guest access.");
        }
        System.out.println("-------------------------");
    }
}

Book 1: Part 5 (Robustness & Data Persistence)

Chapter 25: Try-Catch-Finally.

1. Quick Theory: Ensuring Cleanup, Always.

In Chapter 17, we introduced try-catch to prevent crashes when exceptions occur. While catch blocks handle errors, sometimes you have critical operations (like closing files, database connections, or network sockets) that must happen whether an error occurs or not. Forgetting to clean up these "resources" can lead to memory leaks, corrupted data, or system instability.

This is where the finally block comes into play. The code within a finally block is guaranteed to execute, no matter what happens in the try block – whether an exception is thrown and caught, an exception is thrown and not caught, or the try block completes successfully. The "Why": The finally block is your safeguard for resource management. It ensures that crucial cleanup operations are performed consistently, making your applications more stable and preventing system-level issues like resource exhaustion. This is a core principle of robust programming.

2. Professional Code: The finally Block in Action

Example 25.1: Handling Input and Arithmetic with Cleanup

// Chapter25/RobustCalculatorWithCleanup.java
import java.util.InputMismatchException;
import java.util.Scanner;

public class RobustCalculatorWithCleanup {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        int num1 = 0;
        int num2 = 0;
        double result = 0;
        boolean inputSuccessful = false;

        System.out.println("--- Robust Division Calculator ---");

        // Input for first number
        while (!inputSuccessful) {
            System.out.print("Enter the first integer: ");
            try {
                num1 = scanner.nextInt();
                inputSuccessful = true; // Input was valid
            } catch (InputMismatchException e) {
                System.out.println("Invalid input. Please enter a whole number.");
                scanner.nextLine(); // Clear the invalid input from the buffer
            }
        }

        // Input for second number
        inputSuccessful = false; // Reset flag for next input
        while (!inputSuccessful) {
            System.out.print("Enter the second integer: ");
            try {
                num2 = scanner.nextInt();
                inputSuccessful = true; // Input was valid
            } catch (InputMismatchException e) {
                System.out.println("Invalid input. Please enter a whole number.");
                scanner.nextLine(); // Clear the invalid input from the buffer
            }
        }

        // Perform division in a try block, with finally for cleanup
        try {
            if (num2 == 0) {
                // Manually throw an ArithmeticException for clarity, though Java would do it automatically
                throw new ArithmeticException("Division by zero is not allowed.");
            }
            result = (double) num1 / num2; // Perform division
            System.out.printf("Result of %d / %d = %.2f\n", num1, num2, result);
        } catch (ArithmeticException e) {
            // Catch and handle the division by zero error
            System.err.println("Calculation error: " + e.getMessage()); // System.err for error messages
        } catch (Exception e) { // Catch any other unexpected exceptions
            System.err.println("An unexpected error occurred: " + e.getMessage());
        } finally {
            // This block ALWAYS executes. It's perfect for closing resources.
            System.out.println("--- Calculator operations finished. ---");
            // If scanner was created inside try-catch, it might not be in scope here.
            // Best practice is to declare it outside as done here.
            scanner.close(); // Ensure the scanner resource is always closed.
            System.out.println("Scanner closed.");
        }

        System.out.println("Program continues gracefully after try-catch-finally.");
        System.out.println("------------------------------------");
    }
}

Example 25.2: finally with an Uncaught Exception (Illustrative)

// Chapter25/FinallyWithUncaught.java
public class FinallyWithUncaught {

    public static void main(String[] args) {
        System.out.println("--- Finally with Uncaught Exception ---");

        try {
            System.out.println("Inside try block.");
            // This line will cause an exception that is NOT explicitly caught by a catch block below.
            // An ArrayIndexOutOfBoundsException will be thrown.
            int[] numbers = new int[5];
            System.out.println(numbers[10]); // Accessing an invalid index
            System.out.println("This line will not be reached."); // This line is skipped
        } catch (InputMismatchException e) { // This catch block won't match ArrayIndexOutOfBoundsException
            System.out.println("Caught InputMismatchException: " + e.getMessage());
        } finally {
            // IMPORTANT: Even though ArrayIndexOutOfBoundsException is NOT caught here,
            // the 'finally' block *still executes* before the program crashes.
            System.out.println("Finally block executed. Performing cleanup.");
            // Imagine closing a file or network connection here.
        }

        // The program will crash here because the ArrayIndexOutOfBoundsException was not handled.
        // However, the 'finally' block still had a chance to execute.
        System.out.println("This line will NOT be reached if exception is uncaught.");
        System.out.println("---------------------------------------");
    }
}

3. Pro-Tips: Defensive Programming with finally

4. Unsolved Exercise: Safe Array Access

Create a program that attempts to access an element of an array at a user-specified index.

  1. Create a class named SafeArrayAccess.
  2. Declare an int array data and initialize it with 5 elements (e.g., {1, 2, 3, 4, 5}).
  3. Use a Scanner to ask the user to enter an index (integer).
  4. Implement a try-catch-finally block:
    • In the try block, attempt to read the index and then print the element at data[index].
    • Catch InputMismatchException if the user enters non-integer input.
    • Catch ArrayIndexOutOfBoundsException if the index is invalid.
    • In the finally block, ensure the Scanner is closed and print a message indicating cleanup.
  5. After the finally block, print "Program completed."

6. Complete Solution: Safe Array Access

// Chapter25/SafeArrayAccess.java
import java.util.InputMismatchException;
import java.util.Scanner;

public class SafeArrayAccess {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        int[] data = {1, 2, 3, 4, 5}; // 5 elements, indices 0-4
        int index = 0;

        System.out.println("--- Safe Array Access ---");
        System.out.println("Array elements: " + java.util.Arrays.toString(data));
        System.out.print("Enter an index to access (0-" + (data.length - 1) + "): ");

        try {
            // Attempt to read the index
            index = scanner.nextInt();

            // Attempt to access and print the element
            System.out.println("Element at index " + index + ": " + data[index]);
        } catch (InputMismatchException e) {
            // Handle non-integer input
            System.err.println("Error: Invalid input! Please enter a whole number for the index.");
            scanner.nextLine(); // Clear buffer
        } catch (ArrayIndexOutOfBoundsException e) {
            // Handle out-of-bounds index
            System.err.println("Error: Index " + index + " is out of bounds for array length " + data.length + ".");
            System.err.println("Valid indices are 0 to " + (data.length - 1) + ".");
        } catch (Exception e) { // Catch any other unexpected exceptions
            System.err.println("An unexpected error occurred: " + e.getMessage());
        } finally {
            // Ensure the scanner is always closed
            System.out.println("--- Cleanup: Closing scanner. ---");
            scanner.close();
        }

        System.out.println("Program completed.");
        System.out.println("-------------------------");
    }
}

Chapter 26: Custom Exceptions.

1. Quick Theory: Defining Your Own Errors

While Java provides many built-in exception types (like InputMismatchException or ArithmeticException), sometimes these aren't specific enough for the unique error conditions in your own applications. You might have business rules that, if violated, constitute an "error" in your program's logic, even if no standard Java exception applies.

This is where custom exceptions come in. You can define your own exception classes by extending Java's Exception class (for checked exceptions) or RuntimeException (for unchecked exceptions, which we'll use for simplicity here). Once defined, you can throw instances of your custom exceptions when a specific problem occurs in your code. This allows you to create more descriptive and context-specific error messages and types, improving the clarity and maintainability of your error handling. The "Why": Custom exceptions allow you to signal unique error conditions in a structured, consistent, and typesafe manner. Instead of returning error codes or printing generic messages, you create specific error types that can be caught and handled with precision, making your application's error behavior explicit and professional.

2. Professional Code: Throwing Your Own Errors

Example 26.1: Validating Age with a Custom Exception (Illustrative)

// Chapter26/AgeValidationException.java

// 1. Define a simple custom exception class
// By extending RuntimeException, it becomes an "unchecked" exception.
// This means methods that throw it are not *required* to declare it in their signature.
class InvalidAgeException extends RuntimeException {
    public InvalidAgeException(String message) {
        super(message); // Call the constructor of the parent class (RuntimeException)
    }
}

public class AgeValidationException {

    public static void main(String[] args) {
        System.out.println("--- Age Validation with Custom Exception ---");

        // Test with a valid age
        try {
            validateAge(25);
            System.out.println("Age 25 is valid.");
        } catch (InvalidAgeException e) {
            System.err.println("Error for age 25: " + e.getMessage());
        }

        // Test with an invalid age (too young)
        try {
            validateAge(5);
            System.out.println("Age 5 is valid."); // This line won't be reached
        } catch (InvalidAgeException e) {
            System.err.println("Error for age 5: " + e.getMessage()); // Custom error caught
        }

        // Test with another invalid age (too old)
        try {
            validateAge(150);
            System.out.println("Age 150 is valid."); // This line won't be reached
        } catch (InvalidAgeException e) {
            System.err.println("Error for age 150: " + e.getMessage()); // Custom error caught
        }

        System.out.println("Program continues after all validation attempts.");
        System.out.println("------------------------------------------");
    }

    /**
     * Validates if the given age is within a reasonable range (0-120).
     * @param age The age to validate.
     * @throws InvalidAgeException if the age is outside the valid range.
     */
    public static void validateAge(int age) {
        if (age < 0 || age > 120) {
            // 2. Throwing an instance of our custom exception
            throw new InvalidAgeException("Age must be between 0 and 120, but got: " + age);
        }
        System.out.println("  Validation successful for age: " + age);
    }
}

Example 26.2: Throwing a Standard Exception for Validation

// Chapter26/OrderProcessor.java
public class OrderProcessor {

    public static void main(String[] args) {
        System.out.println("--- Order Processing Example ---");

        // Test valid order
        try {
            processOrder("Laptop", 2);
            System.out.println("Order for Laptop x2 processed.");
        } catch (IllegalArgumentException e) {
            System.err.println("Order error: " + e.getMessage());
        }

        // Test order with invalid quantity
        try {
            processOrder("Mouse", 0);
            System.out.println("Order for Mouse x0 processed."); // This won't be reached
        } catch (IllegalArgumentException e) {
            System.err.println("Order error: " + e.getMessage()); // Catches our thrown exception
        }

        // Test order with negative quantity
        try {
            processOrder("Keyboard", -1);
            System.out.println("Order for Keyboard x-1 processed."); // This won't be reached
        } catch (IllegalArgumentException e) {
            System.err.println("Order error: " + e.getMessage()); // Catches our thrown exception
        }

        System.out.println("Program finished order processing attempts.");
        System.out.println("------------------------------");
    }

    /**
     * Processes an order for a given item and quantity.
     * Throws an IllegalArgumentException if the quantity is invalid.
     * @param item The name of the item.
     * @param quantity The quantity of the item.
     * @throws IllegalArgumentException if quantity is less than or equal to 0.
     */
    public static void processOrder(String item, int quantity) {
        if (quantity <= 0) {
            // Throw a standard Java exception (IllegalArgumentException)
            // This is suitable when an argument passed to a method is invalid.
            throw new IllegalArgumentException("Quantity must be greater than 0 for item: " + item + ", but got " + quantity);
        }
        // Simulate processing the order
        System.out.println("  Processing " + quantity + " units of " + item + "...");
        // More complex logic would go here
    }
}

3. Pro-Tips: Judicious Error Throwing

4. Unsolved Exercise: Product Stock Validator

You're writing a method to check if there's enough stock for a product.

  1. Create a class named StockValidator.
  2. Define a custom exception class InsufficientStockException that extends RuntimeException. It should have a constructor that takes a String message.
  3. In StockValidator, implement a public static void method checkStock(String productName, int availableStock, int requestedQuantity):
    • If requestedQuantity is less than or equal to 0, throw an IllegalArgumentException with a descriptive message.
    • If requestedQuantity is greater than availableStock, throw an InsufficientStockException with a message like "Not enough stock for [productName]. Available: [availableStock], Requested: [requestedQuantity]".
    • If stock is sufficient, print "Stock is sufficient for [productName].".
  4. In the main method, test checkStock with three scenarios using try-catch blocks for each:
    • Valid request (e.g., "Keyboard", 10, 3)
    • Invalid quantity (e.g., "Mouse", 5, 0)
    • Insufficient stock (e.g., "Laptop", 2, 5)

6. Complete Solution: Product Stock Validator

// Chapter26/StockValidator.java

// 1. Define custom exception class
class InsufficientStockException extends RuntimeException {
    public InsufficientStockException(String message) {
        super(message);
    }
}

public class StockValidator {

    public static void main(String[] args) {
        System.out.println("--- Product Stock Validator ---");

        // Scenario 1: Valid request
        try {
            checkStock("Keyboard", 10, 3);
        } catch (IllegalArgumentException | InsufficientStockException e) {
            System.err.println("Validation Error: " + e.getMessage());
        }

        System.out.println(); // New line for separation

        // Scenario 2: Invalid quantity (zero)
        try {
            checkStock("Mouse", 5, 0);
        } catch (IllegalArgumentException | InsufficientStockException e) {
            System.err.println("Validation Error: " + e.getMessage());
        }

        System.out.println(); // New line for separation

        // Scenario 3: Insufficient stock
        try {
            checkStock("Laptop", 2, 5);
        } catch (IllegalArgumentException | InsufficientStockException e) {
            System.err.println("Validation Error: " + e.getMessage());
        }

        System.out.println("\nAll stock checks completed.");
        System.out.println("-----------------------------");
    }

    /**
     * Checks if the requested quantity for a product can be fulfilled from available stock.
     * Throws exceptions for invalid requests or insufficient stock.
     * @param productName The name of the product.
     * @param availableStock The current stock level.
     * @param requestedQuantity The quantity requested by the user.
     * @throws IllegalArgumentException if requestedQuantity is not positive.
     * @throws InsufficientStockException if requestedQuantity exceeds availableStock.
     */
    public static void checkStock(String productName, int availableStock, int requestedQuantity) {
        System.out.println("Checking stock for " + productName + ": Available=" + availableStock + ", Requested=" + requestedQuantity);

        if (requestedQuantity <= 0) {
            throw new IllegalArgumentException("Requested quantity must be positive, but got: " + requestedQuantity);
        }

        if (requestedQuantity > availableStock) {
            throw new InsufficientStockException("Not enough stock for " + productName + ". Available: " + availableStock + ", Requested: " + requestedQuantity);
        }

        System.out.println("  Stock is sufficient for " + productName + ". " + (availableStock - requestedQuantity) + " left.");
    }
}

Chapter 27: Writing to Files.

1. Quick Theory: Saving Your Program's Work

So far, all data in your programs (variables, arrays) exists only while the program is running. Once the program terminates, that data is lost. This is not useful for real-world applications! Data persistence is the ability of data to outlive the process that created it. The most common and fundamental way to achieve this is by writing data to files.

When writing to files in Java, you typically use FileWriter or FileOutputStream to establish a connection to the file, and then PrintWriter or BufferedWriter to provide convenient methods for writing text (like println(), print(), printf()). File operations are inherently risky: the file might not exist, you might not have permission to write, or the disk might be full. These issues throw IOExceptions, which are "checked exceptions" (meaning you must explicitly try-catch them or declare them with throws in your method signature). The "Why": Writing to files is essential for saving user data, configurations, logs, reports, and any other information your program needs to store long-term. Mastering file writing is your first step towards building applications that can save and retrieve their state, moving beyond ephemeral runtime data.

2. Professional Code: Storing Data in Files

Example 27.1: Writing Simple Text to a File

// Chapter27/SimpleFileWriter.java
import java.io.FileWriter; // Needed for writing character data to a file
import java.io.IOException; // Needed for handling potential file operation errors
import java.io.PrintWriter; // Provides convenient methods like println()

public class SimpleFileWriter {
    public static void main(String[] args) {
        // Define the name of the file we want to write to
        String fileName = "output_log.txt";

        // Declare FileWriter and PrintWriter outside the try block
        // so they can be accessed in the finally block for closing.
        FileWriter fileWriter = null;
        PrintWriter printWriter = null;

        System.out.println("--- Writing Simple Text to File ---");

        try {
            // 1. Create a FileWriter object.
            //    The 'false' argument (default) means overwrite if file exists.
            //    Using 'true' as second argument would append to the file.
            fileWriter = new FileWriter(fileName, false); // Overwrite mode

            // 2. Wrap the FileWriter in a PrintWriter for easier writing (like System.out.println)
            printWriter = new PrintWriter(fileWriter);

            // 3. Write data to the file
            printWriter.println("This is the first line of my log file.");
            printWriter.println("Java file writing is powerful.");
            printWriter.printf("Current value of PI is %.5f.\n", Math.PI);
            printWriter.print("This is without a newline."); // Use print() for no newline

            System.out.println("Data successfully written to " + fileName);

        } catch (IOException e) {
            // Catch and handle potential I/O errors (e.g., permission denied, disk full)
            System.err.println("An I/O error occurred while writing to file: " + e.getMessage());
        } finally {
            // 4. IMPORTANT: Always close the writers in a finally block
            //    to release system resources and ensure data is flushed to disk.
            //    Close PrintWriter first, then FileWriter.
            if (printWriter != null) {
                printWriter.close(); // Closes the underlying FileWriter too if not explicitly closed
            }
            // if (fileWriter != null) { // This is generally not needed if PrintWriter is closing FileWriter
            //     try {
            //         fileWriter.close();
            //     } catch (IOException e) {
            //         System.err.println("Error closing FileWriter: " + e.getMessage());
            //     }
            // }
            System.out.println("File writers closed.");
        }
        System.out.println("-----------------------------------");
    }
}

Example 27.2: Appending Data and Saving from an Array

// Chapter27/AppendToFileFromArray.java
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;

public class AppendToFileFromArray {
    public static void main(String[] args) {
        String fileName = "names_and_scores.txt";
        String[] studentNames = {"Alice", "Bob", "Charlie", "Diana"};
        int[] scores = {85, 92, 78, 95};

        FileWriter fileWriter = null;
        PrintWriter printWriter = null;

        System.out.println("--- Appending Data to File from Array ---");

        try {
            // Create FileWriter in append mode (true as second argument)
            fileWriter = new FileWriter(fileName, true); // Append mode

            // Wrap in PrintWriter
            printWriter = new PrintWriter(fileWriter);

            // Write a header or separator for clarity in append mode
            printWriter.println("\n--- New Data Entry ---");

            // Write data from arrays
            for (int i = 0; i < studentNames.length; i++) {
                printWriter.println("Name: " + studentNames[i] + ", Score: " + scores[i]);
            }

            System.out.println("Data successfully appended to " + fileName);

        } catch (IOException e) {
            System.err.println("An I/O error occurred while appending to file: " + e.getMessage());
        } finally {
            if (printWriter != null) {
                printWriter.close();
            }
            System.out.println("File writers closed.");
        }
        System.out.println("-----------------------------------------");
    }
}

3. Pro-Tips: Persistent Data Best Practices

4. Unsolved Exercise: Employee Records

You need to save employee details to a file.

  1. Create a class named EmployeeRecordsWriter.
  2. Declare two String arrays: employeeNames (e.g., "John Doe", "Jane Smith") and employeeIDs (e.g., "E001", "E002").
  3. Define a file name, e.g., "employees.csv".
  4. Write a program that uses FileWriter and PrintWriter to write each employee's name and ID to the file, with each entry on a new line, separated by a comma. (e.g., John Doe,E001). Overwrite the file if it exists.
  5. Ensure proper try-catch for IOException and finally for closing resources.

6. Complete Solution: Employee Records

// Chapter27/EmployeeRecordsWriter.java
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;

public class EmployeeRecordsWriter {
    public static void main(String[] args) {
        // 2. Declare employee data arrays
        String[] employeeNames = {"John Doe", "Jane Smith", "Peter Jones"};
        String[] employeeIDs = {"E001", "E002", "E003"};

        // 3. Define the file name
        String fileName = "employees.csv";

        FileWriter fileWriter = null;
        PrintWriter printWriter = null;

        System.out.println("--- Writing Employee Records to File ---");

        try {
            // 4. Create FileWriter (overwrite mode) and PrintWriter
            fileWriter = new FileWriter(fileName, false); // Overwrite mode
            printWriter = new PrintWriter(fileWriter);

            // Write a header line
            printWriter.println("Name,ID");

            // Loop through employee data and write to file
            for (int i = 0; i < employeeNames.length; i++) {
                printWriter.println(employeeNames[i] + "," + employeeIDs[i]);
            }

            System.out.println("Employee records successfully written to " + fileName);

        } catch (IOException e) {
            System.err.println("An I/O error occurred: " + e.getMessage());
        } finally {
            // 5. Ensure resources are closed
            if (printWriter != null) {
                printWriter.close();
            }
            System.out.println("File writers closed.");
        }
        System.out.println("----------------------------------------");
    }
}

Chapter 28: Reading from Files.

1. Quick Theory: Loading Your Stored Data

Once you've saved data to a file, the next crucial step is to retrieve it. Reading from files allows your programs to load previously saved information, resume state, or process external datasets. This makes your applications truly dynamic and capable of interacting with the stored world.

To read text files in Java, you typically use the File class to represent the physical file on the disk, and then the Scanner class (which you're already familiar with for console input) linked to that File object. Just like writing, file reading operations are susceptible to IOExceptions (e.g., the file doesn't exist, you lack read permissions). A common specific IOException for reading is FileNotFoundException. You'll often use a while(scanner.hasNextLine()) loop to read the file line by line until the end is reached. The "Why": Reading from files is the other half of data persistence. Without it, saved data would be useless. It enables your programs to be configured, to process logs, to display past user actions, and generally to leverage data that outlives a single program run.

2. Professional Code: Retrieving Data from Files

Example 28.1: Reading a File Line by Line

// Chapter28/SimpleFileReader.java
import java.io.File; // Represents the file in the file system
import java.io.FileNotFoundException; // Specific exception for when file doesn't exist
import java.util.Scanner; // Used to read from the file

public class SimpleFileReader {
    public static void main(String[] args) {
        String fileName = "output_log.txt"; // Assuming this file was created by Example 27.1

        File file = new File(fileName); // Create a File object representing the file
        Scanner fileScanner = null; // Declare Scanner outside try block for finally

        System.out.println("--- Reading File Line by Line ---");

        try {
            // 1. Create a Scanner object, linking it to our File object
            fileScanner = new Scanner(file);

            System.out.println("Contents of " + fileName + ":");
            // 2. Loop through the file as long as there are more lines
            while (fileScanner.hasNextLine()) {
                String line = fileScanner.nextLine(); // Read the entire next line
                System.out.println("  " + line); // Print the line to console
            }

            System.out.println("\nSuccessfully read all lines from " + fileName);

        } catch (FileNotFoundException e) {
            // This specific exception is caught if the file does not exist at the specified path
            System.err.println("Error: File not found at '" + fileName + "'. " + e.getMessage());
            System.err.println("Please ensure the file exists in the correct directory (e.g., project root).");
        } catch (Exception e) { // Catch any other unexpected exceptions during file reading
            System.err.println("An unexpected error occurred while reading file: " + e.getMessage());
        } finally {
            // 3. IMPORTANT: Always close the Scanner in a finally block
            //    to release system resources.
            if (fileScanner != null) {
                fileScanner.close();
            }
            System.out.println("File scanner closed.");
        }
        System.out.println("-------------------------------");
    }
}

Example 28.2: Reading and Processing Delimited Data from File

// Chapter28/StudentDataReader.java
import java.io.File;
import java.io.FileNotFoundException;
import java.util.InputMismatchException;
import java.util.Scanner;

public class StudentDataReader {
    public static void main(String[] args) {
        // Assume 'grades.txt' contains lines like "Alice,95", "Bob,88", etc.
        // It's good practice to create this file manually or via a separate program for testing.
        String fileName = "student_grades.txt";
        
        // Example content for student_grades.txt:
        // Alice,95
        // Bob,88
        // Charlie,72
        // Diana,invalid_score // This will cause an InputMismatchException

        File file = new File(fileName);
        Scanner fileScanner = null;
        int totalValidScores = 0;
        int validStudentCount = 0;

        System.out.println("--- Reading Student Data from File ---");

        try {
            fileScanner = new Scanner(file);

            System.out.println("Processing student grades:");
            while (fileScanner.hasNextLine()) {
                String line = fileScanner.nextLine();
                // Use String.split() to parse the line (assuming comma-separated: Name,Score)
                String[] parts = line.split(",");

                if (parts.length == 2) {
                    String name = parts[0].trim(); // Trim whitespace from name
                    try {
                        int score = Integer.parseInt(parts[1].trim()); // Parse score, trim whitespace
                        totalValidScores += score; // Accumulate score
                        validStudentCount++;       // Count valid students
                        System.out.println("  Processed: " + name + " (Score: " + score + ")");
                    } catch (NumberFormatException e) {
                        System.err.println("  Error: Invalid score format for '" + name + "'. Skipping line: " + line);
                    }
                } else {
                    System.err.println("  Error: Invalid line format. Expected 'Name,Score'. Skipping line: " + line);
                }
            }

            if (validStudentCount > 0) {
                double averageScore = (double) totalValidScores / validStudentCount;
                System.out.printf("\nTotal valid students: %d, Average score: %.2f\n", validStudentCount, averageScore);
            } else {
                System.out.println("\nNo valid student data found or processed.");
            }

        } catch (FileNotFoundException e) {
            System.err.println("Error: Student grades file '" + fileName + "' not found. " + e.getMessage());
        } catch (Exception e) {
            System.err.println("An unexpected error occurred: " + e.getMessage());
        } finally {
            if (fileScanner != null) {
                fileScanner.close();
            }
            System.out.println("File scanner closed.");
        }
        System.out.println("--------------------------------------");
    }
}

3. Pro-Tips: Robust File Reading

4. Unsolved Exercise: Read Employee Records

You need to read the employees.csv file created in the previous exercise and display its contents.

  1. Create a class named EmployeeRecordsReader.
  2. Define the file name employees.csv.
  3. Use a File object and a Scanner to read the file line by line.
  4. Print each line to the console.
  5. Ensure FileNotFoundException is caught and the Scanner is closed in a finally block.

6. Complete Solution: Read Employee Records

// Chapter28/EmployeeRecordsReader.java
import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;

public class EmployeeRecordsReader {
    public static void main(String[] args) {
        // 2. Define the file name
        String fileName = "employees.csv"; // Assuming this file was created by EmployeeRecordsWriter

        File employeeFile = new File(fileName);
        Scanner fileScanner = null;

        System.out.println("--- Reading Employee Records from File ---");

        try {
            // 3. Create Scanner linked to the File object
            fileScanner = new Scanner(employeeFile);

            System.out.println("Contents of " + fileName + ":");
            // 4. Read and print each line
            while (fileScanner.hasNextLine()) {
                String line = fileScanner.nextLine();
                System.out.println("  " + line);
            }

            System.out.println("\nSuccessfully read all employee records.");

        } catch (FileNotFoundException e) {
            // 5. Catch FileNotFoundException
            System.err.println("Error: Employee records file '" + fileName + "' not found. " + e.getMessage());
            System.err.println("Please ensure the file exists in the same directory as the program.");
        } finally {
            // 5. Ensure Scanner is closed
            if (fileScanner != null) {
                fileScanner.close();
            }
            System.out.println("File scanner closed.");
        }
        System.out.println("----------------------------------------");
    }
}

Chapter 29: The 'Try-with-Resources' Pattern.

1. Quick Theory: Automatic Resource Management

We've learned that closing resources (like Scanner, FileWriter, PrintWriter) is absolutely critical to prevent leaks and ensure data integrity. The finally block helps, but it can still be verbose, especially if you have multiple resources that need closing, each requiring its own null check and try-catch for the close() method. This is a common boilerplate code.

Java 7 introduced the try-with-resources statement to elegantly solve this problem. It's a special try statement that declares one or more "resources" (objects that implement the java.lang.AutoCloseable interface) within its parentheses. At the end of the try block (whether it completes normally or an exception is thrown), Java automatically and implicitly calls the close() method on these declared resources. The "Why": try-with-resources is the professional, modern way to handle resources in Java. It drastically reduces boilerplate code, improves readability, and, most importantly, makes your programs more robust by guaranteeing that resources are always closed, even if you forget or if an unexpected exception occurs. This is a crucial feature for writing bulletproof applications.

2. Professional Code: Streamlined Resource Handling

Example 29.1: Writing to File with Try-with-Resources

// Chapter29/FileWriterWithResources.java
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;

public class FileWriterWithResources {
    public static void main(String[] args) {
        String fileName = "auto_closed_log.txt";

        System.out.println("--- Writing to File with Try-with-Resources ---");

        // The resources (FileWriter and PrintWriter) are declared inside the try parentheses.
        // They will be automatically closed by Java when the try block exits,
        // regardless of success or failure.
        try (FileWriter fileWriter = new FileWriter(fileName, false); // Overwrite mode
             PrintWriter printWriter = new PrintWriter(fileWriter)) {

            printWriter.println("This log entry was written using try-with-resources.");
            printWriter.println("It's much cleaner and safer!");
            printWriter.printf("Random number: %.2f\n", Math.random());
            
            System.out.println("Data successfully written to " + fileName);

        } catch (IOException e) {
            // Only need to catch exceptions that might occur *during* writing or resource creation.
            System.err.println("An I/O error occurred: " + e.getMessage());
        }
        // No 'finally' block needed for closing resources, Java handles it!
        // You would still use 'finally' if you had other cleanup logic not related to AutoCloseable resources.

        System.out.println("Program continues. Resources are guaranteed to be closed.");
        System.out.println("-----------------------------------------------");
    }
}

Example 29.2: Reading from File with Try-with-Resources

// Chapter29/FileReaderWithResources.java
import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;

public class FileReaderWithResources {
    public static void main(String[] args) {
        String fileName = "auto_closed_log.txt"; // Use the file from the previous example

        System.out.println("--- Reading from File with Try-with-Resources ---");

        // Declare the Scanner (which is AutoCloseable) within the try-with-resources statement.
        try (Scanner fileScanner = new Scanner(new File(fileName))) {
            System.out.println("Contents of " + fileName + ":");
            while (fileScanner.hasNextLine()) {
                String line = fileScanner.nextLine();
                System.out.println("  " + line);
            }
            System.out.println("\nSuccessfully read all lines from " + fileName);

        } catch (FileNotFoundException e) {
            // Catch specific exception for file not found
            System.err.println("Error: File '" + fileName + "' not found. " + e.getMessage());
        } catch (Exception e) {
            // Catch any other general exceptions during reading
            System.err.println("An unexpected error occurred: " + e.getMessage());
        }
        // No 'finally' block needed for closing fileScanner!

        System.out.println("Program continues. File scanner is guaranteed to be closed.");
        System.out.println("---------------------------------------------");
    }
}

3. Pro-Tips: Embrace Try-with-Resources

4. Unsolved Exercise: Config File Management

Rewrite the EmployeeRecordsWriter and EmployeeRecordsReader exercises from Chapters 27 and 28 to use the try-with-resources pattern.

  1. Create a class named ConfigFileManager.
  2. Inside main, first, write the employee data to employees.csv using try-with-resources.
  3. Then, read and print the employees.csv data using another try-with-resources block.
  4. Ensure all necessary exceptions are caught for both operations.

6. Complete Solution: Config File Management

// Chapter29/ConfigFileManager.java
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Scanner;

public class ConfigFileManager {
    public static void main(String[] args) {
        String fileName = "employees.csv";
        String[] employeeNames = {"John Doe", "Jane Smith", "Peter Jones", "Alice Wonderland"};
        String[] employeeIDs = {"E001", "E002", "E003", "E004"};

        System.out.println("--- Config File Manager ---");

        // Part 1: Writing employee data to file using try-with-resources
        System.out.println("Attempting to write employee data to " + fileName + "...");
        try (FileWriter fileWriter = new FileWriter(fileName, false); // Overwrite mode
             PrintWriter printWriter = new PrintWriter(fileWriter)) {

            printWriter.println("Name,ID"); // Header
            for (int i = 0; i < employeeNames.length; i++) {
                printWriter.println(employeeNames[i] + "," + employeeIDs[i]);
            }
            System.out.println("  Employee data successfully written.");

        } catch (IOException e) {
            System.err.println("  Error writing employee data: " + e.getMessage());
        }

        System.out.println("\n--- Now Reading Employee Data ---");

        // Part 2: Reading employee data from file using try-with-resources
        try (Scanner fileScanner = new Scanner(new File(fileName))) {
            System.out.println("Contents of " + fileName + ":");
            while (fileScanner.hasNextLine()) {
                String line = fileScanner.nextLine();
                System.out.println("  " + line);
            }
            System.out.println("  Employee data successfully read.");

        } catch (FileNotFoundException e) {
            System.err.println("  Error: File '" + fileName + "' not found. " + e.getMessage());
        } catch (Exception e) { // Catch any other general exceptions during reading
            System.err.println("  An unexpected error occurred while reading file: " + e.getMessage());
        }

        System.out.println("\n--- Operations Complete ---");
        System.out.println("All file resources are guaranteed to be closed.");
        System.out.println("---------------------------");
    }
}

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.

3º Java - The Java Engineer: Data, Testing & Tooling:

Chapter 1: Lambda Expressions Deep Dive

Technical Theory: Imperative vs Declarative

In software development, we often distinguish between two primary programming styles: imperative and declarative.

Lambda expressions in Java lean heavily towards the declarative style. They allow us to treat functionality as a method argument or code as data, making our code more expressive and concise, especially when working with functional interfaces.

The syntax of a lambda expression is (parameters) -> {body}.

Functional interfaces are crucial here; they are interfaces with exactly one abstract method. Lambdas provide an inline implementation for these single-method interfaces. Common built-in functional interfaces include Predicate<T> (takes T, returns boolean), and Consumer<T> (takes T, returns void).

Professional Code

Let's see how lambdas replace verbose anonymous inner classes.

Example 1: Implementing Runnable

// Before: Anonymous Inner Class
class TaskRunnerBefore {
    public void execute() {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("Executing task (old way)");
            }
        });
        thread.start();
    }
}

// After: Lambda Expression
class TaskRunnerAfter {
    public void execute() {
        Thread thread = new Thread(() -> System.out.println("Executing task (new way)"));
        thread.start();
    }
}

public class LambdaRunnableExample {
    public static void main(String[] args) {
        new TaskRunnerBefore().execute();
        new TaskRunnerAfter().execute();
    }
}

Example 2: Filtering a list with Predicate

import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;

class User {
    private String name;
    private int age;

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

    public int getAge() {
        return age;
    }

    public String getName() {
        return name;
    }

    @Override
    public String toString() {
        return "User{" + "name='" + name + '\'' + ", age=" + age + '}';
    }
}

// Before: Custom filtering logic
class UserFilterBefore {
    public List<User> filterUsersByAge(List<User> users, int minAge) {
        List<User> filteredUsers = new ArrayList<>();
        for (User user : users) {
            if (user.getAge() >= minAge) {
                filteredUsers.add(user);
            }
        }
        return filteredUsers;
    }
}

// After: Using Predicate with Lambda
class UserFilterAfter {
    public List<User> filterUsers(List<User> users, Predicate<User> predicate) {
        List<User> filteredUsers = new ArrayList<>();
        for (User user : users) {
            if (predicate.test(user)) {
                filteredUsers.add(user);
            }
        }
        return filteredUsers;
    }
}

public class PredicateLambdaExample {
    public static void main(String[] args) {
        List<User> users = new ArrayList<>();
        users.add(new User("Alice", 25));
        users.add(new User("Bob", 30));
        users.add(new User("Charlie", 20));

        System.out.println("--- Filtering Before ---");
        UserFilterBefore filterBefore = new UserFilterBefore();
        List<User> oldUsersBefore = filterBefore.filterUsersByAge(users, 28);
        oldUsersBefore.forEach(System.out::println);

        System.out.println("--- Filtering After ---");
        UserFilterAfter filterAfter = new UserFilterAfter();
        // Lambda for users older than 28
        List<User> oldUsersAfter = filterAfter.filterUsers(users, user -> user.getAge() > 28);
        oldUsersAfter.forEach(System.out::println);

        // Another lambda for users whose name starts with 'A'
        List<User> usersWithNameA = filterAfter.filterUsers(users, user -> user.getName().startsWith("A"));
        System.out.println("\nUsers with name starting with 'A':");
        usersWithNameA.forEach(System.out::println);
    }
}

Example 3: Processing elements with Consumer

import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;

// Before: Traditional loop for processing
class ListProcessorBefore {
    public void process(List<String> items) {
        for (String item : items) {
            System.out.println("Processing item (old way): " + item);
        }
    }
}

// After: Using Consumer with Lambda
class ListProcessorAfter {
    public void process(List<String> items, Consumer<String> consumer) {
        for (String item : items) {
            consumer.accept(item);
        }
    }
}

public class ConsumerLambdaExample {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("Java", "Kotlin", "Scala");

        System.out.println("--- Processing Before ---");
        ListProcessorBefore processorBefore = new ListProcessorBefore();
        processorBefore.process(names);

        System.out.println("--- Processing After ---");
        ListProcessorAfter processorAfter = new ListProcessorAfter();
        // Lambda for printing each item
        processorAfter.process(names, item -> System.out.println("Processing item (new way): " + item.toUpperCase()));

        // Another lambda for custom action
        System.out.println("\n--- Custom Processing ---");
        processorAfter.process(names, item -> {
            System.out.println("Item length: " + item.length());
        });
    }
}

Clean Code Tip: Less code = Fewer bugs Lambda expressions significantly reduce boilerplate code, especially when dealing with functional interfaces. Less code means less surface area for bugs to hide, and it's generally easier to read and maintain concise, focused pieces of logic.

Exercise & Solution

Exercise: Given a list of String objects, filter out all strings that have a length less than 5 characters using a Predicate lambda. Then, print the filtered strings.

import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;

public class LambdaExercise {
    public static void main(String[] args) {
        List<String> words = new ArrayList<>();
        words.add("apple");
        words.add("cat");
        words.add("banana");
        words.add("dog");
        words.add("elephant");
        words.add("go");

        System.out.println("Original words: " + words);

        // Your code here: Filter words with length < 5 using a Predicate lambda
        // And store them in a new list called 'longWords'
        List<String> longWords = new ArrayList<>();

        // ...
        // Solution placeholder
        // ...

        System.out.println("Words with length >= 5: " + longWords);
    }
}

Solution:

import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors; // Will be covered in Chapter 3, but useful here

public class LambdaExerciseSolution {
    public static void main(String[] args) {
        List<String> words = new ArrayList<>();
        words.add("apple");
        words.add("cat");
        words.add("banana");
        words.add("dog");
        words.add("elephant");
        words.add("go");

        System.out.println("Original words: " + words);

        // Solution using Predicate and a loop
        List<String> longWords = new ArrayList<>();
        Predicate<String> isLongEnough = word -> word.length() >= 5;
        for (String word : words) {
            if (isLongEnough.test(word)) {
                longWords.add(word);
            }
        }

        // Alternative (more modern) solution using Streams (covered in next chapters)
        // List<String> longWords = words.stream()
        //                               .filter(word -> word.length() >= 5)
        //                               .collect(Collectors.toList());

        System.out.println("Words with length >= 5: " + longWords);
    }
}

Chapter 2: The Stream API (Filter & Map)

Technical Theory: How to process collections like a pro

The Java Stream API, introduced in Java 8, provides a powerful and declarative way to process collections of data. A stream is a sequence of elements that supports sequential and parallel aggregate operations. Crucially, streams are:

filter() and map() are two of the most fundamental intermediate operations in the Stream API.

Professional Code

We'll use our User class from the previous chapter.

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

class User {
    private String name;
    private int age;
    private String email;

    public User(String name, int age, String email) {
        this.name = name;
        this.age = age;
        this.email = email;
    }

    public int getAge() {
        return age;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }

    @Override
    public String toString() {
        return "User{" + "name='" + name + '\'' + ", age=" + age + ", email='" + email + '\'' + '}';
    }
}

public class StreamFilterMapExample {

    public static void main(String[] args) {
        List<User> users = new ArrayList<>();
        users.add(new User("Alice", 25, "alice@example.com"));
        users.add(new User("Bob", 30, "bob@example.com"));
        users.add(new User("Charlie", 20, "charlie@example.com"));
        users.add(new User("David", 35, "david@example.com"));
        users.add(new User("Eve", 28, "eve@example.com"));

        // Example 1: Filtering a list of 'Users' by age
        System.out.println("--- Filtering Users by Age ---");

        // Before: Imperative style
        List<User> youngUsersBefore = new ArrayList<>();
        for (User user : users) {
            if (user.getAge() < 30) {
                youngUsersBefore.add(user);
            }
        }
        System.out.println("Before (age < 30): " + youngUsersBefore);

        // After: Stream API with filter()
        List<User> youngUsersAfter = users.stream()
                                         .filter(user -> user.getAge() < 30)
                                         .collect(Collectors.toList());
        System.out.println("After (age < 30):  " + youngUsersAfter);

        // Example 2: Transforming a list of 'Users' into a list of 'Names'
        System.out.println("\n--- Transforming Users to Names ---");

        // Before: Imperative style
        List<String> userNamesBefore = new ArrayList<>();
        for (User user : users) {
            userNamesBefore.add(user.getName());
        }
        System.out.println("Before (names): " + userNamesBefore);

        // After: Stream API with map()
        List<String> userNamesAfter = users.stream()
                                          .map(User::getName) // Method reference, covered in Chapter 6, but common here. Equivalent to user -> user.getName()
                                          .collect(Collectors.toList());
        System.out.println("After (names):  " + userNamesAfter);

        // Example 3: Chaining Filter and Map - Filter by age, then get names
        System.out.println("\n--- Filter by Age AND Get Names ---");

        // Before: Imperative style (multiple loops or nested conditions)
        List<String> namesOfAdultUsersBefore = new ArrayList<>();
        for (User user : users) {
            if (user.getAge() >= 25) { // Filter
                namesOfAdultUsersBefore.add(user.getName()); // Map
            }
        }
        System.out.println("Before (age >= 25 names): " + namesOfAdultUsersBefore);

        // After: Stream API with chained filter() and map()
        List<String> namesOfAdultUsersAfter = users.stream()
                                                  .filter(user -> user.getAge() >= 25) // Intermediate operation
                                                  .map(User::getName)                 // Intermediate operation
                                                  .collect(Collectors.toList());      // Terminal operation
        System.out.println("After (age >= 25 names):  " + namesOfAdultUsersAfter);
    }
}

Clean Code Tip: Stream operations are declarative and compose well Instead of writing explicit loops and conditional logic (imperative), streams allow you to declare what you want to achieve (filter, map, sort). This makes your code much more readable, especially when chaining multiple operations. Each operation focuses on a single responsibility, leading to cleaner, more maintainable code.

Exercise & Solution

Exercise: Given a list of Product objects, filter for products that are inStock and have a price greater than 50. Then, transform these filtered products into a list of Strings, where each string combines the product's name and price (e.g., "Laptop ($1200.0)").

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

class Product {
    private String name;
    private double price;
    private 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=" + price + ", inStock=" + inStock + '}';
    }
}

public class StreamFilterMapExercise {
    public static void main(String[] args) {
        List<Product> products = new ArrayList<>();
        products.add(new Product("Keyboard", 75.0, true));
        products.add(new Product("Mouse", 25.0, true));
        products.add(new Product("Monitor", 150.0, false));
        products.add(new Product("Laptop", 1200.0, true));
        products.add(new Product("Webcam", 40.0, true));
        products.add(new Product("Headphones", 90.0, false));

        System.out.println("Original products: " + products);

        // Your code here: Filter in-stock products with price > 50,
        // then map to "Name ($Price)" strings.
        List<String> highValueInStockProducts = new ArrayList<>();

        // ...
        // Solution placeholder
        // ...

        System.out.println("High-value in-stock products (name and price): " + highValueInStockProducts);
    }
}

Solution:

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

class Product {
    private String name;
    private double price;
    private 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=" + price + ", inStock=" + inStock + '}';
    }
}

public class StreamFilterMapExerciseSolution {
    public static void main(String[] args) {
        List<Product> products = new ArrayList<>();
        products.add(new Product("Keyboard", 75.0, true));
        products.add(new Product("Mouse", 25.0, true));
        products.add(new Product("Monitor", 150.0, false));
        products.add(new Product("Laptop", 1200.0, true));
        products.add(new Product("Webcam", 40.0, true));
        products.add(new Product("Headphones", 90.0, false));

        System.out.println("Original products: " + products);

        List<String> highValueInStockProducts = products.stream()
                                                        .filter(product -> product.isInStock() && product.getPrice() > 50.0)
                                                        .map(product -> product.getName() + " ($" + product.getPrice() + ")")
                                                        .collect(Collectors.toList());

        System.out.println("High-value in-stock products (name and price): " + highValueInStockProducts);
    }
}

Chapter 3: Stream Terminal Operations

Technical Theory: Triggering the Stream Pipeline

Intermediate stream operations (like filter, map, sorted) are lazy. They return a new stream and don't perform any actual computation until a terminal operation is invoked. A terminal operation consumes the stream and produces a result or a side effect. After a terminal operation, the stream cannot be reused.

Let's explore some common terminal operations:

Professional Code

We'll continue using our User class.

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

class User {
    private String name;
    private int age;
    private String city;

    public User(String name, int age, String city) {
        this.name = name;
        this.age = age;
        this.city = city;
    }

    public int getAge() {
        return age;
    }

    public String getName() {
        return name;
    }

    public String getCity() {
        return city;
    }

    @Override
    public String toString() {
        return "User{" + "name='" + name + '\'' + ", age=" + age + ", city='" + city + '\'' + '}';
    }
}

public class StreamTerminalOperationsExample {

    public static void main(String[] args) {
        List<User> users = new ArrayList<>();
        users.add(new User("Alice", 25, "New York"));
        users.add(new User("Bob", 30, "London"));
        users.add(new User("Charlie", 20, "New York"));
        users.add(new User("David", 35, "Paris"));
        users.add(new User("Eve", 28, "London"));
        users.add(new User("Frank", 40, "New York"));

        System.out.println("Original Users: " + users);

        // Example 1: Collecting results into a List
        System.out.println("\n--- Collect to List ---");

        // Before: Manual iteration and adding
        List<String> namesOfAdultsBefore = new ArrayList<>();
        for (User user : users) {
            if (user.getAge() >= 25) {
                namesOfAdultsBefore.add(user.getName());
            }
        }
        System.out.println("Adult names (Before): " + namesOfAdultsBefore);

        // After: Using .filter().map().collect(Collectors.toList())
        List<String> namesOfAdultsAfter = users.stream()
                                              .filter(user -> user.getAge() >= 25)
                                              .map(User::getName)
                                              .collect(Collectors.toList());
        System.out.println("Adult names (After):  " + namesOfAdultsAfter);

        // Example 2: Counting elements
        System.out.println("\n--- Counting Elements ---");

        // Before: Manual counter
        int nyUsersCountBefore = 0;
        for (User user : users) {
            if (user.getCity().equals("New York")) {
                nyUsersCountBefore++;
            }
        }
        System.out.println("Users in New York (Before): " + nyUsersCountBefore);

        // After: Using .filter().count()
        long nyUsersCountAfter = users.stream()
                                      .filter(user -> user.getCity().equals("New York"))
                                      .count();
        System.out.println("Users in New York (After):  " + nyUsersCountAfter);

        // Example 3: Performing an action for each element
        System.out.println("\n--- For Each Element ---");

        // Before: Enhanced for loop
        System.out.println("All users (Before):");
        for (User user : users) {
            System.out.println("- " + user.getName() + " is " + user.getAge());
        }

        // After: Using .forEach()
        System.out.println("All users (After):");
        users.stream()
             .forEach(user -> System.out.println("- " + user.getName() + " is " + user.getAge()));

        // Bonus: Grouping users by city using Collectors.groupingBy
        System.out.println("\n--- Grouping Users by City ---");
        Map<String, List<User>> usersByCity = users.stream()
                                                    .collect(Collectors.groupingBy(User::getCity));
        usersByCity.forEach((city, userList) -> {
            System.out.println("City: " + city);
            userList.forEach(user -> System.out.println("  - " + user.getName()));
        });
    }
}

Clean Code Tip: Use appropriate terminal operations for specific needs Don't collect to a list if all you need is a count. Don't forEach if you need to build a new collection. Choosing the right terminal operation makes your intent clear and often leads to more efficient code by avoiding unnecessary intermediate data structures.

Exercise & Solution

Exercise: You have a list of Order objects.

  1. Count how many orders have a status of "PENDING".
  2. Collect the customerName of all orders that have a status of "COMPLETED" into a List<String>.
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

class Order {
    private String orderId;
    private String customerName;
    private String status;
    private double totalAmount;

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

    public String getOrderId() {
        return orderId;
    }

    public String getCustomerName() {
        return customerName;
    }

    public String getStatus() {
        return status;
    }

    public double getTotalAmount() {
        return totalAmount;
    }

    @Override
    public String toString() {
        return "Order{" + "orderId='" + orderId + '\'' + ", customerName='" + customerName + '\'' + ", status='" + status + '\'' + ", totalAmount=" + totalAmount + '}';
    }
}

public class StreamTerminalExercise {
    public static void main(String[] args) {
        List<Order> orders = new ArrayList<>();
        orders.add(new Order("ORD001", "Alice", "PENDING", 150.0));
        orders.add(new Order("ORD002", "Bob", "COMPLETED", 200.0));
        orders.add(new Order("ORD003", "Charlie", "PENDING", 75.0));
        orders.add(new Order("ORD004", "David", "COMPLETED", 300.0));
        orders.add(new Order("ORD005", "Eve", "PROCESSING", 100.0));
        orders.add(new Order("ORD006", "Frank", "COMPLETED", 50.0));

        System.out.println("All Orders: " + orders);

        // Your code here:
        // 1. Count pending orders
        long pendingOrdersCount = 0;

        // 2. Collect customer names of completed orders
        List<String> completedOrderCustomerNames = new ArrayList<>();

        // ...
        // Solution placeholder
        // ...

        System.out.println("\nNumber of PENDING orders: " + pendingOrdersCount);
        System.out.println("Customer names for COMPLETED orders: " + completedOrderCustomerNames);
    }
}

Solution:

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

class Order {
    private String orderId;
    private String customerName;
    private String status;
    private double totalAmount;

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

    public String getOrderId() {
        return orderId;
    }

    public String getCustomerName() {
        return customerName;
    }

    public String getStatus() {
        return status;
    }

    public double getTotalAmount() {
        return totalAmount;
    }

    @Override
    public String toString() {
        return "Order{" + "orderId='" + orderId + '\'' + ", customerName='" + customerName + '\'' + ", status='" + status + '\'' + ", totalAmount=" + totalAmount + '}';
    }
}

public class StreamTerminalExerciseSolution {
    public static void main(String[] args) {
        List<Order> orders = new ArrayList<>();
        orders.add(new Order("ORD001", "Alice", "PENDING", 150.0));
        orders.add(new Order("ORD002", "Bob", "COMPLETED", 200.0));
        orders.add(new Order("ORD003", "Charlie", "PENDING", 75.0));
        orders.add(new Order("ORD004", "David", "COMPLETED", 300.0));
        orders.add(new Order("ORD005", "Eve", "PROCESSING", 100.0));
        orders.add(new Order("ORD006", "Frank", "COMPLETED", 50.0));

        System.out.println("All Orders: " + orders);

        // 1. Count pending orders
        long pendingOrdersCount = orders.stream()
                                        .filter(order -> "PENDING".equals(order.getStatus()))
                                        .count();

        // 2. Collect customer names of completed orders
        List<String> completedOrderCustomerNames = orders.stream()
                                                         .filter(order -> "COMPLETED".equals(order.getStatus()))
                                                         .map(Order::getCustomerName)
                                                         .collect(Collectors.toList());

        System.out.println("\nNumber of PENDING orders: " + pendingOrdersCount);
        System.out.println("Customer names for COMPLETED orders: " + completedOrderCustomerNames);
    }
}

Chapter 4: Sorting with Streams

Technical Theory: Sorting Made Easy

The sorted() intermediate operation in the Stream API allows you to sort elements within a stream. It comes in two main forms:

The Comparator interface is a functional interface, making it a perfect candidate for lambdas. The Comparator.comparing() static method is incredibly useful for creating comparators based on extracting a comparable key. You can also chain comparators using thenComparing().

Professional Code

We'll use a slightly modified User class to demonstrate sorting.

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

class User implements Comparable<User> { // Implementing Comparable for natural order by name
    private String name;
    private int age;
    private String department;

    public User(String name, int age, String department) {
        this.name = name;
        this.age = age;
        this.department = department;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    public String getDepartment() {
        return department;
    }

    @Override
    public String toString() {
        return "User{" + "name='" + name + '\'' + ", age=" + age + ", department='" + department + '\'' + '}';
    }

    // Natural order by name (for .sorted() without a comparator)
    @Override
    public int compareTo(User other) {
        return this.name.compareTo(other.name);
    }
}

public class StreamSortingExample {

    public static void main(String[] args) {
        List<User> users = new ArrayList<>();
        users.add(new User("Alice", 30, "HR"));
        users.add(new User("Charlie", 25, "Engineering"));
        users.add(new User("Bob", 35, "HR"));
        users.add(new User("David", 22, "Marketing"));
        users.add(new User("Eve", 30, "Engineering"));

        System.out.println("Original Users:");
        users.forEach(System.out::println);

        // Example 1: Sorting a list of objects by natural order (if Comparable)
        System.out.println("\n--- Sorting by Natural Order (User Name) ---");
        // Before: Collections.sort with custom object
        List<User> sortedUsersNaturalBefore = new ArrayList<>(users);
        sortedUsersNaturalBefore.sort(new Comparator<User>() {
            @Override
            public int compare(User u1, User u2) {
                return u1.getName().compareTo(u2.getName());
            }
        });
        System.out.println("Before (by Name):");
        sortedUsersNaturalBefore.forEach(System.out::println);

        // After: Stream API with .sorted() (relies on User implementing Comparable<User>)
        List<User> sortedUsersNaturalAfter = users.stream()
                                                  .sorted() // Uses User's compareTo method
                                                  .collect(Collectors.toList());
        System.out.println("After (by Name):");
        sortedUsersNaturalAfter.forEach(System.out::println);


        // Example 2: Sorting User objects by age (custom comparator)
        System.out.println("\n--- Sorting Users by Age Ascending ---");

        // Before: Anonymous Comparator
        List<User> sortedByAgeBefore = new ArrayList<>(users);
        sortedByAgeBefore.sort(new Comparator<User>() {
            @Override
            public int compare(User u1, User u2) {
                return Integer.compare(u1.getAge(), u2.getAge());
            }
        });
        System.out.println("Before (by Age):");
        sortedByAgeBefore.forEach(System.out::println);

        // After: Stream API with .sorted(Comparator.comparingInt)
        List<User> sortedByAgeAfter = users.stream()
                                           .sorted(Comparator.comparingInt(User::getAge))
                                           .collect(Collectors.toList());
        System.out.println("After (by Age):");
        sortedByAgeAfter.forEach(System.out::println);

        // Example 3: Sorting User objects by department then by age (chained comparators)
        System.out.println("\n--- Sorting by Department then Age Descending ---");

        // Before: Chained comparators (verbose)
        List<User> sortedByDeptAgeBefore = new ArrayList<>(users);
        sortedByDeptAgeBefore.sort(new Comparator<User>() {
            @Override
            public int compare(User u1, User u2) {
                int deptCompare = u1.getDepartment().compareTo(u2.getDepartment());
                if (deptCompare != 0) {
                    return deptCompare;
                }
                // If departments are same, sort by age descending
                return Integer.compare(u2.getAge(), u1.getAge()); // u2 vs u1 for descending
            }
        });
        System.out.println("Before (by Department then Age Desc):");
        sortedByDeptAgeBefore.forEach(System.out::println);

        // After: Stream API with Comparator.comparing().thenComparing()
        List<User> sortedByDeptAgeAfter = users.stream()
                                              .sorted(Comparator.comparing(User::getDepartment)
                                                                .thenComparing(Comparator.comparingInt(User::getAge).reversed()))
                                              .collect(Collectors.toList());
        System.out.println("After (by Department then Age Desc):");
        sortedByDeptAgeAfter.forEach(System.out::println);
    }
}

Clean Code Tip: Comparator.comparing() and thenComparing() for fluent sorting These static methods on the Comparator interface allow you to build complex sorting logic in a highly readable and fluent way, avoiding nested if statements or anonymous inner classes. Always prefer Comparator.comparing() for its conciseness and clarity.

Exercise & Solution

Exercise: Given a list of Employee objects:

  1. Sort the employees first by their department alphabetically (ascending).
  2. If employees are in the same department, sort them by salary in descending order.
  3. Collect the sorted employees into a new list.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;

class Employee {
    private String name;
    private String department;
    private double salary;

    public Employee(String name, String department, double salary) {
        this.name = name;
        this.department = department;
        this.salary = salary;
    }

    public String getName() {
        return name;
    }

    public String getDepartment() {
        return department;
    }

    public double getSalary() {
        return salary;
    }

    @Override
    public String toString() {
        return "Employee{" + "name='" + name + '\'' + ", department='" + department + '\'' + ", salary=" + salary + '}';
    }
}

public class StreamSortingExercise {
    public static void main(String[] args) {
        List<Employee> employees = new ArrayList<>();
        employees.add(new Employee("Alice", "HR", 60000.0));
        employees.add(new Employee("Bob", "Engineering", 90000.0));
        employees.add(new Employee("Charlie", "HR", 75000.0));
        employees.add(new Employee("David", "Engineering", 80000.0));
        employees.add(new Employee("Eve", "Marketing", 65000.0));
        employees.add(new Employee("Frank", "Engineering", 95000.0));

        System.out.println("Original Employees:");
        employees.forEach(System.out::println);

        // Your code here: Sort employees by department (asc) then salary (desc)
        List<Employee> sortedEmployees = new ArrayList<>();

        // ...
        // Solution placeholder
        // ...

        System.out.println("\nSorted Employees (Department ASC, Salary DESC):");
        sortedEmployees.forEach(System.out::println);
    }
}

Solution:

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

class Employee {
    private String name;
    private String department;
    private double salary;

    public Employee(String name, String department, double salary) {
        this.name = name;
        this.department = department;
        this.salary = salary;
    }

    public String getName() {
        return name;
    }

    public String getDepartment() {
        return department;
    }

    public double getSalary() {
        return salary;
    }

    @Override
    public String toString() {
        return "Employee{" + "name='" + name + '\'' + ", department='" + department + '\'' + ", salary=" + salary + '}';
    }
}

public class StreamSortingExerciseSolution {
    public static void main(String[] args) {
        List<Employee> employees = new ArrayList<>();
        employees.add(new Employee("Alice", "HR", 60000.0));
        employees.add(new Employee("Bob", "Engineering", 90000.0));
        employees.add(new Employee("Charlie", "HR", 75000.0));
        employees.add(new Employee("David", "Engineering", 80000.0));
        employees.add(new Employee("Eve", "Marketing", 65000.0));
        employees.add(new Employee("Frank", "Engineering", 95000.0));

        System.out.println("Original Employees:");
        employees.forEach(System.out::println);

        List<Employee> sortedEmployees = employees.stream()
                                                  .sorted(Comparator.comparing(Employee::getDepartment) // Sort by department ascending
                                                                    .thenComparing(Comparator.comparingDouble(Employee::getSalary).reversed())) // Then by salary descending
                                                  .collect(Collectors.toList());

        System.out.println("\nSorted Employees (Department ASC, Salary DESC):");
        sortedEmployees.forEach(System.out::println);
    }
}

Chapter 5: The Optional Class

Technical Theory: The ultimate weapon against NullPointerException

NullPointerException (NPE) is one of the most common and frustrating runtime errors in Java. It occurs when you try to use a reference that points to null as if it were a valid object. Java 8 introduced the Optional<T> class to help developers design APIs that explicitly declare when a value might be absent, forcing consumers of these APIs to handle the absence, thus preventing NPEs.

Optional<T> is a container object that may or may not contain a non-null value.

Key methods of Optional:

Professional Code

We'll use a User class with an optional email.

import java.util.Optional;

class User {
    private String name;
    private Optional<String> email; // Email is optional

    public User(String name, String email) {
        this.name = name;
        this.email = Optional.ofNullable(email); // Use ofNullable to handle potentially null emails
    }

    public String getName() {
        return name;
    }

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

    @Override
    public String toString() {
        return "User{" + "name='" + name + '\'' + ", email=" + email.orElse("N/A") + '}';
    }
}

public class OptionalExample {

    public static void main(String[] args) {
        User user1 = new User("Alice", "alice@example.com");
        User user2 = new User("Bob", null); // Bob doesn't have an email

        // Example 1: Basic usage with .orElse()
        System.out.println("--- Handling Optional with .orElse() ---");

        // Before: Traditional null check
        String email1Before = user1.getEmail().isPresent() ? user1.getEmail().get() : "No email provided";
        String email2Before = user2.getEmail().isPresent() ? user2.getEmail().get() : "No email provided";
        System.out.println("Alice's email (Before): " + email1Before);
        System.out.println("Bob's email (Before):   " + email2Before);

        // After: Using Optional.orElse()
        String email1After = user1.getEmail().orElse("No email provided");
        String email2After = user2.getEmail().orElse("No email provided");
        System.out.println("Alice's email (After): " + email1After);
        System.out.println("Bob's email (After):   " + email2After);

        // Example 2: Transforming value with .map()
        System.out.println("\n--- Transforming Optional with .map() ---");

        // Before: Null checks before transformation
        String domain1Before = "N/A";
        if (user1.getEmail().isPresent()) {
            String fullEmail = user1.getEmail().get();
            if (fullEmail.contains("@")) {
                domain1Before = fullEmail.substring(fullEmail.indexOf("@") + 1);
            }
        }
        System.out.println("Alice's email domain (Before): " + domain1Before);

        String domain2Before = "N/A"; // Bob has no email, so domain remains N/A
        System.out.println("Bob's email domain (Before):   " + domain2Before);

        // After: Using Optional.map()
        Optional<String> domain1After = user1.getEmail()
                                            .map(email -> email.substring(email.indexOf("@") + 1));
        System.out.println("Alice's email domain (After): " + domain1After.orElse("N/A"));

        Optional<String> domain2After = user2.getEmail()
                                            .map(email -> email.substring(email.indexOf("@") + 1));
        System.out.println("Bob's email domain (After):   " + domain2After.orElse("N/A"));


        // Example 3: Performing actions with .ifPresent()
        System.out.println("\n--- Performing actions with .ifPresent() ---");

        // Before: Conditional action
        System.out.println("Users with email (Before):");
        if (user1.getEmail().isPresent()) {
            System.out.println("User " + user1.getName() + " has email: " + user1.getEmail().get());
        }
        if (user2.getEmail().isPresent()) { // This block won't execute
            System.out.println("User " + user2.getName() + " has email: " + user2.getEmail().get());
        }

        // After: Using Optional.ifPresent()
        System.out.println("Users with email (After):");
        user1.getEmail().ifPresent(email -> System.out.println("User " + user1.getName() + " has email: " + email));
        user2.getEmail().ifPresent(email -> System.out.println("User " + user2.getName() + " has email: " + email)); // Does nothing
    }
}

Clean Code Tip: Use Optional for return types where a value might be absent This makes nullability explicit in your API contracts, forcing callers to consider the absence of a value. Avoid using Optional as a field type or as a method parameter, as it adds unnecessary overhead and doesn't achieve its primary goal of preventing NPEs in those contexts.

Exercise & Solution

Exercise: Create a UserRepository class with a method findUserById(long id) that simulates retrieving a user, which may or may not exist. This method should return Optional<User>. In your main method:

  1. Call findUserById for an existing user (ID 1L) and, if present, print their name and email. If email is not present, print "No Email Provided".
  2. Call findUserById for a non-existing user (ID 99L) and print "User not found" if absent.
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

class User {
    private long id;
    private String name;
    private Optional<String> email;

    public User(long id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = Optional.ofNullable(email);
    }

    public long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

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

    @Override
    public String toString() {
        return "User{" + "id=" + id + ", name='" + name + '\'' + ", email=" + email.orElse("N/A") + '}';
    }
}

class UserRepository {
    private Map<Long, User> users = new HashMap<>();

    public UserRepository() {
        users.put(1L, new User(1L, "Alice", "alice@example.com"));
        users.put(2L, new User(2L, "Bob", null)); // Bob has no email
        users.put(3L, new User(3L, "Charlie", "charlie@example.com"));
    }

    // Your code here: Method to find user by ID, returning Optional<User>
    public Optional<User> findUserById(long id) {
        // ...
        // Solution placeholder
        return Optional.empty(); // Placeholder
        // ...
    }
}

public class OptionalExercise {
    public static void main(String[] args) {
        UserRepository userRepository = new UserRepository();

        // Scenario 1: Existing user with email
        System.out.println("--- Scenario 1: Existing user (ID 1) ---");
        // Your code here: Retrieve user 1, print name and email (or "No Email Provided")
        // ...

        // Scenario 2: Existing user without email (ID 2)
        System.out.println("\n--- Scenario 2: Existing user (ID 2, no email) ---");
        // Your code here: Retrieve user 2, print name and email (or "No Email Provided")
        // ...

        // Scenario 3: Non-existing user
        System.out.println("\n--- Scenario 3: Non-existing user (ID 99) ---");
        // Your code here: Retrieve user 99, print "User not found" if absent
        // ...
    }
}

Solution:

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

class User {
    private long id;
    private String name;
    private Optional<String> email;

    public User(long id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = Optional.ofNullable(email);
    }

    public long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

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

    @Override
    public String toString() {
        return "User{" + "id=" + id + ", name='" + name + '\'' + ", email=" + email.orElse("N/A") + '}';
    }
}

class UserRepository {
    private Map<Long, User> users = new HashMap<>();

    public UserRepository() {
        users.put(1L, new User(1L, "Alice", "alice@example.com"));
        users.put(2L, new User(2L, "Bob", null)); // Bob has no email
        users.put(3L, new User(3L, "Charlie", "charlie@example.com"));
    }

    public Optional<User> findUserById(long id) {
        return Optional.ofNullable(users.get(id));
    }
}

public class OptionalExerciseSolution {
    public static void main(String[] args) {
        UserRepository userRepository = new UserRepository();

        // Scenario 1: Existing user with email (ID 1)
        System.out.println("--- Scenario 1: Existing user (ID 1) ---");
        Optional<User> user1 = userRepository.findUserById(1L);
        user1.ifPresent(u -> {
            System.out.println("User Name: " + u.getName());
            System.out.println("User Email: " + u.getEmail().orElse("No Email Provided"));
        });

        // Scenario 2: Existing user without email (ID 2)
        System.out.println("\n--- Scenario 2: Existing user (ID 2, no email) ---");
        Optional<User> user2 = userRepository.findUserById(2L);
        user2.ifPresent(u -> {
            System.out.println("User Name: " + u.getName());
            System.out.println("User Email: " + u.getEmail().orElse("No Email Provided"));
        });

        // Scenario 3: Non-existing user (ID 99)
        System.out.println("\n--- Scenario 3: Non-existing user (ID 99) ---");
        Optional<User> user99 = userRepository.findUserById(99L);
        System.out.println(user99.map(u -> "Found user: " + u.getName()).orElse("User not found"));
    }
}

Chapter 6: Method References

Technical Theory: Cleaning up your lambdas

Method references are a special syntax in Java 8 that provide a shorthand for lambda expressions, making your code even more concise and readable in specific situations. They are used when a lambda expression just calls an existing method. Instead of providing the lambda body, you simply refer to the method by name.

A method reference is of the form ClassName::methodName or objectName::methodName.

There are four main kinds of method references:

  1. Static method reference: ClassName::staticMethodName

    • Equivalent to (args) -> ClassName.staticMethodName(args)
    • Example: Math::max for (a, b) -> Math.max(a, b)
  2. Instance method reference of a particular object: objectInstance::instanceMethodName

    • Equivalent to (args) -> objectInstance.instanceMethodName(args)
    • Example: System.out::println for (s) -> System.out.println(s)
  3. Instance method reference of an arbitrary object of a particular type: ClassName::instanceMethodName

    • Equivalent to (object, args) -> object.instanceMethodName(args)
    • This is used when the lambda's first parameter is the target of the instance method.
    • Example: String::length for (s) -> s.length()
  4. Constructor reference: ClassName::new

    • Equivalent to (args) -> new ClassName(args)
    • Example: ArrayList::new for () -> new ArrayList<>() or Integer::new for (s) -> new Integer(s)

Method references don't introduce new functionality; they just make existing lambdas more compact and often more readable when applicable.

Professional Code

Let's see these in action.

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;
import java.util.stream.Collectors;

public class MethodReferenceExample {

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

        // Example 1: Static method reference (System.out::println, Math::sqrt)
        System.out.println("--- Static Method Reference ---");

        // Before: Lambda for printing
        System.out.println("Printing names (Lambda):");
        names.forEach(s -> System.out.println(s));

        // After: Method reference for printing
        System.out.println("Printing names (Method Reference):");
        names.forEach(System.out::println); // Equivalent to (s) -> System.out.println(s)

        List<Integer> numbers = Arrays.asList(4, 9, 16, 25);
        // Before: Lambda for calculating square root
        List<Double> sqrtNumbersBefore = numbers.stream()
                                                .map(num -> Math.sqrt(num))
                                                .collect(Collectors.toList());
        System.out.println("Square roots (Lambda): " + sqrtNumbersBefore);

        // After: Method reference for Math.sqrt
        List<Double> sqrtNumbersAfter = numbers.stream()
                                               .map(Math::sqrt) // Equivalent to (num) -> Math.sqrt(num)
                                               .collect(Collectors.toList());
        System.out.println("Square roots (Method Reference): " + sqrtNumbersAfter);


        // Example 2: Instance method reference of a particular object
        System.out.println("\n--- Instance Method Reference (Specific Object) ---");

        // We can create a custom instance and refer to its method
        MyPrinter printer = new MyPrinter();

        // Before: Lambda using printer instance
        System.out.println("Printing with custom printer (Lambda):");
        names.forEach(s -> printer.printCustom(s));

        // After: Method reference using printer instance
        System.out.println("Printing with custom printer (Method Reference):");
        names.forEach(printer::printCustom); // Equivalent to (s) -> printer.printCustom(s)


        // Example 3: Instance method reference of an arbitrary object of a particular type
        System.out.println("\n--- Instance Method Reference (Arbitrary Object of Type) ---");

        // Before: Lambda for String::toUpperCase
        List<String> upperNamesBefore = names.stream()
                                            .map(s -> s.toUpperCase())
                                            .collect(Collectors.toList());
        System.out.println("Uppercase names (Lambda): " + upperNamesBefore);

        // After: Method reference for String::toUpperCase
        List<String> upperNamesAfter = names.stream()
                                            .map(String::toUpperCase) // Equivalent to (s) -> s.toUpperCase()
                                            .collect(Collectors.toList());
        System.out.println("Uppercase names (Method Reference): " + upperNamesAfter);

        // Before: Lambda for String::length
        List<Integer> nameLengthsBefore = names.stream()
                                               .map(s -> s.length())
                                               .collect(Collectors.toList());
        System.out.println("Name lengths (Lambda): " + nameLengthsBefore);

        // After: Method reference for String::length
        List<Integer> nameLengthsAfter = names.stream()
                                              .map(String::length) // Equivalent to (s) -> s.length()
                                              .collect(Collectors.toList());
        System.out.println("Name lengths (Method Reference): " + nameLengthsAfter);


        // Example 4: Constructor reference
        System.out.println("\n--- Constructor Reference ---");

        // Before: Lambda for creating a new ArrayList
        List<String> newListBefore = numbers.stream()
                                            .map(String::valueOf) // Convert Integer to String
                                            .collect(() -> new ArrayList<>(), List::add, List::addAll); // Old style collect
        System.out.println("New List (Lambda constructor): " + newListBefore);

        // After: Method reference for creating a new ArrayList (using Collectors.toCollection)
        List<String> newListAfter = numbers.stream()
                                           .map(String::valueOf)
                                           .collect(Collectors.toCollection(ArrayList::new)); // Equivalent to () -> new ArrayList<>()
        System.out.println("New List (Constructor Reference): " + newListAfter);
    }

    static class MyPrinter {
        public void printCustom(String message) {
            System.out.println("Custom Print: " + message);
        }
    }
}

Clean Code Tip: Prefer method references over lambdas when the lambda body simply invokes an existing method Method references are more concise and often clearer because they directly state the intent: "apply this method." They remove the slight cognitive overhead of parsing the (args) -> someObject.someMethod(args) syntax when someMethod is exactly what you want to do.

Exercise & Solution

Exercise: Given a list of Strings, convert all of them to uppercase using a method reference. Then, print each string from the new uppercase list to the console, again using a method reference.

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class MethodReferenceExercise {
    public static void main(String[] args) {
        List<String> words = Arrays.asList("hello", "world", "java", "streams");

        System.out.println("Original words: " + words);

        // Your code here:
        // 1. Convert words to uppercase using a method reference.
        //    Store the result in a new List<String> called 'upperCaseWords'.
        List<String> upperCaseWords = new ArrayList<>();

        // 2. Print each word from 'upperCaseWords' to the console using a method reference.
        System.out.println("\nUppercase words:");
        // ...
        // Solution placeholder
        // ...
    }
}

Solution:

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class MethodReferenceExerciseSolution {
    public static void main(String[] args) {
        List<String> words = Arrays.asList("hello", "world", "java", "streams");

        System.out.println("Original words: " + words);

        // 1. Convert words to uppercase using a method reference.
        List<String> upperCaseWords = words.stream()
                                           .map(String::toUpperCase) // Arbitrary object of a particular type
                                           .collect(Collectors.toList());

        System.out.println("\nUppercase words:");
        // 2. Print each word from 'upperCaseWords' to the console using a method reference.
        upperCaseWords.forEach(System.out::println); // Instance method reference of a particular object (System.out)
    }
}

Chapter 7: Introduction to Maven

Quick Theory: Why databases are better than .txt files

When it comes to storing application data, plain text files (like .txt or .csv) are highly inefficient and problematic for anything beyond trivial use cases. They offer no inherent structure, making data retrieval, modification, and deletion complex and error-prone. Concurrency control (multiple users accessing at once) is practically non-existent, and data integrity (ensuring data is valid and consistent) must be entirely handled by the application logic, leading to fragile systems.

Relational databases, on the other hand, provide a robust, structured, and efficient solution for data persistence. They offer powerful features like ACID properties (Atomicity, Consistency, Isolation, Durability) to ensure data integrity, built-in query languages (SQL) for efficient data manipulation, and sophisticated mechanisms for concurrency control and user management. Modern applications almost universally rely on databases for their backend storage, guaranteeing reliable and scalable data management.

Maven is a powerful build automation tool used primarily for Java projects. It simplifies the build process by managing dependencies, compiling code, running tests, and packaging applications. The core of a Maven project is the pom.xml (Project Object Model) file, which describes the project's configuration, dependencies, and build lifecycle.

Professional Code

Let's set up a basic pom.xml for our project. We'll include a dependency for SQLite, a lightweight, file-based database, which is excellent for learning and simple applications as it doesn't require a separate server.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example.persistence</groupId>
    <artifactId>java-persistence-app</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <!-- SQLite JDBC Driver Dependency -->
        <dependency>
            <groupId>org.xerial</groupId>
            <artifactId>sqlite-jdbc</artifactId>
            <version>3.45.1.0</version> <!-- Always use the latest stable version -->
        </dependency>

        <!-- JUnit 5 for testing (Good practice to include) -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>5.10.0</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>5.10.0</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!-- Maven Compiler Plugin -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.11.0</version>
                <configuration>
                    <source>${maven.compiler.source}</source>
                    <target>${maven.compiler.target}</target>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

Clean Code Tip: Build tools are essential Always use a build tool like Maven or Gradle for any non-trivial Java project. They automate tedious tasks like dependency management (downloading JARs), compilation, testing, and packaging, ensuring consistency across development environments and significantly reducing manual errors. The pom.xml serves as a central, declarative source of truth for your project's configuration.

Exercise & Solution

Exercise: Create a new directory for a Java project. Inside, create a pom.xml file that:

  1. Sets the groupId, artifactId, and version to com.mycompany, my-database-app, 1.0-SNAPSHOT respectively.
  2. Configures Java 17 for compilation.
  3. Includes the sqlite-jdbc dependency.
<!-- Your pom.xml structure goes here -->

Solution:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.mycompany</groupId>
    <artifactId>my-database-app</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <!-- SQLite JDBC Driver -->
        <dependency>
            <groupId>org.xerial</groupId>
            <artifactId>sqlite-jdbc</artifactId>
            <version>3.45.1.0</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.11.0</version>
                <configuration>
                    <source>${maven.compiler.source}</source>
                    <target>${maven.compiler.target}</target>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

Chapter 8: JDBC Fundamentals

Quick Theory: The Java Database Connectivity (JDBC) API

JDBC (Java Database Connectivity) is a standard Java API for connecting to and interacting with relational databases. It provides a common interface for Java applications to communicate with various database systems (like MySQL, PostgreSQL, Oracle, SQLite), abstracting away the vendor-specific details. This means you can write your database access code once, and it will work with different databases by simply changing the JDBC driver.

The core steps involved in JDBC are often summarized as four stages:

  1. Load the Driver: Makes the database driver available to the Java application. For modern JDBC (Java 6+), Class.forName() is often implicit or no longer required for most drivers as they register themselves.
  2. Establish a Connection: Connects the Java application to the database using DriverManager.getConnection(), providing a JDBC URL, username, and password.
  3. Execute SQL Queries: Creates and executes SQL statements (Statement or PreparedStatement) to perform CRUD operations. Results are typically retrieved using a ResultSet.
  4. Close Resources: Releases database resources (Connection, Statement, ResultSet) to prevent leaks and ensure efficient resource management. This is best handled using try-with-resources.

Professional Code

Let's write a simple Java program to connect to an SQLite database and create a Product table.

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;

public class JdbcFundamentals {

    // Database URL for SQLite. This will create a 'products.db' file in your project root.
    private static final String JDBC_URL = "jdbc:sqlite:products.db";

    public static void main(String[] args) {
        // 1. Load the Driver (No explicit Class.forName() needed for modern JDBC drivers like SQLite)
        //    The driver typically registers itself when it's loaded onto the classpath by Maven.
        System.out.println("Attempting to connect to the database...");

        // 2. Establish a Connection & 3. Execute SQL Query & 4. Close Resources
        //    Using try-with-resources to ensure Connection and Statement are closed automatically.
        try (Connection connection = DriverManager.getConnection(JDBC_URL);
             Statement statement = connection.createStatement()) {

            System.out.println("Connection to SQLite established successfully.");

            // SQL statement to create the 'products' table if it doesn't already exist.
            String createTableSQL = """
                    CREATE TABLE IF NOT EXISTS products (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        name TEXT NOT NULL,
                        price REAL NOT NULL,
                        stock_quantity INTEGER NOT NULL
                    );
                    """;

            // Execute the DDL (Data Definition Language) query.
            statement.execute(createTableSQL);
            System.out.println("Table 'products' checked/created successfully.");

        } catch (SQLException e) {
            // Catch any SQL exceptions that occur during connection or query execution.
            System.err.println("Database error: " + e.getMessage());
            e.printStackTrace();
        }
        System.out.println("Database operation completed. Resources closed.");
    }
}

Clean Code Tip: Always close resources with try-with-resources JDBC resources like Connection, Statement, and ResultSet consume system resources. Failing to close them leads to resource leaks, which can eventually exhaust your application's memory or database connections. The try-with-resources statement (introduced in Java 7) is the cleanest and safest way to ensure these resources are automatically closed, even if exceptions occur.

Exercise & Solution

Exercise: Modify the JdbcFundamentals example to also create a categories table with id (INTEGER PRIMARY KEY AUTOINCREMENT) and name (TEXT NOT NULL) columns, right after creating the products table.

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;

public class JdbcExercise {

    private static final String JDBC_URL = "jdbc:sqlite:products.db";

    public static void main(String[] args) {
        try (Connection connection = DriverManager.getConnection(JDBC_URL);
             Statement statement = connection.createStatement()) {

            System.out.println("Connection to SQLite established successfully.");

            String createProductsTableSQL = """
                    CREATE TABLE IF NOT EXISTS products (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        name TEXT NOT NULL,
                        price REAL NOT NULL,
                        stock_quantity INTEGER NOT NULL
                    );
                    """;
            statement.execute(createProductsTableSQL);
            System.out.println("Table 'products' checked/created successfully.");

            // Your code here: Add SQL to create the 'categories' table

            System.out.println("Table 'categories' checked/created successfully.");

        } catch (SQLException e) {
            System.err.println("Database error: " + e.getMessage());
            e.printStackTrace();
        }
        System.out.println("Database operation completed. Resources closed.");
    }
}

Solution:

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;

public class JdbcExerciseSolution {

    private static final String JDBC_URL = "jdbc:sqlite:products.db";

    public static void main(String[] args) {
        try (Connection connection = DriverManager.getConnection(JDBC_URL);
             Statement statement = connection.createStatement()) {

            System.out.println("Connection to SQLite established successfully.");

            String createProductsTableSQL = """
                    CREATE TABLE IF NOT EXISTS products (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        name TEXT NOT NULL,
                        price REAL NOT NULL,
                        stock_quantity INTEGER NOT NULL
                    );
                    """;
            statement.execute(createProductsTableSQL);
            System.out.println("Table 'products' checked/created successfully.");

            // Solution: Add SQL to create the 'categories' table
            String createCategoriesTableSQL = """
                    CREATE TABLE IF NOT EXISTS categories (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        name TEXT NOT NULL UNIQUE
                    );
                    """;
            statement.execute(createCategoriesTableSQL);
            System.out.println("Table 'categories' checked/created successfully.");

        } catch (SQLException e) {
            System.err.println("Database error: " + e.getMessage());
            e.printStackTrace();
        }
        System.out.println("Database operation completed. Resources closed.");
    }
}

Chapter 9: The CRUD Operations (Create, Read, Update, Delete)

Quick Theory: Understanding CRUD

CRUD is an acronym that stands for Create, Read, Update, and Delete. These four basic operations are the fundamental functions of persistent storage and are the bedrock of most database-driven applications. Almost every piece of data you interact with in an application (a user, a product, an order) will at some point undergo one of these operations.

Understanding and implementing these operations efficiently and securely is paramount for any developer working with databases. In this chapter, we'll demonstrate them using basic Statement objects, but remember that for production code, PreparedStatement (covered next) is always the preferred and secure choice.

Professional Code

Let's build a simple Product class and a ProductManager to perform CRUD operations.

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

// --- Product Model Class ---
class Product {
    private int id;
    private String name;
    private double price;
    private int stockQuantity;

    // Constructor for creating new products (without ID)
    public Product(String name, double price, int stockQuantity) {
        this.name = name;
        this.price = price;
        this.stockQuantity = stockQuantity;
    }

    // Constructor for retrieving existing products (with ID)
    public Product(int id, String name, double price, int stockQuantity) {
        this.id = id;
        this.name = name;
        this.price = price;
        this.stockQuantity = stockQuantity;
    }

    // Getters and Setters
    public int getId() { return id; }
    public void setId(int id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public double getPrice() { return price; }
    public void setPrice(double price) { this.price = price; }
    public int getStockQuantity() { return stockQuantity; }
    public void setStockQuantity(int stockQuantity) { this.stockQuantity = stockQuantity; }

    @Override
    public String toString() {
        return "Product{id=" + id + ", name='" + name + "', price=" + price + ", stockQuantity=" + stockQuantity + '}';
    }
}

// --- ProductManager Class for CRUD Operations ---
public class ProductManager {

    private static final String JDBC_URL = "jdbc:sqlite:products.db"; // Our database file

    // Helper method to get a database connection
    private Connection getConnection() throws SQLException {
        return DriverManager.getConnection(JDBC_URL);
    }

    // CREATE operation
    public void addProduct(Product product) {
        // SQL query to insert a new product. ID is AUTOINCREMENT, so we don't include it.
        // NOTE: For security, PreparedStatements are preferred (see Chapter 10).
        String sql = "INSERT INTO products (name, price, stock_quantity) VALUES ('" +
                     product.getName() + "', " + product.getPrice() + ", " + product.getStockQuantity() + ");";

        try (Connection connection = getConnection();
             Statement statement = connection.createStatement()) {

            int rowsAffected = statement.executeUpdate(sql); // executeUpdate for INSERT, UPDATE, DELETE
            if (rowsAffected > 0) {
                // To get the auto-generated ID, we would typically use PreparedStatement.RETURN_GENERATED_KEYS.
                // For this basic example with Statement, we'll just acknowledge creation.
                System.out.println("Product '" + product.getName() + "' added successfully.");
            } else {
                System.err.println("Failed to add product: " + product.getName());
            }

        } catch (SQLException e) {
            System.err.println("Error adding product: " + e.getMessage());
            e.printStackTrace();
        }
    }

    // READ operation - Get all products
    public List<Product> getAllProducts() {
        List<Product> products = new ArrayList<>();
        String sql = "SELECT id, name, price, stock_quantity FROM products;";

        try (Connection connection = getConnection();
             Statement statement = connection.createStatement();
             ResultSet resultSet = statement.executeQuery(sql)) { // executeQuery for SELECT

            while (resultSet.next()) { // Iterate through each row in the result set
                int id = resultSet.getInt("id");
                String name = resultSet.getString("name");
                double price = resultSet.getDouble("price");
                int stockQuantity = resultSet.getInt("stock_quantity");
                products.add(new Product(id, name, price, stockQuantity));
            }

        } catch (SQLException e) {
            System.err.println("Error retrieving all products: " + e.getMessage());
            e.printStackTrace();
        }
        return products;
    }

    // READ operation - Get product by ID
    public Optional<Product> getProductById(int productId) {
        // NOTE: For security, PreparedStatements are preferred (see Chapter 10).
        String sql = "SELECT id, name, price, stock_quantity FROM products WHERE id = " + productId + ";";

        try (Connection connection = getConnection();
             Statement statement = connection.createStatement();
             ResultSet resultSet = statement.executeQuery(sql)) {

            if (resultSet.next()) { // If a row is found
                int id = resultSet.getInt("id");
                String name = resultSet.getString("name");
                double price = resultSet.getDouble("price");
                int stockQuantity = resultSet.getInt("stock_quantity");
                return Optional.of(new Product(id, name, price, stockQuantity));
            }

        } catch (SQLException e) {
            System.err.println("Error retrieving product by ID " + productId + ": " + e.getMessage());
            e.printStackTrace();
        }
        return Optional.empty(); // No product found or an error occurred
    }

    // UPDATE operation
    public void updateProduct(Product product) {
        // NOTE: For security, PreparedStatements are preferred (see Chapter 10).
        String sql = "UPDATE products SET name = '" + product.getName() + "', " +
                     "price = " + product.getPrice() + ", " +
                     "stock_quantity = " + product.getStockQuantity() + " " +
                     "WHERE id = " + product.getId() + ";";

        try (Connection connection = getConnection();
             Statement statement = connection.createStatement()) {

            int rowsAffected = statement.executeUpdate(sql);
            if (rowsAffected > 0) {
                System.out.println("Product ID " + product.getId() + " updated successfully.");
            } else {
                System.out.println("Product ID " + product.getId() + " not found or no changes made.");
            }

        } catch (SQLException e) {
            System.err.println("Error updating product ID " + product.getId() + ": " + e.getMessage());
            e.printStackTrace();
        }
    }

    // DELETE operation
    public void deleteProduct(int productId) {
        // NOTE: For security, PreparedStatements are preferred (see Chapter 10).
        String sql = "DELETE FROM products WHERE id = " + productId + ";";

        try (Connection connection = getConnection();
             Statement statement = connection.createStatement()) {

            int rowsAffected = statement.executeUpdate(sql);
            if (rowsAffected > 0) {
                System.out.println("Product ID " + productId + " deleted successfully.");
            } else {
                System.out.println("Product ID " + productId + " not found.");
            }

        } catch (SQLException e) {
            System.err.println("Error deleting product ID " + productId + ": " + e.getMessage());
            e.printStackTrace();
        }
    }


    // --- Main method to demonstrate CRUD operations ---
    public static void main(String[] args) {
        ProductManager manager = new ProductManager();

        // Ensure the table exists before starting CRUD operations
        try (Connection connection = DriverManager.getConnection(JDBC_URL);
             Statement statement = connection.createStatement()) {
            String createTableSQL = """
                    CREATE TABLE IF NOT EXISTS products (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        name TEXT NOT NULL,
                        price REAL NOT NULL,
                        stock_quantity INTEGER NOT NULL
                    );
                    """;
            statement.execute(createTableSQL);
            System.out.println("Products table ensured to exist.\n");
        } catch (SQLException e) {
            System.err.println("Setup error: " + e.getMessage());
            return; // Exit if table creation fails
        }


        // 1. Create Products
        System.out.println("--- Adding Products ---");
        manager.addProduct(new Product("Laptop", 1200.00, 10)); // ID will be auto-generated as 1
        manager.addProduct(new Product("Mouse", 25.50, 50));   // ID will be auto-generated as 2
        manager.addProduct(new Product("Keyboard", 75.00, 20)); // ID will be auto-generated as 3
        System.out.println();

        // 2. Read All Products
        System.out.println("--- All Products ---");
        List<Product> allProducts = manager.getAllProducts();
        allProducts.forEach(System.out::println);
        System.out.println();

        // 3. Read a specific Product by ID
        System.out.println("--- Product by ID (ID 2) ---");
        manager.getProductById(2).ifPresentOrElse(
            System.out::println,
            () -> System.out.println("Product with ID 2 not found.")
        );
        System.out.println();

        // 4. Update a Product (change Mouse to a Gaming Mouse, reduce stock)
        System.out.println("--- Updating Product ID 2 ---");
        // We retrieve it first to ensure we have the correct ID
        manager.getProductById(2).ifPresent(p -> {
            p.setName("Gaming Mouse");
            p.setPrice(59.99);
            p.setStockQuantity(30);
            manager.updateProduct(p);
        });
        // Verify update
        System.out.println("Updated Product ID 2:");
        manager.getProductById(2).ifPresent(System.out::println);
        System.out.println();

        // 5. Delete a Product (delete Keyboard by ID 3)
        System.out.println("--- Deleting Product ID 3 ---");
        manager.deleteProduct(3);
        System.out.println();

        // Read all products again to confirm deletion
        System.out.println("--- All Products After Deletion ---");
        manager.getAllProducts().forEach(System.out::println);
        System.out.println();

        // Attempt to delete a non-existent product
        System.out.println("--- Attempting to Delete Non-existent Product ID 99 ---");
        manager.deleteProduct(99);
        System.out.println();
    }
}

Clean Code Tip: Use constants for SQL queries Store your SQL queries as private static final String constants. This improves readability, reduces the chance of typos, and makes queries easier to maintain. For complex queries or applications with many queries, externalizing them (e.g., in properties files) can be even better.

Exercise & Solution

Exercise: Based on the ProductManager example, implement a method int getTotalStockValue() that calculates the sum of (price * stock_quantity) for all products in the database.

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

// (Product class and existing CRUD methods omitted for brevity, assume they are present)
class Product {
    private int id;
    private String name;
    private double price;
    private int stockQuantity;

    public Product(String name, double price, int stockQuantity) {
        this.name = name;
        this.price = price;
        this.stockQuantity = stockQuantity;
    }
    public Product(int id, String name, double price, int stockQuantity) {
        this.id = id; this.name = name; this.price = price; this.stockQuantity = stockQuantity;
    }
    public int getId() { return id; }
    public void setId(int id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public double getPrice() { return price; }
    public void setPrice(double price) { this.price = price; }
    public int getStockQuantity() { return stockQuantity; }
    public void setStockQuantity(int stockQuantity) { this.stockQuantity = stockQuantity; }
    @Override
    public String toString() {
        return "Product{id=" + id + ", name='" + name + "', price=" + price + ", stockQuantity=" + stockQuantity + '}';
    }
}

public class ProductManagerExercise {

    private static final String JDBC_URL = "jdbc:sqlite:products.db";

    private Connection getConnection() throws SQLException {
        return DriverManager.getConnection(JDBC_URL);
    }

    // (Existing addProduct, getAllProducts, getProductById, updateProduct, deleteProduct methods omitted)
    public void addProduct(Product product) { /* ... */ }
    public List<Product> getAllProducts() { /* ... */ return new ArrayList<>(); }
    public Optional<Product> getProductById(int productId) { /* ... */ return Optional.empty(); }
    public void updateProduct(Product product) { /* ... */ }
    public void deleteProduct(int productId) { /* ... */ }


    // Your code here: Implement getTotalStockValue()
    public double getTotalStockValue() {
        double totalValue = 0.0;
        // ...
        return totalValue;
    }

    public static void main(String[] args) {
        ProductManagerExercise manager = new ProductManagerExercise();

        // (Table creation and adding initial products omitted for brevity, assume they are done)
        // For testing, let's ensure some data is there:
        try (Connection connection = DriverManager.getConnection(JDBC_URL);
             Statement statement = connection.createStatement()) {
            statement.execute("DROP TABLE IF EXISTS products;"); // Clear for fresh test
            statement.execute("""
                    CREATE TABLE products (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        name TEXT NOT NULL,
                        price REAL NOT NULL,
                        stock_quantity INTEGER NOT NULL
                    );
                    """);
            statement.executeUpdate("INSERT INTO products (name, price, stock_quantity) VALUES ('TV', 500.0, 5);");
            statement.executeUpdate("INSERT INTO products (name, price, stock_quantity) VALUES ('Soundbar', 150.0, 10);");
            System.out.println("Test data prepared.\n");
        } catch (SQLException e) {
            System.err.println("Test setup error: " + e.getMessage());
            return;
        }


        System.out.println("Total stock value: " + manager.getTotalStockValue());
    }
}

Solution:

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

class Product { // Full Product class for solution context
    private int id;
    private String name;
    private double price;
    private int stockQuantity;

    public Product(String name, double price, int stockQuantity) {
        this.name = name;
        this.price = price;
        this.stockQuantity = stockQuantity;
    }
    public Product(int id, String name, double price, int stockQuantity) {
        this.id = id; this.name = name; this.price = price; this.stockQuantity = stockQuantity;
    }
    public int getId() { return id; }
    public void setId(int id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public double getPrice() { return price; }
    public void setPrice(double price) { this.price = price; }
    public int getStockQuantity() { return stockQuantity; }
    public void setStockQuantity(int stockQuantity) { this.stockQuantity = stockQuantity; }
    @Override
    public String toString() {
        return "Product{id=" + id + ", name='" + name + "', price=" + price + ", stockQuantity=" + stockQuantity + '}';
    }
}

public class ProductManagerExerciseSolution {

    private static final String JDBC_URL = "jdbc:sqlite:products.db";

    private Connection getConnection() throws SQLException {
        return DriverManager.getConnection(JDBC_URL);
    }

    // Add back the necessary methods from ProductManager if running standalone
    public void addProduct(Product product) {
        String sql = "INSERT INTO products (name, price, stock_quantity) VALUES ('" +
                product.getName() + "', " + product.getPrice() + ", " + product.getStockQuantity() + ");";
        try (Connection connection = getConnection(); Statement statement = connection.createStatement()) {
            statement.executeUpdate(sql);
        } catch (SQLException e) { e.printStackTrace(); }
    }
    public List<Product> getAllProducts() {
        List<Product> products = new ArrayList<>();
        String sql = "SELECT id, name, price, stock_quantity FROM products;";
        try (Connection connection = getConnection();
             Statement statement = connection.createStatement();
             ResultSet resultSet = statement.executeQuery(sql)) {
            while (resultSet.next()) {
                products.add(new Product(resultSet.getInt("id"), resultSet.getString("name"),
                        resultSet.getDouble("price"), resultSet.getInt("stock_quantity")));
            }
        } catch (SQLException e) { e.printStackTrace(); }
        return products;
    }
    public Optional<Product> getProductById(int productId) {
        String sql = "SELECT id, name, price, stock_quantity FROM products WHERE id = " + productId + ";";
        try (Connection connection = getConnection();
             Statement statement = connection.createStatement();
             ResultSet resultSet = statement.executeQuery(sql)) {
            if (resultSet.next()) {
                return Optional.of(new Product(resultSet.getInt("id"), resultSet.getString("name"),
                        resultSet.getDouble("price"), resultSet.getInt("stock_quantity")));
            }
        } catch (SQLException e) { e.printStackTrace(); }
        return Optional.empty();
    }
    public void updateProduct(Product product) {
        String sql = "UPDATE products SET name = '" + product.getName() + "', " +
                "price = " + product.getPrice() + ", " +
                "stock_quantity = " + product.getStockQuantity() + " " +
                "WHERE id = " + product.getId() + ";";
        try (Connection connection = getConnection(); Statement statement = connection.createStatement()) {
            statement.executeUpdate(sql);
        } catch (SQLException e) { e.printStackTrace(); }
    }
    public void deleteProduct(int productId) {
        String sql = "DELETE FROM products WHERE id = " + productId + ";";
        try (Connection connection = getConnection(); Statement statement = connection.createStatement()) {
            statement.executeUpdate(sql);
        } catch (SQLException e) { e.printStackTrace(); }
    }


    // Solution: Implement getTotalStockValue()
    public double getTotalStockValue() {
        double totalValue = 0.0;
        // We can either retrieve all products and sum them in Java,
        // or let the database do the calculation which is usually more efficient.
        String sql = "SELECT SUM(price * stock_quantity) AS total_value FROM products;";

        try (Connection connection = getConnection();
             Statement statement = connection.createStatement();
             ResultSet resultSet = statement.executeQuery(sql)) {

            if (resultSet.next()) { // Expecting only one row with the sum
                totalValue = resultSet.getDouble("total_value");
            }

        } catch (SQLException e) {
            System.err.println("Error calculating total stock value: " + e.getMessage());
            e.printStackTrace();
        }
        return totalValue;
    }

    public static void main(String[] args) {
        ProductManagerExerciseSolution manager = new ProductManagerExerciseSolution();

        // For testing, let's ensure some data is there:
        try (Connection connection = DriverManager.getConnection(JDBC_URL);
             Statement statement = connection.createStatement()) {
            statement.execute("DROP TABLE IF EXISTS products;"); // Clear for fresh test
            statement.execute("""
                    CREATE TABLE products (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        name TEXT NOT NULL,
                        price REAL NOT NULL,
                        stock_quantity INTEGER NOT NULL
                    );
                    """);
            statement.executeUpdate("INSERT INTO products (name, price, stock_quantity) VALUES ('TV', 500.0, 5);"); // Value: 2500
            statement.executeUpdate("INSERT INTO products (name, price, stock_quantity) VALUES ('Soundbar', 150.0, 10);"); // Value: 1500
            System.out.println("Test data prepared.\n");
        } catch (SQLException e) {
            System.err.println("Test setup error: " + e.getMessage());
            return;
        }

        System.out.println("Total stock value: " + manager.getTotalStockValue()); // Expected: 4000.0
    }
}

Chapter 10: PreparedStatements

Quick Theory: The Necessity of PreparedStatements

Using plain Statement objects for executing SQL queries where user input is directly concatenated into the SQL string is a critical security vulnerability known as SQL Injection. An attacker can inject malicious SQL code through user input, potentially leading to unauthorized data access, modification, or even deletion of entire tables. For instance, if a user inputs ' OR '1'='1 into a login field, a naive Statement might execute SELECT * FROM users WHERE username = '' OR '1'='1' AND password = '', effectively bypassing authentication.

PreparedStatement is the solution to SQL injection. It pre-compiles the SQL query string with ? placeholders for parameters. When you set parameters using methods like setString(), setInt(), setDouble(), etc., the database driver treats these values as literal data, not as executable SQL code. This ensures that no matter what malicious characters are in the user input, they are never interpreted as part of the query's structure, eliminating the risk of injection. PreparedStatement also offers performance benefits by allowing the database to parse and optimize the query once.

Professional Code

Let's refactor our ProductManager to use PreparedStatement for all CRUD operations. This is a mandatory practice for secure and robust database interactions.


Clean Code Tip: PreparedStatements are mandatory for security Never use Statement for queries involving user input. Always, always use PreparedStatement with ? placeholders for parameters. This is the single most important rule for preventing SQL injection vulnerabilities and ensuring the security and integrity of your database.

Exercise & Solution

Exercise: Given the ProductManagerPreparedStatement class, refactor the getTotalStockValue() method from the previous exercise to use a PreparedStatement (even though it doesn't strictly need parameters, it's good practice for consistency and future parameterization).

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

// (Product class and existing CRUD methods omitted for brevity, assume they are present)
class Product {
    private int id; private String name; private double price; private int stockQuantity;
    public Product(String name, double price, int stockQuantity) { this.name = name; this.price = price; this.stockQuantity = stockQuantity; }
    public Product(int id, String name, double price, int stockQuantity) { this.id = id; this.name = name; this.price = price; this.stockQuantity = stockQuantity; }
    public int getId() { return id; } public void setId(int id) { this.id = id; }
    public String getName() { return name; } public void setName(String name) { this.name = name; }
    public double getPrice() { return price; } public void setPrice(double price) { this.price = price; }
    public int getStockQuantity() { return stockQuantity; } public void setStockQuantity(int stockQuantity) { this.stockQuantity = stockQuantity; }
    @Override public String toString() { return "Product{id=" + id + ", name='" + name + "', price=" + price + ", stockQuantity=" + stockQuantity + '}'; }
}

public class PreparedStatementExercise {

    private static final String JDBC_URL = "jdbc:sqlite:products.db";

    private Connection getConnection() throws SQLException {
        return DriverManager.getConnection(JDBC_URL);
    }

    // (Existing addProduct, getAllProducts, getProductById, updateProduct, deleteProduct methods omitted)
    // Assume these are implemented using PreparedStatements.

    // Your code here: Refactor getTotalStockValue() to use PreparedStatement
    public double getTotalStockValue() {
        double totalValue = 0.0;
        // ...
        // Solution Placeholder
        // ...
        return totalValue;
    }

    public static void main(String[] args) {
        PreparedStatementExercise manager = new PreparedStatementExercise();

        // (Test data setup omitted for brevity, assume it's there)
        try (Connection connection = DriverManager.getConnection(JDBC_URL);
             Statement statement = connection.createStatement()) {
            statement.execute("DROP TABLE IF EXISTS products;");
            statement.execute("""
                    CREATE TABLE products (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        name TEXT NOT NULL,
                        price REAL NOT NULL,
                        stock_quantity INTEGER NOT NULL
                    );
                    """);
            statement.executeUpdate("INSERT INTO products (name, price, stock_quantity) VALUES ('TV', 500.0, 5);"); // Value: 2500
            statement.executeUpdate("INSERT INTO products (name, price, stock_quantity) VALUES ('Soundbar', 150.0, 10);"); // Value: 1500
            System.out.println("Test data prepared.\n");
        } catch (SQLException e) {
            System.err.println("Test setup error: " + e.getMessage());
            return;
        }

        System.out.println("Total stock value: " + manager.getTotalStockValue());
    }
}

Solution:

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

class Product { // Full Product class for solution context
    private int id; private String name; private double price; private int stockQuantity;
    public Product(String name, double price, int stockQuantity) { this.name = name; this.price = price; this.stockQuantity = stockQuantity; }
    public Product(int id, String name, double price, int stockQuantity) { this.id = id; this.name = name; this.price = price; this.stockQuantity = stockQuantity; }
    public int getId() { return id; } public void setId(int id) { this.id = id; }
    public String getName() { return name; } public void setName(String name) { this.name = name; }
    public double getPrice() { return price; } public void setPrice(double price) { this.price = price; }
    public int getStockQuantity() { return stockQuantity; } public void setStockQuantity(int stockQuantity) { this.stockQuantity = stockQuantity; }
    @Override public String toString() { return "Product{id=" + id + ", name='" + name + "', price=" + price + ", stockQuantity=" + stockQuantity + '}'; }
}

public class PreparedStatementExerciseSolution {

    private static final String JDBC_URL = "jdbc:sqlite:products.db";

    private Connection getConnection() throws SQLException {
        return DriverManager.getConnection(JDBC_URL);
    }

    // Solution: Refactor getTotalStockValue() to use PreparedStatement
    public double getTotalStockValue() {
        double totalValue = 0.0;
        String sql = "SELECT SUM(price * stock_quantity) AS total_value FROM products;";

        try (Connection connection = getConnection();
             // Even without parameters, using PreparedStatement is good for consistency and slight performance benefit
             // if the query is executed multiple times in a real application.
             PreparedStatement preparedStatement = connection.prepareStatement(sql);
             ResultSet resultSet = preparedStatement.executeQuery()) {

            if (resultSet.next()) {
                totalValue = resultSet.getDouble("total_value");
            }

        } catch (SQLException e) {
            System.err.println("Error calculating total stock value: " + e.getMessage());
            e.printStackTrace();
        }
        return totalValue;
    }

    public static void main(String[] args) {
        PreparedStatementExerciseSolution manager = new PreparedStatementExerciseSolution();

        try (Connection connection = DriverManager.getConnection(JDBC_URL);
             Statement statement = connection.createStatement()) {
            statement.execute("DROP TABLE IF EXISTS products;");
            statement.execute("""
                    CREATE TABLE products (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        name TEXT NOT NULL,
                        price REAL NOT NULL,
                        stock_quantity INTEGER NOT NULL
                    );
                    """);
            statement.executeUpdate("INSERT INTO products (name, price, stock_quantity) VALUES ('TV', 500.0, 5);");
            statement.executeUpdate("INSERT INTO products (name, price, stock_quantity) VALUES ('Soundbar', 150.0, 10);");
            System.out.println("Test data prepared.\n");
        } catch (SQLException e) {
            System.err.println("Test setup error: " + e.getMessage());
            return;
        }

        System.out.println("Total stock value: " + manager.getTotalStockValue());
    }
}

Chapter 11: Transaction Management

Technical Theory: Ensuring Atomicity with Transactions

Database transactions are fundamental for maintaining data integrity and consistency, especially when multiple related database operations need to be treated as a single, indivisible unit. The ACID properties (Atomicity, Consistency, Isolation, Durability) are the standard for reliable transaction processing.

In JDBC, Connection objects are in auto-commit mode by default, meaning each SQL statement is treated as a separate transaction and committed immediately. To group multiple statements into a single transaction, you must disable auto-commit using connection.setAutoCommit(false). Then, you explicitly call connection.commit() if all operations succeed, or connection.rollback() if any operation fails.

Professional Code

Let's simulate a scenario where money is transferred between two bank accounts to demonstrate transaction management. If either debit or credit fails, the entire transaction should be rolled back.

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

public class TransactionManagement {

    private static final String JDBC_URL = "jdbc:sqlite:bank.db"; // A separate DB for bank accounts

    // --- Helper methods to manage bank accounts table ---

    // Initializes the 'accounts' table and inserts some test data.
    private static void setupDatabase() {
        try (Connection connection = DriverManager.getConnection(JDBC_URL);
             Statement statement = connection.createStatement()) {
            statement.execute("DROP TABLE IF EXISTS accounts;"); // Start fresh for demo
            String createTableSQL = """
                    CREATE TABLE accounts (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        account_number TEXT NOT NULL UNIQUE,
                        balance REAL NOT NULL DEFAULT 0.0
                    );
                    """;
            statement.execute(createTableSQL);
            System.out.println("Accounts table created.");

            // Insert test accounts
            statement.executeUpdate("INSERT INTO accounts (account_number, balance) VALUES ('ACC001', 1000.00);");
            statement.executeUpdate("INSERT INTO accounts (account_number, balance) VALUES ('ACC002', 500.00);");
            System.out.println("Test accounts 'ACC001' (1000.00) and 'ACC002' (500.00) created.\n");

        } catch (SQLException e) {
            System.err.println("Database setup error: " + e.getMessage());
            e.printStackTrace();
        }
    }

    // Prints the current balances of all accounts
    private static void printAccountBalances() {
        System.out.println("--- Current Account Balances ---");
        String sql = "SELECT account_number, balance FROM accounts;";
        try (Connection connection = DriverManager.getConnection(JDBC_URL);
             PreparedStatement ps = connection.prepareStatement(sql);
             ResultSet rs = ps.executeQuery()) {
            while (rs.next()) {
                System.out.println(rs.getString("account_number") + ": " + rs.getDouble("balance"));
            }
        } catch (SQLException e) {
            System.err.println("Error retrieving balances: " + e.getMessage());
            e.printStackTrace();
        }
        System.out.println("--------------------------------\n");
    }

    // --- Transactional Transfer Money Method ---

    /**
     * Transfers a specified amount from one account to another in a single transaction.
     * Ensures atomicity: either both debit and credit succeed, or both fail.
     * @param fromAccountNumber The account to debit.
     * @param toAccountNumber The account to credit.
     * @param amount The amount to transfer.
     * @return true if transfer was successful, false otherwise.
     */
    public boolean transferMoney(String fromAccountNumber, String toAccountNumber, double amount) {
        // We will use a single Connection object for the entire transaction.
        // It's crucial that all operations within a transaction use the same connection.
        Connection connection = null;
        try {
            connection = DriverManager.getConnection(JDBC_URL);
            connection.setAutoCommit(false); // Disable auto-commit to start a transaction

            // 1. Debit from the source account
            String debitSql = "UPDATE accounts SET balance = balance - ? WHERE account_number = ? AND balance >= ?;";
            try (PreparedStatement debitPs = connection.prepareStatement(debitSql)) {
                debitPs.setDouble(1, amount);
                debitPs.setString(2, fromAccountNumber);
                debitPs.setDouble(3, amount); // Check for sufficient balance
                int debitRowsAffected = debitPs.executeUpdate();

                if (debitRowsAffected == 0) {
                    System.out.println("Transfer failed: Insufficient funds or source account not found for " + fromAccountNumber);
                    connection.rollback(); // Rollback if debit fails (e.g., insufficient funds)
                    return false;
                }
            }

            // (Optional) Simulate an error here to test rollback
            // if (fromAccountNumber.equals("ACC001") && amount == 150.0) {
            //     throw new SQLException("Simulated error during transfer to test rollback!");
            // }

            // 2. Credit to the destination account
            String creditSql = "UPDATE accounts SET balance = balance + ? WHERE account_number = ?;";
            try (PreparedStatement creditPs = connection.prepareStatement(creditSql)) {
                creditPs.setDouble(1, amount);
                creditPs.setString(2, toAccountNumber);
                int creditRowsAffected = creditPs.executeUpdate();

                if (creditRowsAffected == 0) {
                    System.out.println("Transfer failed: Destination account not found for " + toAccountNumber);
                    connection.rollback(); // Rollback if credit fails (e.g., destination account doesn't exist)
                    return false;
                }
            }

            connection.commit(); // Commit the transaction if both operations succeed
            System.out.println("Transfer of " + amount + " from " + fromAccountNumber + " to " + toAccountNumber + " successful.");
            return true;

        } catch (SQLException e) {
            System.err.println("Transfer failed due to a database error: " + e.getMessage());
            if (connection != null) {
                try {
                    System.out.println("Attempting to rollback changes...");
                    connection.rollback(); // Rollback on any SQLException
                    System.out.println("Rollback successful.");
                } catch (SQLException rollbackEx) {
                    System.err.println("Error during rollback: " + rollbackEx.getMessage());
                }
            }
            e.printStackTrace();
            return false;
        } finally {
            if (connection != null) {
                try {
                    connection.setAutoCommit(true); // Restore auto-commit mode
                    connection.close(); // Close the connection
                } catch (SQLException closeEx) {
                    System.err.println("Error closing connection: " + closeEx.getMessage());
                }
            }
        }
    }

    public static void main(String[] args) {
        setupDatabase(); // Initialize our bank accounts database
        TransactionManagement manager = new TransactionManagement();

        System.out.println("Initial balances:");
        printAccountBalances();

        // Scenario 1: Successful transfer
        System.out.println("--- Attempting successful transfer: ACC001 -> ACC002, $100.00 ---");
        manager.transferMoney("ACC001", "ACC002", 100.00);
        printAccountBalances(); // Balances should be: ACC001: 900.00, ACC002: 600.00

        // Scenario 2: Transfer with insufficient funds (ACC001 has 900, try to transfer 1000)
        System.out.println("--- Attempting transfer with insufficient funds: ACC001 -> ACC002, $1000.00 ---");
        manager.transferMoney("ACC001", "ACC002", 1000.00);
        printAccountBalances(); // Balances should be unchanged: ACC001: 900.00, ACC002: 600.00

        // Scenario 3: Transfer to a non-existent account (will trigger rollback)
        System.out.println("--- Attempting transfer to non-existent account: ACC001 -> ACC999, $50.00 ---");
        manager.transferMoney("ACC001", "ACC999", 50.00);
        printAccountBalances(); // Balances should be unchanged: ACC001: 900.00, ACC002: 600.00
    }
}

Clean Code Tip: Transactions are for critical multi-step operations Use transactions whenever multiple database operations must succeed or fail together to maintain data consistency (atomicity). Always disable auto-commit, explicitly call commit() on success, and rollback() on failure (typically within a catch block). Remember to set auto-commit back to true and close the connection in a finally block to prevent resource leaks and unexpected behavior for subsequent database interactions.

Exercise & Solution

Exercise: Implement a method batchUpdateProductPrices(double percentageIncrease) that updates the price of all products by a given percentage. This operation should be transactional. If any error occurs during the update process (e.g., a constraint violation, although less likely with a simple update), all price changes should be rolled back. For this exercise, you'll need the Product class and basic setup from previous chapters.

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;

// (Product class and a getConnection helper, setupDatabase method from previous chapters are assumed)
class Product { // Full Product class for solution context
    private int id; private String name; private double price; private int stockQuantity;
    public Product(String name, double price, int stockQuantity) { this.name = name; this.price = price; this.stockQuantity = stockQuantity; }
    public Product(int id, String name, double price, int stockQuantity) { this.id = id; this.name = name; this.price = price; this.stockQuantity = stockQuantity; }
    public int getId() { return id; } public void setId(int id) { this.id = id; }
    public String getName() { return name; } public void setName(String name) { this.name = name; }
    public double getPrice() { return price; } public void setPrice(double price) { this.price = price; }
    public int getStockQuantity() { return stockQuantity; } public void setStockQuantity(int stockQuantity) { this.stockQuantity = stockQuantity; }
    @Override public String toString() { return "Product{id=" + id + ", name='" + name + "', price=" + price + ", stockQuantity=" + stockQuantity + '}'; }
}

public class TransactionExercise {

    private static final String JDBC_URL = "jdbc:sqlite:products.db";

    private Connection getConnection() throws SQLException {
        return DriverManager.getConnection(JDBC_URL);
    }

    private static void setupDatabase() {
        try (Connection connection = DriverManager.getConnection(JDBC_URL);
             Statement statement = connection.createStatement()) {
            statement.execute("DROP TABLE IF EXISTS products;"); // Clear for fresh test
            String createTableSQL = """
                    CREATE TABLE products (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        name TEXT NOT NULL,
                        price REAL NOT NULL,
                        stock_quantity INTEGER NOT NULL
                    );
                    """;
            statement.execute(createTableSQL);
            statement.executeUpdate("INSERT INTO products (name, price, stock_quantity) VALUES ('Laptop', 1000.00, 10);");
            statement.executeUpdate("INSERT INTO products (name, price, stock_quantity) VALUES ('Monitor', 300.00, 15);");
            statement.executeUpdate("INSERT INTO products (name, price, stock_quantity) VALUES ('Webcam', 50.00, 20);");
            System.out.println("Products table created with initial data.\n");
        } catch (SQLException e) {
            System.err.println("Database setup error: " + e.getMessage());
            e.printStackTrace();
        }
    }

    // Helper to print all products
    public List<Product> getAllProducts() {
        List<Product> products = new ArrayList<>();
        String sql = "SELECT id, name, price, stock_quantity FROM products;";
        try (Connection connection = getConnection();
             PreparedStatement preparedStatement = connection.prepareStatement(sql);
             ResultSet resultSet = preparedStatement.executeQuery()) {
            while (resultSet.next()) {
                products.add(new Product(resultSet.getInt("id"), resultSet.getString("name"),
                        resultSet.getDouble("price"), resultSet.getInt("stock_quantity")));
            }
        } catch (SQLException e) { e.printStackTrace(); }
        return products;
    }


    // Your code here: Implement batchUpdateProductPrices()
    public boolean batchUpdateProductPrices(double percentageIncrease) {
        // ...
        return false; // Placeholder
    }

    public static void main(String[] args) {
        setupDatabase();
        TransactionExercise manager = new TransactionExercise();

        System.out.println("Products before update:");
        manager.getAllProducts().forEach(System.out::println);

        System.out.println("\n--- Attempting 10% price increase ---");
        if (manager.batchUpdateProductPrices(0.10)) { // 10% increase
            System.out.println("\nProducts after successful update:");
            manager.getAllProducts().forEach(System.out::println);
        } else {
            System.out.println("\nPrice update failed. Products should be unchanged:");
            manager.getAllProducts().forEach(System.out::println);
        }

        System.out.println("\n--- Attempting 20% price increase (and simulate an error) ---");
        // To simulate error, you might temporarily introduce a bug in the SQL or trigger a constraint.
        // For example, if you set a price to be negative (if allowed by DB, but bad logic).
        // Or if you update the SQL string to be invalid: String sql = "UPDATE products SET price_INVALID = price * (1 + ?);";
        // This will trigger the rollback.
        if (manager.batchUpdateProductPrices(0.20)) {
            System.out.println("\nProducts after (unexpectedly) successful update:");
            manager.getAllProducts().forEach(System.out::println);
        } else {
            System.out.println("\nPrice update failed as expected. Products should be unchanged (or rolled back):");
            manager.getAllProducts().forEach(System.out::println);
        }
    }
}

Solution:

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;

class Product { // Full Product class for solution context
    private int id; private String name; private double price; private int stockQuantity;
    public Product(String name, double price, int stockQuantity) { this.name = name; this.price = price; this.stockQuantity = stockQuantity; }
    public Product(int id, String name, double price, int stockQuantity) { this.id = id; this.name = name; this.price = price; this.stockQuantity = stockQuantity; }
    public int getId() { return id; } public void setId(int id) { this.id = id; }
    public String getName() { return name; } public void setName(String name) { this.name = name; }
    public double getPrice() { return price; } public void setPrice(double price) { this.price = price; }
    public int getStockQuantity() { return stockQuantity; } public void setStockQuantity(int stockQuantity) { this.stockQuantity = stockQuantity; }
    @Override public String toString() { return "Product{id=" + id + ", name='" + name + "', price=" + price + ", stockQuantity=" + stockQuantity + '}'; }
}

public class TransactionExerciseSolution {

    private static final String JDBC_URL = "jdbc:sqlite:products.db";

    private Connection getConnection() throws SQLException {
        return DriverManager.getConnection(JDBC_URL);
    }

    private static void setupDatabase() {
        try (Connection connection = DriverManager.getConnection(JDBC_URL);
             Statement statement = connection.createStatement()) {
            statement.execute("DROP TABLE IF EXISTS products;"); // Clear for fresh test
            String createTableSQL = """
                    CREATE TABLE products (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        name TEXT NOT NULL,
                        price REAL NOT NULL,
                        stock_quantity INTEGER NOT NULL
                    );
                    """;
            statement.execute(createTableSQL);
            statement.executeUpdate("INSERT INTO products (name, price, stock_quantity) VALUES ('Laptop', 1000.00, 10);");
            statement.executeUpdate("INSERT INTO products (name, price, stock_quantity) VALUES ('Monitor', 300.00, 15);");
            statement.executeUpdate("INSERT INTO products (name, price, stock_quantity) VALUES ('Webcam', 50.00, 20);");
            System.out.println("Products table created with initial data.\n");
        } catch (SQLException e) {
            System.err.println("Database setup error: " + e.getMessage());
            e.printStackTrace();
        }
    }

    public List<Product> getAllProducts() {
        List<Product> products = new ArrayList<>();
        String sql = "SELECT id, name, price, stock_quantity FROM products;";
        try (Connection connection = getConnection();
             PreparedStatement preparedStatement = connection.prepareStatement(sql);
             ResultSet resultSet = preparedStatement.executeQuery()) {
            while (resultSet.next()) {
                products.add(new Product(resultSet.getInt("id"), resultSet.getString("name"),
                        resultSet.getDouble("price"), resultSet.getInt("stock_quantity")));
            }
        } catch (SQLException e) { e.printStackTrace(); }
        return products;
    }


    // Solution: Implement batchUpdateProductPrices()
    public boolean batchUpdateProductPrices(double percentageIncrease) {
        Connection connection = null;
        try {
            connection = getConnection();
            connection.setAutoCommit(false); // Begin transaction

            String sql = "UPDATE products SET price = price * (1 + ?);"; // Update all prices
            // To simulate an error, you could temporarily change `price * (1 + ?)` to `price_INVALID * (1 + ?)`
            // or even just `price_INVALID` to cause a SQL syntax error.
            // String sql = "UPDATE products SET price_INVALID = price * (1 + ?);"; // Uncomment to test rollback

            try (PreparedStatement ps = connection.prepareStatement(sql)) {
                ps.setDouble(1, percentageIncrease);
                int rowsAffected = ps.executeUpdate();
                System.out.println("Attempted to update prices for " + rowsAffected + " products.");
            }

            connection.commit(); // Commit if all updates succeed
            System.out.println("Batch price update successful by " + (percentageIncrease * 100) + "%.");
            return true;

        } catch (SQLException e) {
            System.err.println("Batch price update failed due to a database error: " + e.getMessage());
            if (connection != null) {
                try {
                    System.out.println("Attempting to rollback changes...");
                    connection.rollback(); // Rollback on any SQL exception
                    System.out.println("Rollback successful.");
                } catch (SQLException rollbackEx) {
                    System.err.println("Error during rollback: " + rollbackEx.getMessage());
                }
            }
            e.printStackTrace();
            return false;
        } finally {
            if (connection != null) {
                try {
                    connection.setAutoCommit(true); // Restore auto-commit
                    connection.close(); // Close the connection
                } catch (SQLException closeEx) {
                    System.err.println("Error closing connection: " + closeEx.getMessage());
                }
            }
        }
    }

    public static void main(String[] args) {
        setupDatabase();
        TransactionExerciseSolution manager = new TransactionExerciseSolution();

        System.out.println("Products before update:");
        manager.getAllProducts().forEach(System.out::println);

        System.out.println("\n--- Attempting 10% price increase ---");
        if (manager.batchUpdateProductPrices(0.10)) { // 10% increase
            System.out.println("\nProducts after successful update:");
            manager.getAllProducts().forEach(System.out::println);
        } else {
            System.out.println("\nPrice update failed. Products should be unchanged:");
            manager.getAllProducts().forEach(System.out::println);
        }

        System.out.println("\n--- Attempting 20% price increase (and simulate an error) ---");
        // To simulate error for testing:
        // Temporarily change the SQL in batchUpdateProductPrices to be invalid, e.g.,
        // String sql = "UPDATE products SET price_INVALID = price * (1 + ?);";
        // Then run this scenario.
        if (manager.batchUpdateProductPrices(0.20)) {
            System.out.println("\nProducts after (unexpectedly) successful update:");
            manager.getAllProducts().forEach(System.out::println);
        } else {
            System.out.println("\nPrice update failed as expected. Products should be unchanged (or rolled back):");
            manager.getAllProducts().forEach(System.out::println);
        }
    }
}

Chapter 12: DAO Pattern (Data Access Object)

Technical Theory: Professional Architectural Pattern

The Data Access Object (DAO) pattern is a widely used architectural pattern in enterprise applications to separate the low-level data access logic from the high-level business logic. Its primary goal is to abstract how data is persisted, retrieved, updated, and deleted, allowing the rest of the application to interact with data objects without needing to know the specifics of the underlying database (SQL, JDBC, ORM, etc.).

Benefits of the DAO pattern:

A typical DAO structure involves:

  1. Model/Entity Class: Represents the data structure (e.g., Product).
  2. DAO Interface: Defines the contract for data operations (e.g., ProductDAO with methods like add, getById, update, delete).
  3. DAO Implementation Class: Implements the DAO interface, containing the actual JDBC (or other persistence technology) code. (e.g., ProductDAOImpl).
  4. Client/Service Class: Uses the DAO interface to perform business operations, without knowing the implementation details.

Professional Code

Let's refactor our Product CRUD operations into the DAO pattern.

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

// 1. Model/Entity Class (Product) - No changes from previous chapters
class Product {
    private int id;
    private String name;
    private double price;
    private int stockQuantity;

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

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

    public int getId() { return id; }
    public void setId(int id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public double getPrice() { return price; }
    public void setPrice(double price) { this.price = price; }
    public int getStockQuantity() { return stockQuantity; }
    public void setStockQuantity(int stockQuantity) { this.stockQuantity = stockQuantity; }

    @Override
    public String toString() {
        return "Product{id=" + id + ", name='" + name + "', price=" + price + ", stockQuantity=" + stockQuantity + '}';
    }
}

// 2. DAO Interface: Defines the contract for Product data access
interface ProductDAO {
    void addProduct(Product product);
    Optional<Product> getProductById(int id);
    List<Product> getAllProducts();
    void updateProduct(Product product);
    void deleteProduct(int id);
}

// 3. DAO Implementation Class: Contains the JDBC logic
class ProductDAOImpl implements ProductDAO {

    private static final String JDBC_URL = "jdbc:sqlite:products.db";

    // Helper method to get a database connection
    private Connection getConnection() throws SQLException {
        return DriverManager.getConnection(JDBC_URL);
    }

    @Override
    public void addProduct(Product product) {
        String sql = "INSERT INTO products (name, price, stock_quantity) VALUES (?, ?, ?);";
        try (Connection connection = getConnection();
             PreparedStatement ps = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
            ps.setString(1, product.getName());
            ps.setDouble(2, product.getPrice());
            ps.setInt(3, product.getStockQuantity());
            int rowsAffected = ps.executeUpdate();

            if (rowsAffected > 0) {
                try (ResultSet generatedKeys = ps.getGeneratedKeys()) {
                    if (generatedKeys.next()) {
                        product.setId(generatedKeys.getInt(1)); // Set the generated ID back to the product
                        System.out.println("[DAO] Product '" + product.getName() + "' added with ID: " + product.getId());
                    }
                }
            }
        } catch (SQLException e) {
            System.err.println("[DAO] Error adding product: " + e.getMessage());
            e.printStackTrace();
        }
    }

    @Override
    public Optional<Product> getProductById(int id) {
        String sql = "SELECT id, name, price, stock_quantity FROM products WHERE id = ?;";
        try (Connection connection = getConnection();
             PreparedStatement ps = connection.prepareStatement(sql)) {
            ps.setInt(1, id);
            try (ResultSet rs = ps.executeQuery()) {
                if (rs.next()) {
                    return Optional.of(new Product(rs.getInt("id"), rs.getString("name"),
                                                   rs.getDouble("price"), rs.getInt("stock_quantity")));
                }
            }
        } catch (SQLException e) {
            System.err.println("[DAO] Error retrieving product by ID " + id + ": " + e.getMessage());
            e.printStackTrace();
        }
        return Optional.empty();
    }

    @Override
    public List<Product> getAllProducts() {
        List<Product> products = new ArrayList<>();
        String sql = "SELECT id, name, price, stock_quantity FROM products;";
        try (Connection connection = getConnection();
             PreparedStatement ps = connection.prepareStatement(sql);
             ResultSet rs = ps.executeQuery()) {
            while (rs.next()) {
                products.add(new Product(rs.getInt("id"), rs.getString("name"),
                                         rs.getDouble("price"), rs.getInt("stock_quantity")));
            }
        } catch (SQLException e) {
            System.err.println("[DAO] Error retrieving all products: " + e.getMessage());
            e.printStackTrace();
        }
        return products;
    }

    @Override
    public void updateProduct(Product product) {
        String sql = "UPDATE products SET name = ?, price = ?, stock_quantity = ? WHERE id = ?;";
        try (Connection connection = getConnection();
             PreparedStatement ps = connection.prepareStatement(sql)) {
            ps.setString(1, product.getName());
            ps.setDouble(2, product.getPrice());
            ps.setInt(3, product.getStockQuantity());
            ps.setInt(4, product.getId());
            int rowsAffected = ps.executeUpdate();
            if (rowsAffected > 0) {
                System.out.println("[DAO] Product ID " + product.getId() + " updated.");
            } else {
                System.out.println("[DAO] Product ID " + product.getId() + " not found for update.");
            }
        } catch (SQLException e) {
            System.err.println("[DAO] Error updating product ID " + product.getId() + ": " + e.getMessage());
            e.printStackTrace();
        }
    }

    @Override
    public void deleteProduct(int id) {
        String sql = "DELETE FROM products WHERE id = ?;";
        try (Connection connection = getConnection();
             PreparedStatement ps = connection.prepareStatement(sql)) {
            ps.setInt(1, id);
            int rowsAffected = ps.executeUpdate();
            if (rowsAffected > 0) {
                System.out.println("[DAO] Product ID " + id + " deleted.");
            } else {
                System.out.println("[DAO] Product ID " + id + " not found for deletion.");
            }
        } catch (SQLException e) {
            System.err.println("[DAO] Error deleting product ID " + id + ": " + e.getMessage());
            e.printStackTrace();
        }
    }
}

// 4. Client/Service Class: Uses the DAO interface
public class ProductService { // Renamed from main class to represent a higher-level service

    private ProductDAO productDAO; // Depending on the interface, not the implementation

    public ProductService(ProductDAO productDAO) {
        this.productDAO = productDAO;
    }

    // Business logic methods that use the DAO
    public void createNewProduct(String name, double price, int stock) {
        Product product = new Product(name, price, stock);
        productDAO.addProduct(product);
        System.out.println("[Service] Created product: " + product);
    }

    public void displayAllProducts() {
        System.out.println("\n[Service] --- All Products ---");
        List<Product> products = productDAO.getAllProducts();
        if (products.isEmpty()) {
            System.out.println("No products available.");
        } else {
            products.forEach(System.out::println);
        }
    }

    public void updateProductDetails(int id, String newName, double newPrice, int newStock) {
        Optional<Product> existingProduct = productDAO.getProductById(id);
        if (existingProduct.isPresent()) {
            Product productToUpdate = existingProduct.get();
            productToUpdate.setName(newName);
            productToUpdate.setPrice(newPrice);
            productToUpdate.setStockQuantity(newStock);
            productDAO.updateProduct(productToUpdate);
            System.out.println("[Service] Updated product ID " + id);
        } else {
            System.out.println("[Service] Product ID " + id + " not found for update.");
        }
    }

    public void removeProduct(int id) {
        productDAO.deleteProduct(id);
        System.out.println("[Service] Attempted to remove product ID " + id);
    }

    // --- Main method to demonstrate DAO pattern usage ---
    public static void main(String[] args) {
        // --- Database Setup (can be extracted to a separate utility/init) ---
        try (Connection connection = DriverManager.getConnection("jdbc:sqlite:products.db");
             Statement statement = connection.createStatement()) {
            statement.execute("DROP TABLE IF EXISTS products;"); // Clear for fresh demo
            String createTableSQL = """
                    CREATE TABLE IF NOT EXISTS products (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        name TEXT NOT NULL,
                        price REAL NOT NULL,
                        stock_quantity INTEGER NOT NULL
                    );
                    """;
            statement.execute(createTableSQL);
            System.out.println("Products table ensured to exist and cleared.\n");
        } catch (SQLException e) {
            System.err.println("Database setup error: " + e.getMessage());
            return;
        }
        // --- End Database Setup ---

        // Inject the DAO implementation into the service
        ProductDAO productDAO = new ProductDAOImpl();
        ProductService productService = new ProductService(productDAO);

        // Perform operations via the Service layer
        productService.createNewProduct("Laptop", 1200.00, 10); // ID: 1
        productService.createNewProduct("Mouse", 25.50, 50);    // ID: 2
        productService.createNewProduct("Keyboard", 75.00, 20); // ID: 3

        productService.displayAllProducts();

        productService.updateProductDetails(2, "Wireless Mouse", 35.99, 45); // Update ID 2
        productDAO.getProductById(2).ifPresent(p -> System.out.println("Verified update: " + p));

        productService.removeProduct(3); // Delete ID 3
        productService.removeProduct(99); // Attempt to delete non-existent

        productService.displayAllProducts();
    }
}

Clean Code Tip: DAO decouples business logic from persistence Always implement the DAO pattern (or use an ORM like Hibernate which provides its own abstraction) to separate your application's business logic from its persistence logic. This promotes modularity, makes your code more robust to changes in database technology, and drastically improves testability. Depend on the DAO interface, not the concrete implementation.

Exercise & Solution

Exercise: Extend the DAO pattern to include a Category entity.

  1. Create a Category model class (id, name).
  2. Create a CategoryDAO interface and CategoryDAOImpl class (with add, getById, getAll).
  3. Modify the products table to include a category_id (INTEGER, REFERENCES categories(id)) column.
  4. Update Product model to include a categoryId field.
  5. Update ProductDAO methods (addProduct, getProductById, getAllProducts) and ProductDAOImpl to handle the category_id.
  6. Demonstrate usage in main by adding categories first, then products associated with those categories.
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

// --- 1. Category Model Class ---
class Category {
    private int id;
    private String name;

    public Category(String name) { this.name = name; }
    public Category(int id, String name) { this.id = id; this.name = name; }
    public int getId() { return id; } public void setId(int id) { this.id = id; }
    public String getName() { return name; } public void setName(String name) { this.name = name; }
    @Override public String toString() { return "Category{id=" + id + ", name='" + name + "'}"; }
}

// --- Product Model Class (Modified) ---
class Product {
    private int id; private String name; private double price; private int stockQuantity; private int categoryId;

    public Product(String name, double price, int stockQuantity, int categoryId) {
        this.name = name; this.price = price; this.stockQuantity = stockQuantity; this.categoryId = categoryId;
    }
    public Product(int id, String name, double price, int stockQuantity, int categoryId) {
        this.id = id; this.name = name; this.price = price; this.stockQuantity = stockQuantity; this.categoryId = categoryId;
    }
    public int getId() { return id; } public void setId(int id) { this.id = id; }
    public String getName() { return name; } public void setName(String name) { this.name = name; }
    public double getPrice() { return price; } public void setPrice(double price) { this.price = price; }
    public int getStockQuantity() { return stockQuantity; } public void setStockQuantity(int stockQuantity) { this.stockQuantity = stockQuantity; }
    public int getCategoryId() { return categoryId; } public void setCategoryId(int categoryId) { this.categoryId = categoryId; }
    @Override public String toString() {
        return "Product{id=" + id + ", name='" + name + "', price=" + price + ", stockQuantity=" + stockQuantity + ", categoryId=" + categoryId + '}';
    }
}

// --- 2. CategoryDAO Interface ---
interface CategoryDAO {
    void addCategory(Category category);
    Optional<Category> getCategoryById(int id);
    List<Category> getAllCategories();
}

// --- ProductDAO Interface (Modified) ---
interface ProductDAO {
    void addProduct(Product product);
    Optional<Product> getProductById(int id);
    List<Product> getAllProducts();
    void updateProduct(Product product);
    void deleteProduct(int id);
}

// --- 3. CategoryDAOImpl Class ---
class CategoryDAOImpl implements CategoryDAO {
    private static final String JDBC_URL = "jdbc:sqlite:products.db";
    private Connection getConnection() throws SQLException { return DriverManager.getConnection(JDBC_URL); }

    @Override public void addCategory(Category category) { /* ... */ }
    @Override public Optional<Category> getCategoryById(int id) { /* ... */ return Optional.empty(); }
    @Override public List<Category> getAllCategories() { /* ... */ return new ArrayList<>(); }
}

// --- ProductDAOImpl Class (Modified) ---
class ProductDAOImpl implements ProductDAO {
    private static final String JDBC_URL = "jdbc:sqlite:products.db";
    private Connection getConnection() throws SQLException { return DriverManager.getConnection(JDBC_URL); }

    @Override public void addProduct(Product product) { /* ... */ }
    @Override public Optional<Product> getProductById(int id) { /* ... */ return Optional.empty(); }
    @Override public List<Product> getAllProducts() { /* ... */ return new ArrayList<>(); }
    @Override public void updateProduct(Product product) { /* ... */ }
    @Override public void deleteProduct(int id) { /* ... */ }
}

// --- Client/Service Class (Modified Main) ---
public class DaoExercise {
    private static final String JDBC_URL = "jdbc:sqlite:products.db";

    private static void setupDatabase() {
        try (Connection connection = DriverManager.getConnection(JDBC_URL);
             Statement statement = connection.createStatement()) {
            statement.execute("DROP TABLE IF EXISTS products;");
            statement.execute("DROP TABLE IF EXISTS categories;");

            String createCategoriesTableSQL = """
                    CREATE TABLE categories (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        name TEXT NOT NULL UNIQUE
                    );
                    """;
            statement.execute(createCategoriesTableSQL);
            System.out.println("Categories table created.");

            String createProductsTableSQL = """
                    CREATE TABLE products (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        name TEXT NOT NULL,
                        price REAL NOT NULL,
                        stock_quantity INTEGER NOT NULL,
                        category_id INTEGER,
                        FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
                    );
                    """;
            statement.execute(createProductsTableSQL);
            System.out.println("Products table created.\n");
        } catch (SQLException e) {
            System.err.println("Database setup error: " + e.getMessage());
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        setupDatabase();

        CategoryDAO categoryDAO = new CategoryDAOImpl();
        ProductDAO productDAO = new ProductDAOImpl();

        // Your code here:
        // 1. Add some categories (e.g., "Electronics", "Books")
        // 2. Retrieve their IDs
        // 3. Add products, associating them with categories
        // 4. Display all categories and products

        System.out.println("\n--- All Categories ---");
        categoryDAO.getAllCategories().forEach(System.out::println);

        System.out.println("\n--- All Products ---");
        productDAO.getAllProducts().forEach(System.out::println);
    }
}

Solution:

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

// --- 1. Category Model Class ---
class Category {
    private int id;
    private String name;

    public Category(String name) { this.name = name; }
    public Category(int id, String name) { this.id = id; this.name = name; }
    public int getId() { return id; } public void setId(int id) { this.id = id; }
    public String getName() { return name; } public void setName(String name) { this.name = name; }
    @Override public String toString() { return "Category{id=" + id + ", name='" + name + "'}"; }
}

// --- Product Model Class (Modified) ---
class Product {
    private int id; private String name; private double price; private int stockQuantity; private int categoryId; // Added categoryId

    public Product(String name, double price, int stockQuantity, int categoryId) {
        this.name = name; this.price = price; this.stockQuantity = stockQuantity; this.categoryId = categoryId;
    }
    public Product(int id, String name, double price, int stockQuantity, int categoryId) {
        this.id = id; this.name = name; this.price = price; this.stockQuantity = stockQuantity; this.categoryId = categoryId;
    }
    public int getId() { return id; } public void setId(int id) { this.id = id; }
    public String getName() { return name; } public void setName(String name) { this.name = name; }
    public double getPrice() { return price; } public void setPrice(double price) { this.price = price; }
    public int getStockQuantity() { return stockQuantity; } public void setStockQuantity(int stockQuantity) { this.stockQuantity = stockQuantity; }
    public int getCategoryId() { return categoryId; } public void setCategoryId(int categoryId) { this.categoryId = categoryId; }
    @Override public String toString() {
        return "Product{id=" + id + ", name='" + name + "', price=" + price + ", stockQuantity=" + stockQuantity + ", categoryId=" + categoryId + '}';
    }
}

// --- 2. CategoryDAO Interface ---
interface CategoryDAO {
    void addCategory(Category category);
    Optional<Category> getCategoryById(int id);
    List<Category> getAllCategories();
}

// --- ProductDAO Interface (Modified) ---
interface ProductDAO {
    void addProduct(Product product);
    Optional<Product> getProductById(int id);
    List<Product> getAllProducts();
    void updateProduct(Product product);
    void deleteProduct(int id);
}

// --- 3. CategoryDAOImpl Class ---
class CategoryDAOImpl implements CategoryDAO {
    private static final String JDBC_URL = "jdbc:sqlite:products.db";
    private Connection getConnection() throws SQLException { return DriverManager.getConnection(JDBC_URL); }

    @Override
    public void addCategory(Category category) {
        String sql = "INSERT INTO categories (name) VALUES (?);";
        try (Connection connection = getConnection();
             PreparedStatement ps = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
            ps.setString(1, category.getName());
            int rowsAffected = ps.executeUpdate();
            if (rowsAffected > 0) {
                try (ResultSet generatedKeys = ps.getGeneratedKeys()) {
                    if (generatedKeys.next()) {
                        category.setId(generatedKeys.getInt(1));
                        System.out.println("[DAO] Category '" + category.getName() + "' added with ID: " + category.getId());
                    }
                }
            }
        } catch (SQLException e) {
            System.err.println("[DAO] Error adding category: " + e.getMessage());
            e.printStackTrace();
        }
    }

    @Override
    public Optional<Category> getCategoryById(int id) {
        String sql = "SELECT id, name FROM categories WHERE id = ?;";
        try (Connection connection = getConnection();
             PreparedStatement ps = connection.prepareStatement(sql)) {
            ps.setInt(1, id);
            try (ResultSet rs = ps.executeQuery()) {
                if (rs.next()) {
                    return Optional.of(new Category(rs.getInt("id"), rs.getString("name")));
                }
            }
        } catch (SQLException e) {
            System.err.println("[DAO] Error retrieving category by ID " + id + ": " + e.getMessage());
            e.printStackTrace();
        }
        return Optional.empty();
    }

    @Override
    public List<Category> getAllCategories() {
        List<Category> categories = new ArrayList<>();
        String sql = "SELECT id, name FROM categories;";
        try (Connection connection = getConnection();
             PreparedStatement ps = connection.prepareStatement(sql);
             ResultSet rs = ps.executeQuery()) {
            while (rs.next()) {
                categories.add(new Category(rs.getInt("id"), rs.getString("name")));
            }
        } catch (SQLException e) {
            System.err.println("[DAO] Error retrieving all categories: " + e.getMessage());
            e.printStackTrace();
        }
        return categories;
    }
}

// --- ProductDAOImpl Class (Modified) ---
class ProductDAOImpl implements ProductDAO {
    private static final String JDBC_URL = "jdbc:sqlite:products.db";
    private Connection getConnection() throws SQLException { return DriverManager.getConnection(JDBC_URL); }

    @Override
    public void addProduct(Product product) {
        String sql = "INSERT INTO products (name, price, stock_quantity, category_id) VALUES (?, ?, ?, ?);"; // Added category_id
        try (Connection connection = getConnection();
             PreparedStatement ps = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
            ps.setString(1, product.getName());
            ps.setDouble(2, product.getPrice());
            ps.setInt(3, product.getStockQuantity());
            ps.setInt(4, product.getCategoryId()); // Set category_id
            int rowsAffected = ps.executeUpdate();

            if (rowsAffected > 0) {
                try (ResultSet generatedKeys = ps.getGeneratedKeys()) {
                    if (generatedKeys.next()) {
                        product.setId(generatedKeys.getInt(1));
                        System.out.println("[DAO] Product '" + product.getName() + "' added with ID: " + product.getId());
                    }
                }
            }
        } catch (SQLException e) {
            System.err.println("[DAO] Error adding product: " + e.getMessage());
            e.printStackTrace();
        }
    }

    @Override
    public Optional<Product> getProductById(int id) {
        String sql = "SELECT id, name, price, stock_quantity, category_id FROM products WHERE id = ?;"; // Added category_id
        try (Connection connection = getConnection();
             PreparedStatement ps = connection.prepareStatement(sql)) {
            ps.setInt(1, id);
            try (ResultSet rs = ps.executeQuery()) {
                if (rs.next()) {
                    return Optional.of(new Product(rs.getInt("id"), rs.getString("name"),
                                                   rs.getDouble("price"), rs.getInt("stock_quantity"),
                                                   rs.getInt("category_id"))); // Retrieve category_id
                }
            }
        } catch (SQLException e) {
            System.err.println("[DAO] Error retrieving product by ID " + id + ": " + e.getMessage());
            e.printStackTrace();
        }
        return Optional.empty();
    }

    @Override
    public List<Product> getAllProducts() {
        List<Product> products = new ArrayList<>();
        String sql = "SELECT id, name, price, stock_quantity, category_id FROM products;"; // Added category_id
        try (Connection connection = getConnection();
             PreparedStatement ps = connection.prepareStatement(sql);
             ResultSet rs = ps.executeQuery()) {
            while (rs.next()) {
                products.add(new Product(rs.getInt("id"), rs.getString("name"),
                                         rs.getDouble("price"), rs.getInt("stock_quantity"),
                                         rs.getInt("category_id"))); // Retrieve category_id
            }
        } catch (SQLException e) {
            System.err.println("[DAO] Error retrieving all products: " + e.getMessage());
            e.printStackTrace();
        }
        return products;
    }

    @Override
    public void updateProduct(Product product) {
        String sql = "UPDATE products SET name = ?, price = ?, stock_quantity = ?, category_id = ? WHERE id = ?;"; // Added category_id
        try (Connection connection = getConnection();
             PreparedStatement ps = connection.prepareStatement(sql)) {
            ps.setString(1, product.getName());
            ps.setDouble(2, product.getPrice());
            ps.setInt(3, product.getStockQuantity());
            ps.setInt(4, product.getCategoryId()); // Set category_id
            ps.setInt(5, product.getId());
            int rowsAffected = ps.executeUpdate();
            if (rowsAffected > 0) {
                System.out.println("[DAO] Product ID " + product.getId() + " updated.");
            } else {
                System.out.println("[DAO] Product ID " + product.getId() + " not found for update.");
            }
        } catch (SQLException e) {
            System.err.println("[DAO] Error updating product ID " + product.getId() + ": " + e.getMessage());
            e.printStackTrace();
        }
    }

    @Override
    public void deleteProduct(int id) {
        String sql = "DELETE FROM products WHERE id = ?;";
        try (Connection connection = getConnection();
             PreparedStatement ps = connection.prepareStatement(sql)) {
            ps.setInt(1, id);
            int rowsAffected = ps.executeUpdate();
            if (rowsAffected > 0) {
                System.out.println("[DAO] Product ID " + id + " deleted.");
            } else {
                System.out.println("[DAO] Product ID " + id + " not found for deletion.");
            }
        } catch (SQLException e) {
            System.err.println("[DAO] Error deleting product ID " + id + ": " + e.getMessage());
            e.printStackTrace();
        }
    }
}

// --- Client/Service Class (Modified Main) ---
public class DaoExerciseSolution {
    private static final String JDBC_URL = "jdbc:sqlite:products.db";

    private static void setupDatabase() {
        try (Connection connection = DriverManager.getConnection(JDBC_URL);
             Statement statement = connection.createStatement()) {
            statement.execute("DROP TABLE IF EXISTS products;");
            statement.execute("DROP TABLE IF EXISTS categories;");

            String createCategoriesTableSQL = """
                    CREATE TABLE categories (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        name TEXT NOT NULL UNIQUE
                    );
                    """;
            statement.execute(createCategoriesTableSQL);
            System.out.println("Categories table created.");

            String createProductsTableSQL = """
                    CREATE TABLE products (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        name TEXT NOT NULL,
                        price REAL NOT NULL,
                        stock_quantity INTEGER NOT NULL,
                        category_id INTEGER,
                        FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
                    );
                    """;
            statement.execute(createProductsTableSQL);
            System.out.println("Products table created.\n");
        } catch (SQLException e) {
            System.err.println("Database setup error: " + e.getMessage());
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        setupDatabase();

        CategoryDAO categoryDAO = new CategoryDAOImpl();
        ProductDAO productDAO = new ProductDAOImpl();

        // 1. Add some categories
        Category electronics = new Category("Electronics");
        categoryDAO.addCategory(electronics); // ID will be set to 1
        Category books = new Category("Books");
        categoryDAO.addCategory(books);       // ID will be set to 2
        Category homeGoods = new Category("Home Goods");
        categoryDAO.addCategory(homeGoods);   // ID will be set to 3

        System.out.println("\n--- All Categories ---");
        categoryDAO.getAllCategories().forEach(System.out::println);

        // 2. Add products, associating them with categories
        productDAO.addProduct(new Product("Laptop", 1200.00, 10, electronics.getId()));
        productDAO.addProduct(new Product("Mouse", 25.50, 50, electronics.getId()));
        productDAO.addProduct(new Product("The Hobbit", 15.00, 100, books.getId()));
        productDAO.addProduct(new Product("Coffee Maker", 75.00, 15, homeGoods.getId()));
        productDAO.addProduct(new Product("Advanced Java", 45.00, 30, books.getId()));
        productDAO.addProduct(new Product("Mystery Novel", 12.00, 80, books.getId()));


        System.out.println("\n--- All Products ---");
        productDAO.getAllProducts().forEach(System.out::println);

        // Example: Update a product's category
        System.out.println("\n--- Updating Mouse category ---");
        Optional<Product> mouse = productDAO.getProductById(2);
        mouse.ifPresent(p -> {
            p.setCategoryId(homeGoods.getId()); // Change mouse to Home Goods category (just for demo)
            productDAO.updateProduct(p);
        });

        System.out.println("\n--- All Products After Update ---");
        productDAO.getAllProducts().forEach(System.out::println);
    }
}

Chapter 19: Introduction to JavaFX

Quick Theory: The Visual Approach

Desktop applications require a visual interface for user interaction. Historically, Java's primary GUI toolkit was Swing, but it has largely been superseded. Swing applications often suffered from an outdated look and feel, performance issues, and complex API design, making them challenging to develop and maintain in a modern context. While still functional, Swing is considered a legacy technology.

JavaFX emerged as the modern, high-performance, and feature-rich platform for building rich client applications in Java. It leverages hardware-accelerated graphics, offers a cleaner API, and supports modern UI concepts like CSS styling, declarative UI with FXML, and media playback. JavaFX provides a robust framework for creating visually appealing and responsive desktop applications that can run across various operating systems.

Professional Code

Let's set up a basic JavaFX application, understanding the core components: Stage, Scene, and Node.

Example 1: Basic "Hello World" JavaFX Application

This example shows the minimal setup for a JavaFX application that displays "Hello, JavaFX!" in a window.

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;

public class HelloWorldFX extends Application {

    // The start method is the main entry point for all JavaFX applications.
    // The primary Stage is the top-level container for a JavaFX application.
    @Override
    public void start(Stage primaryStage) {
        // 1. Create a root node for the scene graph. StackPane is a simple layout
        //    manager that centers its children.
        StackPane root = new StackPane();

        // 2. Create a UI control (a Label in this case) to display text.
        Label helloLabel = new Label("Hello, JavaFX!");

        // 3. Add the label to the root layout pane.
        root.getChildren().add(helloLabel);

        // 4. Create a Scene, which is the container for all content in a scene graph.
        //    A Scene is attached to a Stage. We specify the root node and initial dimensions.
        Scene scene = new Scene(root, 300, 200);

        // 5. Set the title of the primary Stage (the window).
        primaryStage.setTitle("My First JavaFX App");

        // 6. Set the scene on the primary Stage.
        primaryStage.setScene(scene);

        // 7. Show the Stage (make the window visible).
        primaryStage.show();
    }

    // The main method is the standard entry point for Java applications.
    // It calls Application.launch() which handles JavaFX initialization and calls the start method.
    public static void main(String[] args) {
        launch(args);
    }
}

Example 2: Customizing the Window with Background Color

This example builds on the first, showing how to set a custom background color for the scene and incorporate basic styling.

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color; // Import Color class
import javafx.stage.Stage;

public class CustomWindowFX extends Application {

    @Override
    public void start(Stage primaryStage) {
        StackPane root = new StackPane();
        Label welcomeLabel = new Label("Welcome to Custom JavaFX!");

        // Add some basic CSS styling directly to the label
        welcomeLabel.setStyle("-fx-font-size: 24px; -fx-text-fill: white;");

        root.getChildren().add(welcomeLabel);

        // Create a Scene with a specific background color
        // The third argument to the Scene constructor can be a Paint object (e.g., Color.LIGHTBLUE)
        Scene scene = new Scene(root, 400, 250, Color.DARKBLUE); // Set scene background to DARKBLUE

        primaryStage.setTitle("Custom JavaFX Window");
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Clean Code Tip: Start small, understand core concepts When diving into GUI frameworks like JavaFX, resist the urge to build complex UIs immediately. Master the fundamental concepts of Stage (the window), Scene (the content inside), and the Node hierarchy (components and their arrangement) first. Build simple "Hello World" examples, then incrementally add features to solidify your understanding.

Exercise & Solution

Exercise: Create a JavaFX application that displays a window with the title "My Profile" and a Label that says "User: [Your Name]". The window should be 500x150 pixels.

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;

public class ProfileWindowExercise extends Application {

    @Override
    public void start(Stage primaryStage) {
        // Your code here:
        // 1. Create a StackPane as the root layout.
        // 2. Create a Label with your name.
        // 3. Add the label to the root.
        // 4. Create a Scene with the root and dimensions 500x150.
        // 5. Set the Stage title to "My Profile".
        // 6. Set the scene on the primary stage.
        // 7. Show the primary stage.
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Solution:

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;

public class ProfileWindowExerciseSolution extends Application {

    @Override
    public void start(Stage primaryStage) {
        StackPane root = new StackPane();
        Label profileLabel = new Label("User: Jane Doe"); // Replace with your name
        profileLabel.setStyle("-fx-font-size: 20px; -fx-text-fill: #333;");

        root.getChildren().add(profileLabel);

        Scene scene = new Scene(root, 500, 150);

        primaryStage.setTitle("My Profile");
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Chapter 20: Event Handling (Lambdas)

Quick Theory: Making Your UI Interactive

Graphical User Interfaces are inherently event-driven. Users interact with UI components (buttons, text fields, menus), and these interactions trigger events. To make your UI dynamic and responsive, you need to handle these events by attaching event handlers or listeners to components. An event handler is a piece of code that executes when a specific event occurs.

JavaFX, with its modern API design, fully embraces functional programming and lambda expressions for event handling. This dramatically simplifies the syntax compared to the older, more verbose anonymous inner classes used in Swing. Instead of defining a separate class or an anonymous class for each event listener, you can provide the event-handling logic directly as a lambda expression, making your code more concise and readable.

Professional Code

Let's see how to use lambdas to make buttons perform actions and update UI elements.

Example 1: Button Click Prints to Console

This demonstrates a simple button that, when clicked, prints a message to the console using a lambda.

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;

public class EventHandlingConsole extends Application {

    @Override
    public void start(Stage primaryStage) {
        Button clickMeButton = new Button("Click Me!");

        // Set an action for the button using a lambda expression.
        // The lambda takes an ActionEvent object 'e' (or just 'event' or even omit if not used)
        // and defines the code to execute when the button is clicked.
        clickMeButton.setOnAction(e -> {
            System.out.println("Button was clicked! Event: " + e.getEventType());
        });

        StackPane root = new StackPane();
        root.getChildren().add(clickMeButton);

        Scene scene = new Scene(root, 300, 150);
        primaryStage.setTitle("Event Handling Demo (Console)");
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Example 2: Button Click Updates a Label

This example shows how a button click can dynamically change the text of another UI component (a Label).

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.VBox; // Using VBox for vertical alignment
import javafx.geometry.Pos;     // For alignment
import javafx.stage.Stage;

public class EventHandlingUpdateUI extends Application {

    // Declare the Label as a field so it can be accessed and updated from the event handler.
    private Label messageLabel;
    private int clickCount = 0; // To track clicks

    @Override
    public void start(Stage primaryStage) {
        // Initialize the Label
        messageLabel = new Label("Click the button below!");
        messageLabel.setStyle("-fx-font-size: 18px; -fx-text-fill: #007bff;");

        Button updateButton = new Button("Update Message");

        // Lambda expression for the button's action.
        // It updates the text of 'messageLabel' and the click count.
        updateButton.setOnAction(event -> {
            clickCount++;
            messageLabel.setText("Button clicked " + clickCount + " time(s)!");
            System.out.println("Label updated.");
        });

        // VBox layout manager to stack components vertically
        VBox root = new VBox(20); // 20 pixels spacing between children
        root.setAlignment(Pos.CENTER); // Center the children in the VBox
        root.getChildren().addAll(messageLabel, updateButton);

        Scene scene = new Scene(root, 400, 200);
        primaryStage.setTitle("Event Handling Demo (UI Update)");
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Clean Code Tip: Keep event handlers concise, delegate complex logic Event handlers (especially lambdas) should be lightweight and focused on triggering actions, not performing complex business logic themselves. If an action requires significant computation or multiple steps, delegate that work to a separate method or a dedicated service class. This keeps your UI code clean, readable, and easier to test.

Exercise & Solution

Exercise: Create a JavaFX application with a Label showing "Current Count: 0" and two Buttons: "Increment" and "Decrement".

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.geometry.Pos;
import javafx.stage.Stage;

public class CounterAppExercise extends Application {

    private Label countLabel;
    private int currentCount = 0;

    @Override
    public void start(Stage primaryStage) {
        countLabel = new Label("Current Count: " + currentCount);
        countLabel.setStyle("-fx-font-size: 20px; -fx-font-weight: bold;");

        Button incrementButton = new Button("Increment");
        Button decrementButton = new Button("Decrement");

        // Your code here: Add setOnAction for incrementButton and decrementButton using lambdas.

        HBox buttonBox = new HBox(10); // 10 pixels spacing between buttons
        buttonBox.setAlignment(Pos.CENTER);
        buttonBox.getChildren().addAll(incrementButton, decrementButton);

        VBox root = new VBox(20);
        root.setAlignment(Pos.CENTER);
        root.getChildren().addAll(countLabel, buttonBox);

        Scene scene = new Scene(root, 300, 180);
        primaryStage.setTitle("Counter App");
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Solution:

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.geometry.Pos;
import javafx.stage.Stage;

public class CounterAppExerciseSolution extends Application {

    private Label countLabel;
    private int currentCount = 0;

    @Override
    public void start(Stage primaryStage) {
        countLabel = new Label("Current Count: " + currentCount);
        countLabel.setStyle("-fx-font-size: 20px; -fx-font-weight: bold;");

        Button incrementButton = new Button("Increment");
        // Lambda for incrementing the count
        incrementButton.setOnAction(e -> {
            currentCount++;
            countLabel.setText("Current Count: " + currentCount);
        });

        Button decrementButton = new Button("Decrement");
        // Lambda for decrementing the count
        decrementButton.setOnAction(e -> {
            currentCount--;
            countLabel.setText("Current Count: " + currentCount);
        });

        HBox buttonBox = new HBox(10);
        buttonBox.setAlignment(Pos.CENTER);
        buttonBox.getChildren().addAll(incrementButton, decrementButton);

        VBox root = new VBox(20);
        root.setAlignment(Pos.CENTER);
        root.getChildren().addAll(countLabel, buttonBox);

        Scene scene = new Scene(root, 300, 180);
        primaryStage.setTitle("Counter App");
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Chapter 21: Layout Managers (VBox/HBox/GridPane)

Quick Theory: Arranging Components Visually

Designing a user interface isn't just about placing components; it's about arranging them effectively and ensuring they adapt gracefully to different window sizes and screen resolutions. Hardcoding pixel positions (absolute positioning) is generally a bad practice in modern GUI development because it leads to inflexible UIs that break when elements change size or the window is resized.

JavaFX (like other GUI toolkits) provides layout managers (or layout panes) to handle the positioning and sizing of UI components dynamically. These layout panes follow specific rules to organize their children, allowing for responsive and adaptable interfaces. Key layout managers include VBox (vertical stacking), HBox (horizontal stacking), and GridPane (table-like arrangement), among others. Using these greatly simplifies UI construction and improves maintainability.

Professional Code

Let's explore VBox, HBox, and GridPane for organizing UI elements.

Example 1: Using VBox and HBox for Basic Layouts

This example combines VBox and HBox to create a window with a label at the top and a row of buttons at the bottom.

import javafx.application.Application;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

public class LayoutBasics extends Application {

    @Override
    public void start(Stage primaryStage) {
        // --- Top section: A single Label ---
        Label headerLabel = new Label("Welcome to the App!");
        headerLabel.setStyle("-fx-font-size: 24px; -fx-font-weight: bold;");

        // --- Bottom section: Two buttons horizontally arranged ---
        Button saveButton = new Button("Save");
        saveButton.setOnAction(e -> System.out.println("Save clicked!"));

        Button cancelButton = new Button("Cancel");
        cancelButton.setOnAction(e -> System.out.println("Cancel clicked!"));

        // HBox to arrange buttons horizontally
        HBox buttonBox = new HBox(10); // 10 pixels spacing between children
        buttonBox.getChildren().addAll(saveButton, cancelButton);
        buttonBox.setAlignment(Pos.CENTER); // Center buttons horizontally within the HBox

        // --- Main layout: VBox to stack header and buttonBox vertically ---
        VBox root = new VBox(30); // 30 pixels spacing between children
        root.setAlignment(Pos.TOP_CENTER); // Align children to top-center of the VBox
        root.setPadding(new Insets(20)); // Add 20 pixels padding around the VBox content
        root.getChildren().addAll(headerLabel, buttonBox);

        Scene scene = new Scene(root, 400, 250);
        primaryStage.setTitle("VBox & HBox Layout");
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Example 2: Using GridPane for a Form Layout

GridPane is excellent for arranging components in a grid, like a typical form with labels and input fields.

import javafx.application.Application;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.layout.GridPane;
import javafx.stage.Stage;

public class GridPaneForm extends Application {

    @Override
    public void start(Stage primaryStage) {
        GridPane grid = new GridPane();
        grid.setAlignment(Pos.CENTER); // Center the grid in the scene
        grid.setHgap(10); // Horizontal gap between columns
        grid.setVgap(10); // Vertical gap between rows
        grid.setPadding(new Insets(25, 25, 25, 25)); // Padding around the grid

        // --- Add components to the grid ---

        // Row 0, Column 0: Label for User Name
        Label userNameLabel = new Label("User Name:");
        grid.add(userNameLabel, 0, 0); // (node, col, row)

        // Row 0, Column 1: TextField for User Name
        TextField userTextField = new TextField();
        userTextField.setPromptText("Enter your username");
        grid.add(userTextField, 1, 0);

        // Row 1, Column 0: Label for Password
        Label passwordLabel = new Label("Password:");
        grid.add(passwordLabel, 0, 1);

        // Row 1, Column 1: PasswordField (TextField variant for passwords)
        TextField passwordField = new TextField(); // Use PasswordField in real app
        passwordField.setPromptText("Enter your password");
        grid.add(passwordField, 1, 1);

        // Row 2, Column 1: Login Button (spanning multiple columns if needed)
        Button loginButton = new Button("Login");
        // Login button action:
        loginButton.setOnAction(e -> {
            String username = userTextField.getText();
            String password = passwordField.getText();
            System.out.println("Attempting login with Username: " + username + ", Password: " + password);
            // In a real app, this would involve authentication logic
        });
        grid.add(loginButton, 1, 2); // Add button to Column 1, Row 2

        Scene scene = new Scene(grid, 350, 250); // Set scene size
        primaryStage.setTitle("Login Form (GridPane)");
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Clean Code Tip: Favor declarative layouts over hardcoded coordinates Always use JavaFX's layout panes (VBox, HBox, GridPane, BorderPane, AnchorPane, etc.) to arrange your UI components. Avoid setting explicit x, y coordinates, width, or height values unless absolutely necessary. Declarative layouts automatically handle component resizing and positioning, making your UI responsive, flexible, and much easier to maintain across different screen sizes and resolutions.

Exercise & Solution

Exercise: Create a simple "Registration Form" using a GridPane. It should have Labels and TextFields for:

import javafx.application.Application;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.layout.GridPane;
import javafx.stage.Stage;

public class RegistrationFormExercise extends Application {

    @Override
    public void start(Stage primaryStage) {
        GridPane grid = new GridPane();
        grid.setAlignment(Pos.CENTER);
        grid.setHgap(10);
        grid.setVgap(10);
        grid.setPadding(new Insets(25, 25, 25, 25));

        // Your code here: Add Labels and TextFields for First Name, Last Name, Email.
        // Also add a Register Button.

        // Row 0: First Name
        // ...

        // Row 1: Last Name
        // ...

        // Row 2: Email
        // ...

        // Row 3: Register Button
        // ...

        Scene scene = new Scene(grid, 400, 300);
        primaryStage.setTitle("Registration Form");
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Solution:

import javafx.application.Application;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.layout.GridPane;
import javafx.stage.Stage;

public class RegistrationFormExerciseSolution extends Application {

    @Override
    public void start(Stage primaryStage) {
        GridPane grid = new GridPane();
        grid.setAlignment(Pos.CENTER);
        grid.setHgap(10);
        grid.setVgap(10);
        grid.setPadding(new Insets(25, 25, 25, 25));

        // Row 0: First Name
        Label firstNameLabel = new Label("First Name:");
        grid.add(firstNameLabel, 0, 0);
        TextField firstNameField = new TextField();
        firstNameField.setPromptText("John");
        grid.add(firstNameField, 1, 0);

        // Row 1: Last Name
        Label lastNameLabel = new Label("Last Name:");
        grid.add(lastNameLabel, 0, 1);
        TextField lastNameField = new TextField();
        lastNameField.setPromptText("Doe");
        grid.add(lastNameField, 1, 1);

        // Row 2: Email
        Label emailLabel = new Label("Email:");
        grid.add(emailLabel, 0, 2);
        TextField emailField = new TextField();
        emailField.setPromptText("john.doe@example.com");
        grid.add(emailField, 1, 2);

        // Row 3: Register Button
        Button registerButton = new Button("Register");
        registerButton.setOnAction(e -> {
            String firstName = firstNameField.getText();
            String lastName = lastNameField.getText();
            String email = emailField.getText();
            System.out.println("Registering: " + firstName + " " + lastName + " (" + email + ")");
            // In a real application, you'd send this data to a service/database
        });
        grid.add(registerButton, 1, 3);

        Scene scene = new Scene(grid, 400, 300);
        primaryStage.setTitle("Registration Form");
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Chapter 22: MVC Pattern in GUI

Quick Theory: Separating GUI Logic from Data

Building complex GUIs without a proper architectural pattern can quickly lead to spaghetti code, where business logic is intertwined with UI concerns. This makes the application difficult to maintain, extend, and test. The Model-View-Controller (MVC) pattern addresses this by separating an application into three interconnected components:

In a GUI context, MVC ensures a clear separation of concerns, making the codebase more modular, reusable, and testable.

Professional Code

Let's refactor our simple Counter App to demonstrate a basic MVC structure. For simplicity, we'll implement it programmatically without FXML.

Example 1: Counter Application with MVC

import javafx.application.Application;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

// --- 1. Model: Holds the application's data and logic ---
class CounterModel {
    private int count;

    public CounterModel() {
        this.count = 0;
    }

    public int getCount() {
        return count;
    }

    public void increment() {
        count++;
    }

    public void decrement() {
        count--;
    }
}

// --- 2. View: Displays the UI and sends user input to the Controller ---
// (In a real app, this might be split into an interface and implementation, or use FXML)
class CounterView {
    private Label countLabel;
    private Button incrementButton;
    private Button decrementButton;
    private VBox root;

    public CounterView() {
        // Initialize UI components
        countLabel = new Label("Current Count: 0");
        countLabel.setStyle("-fx-font-size: 20px; -fx-font-weight: bold;");

        incrementButton = new Button("Increment");
        decrementButton = new Button("Decrement");

        HBox buttonBox = new HBox(10);
        buttonBox.setAlignment(Pos.CENTER);
        buttonBox.getChildren().addAll(incrementButton, decrementButton);

        root = new VBox(20);
        root.setAlignment(Pos.CENTER);
        root.getChildren().addAll(countLabel, buttonBox);
    }

    public VBox getRoot() {
        return root;
    }

    // Methods to update the view based on model changes
    public void updateCountDisplay(int newCount) {
        countLabel.setText("Current Count: " + newCount);
    }

    // Getters for buttons, so Controller can attach event handlers
    public Button getIncrementButton() {
        return incrementButton;
    }

    public Button getDecrementButton() {
        return decrementButton;
    }
}

// --- 3. Controller: Handles user input, updates the Model, and updates the View ---
class CounterController {
    private CounterModel model;
    private CounterView view;

    public CounterController(CounterModel model, CounterView view) {
        this.model = model;
        this.view = view;
        initView(); // Initialize the view with initial model data
        attachEventHandlers(); // Attach event handlers to view components
    }

    private void initView() {
        // Ensure the view displays the initial state of the model
        view.updateCountDisplay(model.getCount());
    }

    private void attachEventHandlers() {
        view.getIncrementButton().setOnAction(e -> handleIncrement());
        view.getDecrementButton().setOnAction(e -> handleDecrement());
    }

    // Event handling methods
    private void handleIncrement() {
        model.increment(); // Update the model
        view.updateCountDisplay(model.getCount()); // Update the view
        System.out.println("Incremented to: " + model.getCount());
    }

    private void handleDecrement() {
        model.decrement(); // Update the model
        view.updateCountDisplay(model.getCount()); // Update the view
        System.out.println("Decremented to: " + model.getCount());
    }
}

// --- Main Application Class ---
public class MvcCounterApp extends Application {

    @Override
    public void start(Stage primaryStage) {
        // Instantiate Model, View, and Controller
        CounterModel model = new CounterModel();
        CounterView view = new CounterView();
        CounterController controller = new CounterController(model, view); // Controller wires model and view

        Scene scene = new Scene(view.getRoot(), 300, 180);
        primaryStage.setTitle("MVC Counter App");
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Clean Code Tip: Strive for thin controllers, fat models In an MVC (or similar MV* patterns like MVP, MVVM) architecture, aim for "thin controllers" and "fat models." Controllers should primarily handle user input and delegate business logic to the Model. The Model, conversely, should contain most of the application's core logic and data manipulation. This makes your business logic more reusable, testable independently of the UI, and easier to manage as the application grows.

Exercise & Solution

Exercise: Refactor the Login Form (GridPane) from Chapter 21 into a basic MVC structure.

import javafx.application.Application;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

// --- Model (to be implemented) ---
class LoginModel {
    public boolean isValidLogin(String username, String password) {
        // Dummy validation for exercise
        return "admin".equals(username) && "password".equals(password);
    }
}

// --- View (to be implemented) ---
class LoginView {
    private TextField usernameField;
    private TextField passwordField;
    private Button loginButton;
    private Label statusLabel; // To display login status
    private VBox root;

    public LoginView() {
        GridPane grid = new GridPane();
        grid.setAlignment(Pos.CENTER);
        grid.setHgap(10);
        grid.setVgap(10);
        grid.setPadding(new Insets(25, 25, 25, 25));

        grid.add(new Label("User Name:"), 0, 0);
        usernameField = new TextField();
        usernameField.setPromptText("Enter your username");
        grid.add(usernameField, 1, 0);

        grid.add(new Label("Password:"), 0, 1);
        passwordField = new TextField();
        passwordField.setPromptText("Enter your password");
        grid.add(passwordField, 1, 1);

        loginButton = new Button("Login");
        grid.add(loginButton, 1, 2);

        statusLabel = new Label(""); // Initially empty
        statusLabel.setStyle("-fx-font-size: 14px;");

        root = new VBox(10);
        root.setAlignment(Pos.CENTER);
        root.getChildren().addAll(grid, statusLabel);
    }

    public VBox getRoot() { return root; }
    public TextField getUsernameField() { return usernameField; }
    public TextField getPasswordField() { return passwordField; }
    public Button getLoginButton() { return loginButton; }
    public void setStatusMessage(String message, String color) {
        statusLabel.setText(message);
        statusLabel.setStyle("-fx-text-fill: " + color + "; -fx-font-size: 14px;");
    }
}

// --- Controller (to be implemented) ---
class LoginController {
    private LoginModel model;
    private LoginView view;

    public LoginController(LoginModel model, LoginView view) {
        this.model = model;
        this.view = view;
        attachEventHandlers();
    }

    private void attachEventHandlers() {
        // Your code here: Attach an action to the loginButton
        // When clicked, retrieve username/password from view,
        // call model.isValidLogin(), and update view.statusLabel accordingly.
    }

    // Helper method for login logic
    private void handleLogin() {
        String username = view.getUsernameField().getText();
        String password = view.getPasswordField().getText();

        if (model.isValidLogin(username, password)) {
            view.setStatusMessage("Login Successful!", "green");
            System.out.println("Successful login for: " + username);
        } else {
            view.setStatusMessage("Invalid Credentials!", "red");
            System.out.println("Failed login attempt for: " + username);
        }
    }
}


// --- Main Application Class ---
public class MvcLoginAppExercise extends Application {

    @Override
    public void start(Stage primaryStage) {
        LoginModel model = new LoginModel();
        LoginView view = new LoginView();
        LoginController controller = new LoginController(model, view);

        Scene scene = new Scene(view.getRoot(), 350, 250);
        primaryStage.setTitle("MVC Login Form");
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Solution:

import javafx.application.Application;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

// --- Model: Holds the application's data and logic ---
class LoginModel {
    public boolean isValidLogin(String username, String password) {
        // Simulate a simple validation (e.g., against a hardcoded value)
        // In a real application, this would involve database checks, authentication services, etc.
        return "admin".equals(username) && "password".equals(password);
    }
}

// --- View: Displays the UI and sends user input to the Controller ---
class LoginView {
    private TextField usernameField;
    private TextField passwordField;
    private Button loginButton;
    private Label statusLabel;
    private VBox root;

    public LoginView() {
        GridPane grid = new GridPane();
        grid.setAlignment(Pos.CENTER);
        grid.setHgap(10);
        grid.setVgap(10);
        grid.setPadding(new Insets(25, 25, 25, 25));

        grid.add(new Label("User Name:"), 0, 0);
        usernameField = new TextField();
        usernameField.setPromptText("Enter your username");
        grid.add(usernameField, 1, 0);

        grid.add(new Label("Password:"), 0, 1);
        passwordField = new TextField(); // Use PasswordField in a real app for security
        passwordField.setPromptText("Enter your password");
        grid.add(passwordField, 1, 1);

        loginButton = new Button("Login");
        grid.add(loginButton, 1, 2);

        statusLabel = new Label("");
        statusLabel.setStyle("-fx-font-size: 14px;");

        root = new VBox(10);
        root.setAlignment(Pos.CENTER);
        root.getChildren().addAll(grid, statusLabel);
    }

    public VBox getRoot() { return root; }
    public TextField getUsernameField() { return usernameField; }
    public TextField getPasswordField() { return passwordField; }
    public Button getLoginButton() { return loginButton; }
    public void setStatusMessage(String message, String color) {
        statusLabel.setText(message);
        statusLabel.setStyle("-fx-text-fill: " + color + "; -fx-font-size: 14px;");
    }
}

// --- Controller: Handles user input, updates the Model, and updates the View ---
class LoginController {
    private LoginModel model;
    private LoginView view;

    public LoginController(LoginModel model, LoginView view) {
        this.model = model;
        this.view = view;
        attachEventHandlers();
    }

    private void attachEventHandlers() {
        view.getLoginButton().setOnAction(e -> handleLogin());
    }

    private void handleLogin() {
        String username = view.getUsernameField().getText();
        String password = view.getPasswordField().getText();

        if (model.isValidLogin(username, password)) {
            view.setStatusMessage("Login Successful!", "green");
            System.out.println("Successful login for: " + username);
            // In a real application, navigate to main app window
        } else {
            view.setStatusMessage("Invalid Credentials!", "red");
            System.out.println("Failed login attempt for: " + username);
        }
    }
}

// --- Main Application Class ---
public class MvcLoginAppExerciseSolution extends Application {

    @Override
    public void start(Stage primaryStage) {
        LoginModel model = new LoginModel();
        LoginView view = new LoginView();
        LoginController controller = new LoginController(model, view);

        Scene scene = new Scene(view.getRoot(), 350, 250);
        primaryStage.setTitle("MVC Login Form");
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Chapter 23: Maven Deployment

Quick Theory: Creating an Executable JAR (Fat JAR)

Once you've developed your Java application, whether it's a desktop GUI or a backend service, the next step is to package it for distribution and execution. The standard way to package Java code is into a JAR (Java Archive) file. However, a simple JAR file (jar -cvf MyProgram.jar .) only contains your compiled classes. If your application relies on external libraries (which almost all do, managed by Maven), those libraries won't be included, leading to NoClassDefFoundError at runtime.

A "fat JAR" (also known as an "uber JAR") solves this problem by bundling not only your application's compiled classes but also all its transitive dependencies (all the .jar files listed in your pom.xml's <dependencies>) into a single, self-contained JAR file. This makes deployment incredibly simple: you just distribute one JAR file, and it contains everything needed to run the application, without needing to manually manage a classpath with multiple JARs. The Maven Shade Plugin is commonly used to create these fat JARs.

Professional Code

Let's configure a pom.xml to create an executable fat JAR for our JavaFX application.

Example 1: Basic Maven JAR Plugin for Executable JAR

This shows how to configure maven-jar-plugin to make a regular JAR executable (if it has no external dependencies), by specifying the main class. This won't include dependencies.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example.deployment</groupId>
    <artifactId>basic-executable-jar</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <build>
        <plugins>
            <!-- Maven Compiler Plugin -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.11.0</version>
                <configuration>
                    <source>${maven.compiler.source}</source>
                    <target>${maven.compiler.target}</target>
                </configuration>
            </plugin>

            <!-- Maven JAR Plugin: Makes the JAR executable by specifying the main class -->
            <!-- This creates a standard JAR, NOT a fat JAR (dependencies are NOT bundled) -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.3.0</version>
                <configuration>
                    <archive>
                        <manifest>
                            <!-- Specify the fully qualified name of your main class -->
                            <addClasspath>true</addClasspath>
                            <mainClass>com.example.deployment.BasicApp</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

To run this (after mvn clean install), you would use java -jar target/basic-executable-jar-1.0-SNAPSHOT.jar. Note: A dummy BasicApp.java in src/main/java/com/example/deployment/ with a public static void main method is required for this to build.

Example 2: Maven Shade Plugin for a Fat JAR (with JavaFX)

This is the recommended approach for distributing a standalone JavaFX application. It bundles all dependencies, including JavaFX modules, into one JAR. Note: JavaFX modules need to be added as dependencies for a JavaFX app.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example.deployment</groupId>
    <artifactId>javafx-fat-jar</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <javafx.version>17.0.2</javafx.version> <!-- Use a recent LTS version -->
        <main.class>com.example.deployment.FatJarFxApp</main.class>
    </properties>

    <dependencies>
        <!-- JavaFX Core Modules -->
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>${javafx.version}</version>
        </dependency>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-fxml</artifactId>
            <version>${javafx.version}</version>
        </dependency>
        <!-- Add other JavaFX modules as needed (e.g., javafx-graphics, javafx-media) -->
    </dependencies>

    <build>
        <plugins>
            <!-- Maven Compiler Plugin -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.11.0</version>
                <configuration>
                    <source>${maven.compiler.source}</source>
                    <target>${maven.compiler.target}</target>
                </configuration>
            </plugin>

            <!-- Maven Shade Plugin: Creates a single executable JAR with all dependencies -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>3.5.0</version> <!-- Use a recent version -->
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                        <configuration>
                            <createDependencyReducedPom>false</createDependencyReducedPom>
                            <transformers>
                                <!-- Specify the main class for the executable JAR -->
                                <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                    <mainClass>${main.class}</mainClass>
                                </transformer>
                                <!-- Handle JavaFX module-info.class merging for fat JAR -->
                                <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
                            </transformers>
                            <!-- Optional: Relocate packages to avoid conflicts if needed -->
                            <!--
                            <relocations>
                                <relocation>
                                    <pattern>com.google.common</pattern>
                                    <shadedPattern>shaded.com.google.common</shadedPattern>
                                </relocation>
                            </relocations>
                            -->
                        </configuration>
                    </execution>
                </executions>
            </plugin>

            <!-- Workaround for JavaFX module system with older JDKs or when creating fat JARs -->
            <!-- The javafx-maven-plugin is typically used for modular JavaFX,
                 but with shade plugin, the mainClass entry in Manifest handles entry point. -->
            <!-- For JavaFX 11+, you often need a separate launcher class if your main app
                 class extends Application directly and your JDK is <= 10.
                 For JDK 11+, you can directly specify the main Application class.
                 Here, we assume main.class is your JavaFX Application class. -->

        </plugins>
    </build>

</project>

To build this (after mvn clean install), it will create target/javafx-fat-jar-1.0-SNAPSHOT.jar. You can then run it with java -jar target/javafx-fat-jar-1.0-SNAPSHOT.jar. Note: A dummy FatJarFxApp.java that extends javafx.application.Application and has a public static void main method is required.

Clean Code Tip: Automate deployment for consistency and reliability Always automate your build and deployment process using tools like Maven or Gradle. Manual compilation, dependency management, and JAR creation are tedious, error-prone, and inconsistent. Automated builds ensure that your application is always packaged correctly and consistently, which is crucial for reliable delivery to users or production environments.

Exercise & Solution

Exercise: Take any of your previous simple JavaFX applications (e.g., HelloWorldFX from Chapter 19).

  1. Create a new Maven project for it.
  2. Add the necessary JavaFX dependencies for javafx-controls (and javafx-fxml if you were using FXML, though not covered in detail here).
  3. Configure the maven-shade-plugin in your pom.xml to create a fat JAR that includes all JavaFX dependencies and sets your application's main class as the entry point.
<!-- Your pom.xml structure goes here, adapting the JavaFX fat JAR example -->

Solution:

Let's assume the JavaFX application is named MyJavaFxApp located at src/main/java/com/example/app/MyJavaFxApp.java:

// src/main/java/com/example/app/MyJavaFxApp.java
package com.example.app;

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;

public class MyJavaFxApp extends Application {

    @Override
    public void start(Stage primaryStage) {
        StackPane root = new StackPane();
        Label helloLabel = new Label("Hello from Fat JAR JavaFX!");
        root.getChildren().add(helloLabel);

        Scene scene = new Scene(root, 350, 150);
        primaryStage.setTitle("Fat JAR Demo");
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

And the pom.xml to build it:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example.app</groupId>
    <artifactId>my-javafx-fat-app</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <javafx.version>17.0.2</javafx.version>
        <!-- Specify your main JavaFX Application class here -->
        <main.class>com.example.app.MyJavaFxApp</main.class>
    </properties>

    <dependencies>
        <!-- JavaFX Modules -->
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>${javafx.version}</version>
        </dependency>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-fxml</artifactId>
            <version>${javafx.version}</version>
        </dependency>
        <!-- Add other JavaFX modules if your app uses them (e.g., javafx-graphics, javafx-media) -->
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.11.0</version>
                <configuration>
                    <source>${maven.compiler.source}</source>
                    <target>${maven.compiler.target}</target>
                </configuration>
            </plugin>

            <!-- Maven Shade Plugin for creating a fat JAR -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>3.5.0</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                        <configuration>
                            <transformers>
                                <!-- Configure the main class for the executable JAR's manifest -->
                                <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                    <mainClass>${main.class}</mainClass>
                                </transformer>
                                <!-- Essential for JavaFX fat JARs to correctly merge module-info.class content -->
                                <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
                            </transformers>
                            <createDependencyReducedPom>false</createDependencyReducedPom> <!-- Prevent POM generation issues -->
                        </configuration>
                    </execution>
                </executions>
            </plugin>

        </plugins>
    </build>

</project>

Chapter 24: Final Junior Checklist

Quick Theory: Reflecting on Your Journey

You've now traversed a comprehensive path, from the absolute fundamentals of Java syntax to advanced concepts in functional programming, data persistence, GUI development, and project deployment. This curriculum was designed to equip you with the essential knowledge and practical skills expected of a competent Junior Java Developer. The journey doesn't end here; software development is a continuous learning process. However, you've built a strong foundation.

This checklist serves as a self-assessment tool. If you can confidently explain and practically apply the concepts listed below, you are well-prepared for entry-level Java development roles and are ready to tackle more complex challenges and specialized frameworks (like Spring Boot) that build upon this foundation. Keep practicing, keep building, and never stop learning!

Final Junior Checklist

Clean Code Tip: Continuous learning and practice are key The journey of a software developer is one of continuous learning. The concepts covered in these books provide a strong foundation, but the landscape of technology is always evolving. Stay curious, experiment with new tools and frameworks, read documentation, contribute to open-source projects, and consistently build things. Practice is the most effective way to solidify your understanding and grow your skills.


END OF BOOK 3: FINAL PART

You have successfully completed the qualification path. Your dedication to learning and mastering these topics is commendable. You are now equipped with the knowledge and foundational skills to embark on your career as a Junior Java Developer. Good luck on your next steps!