7. Control Flow
Table of Contents
- 7.1 The if Statement
- 7.2 The switch Statement & Expression
- 7.3 Two Forms of switch Statement vs switch Expression
- 7.4 Null Handling
Control flow in Java refers to the order in which individual statements, instructions, or method calls are executed during program runtime.
By default, statements run sequentially from top to bottom, but control flow statements allow the program to make decisions, repeat actions, or branch execution paths based on conditions.
Java provides three main categories of control flow constructs:
- Decision-making statements —
if,if-else,switch - Looping statements —
for,while,do-while, and the enhancedfor - Branching statements —
break,continue, andreturn
Tip
Understanding control flow is essential to seeing how data moves through your program and how each logic decision is evaluated step by step.
7.1 The if Statement
The if statement is a conditional control-flow structure that executes a block of code only if a specified boolean expression evaluates to true. It allows the program to make decisions at runtime.
Syntax:
if (condition) {
// executed only when condition is true
}
An optional else clause handles the alternative path:
if (score >= 60) {
System.out.println("Passed");
} else {
System.out.println("Failed");
}
Multiple conditions can be chained using else if:
if (grade >= 90) {
System.out.println("A");
} else if (grade >= 80) {
System.out.println("B");
} else if (grade >= 70) {
System.out.println("C");
} else {
System.out.println("D or below");
}
Note
The if condition must evaluate to a boolean; numeric or object types cannot be used directly as conditions.
Curly braces {} are optional for single statements but strongly recommended to prevent subtle logic errors.
An if-else chain is evaluated from top to bottom, and only the first branch with a condition evaluating to true is executed.
7.2 The switch Statement & Expression
The switch construct is a control-flow structure that selects one branch among multiple alternatives based on the value of an expression (the selector).
Compared to long chains of if-else-if, a switch:
- Is often easier to read when testing many discrete values (constants, enums, strings).
- Can be safer and more concise when used as a switch expression
because:
- It produces a value.
- The compiler can enforce exhaustiveness and type consistency.
Java 21 supports:
- The classic
switchstatement (control flow only). - The
switchexpression (produces a result). - Pattern matching inside
switch, including type patterns and guards.
Both forms of switch share the same rules concerning the selector (switch target variable) and acceptable case values.
7.2.1 The switch target variable can be
| Control Variable type |
|---|
byte / Byte |
short / Short |
char / Character |
int / Integer |
String |
Enum types (selectors of an enum) |
| Any reference type (with pattern matching) |
var (if it resolves to one of the allowed types) |
Warning
Not allowed as selector types for switch:
booleanlongfloatdouble
7.2.2 Acceptable case Values
For a non-pattern switch, each case label must be a compile-time constant compatible with the selector type.
Allowed as case labels:
- Literals such as
0,'A',"ON". - Enum constants, e.g.,
REDorColor.GREEN. - Final constant variables (compile-time constants).
A compile-time constant variable:
- Must be declared with
finaland initialized in the same statement. - Its initializer must itself be a constant expression (typically using literals and other compile-time constants).
7.2.3 Type Compatibility Between Selector and Case
The selector type and each case label must be compatible:
- Numeric case constants must be within the range of the selector type.
- For an
enumselector, case labels must be constants of thatenum. - For a
Stringselector, case labels must be string constants.
7.2.4 Pattern Matching in Switch
Switch in Java 21 supports pattern matching, including:
- Type patterns:
case String s - Guarded patterns:
case String s when s.length() > 3 - Null pattern:
case null
Example:
String describe(Object o) {
return switch (o) {
case null -> "null";
case Integer i -> "int " + i;
case String s when s.isEmpty() -> "empty string";
case String s -> "string (" + s.length() + ")";
default -> "other";
};
}
Key points:
- Each pattern introduces a pattern variable (such as
iors). - Pattern variables are in scope only within their own arm (or paths where the pattern is known to match).
- Order matters because of dominance: more specific patterns must precede more general ones.
7.2.4.1 Variable Names and Scope Across Branches
With pattern matching, the pattern variable exists only in the scope of the arm in which it is defined. This means you can reuse the same variable name in different case branches.
- Example:
switch (o) {
case String str -> System.out.println(str.length());
case CharSequence str -> System.out.println(str.charAt(0));
default -> { }
}
Note
This last example does not return a value, so it is a statement switch, not a switch expression.
7.2.4.2 Ordering, Dominance and Exhaustiveness in Pattern Switches
When dealing with pattern matching, the ordering of branches is crucial because of dominance and potential unreachable code.
A more general pattern must not appear before a more specific one, or the specific one becomes unreachable.
- Example (unreachable branch):
return switch (o) {
case Object obj -> "object";
case String s -> "string"; // ❌ DOES NOT COMPILE: unreachable, String is already matched by Object
};
- Another example with a guard:
return switch (o) {
case Integer a -> "First";
case Integer a when a > 0 -> "Second"; // ❌ DOES NOT COMPILE: unreachable, the first case matches all Integers
// ...
};
When using pattern matching, switches must be exhaustive; that is, they must handle all possible selector values.
This can be achieved by:
- Providing a
defaultcase that handles all values not matched by any other case. -
Providing a final case clause with a pattern type that matches the selector reference type.
-
Example (not exhaustive):
Number number = Short.valueOf(10);
switch (number) {
case Short s -> System.out.println("A"); // ❌ DOES NOT COMPILE: not exhaustive, selector is of type Number
}
To fix this, you can:
- Change the reference type of
numbertoShort(then exhaustiveness is satisfied by the single case). - Add a
defaultclause that covers all remaining values. - Add a final case clause covering the type of the selector variable, for example:
Number number = Short.valueOf(10);
switch (number) {
case Short s -> System.out.println("A");
case Number n -> System.out.println("B");
}
Warning
The following example, which uses both a default clause and a final clause with the same type as the selector variable, does not compile: the compiler considers one of the two cases as always dominating the other.
Number number = Short.valueOf(10);
switch (number) {
case Short s -> System.out.println("A");
case Number n -> System.out.println("B"); // ❌ DOES NOT COMPILE: dominated by either the default or the Number pattern
default -> System.out.println("C");
}
7.3 Two Forms of switch: switch Statement vs switch Expression
7.3.1 The Switch Statement
A switch statement is used as a control-flow construct.
It does not, by itself, evaluate to a value, although its branches may contain return statements that return from the enclosing method.
switch (mode) { // switch statement
case "ON":
start();
break; // prevents fall-through
case "OFF":
stop();
break;
default:
reset();
}
Key points:
- Each
caseclause includes one or more matching values separated by commas,. A separator follows, which can be either a colon:or, less commonly for statements, the arrow operator->. Finally, an expression or a block (enclosed in{}) defines the code to execute when a match occurs. If you use the arrow operator for one clause, you must use it for all clauses in that switch statement. - Fall-through is possible for colon-style cases unless a branch uses
break,return, orthrow. When present,breakterminates the switch after executing its case; without it, execution continues, in order, into the following branches. - A
defaultclause is optional and can appear anywhere in the switch statement. It runs if there is no match for previous cases. - A switch statement does not yield a value as an expression; you cannot assign a switch statement directly to a variable.
7.3.1.1 Fall-Through Behavior
With colon-style cases, execution jumps to the matching case label.
If there is no break, it continues into the next case until a break, return, or throw is encountered.
int n = 2;
switch (n) {
case 1:
System.out.println("1");
case 2:
System.out.println("2"); // printed
case 3:
System.out.println("3"); // printed (fall-through)
break;
default:
System.out.println("message default");
}
Output:
2
3
Note
If in the previous example we remove the break on case 3, the message from the default branch will also be printed.
7.3.2 The Switch Expression
A switch expression always produces a single value as its result.
- Example:
int len = switch (s) { // switch expression
case null -> 0;
case "" -> 0;
default -> s.length();
};
Key points:
- Each
caseclause includes one or more matching values separated by commas,, followed by the arrow operator->. Then an expression or a block (enclosed in{}) defines the result for that arm. - When used with an assignment or a
returnstatement, a switch expression requires a terminating semicolon;after the expression. - There is no fall-through between arrow arms. Each matching arm executes exactly once.
- A switch expression must be exhaustive: all possible selector values must be covered (via explicit cases and/or
default). - The result type must be consistent across all branches. For example, if one arm yields an
int, the other arms must yield values compatible withint.
7.3.2.1 yield in Switch Expression Blocks
When an arm of a switch expression uses a block instead of a single expression, you must use yield to provide the result of that arm.
int len = switch (s) {
case null -> 0;
default -> {
int l = s.trim().length();
System.out.println("Length: " + l);
yield l; // result of this arm
}
};
Note
yield is used only in switch expressions.
break value; is not allowed as a way to return a value from a switch expression.
7.3.2.2 Exhaustiveness for Switch Expressions
Because a switch expression must return a value, it must also be exhaustive; in other words, it must handle all possible selector values.
You can ensure this by:
- Providing a
defaultcase. - For an enum selector: covering all enum constants explicitly.
- For sealed types or pattern switches: covering all permitted subtypes or providing a
default.
Example, exhaustive via default:
int val = switch (s) {
case "one" -> 1;
case "two" -> 2;
default -> 0;
};
7.4 Null Handling
Classic switch (without patterns)
If the selector expression of a classic switch (without pattern matching) evaluates to null, a NullPointerException is thrown at runtime.
To avoid this, check for null before switching:
if (s == null) {
// handle null
} else {
switch (s) {
case "A" -> ...
default -> ...
}
}
Pattern switch (with case null)
With pattern matching, you can handle null directly inside the switch:
int len = switch (s) {
case null -> 0;
default -> s.length();
};
Note
For switch expressions:
If you do not handle null and the selector is null, a NullPointerException is thrown.
Using case null makes the switch explicitly null-safe.
Warning
Any time case null is used in a switch, the switch is treated as a pattern switch, and all the rules for pattern switches (including exhaustiveness and dominance) apply.