Chapter FIVE
Controlling Program Flow


Exam Objectives

Create program flow control constructs including if/else, switch statements and expressions, loops, and break and continue statements.
Implement inheritance, including abstract and sealed types as well as record classes. Override methods, including that of the Object class. Implement polymorphism and differentiate between object type and reference type. Perform reference type casting, identify object types using the instanceof operator, and pattern matching with the instanceof operator and the switch construct.

Chapter Content


The if Statement

One of the most fundamental control flow statements in Java and many other programming languages is the if statement. It allows your program to make decisions and execute different code paths based on whether certain conditions are met.

In essence, the purpose of an if statement is to conditionally execute a block of code. If the specified condition evaluates to true, the code block will run. If not, that block is skipped over and the program continues with the next statement after the if block.

Here’s the flowchart diagram for the if statement:

          ┌─────────┐
          │  Start  │
          └────┬────┘
               │
         ┌─────┴─────┐
         │ Condition │
         └─────┬─────┘
               │
        ┌──────┴──────┐
   ┌────┤  Is true?   ├────┐
   │    └─────────────┘    │
   │                       │
┌──┴──┐                 ┌──┴──┐
│ Yes │                 │ No  │
│     │                 │     │
│─────┴──────────┐   ┌──┴─────┴─────┐
│    Execute     │   │  Execute     │
│    if block    │   │  else block  │
└─────────┬──────┘   └──────┬───────┘
          │                 │
          └────────┬────────┘
                   │
             ┌─────┴─────┐
             │   End     │
             └───────────┘

The basic syntax of an if statement looks like this:

if (condition) {
    // Code to execute if condition is true
}

The condition goes inside parentheses and must evaluate to a boolean value, either true or false. The code to conditionally execute goes between curly braces. If the code block contains only a single statement, you can omit the curly braces:

if (x > 10) 
    System.out.println("x is greater than 10");

However, using curly braces is considered good practice even for single statements, as it makes your code clearer and less prone to errors if you later add more statements to the block.

You can chain multiple conditions together using the else if construct:

if (condition1) {
    // Code to execute if condition1 is true
} else if (condition2) {  
    // Code to execute if condition1 is false and condition2 is true
} else {
    // Code to execute if both condition1 and condition2 are false
}

Here, each else if condition will only be checked if all the prior if/else if conditions evaluated to false. As soon as one condition is found to be true, its corresponding block executes and the rest of the if/else if/else chain is skipped. The final else block runs if none of the conditions were true.

There’s no hard limit to how many else if statements you can have, but if you find yourself with very long if/else if chains, you may want to consider refactoring to a cleaner approach, such as a switch statement or polymorphism.

A common point of confusion is attempting to access variables declared inside an if block from the corresponding else block:

if (condition) {
    int x = 10;
} else {
    System.out.println(x); // Compile error - x is not in scope! 
}

This fails because variables declared inside an if or else block are only in scope within that block. To use a variable in both the if and else sections, you must declare it outside (before) the if statement.

Pattern Matching in if Statements

Java has been expanding its pattern matching capabilities, making it easier to work with complex data structures. Let’s explore how pattern matching works with if statements.

Type Patterns

Type patterns allow you to test if an object is an instance of a particular type and, if so, create a variable of that type in one step:

if (obj instanceof String s) {
    System.out.println(s.toUpperCase());
}

Here, obj is tested to see if it’s an instance of String. If so, it’s cast to String and assigned to the pattern variable s, which can then be used in the if block.

There are a few rules when using type patterns in if statements:

For example:

if (getObject() instanceof String s) {
    System.out.println(s); // s in scope here
} else {
    System.out.println(s); //Compile error! s is definitely not a String
}

// ...

Object getObject() {
    return "hi";
}

Record Patterns

Java 21 introduced record patterns, which allow you to destructure record instances directly in the if statement. This provides a more declarative and composable way to work with data. Here’s an example:

record Book(String title, String author) {}

static void printDetails(Object obj) {
    if (obj Book(String title, String author)) {
        System.out.println("Title: " + title);
        System.out.println("Author: " + author);
    }
}

In this code, Book(String title, String author) is a record pattern. It not only checks if obj is an instance of Book, but also extracts the title and author components directly into pattern variables. This eliminates the need for separate accessor method calls.

Record patterns can also be nested, allowing you to destructure complex object graphs in a single step:

record Book(String title, String author) {}
record Library(String name, Book bestSeller) {}

Library myLibrary = new Library("City Library", new Book("Java Programming", "John Doe"));

if (myLibrary instanceof Library(var name, Book(var title, var author))) {
    System.out.println("Best seller at " + name + " is '" + title + "' by " + author);
}

In this example, we’re using a nested record pattern to match against the Library record and its Book component simultaneously. If the pattern matches, we get direct access to the name, title, and author components without needing to use accessor methods.

Record patterns also work with generic records. The compiler will infer the type arguments when possible:

record Box<T>(T t) {}

public class GenericRecord {
    static void unbox(Box<Box<Integer>> box) {
        if (box instanceof Box(Box(var u))) {
            System.out.println("Unboxed Integer: " + u);
        }
    }

    public static void main(String[] args) {
        unbox(new Box<>(new Box<>(8))); // Prints Unboxed Integer: 8
    }
}

Here, the compiler infers that u is an Integer, based on the type of box.

It’s important to note that record patterns don’t match against null. If you need to handle potential null values, you should do so before the pattern matching:

if (obj != null && obj instanceof Book(String title, String author)) {
    System.out.println("Title: " + title);
    System.out.println("Author: " + author);
}

