Skip to content

Controlling Program Flow

Step 3 of 20

Controlling Program Flow

Introduction

This lesson covers the second objective of the Oracle Certified Professional Java SE 21 Developer exam (1Z0-830): Controlling Program Flow. Oracle does not publish a weighting per objective, but flow control is everywhere: nearly every code-analysis question contains an if, a loop or a switch, and Java 21 adds the features examiners love most: pattern matching for switch and record patterns, both final in Java 21.

The exam rarely asks "what is a loop". It shows a switch with a missing break, a pattern label placed in the wrong order, or a labeled continue, and asks whether the code compiles and what it prints. You will learn the exact rules of each construct so you can trace such code by hand and also spot compile-time errors (dominated labels, missing default, unreachable statements) in seconds.

Learning objectives

After this lesson you will be able to:

  • Write and trace if/else chains, including the dangling-else rule and instanceof pattern variables and their scope.
  • Use classic switch statements (with fall-through), arrow-form switch statements and switch expressions with yield.
  • Apply pattern matching for switch: type patterns, guards with when, case null, dominance and exhaustiveness.
  • Deconstruct records with record patterns, including nested patterns and var.
  • Use for, enhanced for, while, do/while, break, continue and labels, and detect unreachable code.

1. if/else and instanceof patterns

An if condition must be a boolean expression. if (x = 5) does not compile when x is an int. An else always pairs with the nearest unmatched if, whatever the indentation suggests:

int n = 5;
if (n > 0)
    if (n > 10) System.out.println("big");
else System.out.println("negative");   // belongs to the inner if

This prints nothing: n > 0 is true, the inner if is false, and the else belongs to the inner if. Braces remove the ambiguity.

Pattern matching with instanceof

Since Java 16, instanceof can declare a pattern variable, so no cast is needed:

Object o = "Java 21";
if (o instanceof String s && s.length() > 4) {
    System.out.println(s.toUpperCase());   // JAVA 21
}

The variable s is in scope only where the compiler can prove the match succeeded. In a instanceof String s && s.isEmpty() the right side sees s; in a instanceof String s || s.isEmpty() it does not compile. With a negation, the scope flows to the following statements:

if (!(o instanceof String s)) {
    return;                 // must leave the block
}
System.out.println(s.length());   // s is in scope here

Since Java 21 a pattern whose type is the same as the static type, such as String t = "a"; t instanceof String s, is allowed. What is still an error is a test that can never succeed, such as Integer i = 1; i instanceof String s (incompatible types). Also, null instanceof Anything is always false.

The ternary operator cond ? a : b follows promotion rules: true ? 1 : 2.0 evaluates to 1.0 (a double), and mixing Integer and null can cause an unboxing NullPointerException when assigned to an int.

2. The classic switch statement

The selector of a classic switch can be byte, short, char, int, their wrappers, a String or an enum. It cannot be long, float, double or boolean. Case labels must be compile-time constants and unique. String switches use equals, so matching is case-sensitive, and a null selector throws NullPointerException.

The key behavior is fall-through: after a matching label the code continues into the next label until a break (or the end of the switch).

int n = 2;
switch (n) {
    case 1: System.out.print("a");
    case 2: System.out.print("b");
    case 3: System.out.print("c"); break;
    default: System.out.print("d");
}
// prints: bc

default may appear anywhere, and it is only used when no label matches, but the order of fall-through still follows the source order:

switch (5) {
    default: System.out.print("d");
    case 1:  System.out.print("a");
}
// prints: da

Inside a loop, a break in a classic switch leaves only the switch, while continue applies to the loop. Variables declared in one case group are in scope for the whole switch block (a variable declared under case 1: can be assigned under case 2: but is not initialized there).

3. Arrow-form switch and switch expressions

With -> labels there is no fall-through and no break is needed. A label's right side is an expression, a block or a throw. You cannot mix : and -> in the same switch.

A switch expression produces a value and can be assigned or returned. It must be exhaustive: every possible input must be covered, so for an int or String selector a default is required; for an enum it is enough to list all constants (the compiler adds an implicit failing default); for a sealed hierarchy, all permitted subtypes.

enum Day { MON, TUE, SAT, SUN }

int hours = switch (day) {
    case SAT, SUN -> 0;                 // several constants per label
    case MON -> 6;
    case TUE -> {
        int base = 8;
        yield base - 1;                 // yield returns from a block
    }
};