In summary, here are some key points about record patterns:

  1. They consist of a record class type followed by a parenthesized list of patterns for each component.
  2. They can be nested, allowing you to destructure complex record hierarchies in a single pattern.
  3. The var keyword can be used to infer the type of a component.
  4. null values do not match any record pattern.
  5. For generic record classes, type arguments are inferred if not explicitly provided.

Flow Scoping

An important concept to understand when using pattern matching in if statements is flow scoping. This refers to how the compiler reasons about the scope and availability of pattern variables based on the flow of control through your code.

Consider this example:

if (obj instanceof String s) {
    System.out.println(s); // s is definitely a String here
} else {
    System.out.println(s); // Compiler error: s might not be initialized
}
System.out.println(s); // Compiler error: s is not in scope here

Inside the if block, s is definitely a String, the compiler knows this because the instanceof check must have succeeded for that block to execute. Therefore, it’s safe to use s as a String within this scope.

However, in the corresponding else block, s is not considered initialized. The compiler doesn’t assume the opposite of the if condition, it reasons that if the else block is executing, the instanceof check must have failed, and so s was never assigned a value. Attempting to use s here results in a compile error.

Outside the if-else statement entirely, s is not in scope at all. Pattern variables are only accessible within the if block where they’re declared, and in subsequent else if or else blocks if the compiler can prove they were definitely assigned.

Flow scoping becomes more complex when you have multiple pattern variables in play:

if (obj instanceof String s || obj instanceof Integer i) {
    // s or i is in scope, but not both 
} else {
    // neither s nor i are in scope
}

In this case, inside the if block, only one of s or i will be in scope, depending on which instanceof check succeeded. The compiler doesn’t let you use a pattern variable unless it can definitively say it was assigned.

If you need to use a pattern variable in multiple scopes, you must assign it separately:

String s = null;
if (obj instanceof String temp) {
    s = temp;
}
// s is now in scope, but may be null if the if block didn't execute

This may feel like a limitation, but it is actually a powerful safety feature. By tightly controlling the scope of pattern variables, Java helps prevent common bugs and makes your code more robust.

It’s worth noting that flow scoping only applies to the declared pattern variables themselves, not the original variables. In the example above, obj remains in scope throughout, because it was declared before the if statement.

The switch Statement

Sometimes, you need to check the value of a variable or expression and execute different code depending on what that value is. If there are only a couple of options, an if-else statement works fine:

String animal = "cat";
if(animal.equals("dog")) {
    System.out.println("Woof!");
} else {
    System.out.println("Meow!");
}

But what if there are many possible values to check? You could chain a bunch of if-else statements together:

String animal = "horse";
if(animal.equals("dog")) {
    System.out.println("Woof!"); 
} else if(animal.equals("cat")) {
    System.out.println("Meow!");
} else if(animal.equals("pig")) {
    System.out.println("Oink!");
} else if(animal.equals("horse")) {
    System.out.println("Neigh!");
} else {
    System.out.println("Unknown animal!");
}

However, this can get cumbersome and messy fast. That’s where the switch statement comes in. It allows you to define separate code blocks for different values of a variable or expression.

Here’s the diagram for the switch statement:

┌─────────────────────────────────────┐
│          switch (variable)          │
│   ┌───────────────────────────────┐ │
│   │         case value1:          │ │
│   │           // code block       │ │
│   │           break;              │ │
│   ├───────────────────────────────┤ │
│   │         case value2:          │ │
│   │           // code block       │ │
│   │           break;              │ │
│   ├───────────────────────────────┤ │
│   │         case value3:          │ │
│   │           // code block       │ │
│   │           break;              │ │
│   ├───────────────────────────────┤ │
│   │         default:              │ │
│   │           // code block       │ │
│   └───────────────────────────────┘ │
└─────────────────────────────────────┘

And this is its basic syntax:

switch(variable) {
    case value1:
        // code to run if variable == value1
        break;
    case value2: 
        // code to run if variable == value2
        break;
    default:
        // code to run if no case matches
}

So the animal example could be rewritten more cleanly as:

String animal = "horse";
switch(animal) {
    case "dog":
        System.out.println("Woof!");
        break;
    case "cat":
        System.out.println("Meow!");
        break; 
    case "pig":
        System.out.println("Oink!");
        break;
    case "horse":
        System.out.println("Neigh!");
        break;
    default:
        System.out.println("Unknown animal!");
}

Each case defines a value to compare the switch variable against. If there’s a match, the code for that case executes. The break causes execution to jump to the end of the switch block. If no case matches, the default block runs.

It’s important to include a break (or return) statement for each case, otherwise execution falls through to the next case, which is rarely what you want. The default case doesn’t need an explicit break since it’s the last one.

Types in case Statements

Not just any type can be used in a switch. Historically, switches could only work with these integral types and their wrapper classes:

Then, in later versions of Java, String, records, and the constants of an enum were added as an option.

Also, you can use var in a switch statement as long as the type resolves to one of the other permitted types:

var animal = "horse";
switch(animal) {
    case "dog":
        System.out.println("Woof!");
        break;
    case "cat":
        System.out.println("Meow!");
        break; 
    case "pig":
        System.out.println("Oink!");
        break;
    case "horse":
        System.out.println("Neigh!");
        break;
    default:
        System.out.println("Unknown animal!");
}

In this case, animal is inferred to be a String based on the value assigned to it. Since String is a valid type for a switch, using var here is perfectly fine.

However, if you try to do something like this:

var data = 3.14;
switch(data) {
    // ...
}

You will get a compilation error because data is inferred to be a double, which is not a permitted type for switch statements.

About enums, let’s consider an interface Season and an enum Weather that implements this interface:

sealed interface Season permits Weather {}
enum Weather implements Season { SPRING, SUMMER, FALL, WINTER }

In older versions of Java, the switch statement required you to use only the simple names of the enum constants:

void oldEnumSwitch(Weather w) {
    switch (w) {
        case SPRING -> {
            System.out.println("It's spring!");
        }
        case SUMMER -> {
            System.out.println("It's summer!");
        }
        case FALL -> {
            System.out.println("It's fall!");
        }
        case WINTER -> {
            System.out.println("It's winter!");
        }
    }
}

This restriction worked fine for basic use cases but became cumbersome when dealing with more complex scenarios, such as combining enum types or using sealed interfaces.

In Java 21, you can now use fully qualified names of enum constants and mix them with other case labels, providing greater flexibility and allowing for more complex switch expressions:

void newEnumSwitch1(Season s) {
    switch (s) {
        case Weather.SPRING -> {  // Qualified name of enum constant
            System.out.println("It's spring!");
        }
        case Weather.SUMMER -> {
            System.out.println("It's summer!");
        }
        case Weather.FALL -> {
            System.out.println("It's fall!");
        }
        case Weather.WINTER -> {
            System.out.println("It's winter!");
        }
    }
}

Additionally, the requirement that the selector expression be of an enum type is relaxed, allowing you to use qualified names of enum constants even if the selector expression is not of the enum type, as long as it is assignment compatible, like in the above example.

However, an invalid use case would be when the enum constant is not fully qualified:

void invalidEnumSwitch(Season s) {
    switch (s) {
        case SPRING -> { // Error: SPRING must be qualified as Weather.SPRING
            System.out.println("It's spring!");
        }
        case Weather.SUMMER -> {
            System.out.println("It's summer!");
        }
        case Weather.FALL -> {
            System.out.println("It's fall!");
        }
        case Weather.WINTER -> {
            System.out.println("It's winter!");
        }
        default -> {
            System.out.println("Unknown season");
        }
    }
}

Values in case Statements

When defining the values for each case, there are some important rules to keep in mind. The value must be a compile-time constant, meaning it has to be known at the time the code is compiled, not determined at runtime.

So you can use literal values like "dog" or 3, final variables (as long as they’re initialized with a constant value), and enum constants. But you can’t use a regular variable or a method call, even if the method always returns the same value. For example:

final int NUMBER = 2;

int getSome() {
    return 1;
}

int x = 3;

switch(value) {
    case NUMBER: // OK, NUMBER is final and initialized with a constant
    case getSome(): // Error! Method calls aren't allowed
    case x: // Error! x is not final
    ...
}

Sometimes, you might want to run the same code for multiple case values. Rather than duplicating the code, you can simply list the values together for a single case:

int dayNumber;
switch(dayName) {
    case "Monday":
        dayNumber = 1;
        break;
    case "Tuesday":
        dayNumber = 2;
        break;
    case "Saturday", "Sunday": // Runs the same code for "Saturday" and "Sunday"
        dayNumber = 0;
        break;
    default:
        throw new IllegalArgumentException("Invalid day: " + dayName);
}

I mentioned this earlier, but it’s worth reiterating, don’t forget to break out of each case block (or use return), unless you specifically want execution to fall through to the next case. Forgetting a break is a common source of bugs in switch statements.

The switch Expression

Java 14 officially introduced a new form of switch, known as the switch expression. It has a few key differences from the traditional switch statement. First, here’s the syntax:

variable = switch(anotherVariable) {
    case value1 -> expression1;
    case value2 -> { statements; yield expression2; }
    default -> expression3;
};

Instead of case: and break, the switch expression uses -> to map each case to a value or block of code. If you need multiple statements for a case, use curly braces and the yield keyword to specify the value to return.

Note the semicolons. Each case needs one at the end, as does the entire switch expression.

The switch expression must always return a value, and each case must cover all possibilities (either explicitly or with a default). The data types of all the case results must also be consistent with each other.

Here’s a more concrete example:

String animal = "horse";
String sound = switch(animal) {
    case "dog" -> "Woof!";
    case "cat" -> "Meow!";
    case "pig" -> "Oink!";
    case "horse" -> "Neigh!";
    case "human" -> {
        String greeting = "Hello!";
        yield greeting; // Use yield when there are multiple statements
    }
    default -> throw new IllegalArgumentException("Unknown animal: " + animal);
};

In this case, each animal is mapped directly to the sound it makes, except for human which has a block of code. The default case throws an exception since the switch expression must cover all possible input values.

Pattern Matching in switch Statements

Java 21 introduced a powerful new feature: pattern matching in switch statements and expressions. This allows you to test the structure of an object directly in the switch, making your code more expressive and less error-prone.

Let’s start with a simple example:

Object obj = "Hello, World!";
String result = switch (obj) {
    case Integer i -> "It's an integer: " + i;
    case String s -> "It's a string: " + s;
    case Double d -> "It's a double: " + d;
    default -> "It's something else";
};
System.out.println(result); // Outputs: It's a string: Hello, World!

In this example, we’re switching on an Object, and each case checks if the object is of a specific type. If it matches, we can use the variable declared in the pattern (like s for String) directly in the case body.

We can add guards to our case labels for even more precise matching:

Object obj = 42;
String category = switch (obj) {
    case Integer i when i < 0 -> "Negative integer";
    case Integer i when i > 0 -> "Positive integer";
    case Integer i -> "Zero";
    case String s when s.length() > 5 -> "Long string";
    case String s -> "Short string";
    default -> "Something else";
};
System.out.println(category); // Outputs: Positive integer

The when clause allows us to add additional conditions to our pattern matching.

However, when using pattern matching, the order of cases matters. More specific patterns should come before more general ones:

Object obj = "Hello";
String result = switch (obj) {
    case String s when s.length() > 5 -> "Long string";
    case String s -> "Short string";
    case CharSequence cs -> "Some other CharSequence";
    default -> "Not a CharSequence";
};
System.out.println(result); // Outputs: Long string

If we were to put the case String s before case String s when s.length() > 5, the guard would never be reached, and the compiler would warn us about an unreachable case.

Pattern matching in switch also introduces a more elegant way to handle null values:

String str = null;
String description = switch (str) {
    case null -> "It's null!";
    case String s -> "It's a string of length " + s.length();
};
System.out.println(description); // Outputs: It's null!

In traditional switches, a null value would throw a NullPointerException. With pattern matching, we can explicitly handle the null case.

However, you have to be careful about having only one match-all case label in a switch block. If, for example, you add a default case to the above example:

String str = null;
String description = switch (str) {
    case null -> "It's null!";
    case String s -> "It's a string of length " + s.length();
    default -> "default";
};
System.out.println(description); // Outputs: It's null!

You’ll get a compilation error: switch has both an unconditional pattern and a default label.

Having more than one match-all case labels in a switch statement or expression generates a compile-time error. The match-all case labels are:

However, the following compiles:

Object obj = null; // Notice the Object type
String description = switch (obj) {
    case String s -> "It's a string of length " + s.length();
    case null, default  -> "It's null or not a string!";
};
System.out.println(description); // Outputs: It's null or not a string!

If a selector expression evaluates to null and the switch block does not have null case label, like in the following case:

Object obj = null;
String description = switch (obj) { // Throws NullPointerException
    case String s -> "It's a string of length " + s.length();
    default  -> "It's null or not a string";
};
System.out.println(description); 

Then a NullPointerException is thrown.

Another key benefits of pattern matching in switch is exhaustiveness checking. The compiler ensures that all possible cases are covered:

sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
record Triangle(double base, double height) implements Shape {}

public class SwitchExhaustiveness {
    public static void main(String[] args) {
        Shape shape = new Circle(5);
        double area = switch (shape) {
            case Circle c -> Math.PI * c.radius() * c.radius();
            case Rectangle r -> r.width() * r.height();
            case Triangle t -> 0.5 * t.base() * t.height();
        };
        System.out.println("Area: " + area);
    }
}

In this example, because Shape is a sealed interface and we’ve covered all its permitted subclasses, the compiler knows we’ve exhaustively covered all possibilities. If the expression is a sealed type, only the classes declared in the permits clause of the sealed type need to be handled by the switch.

However, if you don’t cover all the possibilities:

double area = switch (shape) {
    case Circle c -> Math.PI * c.radius() * c.radius();
    case Rectangle r -> r.width() * r.height();
};

A compile-time error is generated: switch expression does not cover all possible input values.

The issue can be fixed simply by adding a default case:

double area = switch (shape) {
    case Circle c -> Math.PI * c.radius() * c.radius();
    case Rectangle r -> r.width() * r.height();
    default -> 0;
};

Finally, in a switch statement, the compiler can also infer the type arguments for a generic record pattern. For example, taking into account the following record declaration:

record Point<T, U>(T x, U y) { }

The compiler can infer Point(var x, var y) as Point<Long, Long>(Long x, Long x):

Point<Long, Long> p = new Point(1L, 2L);

switch (p) {
    case Point(var x, var y) -> 
        System.out.println(x + ", " + y);
}

The while Loop

A while loop allows you to repeatedly execute a block of code as long as a specified boolean condition remains true.

Here’s the flowchart diagram for the while statement:

          ┌─────────┐
          │  Start  │
          └────┬────┘
               │
         ┌─────┴─────┐
    ┌────┤ Condition │
    │    └─────┬─────┘
    │          │
    │    ┌─────┴─────┐
    │    │  Is true? ├───────┐
    │    └─────┬─────┘       │
    │          │             │
    │    ┌─────┴─────┐ ┌─────┴─────┐
    │    │    Yes    │ │    No     │
    │    └─────┬─────┘ └─────┬─────┘
    │          │             │
    │    ┌─────┴─────┐       │
    │    │  Execute  │       │
    │    │   Loop    │       │
    │    │   Body    │       │
    │    └─────┬─────┘       │
    │          │             │
    └──────────┘             │
                             │
                        ┌────┴────┐
                        │  End    │
                        └─────────┘

There are actually two variants of the while loop in Java:

  1. The standard while loop
  2. The do-while loop

The standard while loop has the following structure:

while(condition) {
    // code block to be executed
}

The condition is a boolean expression that is evaluated before each iteration of the loop. If the condition is true, the code block is executed. This process repeats until the condition becomes false.

It’s important to note that if the condition is false when the loop is first reached, the code block will not be executed at all. The loop will be skipped entirely.

Here’s an example that prints the numbers 0 through 9:

int count = 0;
while(count < 10) {
    System.out.println(count);
    count++;
}

The loop will continue executing until count is no longer less than 10.

The do-while loop is similar to the standard while loop but with one key difference: the condition is evaluated after the code block has executed. This means the code block will always execute at least once, even if the condition is initially false.

Here’s the syntax of a do-while loop:

do {
    // code block to be executed
} while(condition);

As you can see, the code block comes before the while keyword and condition. The condition is checked after each iteration, determining whether the loop should continue or terminate.

The following example is functionally equivalent to the previous while loop example:

int count = 0; 
do {
    System.out.println(count);
    count++;
} while(count < 10);