Rules to remember:

  • Inside a switch expression you cannot break, continue or return out of the expression. Use yield to supply the result.
  • With old-style colon labels in a switch expression, yield is also used and fall-through still exists.
  • A switch statement with arrow labels does not need to be exhaustive (unless it uses patterns or null).
  • Case labels can list several constants separated by commas, and since Java 21 enum constants may be qualified (case Day.MON) in pattern-matching switches.
Feature Statement, : form Statement, -> form Expression
Fall-through Yes No Only with : labels
Needs break/yield break No yield for block bodies
Must be exhaustive No No (unless patterns) Yes
Produces a value No No Yes

4. Pattern matching for switch (final in Java 21)

Java 21 lets the selector be any reference type and lets labels be patterns:

static String describe(Object o) {
    return switch (o) {
        case null            -> "null value";
        case Integer i when i > 100 -> "large int " + i;
        case Integer i       -> "int " + i;
        case String s        -> "string of " + s.length();
        case int[] arr       -> "int array of " + arr.length;
        default              -> "something else";
    };
}

Important rules:

  1. case null. Without it, a null selector throws NullPointerException. You can write case null, default -> to handle both. case null cannot be combined with other patterns in one label.
  2. Guards. when adds a boolean condition to a pattern label. Only pattern labels can have guards, not constants.
  3. Dominance. A label that is dominated by an earlier label is a compile-time error. case Integer i before case Integer i when i > 100 does not compile, because the first already matches everything the second matches. Put the specific (guarded) labels first. Likewise, case CharSequence cs placed before case String s is an error. The unguarded default must come last (or at least cannot dominate patterns that follow it in a pattern switch).
  4. Exhaustiveness. A switch with patterns, whether statement or expression, must cover every possible value. With a sealed interface, covering all permitted subtypes is enough, with no default.
  5. No fall-through into a pattern. You cannot fall into a label that declares a pattern variable, since the variable might be uninitialized.
sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}

static double area(Shape s) {
    return switch (s) {
        case Circle c    -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
    };   // no default needed: all permitted subtypes are covered
}

If a new class is later added to the permits clause and area is not recompiled, a MatchException is thrown at runtime for an unexpected subtype. This compile-time exhaustiveness check is the main benefit of sealed types (covered in detail in the object-oriented lesson).

5. Record patterns (final in Java 21)

A record pattern tests that a value is an instance of a record and deconstructs its components in one step:

record Point(int x, int y) {}
record Line(Point from, Point to) {}

Object o = new Line(new Point(2, 1), new Point(2, 9));
if (o instanceof Line(Point(var x1, var y1), Point(var x2, var y2)) && x1 == x2) {
    System.out.println("vertical line at x=" + x1);   // vertical line at x=2
}

Details:

  • Component patterns can be type patterns (Point p, int x), nested record patterns, or var (the type of the component is inferred).
  • The pattern matches only if the value is non-null and an instance of the record type. A null component does match a type pattern that is total for the component type (for example Point(var a, var b) when a is null), but a nested record pattern such as Line(Point(var a, var b), var t) does not match when the from component is null.
  • Components are read through their accessor methods. The pattern's component count and types must match the record header exactly, or the code does not compile.
  • In a switch, record patterns can have guards and be checked for dominance and exhaustiveness exactly like type patterns.
Pattern kind Syntax Matches
Type pattern case String s Non-null instances of String
Guarded pattern case String s when s.isEmpty() Non-null empty strings
Record pattern case Point(int x, int y) Non-null Point instances
Nested record pattern case Line(Point(var a, var b), var t) A Line whose from is non-null
Null label case null Only null

6. Loops, break, continue and labels

The classic for loop has three optional parts: initialization, condition, update. for (;;) is an infinite loop. A variable declared in the initialization is scoped to the loop.

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

Only declarations of the same type are allowed in the initializer (int i = 0, j = 10 is fine, int i = 0, long j = 1 is not).

The enhanced for works on arrays and any Iterable. The loop variable is a copy of each element, so assigning to it does not change the array. Modifying a collection structurally while iterating it with an enhanced for usually throws ConcurrentModificationException.

while checks its condition first and may never run; do { } while (cond); runs at least once and ends with a semicolon.

break ends the nearest enclosing loop or switch. continue skips to the next iteration of the nearest loop. A label lets them target an outer loop:

outer:
for (int i = 0; i < 3; i++) {
    for (int j = 0; j < 3; j++) {
        if (j == 1) continue outer;
        if (i == 2) break outer;
        System.out.print(i + "" + j + " ");
    }
}
// prints: 00 10