Even though the structure is different, this do-while loop achieves the same result as the standard while loop, printing the numbers 0 through 9.

So why would you choose a do-while loop over a standard while loop? It really depends on the specific problem you’re trying to solve. If you know you always want the code block to execute at least once regardless of the initial condition state, a do-while can be a good choice and can make your intention clearer. However, in many cases, a standard while loop is sufficient and more commonly used.

Nested Loops

It’s possible to place one loop inside the body of another loop. This is known as loop nesting. Nested loops allow you to iterate over multiple dimensions, such as the rows and columns of a 2D array.

Here’s an example that uses nested while loops to print out a multiplication table:

int i = 1;
while(i <= 10) {
    int j = 1;
    while(j <= 10) {
        System.out.print(i * j + "\t");
        j++;
    }
    System.out.println();
    i++;
}

The outer loop iterates from 1 to 10, representing the rows of the multiplication table. For each iteration of the outer loop, the inner loop also iterates from 1 to 10, representing the columns. The product of the current row and column values is printed, followed by a tab (\t) character for formatting. After each row is complete, a newline is printed to move to the next row.

While this example uses while loops, you can also nest do-while loops in a similar manner. The choice of loop type depends on the specific requirements of your use case.

The break and continue Statements

The break statement is used to immediately terminate a loop or switch statement. When encountered inside a loop, break causes program control to transfer to the next statement after the loop.

Here’s an example of using break in a while loop:

int count = 0;
while(true) {
    System.out.println(count);
    count++;
    if(count >= 5) {
        break;
    }
}

This loop will continue infinitely because the condition is always true. However, the break statement inside the loop will cause it to terminate once count reaches 5.

On the other hand, the continue statement is used to skip the rest of the current loop iteration and immediately move on to the next iteration.

Here’s an example that uses continue:

int i = 0;
while(i < 10) {
    if(i % 2 == 0) {
        i++;
        continue;
    }
    System.out.println(i);
    i++;
}

This loop iterates from 0 to 9. However, when i is even (divisible by 2), the continue statement is executed, causing the rest of the iteration to be skipped. As a result, only the odd numbers are printed.

However, it’s important to note that using break or continue can sometimes lead to unreachable code, which will cause a compilation error.

Consider this example:

while(condition) {
    // code block
    break;
    // more code
}

The code after the break statement will never be executed because break always causes the loop to terminate. The Java compiler will detect this and raise an “unreachable code” compilation error.

The same applies to continue. Any code placed after a continue statement in the same loop iteration will be unreachable.

To avoid these errors, make sure that any code placed after a break or continue has a chance to execute under some condition.

Adding Labels

Finally, you can associate a label with a loop. Labels provide a way to break out of or continue a specific outer loop from within a nested loop. Here’s the syntax for adding a label to a loop:

label: 
while(condition) {
    // code block
}

The label is an identifier followed by a colon. It’s placed just before the loop declaration.

Here’s an example that demonstrates the use of labels:

int i = 0;
outerLoop:
while(i < 10) {
    int j = 0;
    while(j < 10) {
        if(j == 5) {
            break outerLoop;
        }
        System.out.println("i: " + i + ", j: " + j);
        j++;
    }
    i++;
}

In this case, the outer loop is labeled outerLoop. Inside the nested loop, there’s a condition that checks if j is equal to 5. When this condition is met, the break statement is used with the outerLoop label, causing execution to jump out of both the inner and outer loops. Without the label, the break would only exit the inner loop.

Like break, continue can also be used with a label to skip to the next iteration of an outer loop.

Here’s an example that demonstrates this:

int i = 0;
outerLoop:
while(i < 3) {
    int j = 0;
    while(j < 3) {
        if(i == 1 && j == 1) {
            i++;
            continue outerLoop;
        }
        System.out.println("i: " + i + ", j: " + j);
        j++;
    }
    i++;
}

In this example, the outer loop is labeled outerLoop. The outer loop iterates over the values of i from 0 to 2, and the inner loop iterates over the values of j from 0 to 2.

Inside the nested loops, there’s a condition that checks if both i and j are equal to 1. When this condition is met, the continue statement is used with the outerLoop label. This causes the program control to immediately jump to the next iteration of the outer loop, skipping the rest of the inner loop.

As a result, the output of this code will be:

i: 0, j: 0
i: 0, j: 1
i: 0, j: 2
i: 1, j: 0
i: 2, j: 0
i: 2, j: 1
i: 2, j: 2

Notice that the output i: 1, j: 1 is missing because when i and j are both 1, the continue outerLoop statement is executed, causing the program to skip to the next iteration of the outer loop, bypassing the print statement.

Using continue with a label is less common than using break with a label, but it can be useful in situations where you want to skip multiple levels of nested loops based on a certain condition.

The for Loop

Like while loops, for loops are used to repeatedly execute a block of code. However, for loops provide a more concise syntax for iterating over a range of values or elements in a collection.

In Java, there are two types of for loops:

Here’s a diagram with the key points of the for loops:

┌─────────────────────────────────────────────────────────────┐
│                     Java for Loops                          │
│                                                             │
│  Traditional for Loop        │     for-each Loop            │
│                              │                              │
│  for (int i = 0; i < 5; i++) │  for (int num : numbers) {   │
│  {                           │      // code block           │
│      // code block           │  }                           │
│  }                           │                              │
│                              │                              │
│  Components:                 │  Components:                 │
│  1. Initialization           │  1. Element variable         │
│  2. Condition                │  2. Collection to iterate    │
│  3. Update statement         │                              │
│                              │                              │
│  Use when:                   │  Use when:                   │
│  - Need index                │  - Don't need index          │
│  - Custom increments         │  - Iterating full collection │
│  - Multiple counters         │  - Simpler syntax preferred  │
└─────────────────────────────────────────────────────────────┘

Let’s start by examining in more detail the traditional for loop.

The Traditional for Loop

The traditional for loop has the following structure:

for(initialization; booleanExpression; updateStatement) {
    // code block to be executed
}

The loop consists of three parts separated by semicolons:

  1. Initialization: This is where you initialize the loop variable(s). It’s executed only once at the beginning of the loop.

  2. Boolean Expression: This is the condition that’s checked before each iteration. If it evaluates to true, the loop continues. If it’s false, the loop terminates.

  3. Update Statement: This is where you specify how the loop variable(s) should be updated after each iteration. It’s executed at the end of each iteration.

Here’s a simple example that prints the numbers 0 to 4:

for(int i = 0; i < 5; i++) {
    System.out.println(i);
}

The loop initializes i to 0, checks if i is less than 5, and if so, executes the code block (printing the value of i). After each iteration, i is incremented by 1. The loop continues until i is no longer less than 5.

You can also use the var keyword in the initialization part:

for(var i = 0; i < 5; i++) {
    System.out.println(i);
}

If you omit the boolean expression, it defaults to true, creating an infinite loop:

for(int i = 0; ; i++) {
    System.out.println(i);
}

This loop will continue indefinitely because there’s no condition to make it false. To stop an infinite loop, you’d need to use a break statement or some other means of interrupting the loop.

You can initialize multiple variables and include multiple update statements in a for loop by separating them with commas:

for(int i = 0, j = 10; i < j; i++, j--) {
    System.out.println("i: " + i + ", j: " + j);
}

This loop initializes i to 0 and j to 10, checks if i is less than j, and if so, executes the code block. After each iteration, i is incremented, and j is decremented.

It’s important to note that you cannot redeclare a variable in the initialization block of a for loop:

int i = 0;
for(int i = 0; i < 5; i++) { // Doesn't compile
    System.out.println(i);
}

This code will not compile because i is declared twice. If you need to use a variable that’s already declared, simply omit the data type in the initialization block:

int i = 0;
for(i = 0; i < 5; i++) { // OK
    System.out.println(i);
}

Also, all variables declared in the initialization block must be of the same data type or compatible types:

for(int i = 0, long j = 10; i < j; i++, j--) { // Doesn't compile
    System.out.println("i: " + i + ", j: " + j);
}

This code will not compile because i is of type int, and j is of type long. Here is the corrected example:

for(int i = 0, j = 10; i < j; i++, j--) {
    System.out.println("i: " + i + ", j: " + j);
}

Alternatively, if you need to use different data types, you should declare them before the loop:

int i = 0;
long j = 10;
for(; i < j; i++, j--) {
    System.out.println("i: " + i + ", j: " + j);
}

Regarding the scope of a variable declared in the initialization block, it is limited to the for loop. You cannot use it outside the loop:

for(int i = 0; i < 5; i++) {
    System.out.println(i);
}
System.out.println(i); // Doesn't compile

Once again, if you need to use the final value of the loop variable after the loop, you must declare it before the loop:

int i;
for(i = 0; i < 5; i++) {
    System.out.println(i);
}
System.out.println(i); // OK, prints 5

In many cases, you may need to compare the current loop variable with other elements in the loop. The traditional for loop makes this possible by allowing you to read elements forward or backward:

int[] arr = {1,2,3,4,5};
for(int i = 0; i < arr.length; i++) {
    // Read forward
    if(i < arr.length - 1) {
        System.out.println("Current: " + arr[i] + ", Next: " + arr[i+1]);
    }
 
    // Read backward
    if(i > 0) {
        System.out.println("Current: " + arr[i] + ", Previous: " + arr[i-1]);
    }
}

The forward reading if condition checks if the current element is not the last one and if so, it prints the current element and the next one.

The backward reading if condition checks if the current element is not the first one and if so, it prints the current element and the previous one.

The for-each Loop

The for-each loop, also known as the enhanced for loop, provides a simpler way to iterate over arrays and collections. It eliminates the need to explicitly declare and update loop variables.

The structure of a for-each loop is as follows:

for(dataType item : collection) {
    // code block to be executed
}

The loop consists of two parts with three elements:

  1. dataType: The data type of the elements in the collection.

  2. item: A variable that will hold the current element during each iteration.

  3. collection: The array or collection to be iterated over.

Here’s an example that prints the elements of an array using a for-each loop:

int[] numbers = {1, 2, 3, 4, 5};
for(int num : numbers) {
    System.out.println(num);
}

In each iteration, the loop assigns the next element of the numbers array to the num variable and executes the code block.

The for-each loop can be used with with arrays and with any object that implements the Iterable interface, which includes most collection classes, such as ArrayList and HashSet.

If you’re wondering if everything that applies to for loops applies to for-each loops, the answer is not quite. While for loops and for-each loops share some similarities, there are a few key differences in how they behave and what they can do:

  1. Iteration: A for-each loop automatically iterates over all elements in an array or collection, from the first to the last. You don’t have control over the index or the order of iteration. A traditional for loop, on the other hand, gives you full control over the initialization, condition, and update statements, allowing you to iterate in any order or skip elements.

  2. Modification: A for-each loop does not prevent you from modifying the elements of the array or collection within the loop, but it does not provide direct access to the index. You can modify the elements if the underlying collection supports modification. In contrast, a traditional for loop allows you to modify elements by accessing them via their index.

  3. Iterating over arrays and collections: A for-each loop can be used to iterate over arrays and any object that implements the Iterable interface, which includes most collection classes. A traditional for loop can be used to iterate over arrays and collections, but you need to use an explicit index or iterator.

  4. Accessing index: In a for-each loop, you don’t have direct access to the index of the current element. If you need the index, you’ll have to use a traditional for loop, which gives you access to the index through the loop variable.

  5. Performance: For arrays, the performance difference between a for-each loop and a traditional for loop is generally negligible. For collections, the performance is similar as a for-each loop is syntactic sugar for using an iterator.

Here’s an example that demonstrates a situation where a for-each loop cannot be used:

int[] numbers = {1, 2, 3, 4, 5};
for(int i = 0; i < numbers.length; i++) {
    if(numbers[i] % 2 == 0) {
        numbers[i] *= 2; // Double even numbers
    }
}

In this case, we need to modify the elements of the array based on a condition. We also need access to the index to perform the modification. This cannot be done with a for-each loop.

However, if we just needed to print the doubled even numbers, a for-each loop would be suitable:

int[] numbers = {1, 2, 3, 4, 5};
for(int num : numbers) {
    if(num % 2 == 0) {
        System.out.println(num * 2); // Print doubled even numbers
    }
}

In summary, for-each loops provide a concise syntax for iterating over all elements, while for loops offer more control and flexibility, allowing you to access indexes and iterate in custom ways.

Nested for Loops

Just like while loops, for loops can also be nested. This allows you to iterate over multidimensional arrays or perform complex iterations.

int[][] matrix = { {1, 2, 3}, {4, 5, 6}, {7, 8, 9} };
for(int[] row : matrix) {
    for(int cell : row) {
        System.out.print(cell + " ");
    }
    System.out.println();
}

This code uses two nested for-each loops to iterate through a 2D array. The outer loop iterates over each row, and the inner loop iterates over each cell in the current row.

The break and continue Statements

The break statement can be used in for loops to prematurely terminate the loop.

int[] numbers = {1, 2, 3, 4, 5};
for(int num : numbers) {
    if(num == 3) {
        break;
    }
    System.out.println(num);
}

In this example, the loop will terminate when num is equal to 3. The output will be:

1
2

On the other hand, the continue statement can be used in for loops to skip the rest of the current iteration and move on to the next one.

int[] numbers = {1, 2, 3, 4, 5};
for(int num : numbers) {
    if(num % 2 == 0) {
        continue;
    }
    System.out.println(num);
}

This loop will print only the odd numbers in the array. When num is even, the continue statement is executed, and the rest of the iteration is skipped.

Also, using break or continue in for loops can sometimes lead to unreachable code, resulting in compilation errors.

for(int i = 0; i < 10; i++) {
    System.out.println(i);
    break;
    System.out.println("Unreachable"); // Unreachable code
}

In this example, the code after the break statement is unreachable because break always causes the loop to terminate. The Java compiler will detect this and throw a compilation error.

The same principle applies to continue. Any code after a continue statement in the same iteration will be unreachable.

To avoid these errors, ensure that any code placed after a break or continue statement has a chance to execute under some condition.

Adding Labels

Labels can be added to for loops in the same way as while loops. They are useful for breaking out of or continuing outer loops from within nested loops:

int[][] matrix = { {1, 2, 3}, {4, 5, 6}, {7, 8, 9} };
outerLoop:
for(int[] row : matrix) {
    for(int cell : row) {
        if(cell == 5) {
            break outerLoop;
        }
        System.out.print(cell + " ");
    }
    System.out.println();
}

In this example, the outer loop is labeled as outerLoop. When the value of cell is 5, the break statement is used with the outerLoop label, causing the program to terminate both the inner and outer loops. This is what happens:

  1. The outer loop starts with the first row (1, 2, 3) of matrix.
    • The inner loop prints “1 “, then “2 “, then “3 “.
    • The inner loop completes, and a newline is printed.
  2. The outer loop moves to the second row (4, 5, 6).
    • The inner loop prints “4 “.
    • The inner loop encounters 5, and break outerLoop; is executed.
    • Both the inner and outer loops are terminated.

The program ends at this point. The output is:

1 2 3
4

The third row (7, 8, 9) is never processed because the loops were terminated early.

Key Points

Practice Questions

1. What will be the output of the following program?

public class IfStatementTest {
    public static void main(String[] args) {
        int x = 10;
        if (x > 5) {
            if (x < 20) {
                System.out.println("x is between 5 and 20");
            }
        } else {
            System.out.println("x is 5 or less");
        }
    }
}

A) x is between 5 and 20
B) x is 5 or less
C) x is greater than 20
D) The program does not compile
E) The program compiles but does not produce any output