Unreachable code is a compile-time error: a statement after break, continue, return, throw, or after while (true) { } with no break. A while (false) { ... } body is also an error, while if (false) { ... } is allowed. A final subtlety: for (;;) with no break followed by any statement does not compile.

Worked example: classifying messages with patterns

Problem. A logging service receives Object events: a String message, an Integer code, or a Line/Point from the earlier records. Write label(Object o) so that an Integer above 400 is "error", other integers are "code", null is "none", and everything else is "unknown". A colleague proposes this order:

return switch (o) {
    case Integer i            -> "code";
    case Integer i when i > 400 -> "error";
    case null                 -> "none";
    default                   -> "unknown";
};

Does it compile? If not, how is it fixed, and what does label(500) return after the fix?

Step 1: check dominance. case Integer i matches every non-null Integer. The second label case Integer i when i > 400 can never be reached, which the compiler reports as "this case label is dominated by a preceding case label".

Step 2: reorder. Place the guarded label before the unguarded one. The case null label has no dominance relation with the others, so it can stay anywhere before default.

return switch (o) {
    case null                   -> "none";
    case Integer i when i > 400 -> "error";
    case Integer i              -> "code";
    default                     -> "unknown";
};

Step 3: evaluate. For 500, the first label fails (not null), the second matches (500 > 400), and the result is "error".

Why not the alternatives? Replacing the guard with an if inside the arrow would also work, but it hides the logic and loses the compiler's dominance check. Removing case null would make label(null) throw NullPointerException. Using a classic switch with case Integer.class is not valid Java.

Common pitfalls and exam tips

  • Exam tip: when a question has a switch, first check the label form (: or ->), then whether it is a statement or an expression, then whether it must be exhaustive. Three quick checks catch most compile errors.
  • A missing break in a colon-form switch silently falls through, and default in the middle still falls through to the next label.
  • A break inside a switch inside a loop does not break the loop.
  • Dominance errors: general pattern first, guarded or more specific pattern later. Switching the order fixes it.
  • null selector without case null throws NullPointerException, even in a pattern switch with a default.
  • yield is a restricted identifier: yield is only a statement inside a switch expression body.
  • Pattern variables are only in scope where the match is definitely true. Reusing the same name in an if/else if chain for different branches is legal.
  • A switch on long, boolean, float or double does not compile.

Mini case study: routing support tickets

A help-desk system models requests as sealed interface Ticket permits Bug, Question, Feature. Bug(String id, int severity), Question(String id) and Feature(String id, boolean paid) are records. Routing rules: a Bug with severity 4 or more goes to "oncall", other bugs to "backlog", a Question to "support", a paid Feature to "product", and an unpaid Feature to "backlog".

Task. Write the method and explain what the compiler does if a fourth ticket type is added later.

Solution.

static String route(Ticket t) {
    return switch (t) {
        case Bug(var id, var sev) when sev >= 4 -> "oncall";
        case Bug b                              -> "backlog";
        case Question q                         -> "support";
        case Feature(var id, var paid) when paid -> "product";
        case Feature f                          -> "backlog";
    };
}

The guarded record patterns come before their unguarded counterparts, avoiding dominance errors. The unguarded Bug b, Question q and Feature f labels together make the switch exhaustive over the sealed interface, so no default is written. If a fourth permitted type is added, the switch stops being exhaustive and the compiler reports an error in route at the next compile. That is the intended safety net: a default would have hidden the new case and routed it silently to a wrong queue.

Key terms

  • Fall-through: continuing into the next case group because no break ended the current one.
  • Switch expression: a switch that yields a value and must be exhaustive.
  • yield: statement that provides the value of a switch expression block.
  • Type pattern / record pattern: tests of a type, optionally binding or deconstructing values.
  • Guard: the when boolean condition attached to a pattern label.
  • Dominance: a preceding label that already matches everything a later label would match.
  • Exhaustiveness: every possible input is covered by some label.
  • Label (loop): an identifier marking a statement so break or continue can target it.

Summary checklist

  • I can trace fall-through in a colon-form switch, including a default in the middle.
  • I know which selector types are allowed and that null throws without case null.
  • I can write switch expressions with yield, multiple constants per label and exhaustiveness.
  • I can order pattern labels to avoid dominance errors and use when guards.
  • I can deconstruct nested records with var and know when a record pattern does not match null.
  • I can predict the output of nested loops with labeled break and continue.
  • I can spot unreachable code and scope errors with pattern variables.

Official sources