2. Given the following code:

record Person(String name, int age) {}
record Employee(int id, Person person) {}

public class RecordPattern {
    public static void main(String[] args) {
        Employee emp = new Employee(1001, new Person("Alice", 30));
                                            
        // Insert code here
    }
}

Which of the following options correctly uses record pattern matching in an if statement to extract and print the name and age of a Person record in Java 21?

A)

if (emp instanceof Employee) {
    var (id, Person(name, age)) = emp;
    System.out.println(name + " is " + age + " years old.");
}

B)

if (emp instanceof Employee(_, Person(var name, var age))) {
    System.out.println(name + " is " + age + " years old.");
}

C)

if (emp instanceof Employee e) {
    System.out.println(e.person().name() + " is " + e.person().age() + " years old.");
}

D)

if (emp instanceof Employee(var id, Person(var name, var age))) {
    System.out.println(name + " is " + age + " years old.");
}

E)

if (emp instanceof Employee(var id, var person)) {
    System.out.println(person.name() + " is " + person.age() + " years old.");
}

3. Which of the following code snippets compile without error?

public class FlowScopingTest {
    public static void main(String[] args) {
        int x = 10;
        if (x > 5) {
            int y = x * 2;
        }
        // Code snippet 1
        System.out.println(y);

        if (x < 20) {
            int z = x + 5;
        }
        // Code snippet 2
        z += 5;

        int a = 5;
        if (a > 0) {
            a = 15;
        }
        // Code snippet 3
        System.out.println(a);

        if (x > 0) {
            int b = x + 3;
            if (b > 15) {
                b -= 2;
            }
        }
        // Code snippet 4
        System.out.println(b);
    }
}

A) Code snippet 1
B) Code snippet 2
C) Code snippet 3
D) None of the above

4. What will be the output of the following program?

public class SwitchTest {
    public static void main(String[] args) {
        int dayOfWeek = 3;
        String dayType;
        switch (dayOfWeek) {
            case 1:
            case 7:
                dayType = "Weekend";
                break;
            case 2:
            case 3:
            case 4:
            case 5:
            case 6:
                dayType = "Weekday";
                break;
            default:
                dayType = "Invalid day";
        }
        System.out.println(dayType);
    }
}

A) Weekend
B) Invalid day
C) Weekday
D) The program does not compile
E) The program compiles but does not produce any output

5. What will be the output of the following program?

public class SwitchExpressionTest {
    public static void main(String[] args) {
        int score = 85;
        String grade = switch (score) {
            case 90, 100 -> "A";
            case 80, 89 -> "B";
            case 70, 79 -> "C";
            case 60, 69 -> "D";
            default -> "F";
        };
        System.out.println(grade);
    }
}

A) A
B) F
C) The program does not compile
D) B
E) The program compiles but does not produce any output

6. Given the following code:

public class SwitchEnums {
    sealed interface Vehicle permits CarType {}
    enum CarType implements Vehicle { SEDAN, SUV, HATCHBACK, CONVERTIBLE }

    void processVehicle(Vehicle v) {
        switch(v) {
            // Insert case statements here
        }
    }
}

Which of the following case statements are valid in Java 21 when inserted in the switch expression?

A)

case CarType.SEDAN, CarType.HATCHBACK -> System.out.println("Compact vehicle");
case CarType.SUV -> System.out.println("Large vehicle");
case CarType.CONVERTIBLE -> System.out.println("Open-top vehicle");

B)

case SEDAN, HATCHBACK -> System.out.println("Compact vehicle");
case SUV -> System.out.println("Large vehicle");
case CONVERTIBLE -> System.out.println("Open-top vehicle");

C)

case CarType.SEDAN || CarType.HATCHBACK -> System.out.println("Compact vehicle");
case CarType.SUV -> System.out.println("Large vehicle");
case CarType.CONVERTIBLE -> System.out.println("Open-top vehicle");

D)

case Vehicle.SEDAN, Vehicle.HATCHBACK -> System.out.println("Compact vehicle");
case Vehicle.SUV -> System.out.println("Large vehicle");
case Vehicle.CONVERTIBLE -> System.out.println("Open-top vehicle");

7. Given the following code:

sealed interface Shape permits Circle, Square, Triangle {}
record Circle(double radius) implements Shape {}
record Square(double side) implements Shape {}
record Triangle(double base, double height) implements Shape {}

Shape shape = new Circle(5);
double area = switch (shape) {
    // Insert case statements here
};

Which of the following case statements correctly implements pattern matching for the Shape hierarchy when inserted in the switch expression?

A)

case Circle c -> Math.PI * c.radius() * c.radius();
case Square s -> s.side() * s.side();
case null -> 0;

B)

default -> 0;
case Circle c -> Math.PI * c.radius() * c.radius();
case Square s -> s.side() * s.side();
case Triangle t -> 0.5 * t.base() * t.height();

C)

case Shape s when s instanceof Circle ->
        Math.PI * ((Circle)s).radius() * ((Circle)s).radius();
case Shape s when s instanceof Square ->
        ((Square)s).side() * ((Square)s).side();
case Shape s when s instanceof Triangle ->
        0.5 * ((Triangle)s).base() * ((Triangle)s).height();

D)

case Circle c -> Math.PI * c.radius() * c.radius();
case Square s -> s.side() * s.side();
case Triangle t -> 0.5 * t.base() * t.height();

8. What will be the output of the following program?

public class LabeledBreakTest {
    public static void main(String[] args) {
        int count = 0;
        outerLoop:
        while (count < 5) {
            while (true) {
                count++;
                if (count == 3) {
                    break outerLoop;
                }
            }
        }
        System.out.println(count);
    }
}

A) 2
B) 3
C) 4
D) 5
E) The program does not compile

9. What will be the output of the following program?

public class ForLoopTest {
    public static void main(String[] args) {
        int sum = 0;
        for (int i = 1; i <= 5; i++) {
            sum += i;
        }
        System.out.println(sum);
    }
}

A) 5
B) 10
C) 15
D) 20
E) The program does not compile

10. What will be the output of the following program?

public class EnhancedForLoopTest {
    public static void main(String[] args) {
        int[] numbers = {1, 2, 3, 4, 5};
        int sum = 0;
        for (int num : numbers) {
            if (num % 2 == 0) {
                continue;
            }
            sum += num;
        }
        System.out.println(sum);
    }
}

A) 9
B) 10
C) 12
D) 15
E) The program does not compile

Do you like what you read? Would you consider?


Do you have a problem or something to say?

Report an issue with the book

Contact me