Blog

Control Flow in Java: Loops, if and Switch Expressions

Java’s if and loops look like C’s, with a few rules that catch real bugs. The old switch falls through by default, and switch expressions fix that while making the compiler check every enum value.

Control flow is how a program decides what to run next and how many times. Java’s if, while and for look almost exactly like C’s, but a few rules differ, and each one exists to stop a bug you’d otherwise ship. The biggest change is in switch, which now comes in two very different shapes.

This post covers if and the ternary operator, short-circuit && and ||, every kind of loop, break and continue with labels, the old switch statement and its fall-through bug, and switch expressions with -> and yield. It ends with a small grade calculator that uses all of it. Every program below was run on Java 25, and its output is pasted from the run. To run one yourself, save it as Main.java and run java Main.java.

if needs a real boolean

An if in Java runs its block when a condition is true, and that condition must have the type boolean. Here’s the full if / else if / else shape:

String describe(int celsius) {
    if (celsius < 0) {
        return "freezing";
    } else if (celsius < 20) {
        return "cool";
    } else {
        return "warm";
    }
}

void main() {
    IO.println(describe(-5));
    IO.println(describe(12));
    IO.println(describe(31));
}

It prints:

freezing
cool
warm

The checks run from the top, and the first true one wins. 12 is not below 0, so the first branch is skipped. It is below 20, so "cool" comes back and the else never runs.

In C, any number works as a condition, and zero means false. Java doesn’t allow that. The classic C typo, = where you meant ==, doesn’t get past the compiler:

void main() {
    int x = 3;
    if (x = 5) {
        IO.println("x is five");
    }
}

The build fails with:

Main.java:3: error: incompatible types: int cannot be converted to boolean
    if (x = 5) {
          ^

x = 5 is an assignment, and its value is the int 5. An int isn’t a boolean, so javac stops. In C this line compiles, sets x to 5 and always takes the branch.

There’s one case the rule doesn’t catch. When the variable is itself a boolean, the assignment has type boolean, and it compiles:

void main() {
    boolean done = false;
    if (done = true) {
        IO.println("the branch ran, and done is now " + done);
    }
}

It prints:

the branch ran, and done is now true

We expected at least a lint warning here, and javac -Xlint:all gave none. The fix is to not compare booleans at all. Write if (done) or if (!done), and there’s no = to mistype.

The ternary operator picks one of two values

The ternary operator, condition ? a : b, is an if that produces a value. Use it when both branches are short and you want to assign or return the result:

void main() {
    int items = 1;
    String label = items == 1 ? "item" : "items";
    IO.println(items + " " + label);

    items = 3;
    IO.println(items + " " + (items == 1 ? "item" : "items"));
}

It prints:

1 item
3 items

The condition still has to be a boolean. Keep ternaries flat. A ternary inside another ternary is legal, but an if or a switch expression reads better.

&& and || stop early, & and | don’t

&& and || skip their right side when the left side already decides the answer. That’s called short-circuiting. & and | also work on booleans, but they always evaluate both sides. The difference shows when the right side does something:

boolean check(String name, boolean result) {
    IO.println("  checked " + name);
    return result;
}

void main() {
    IO.println("&&:");
    boolean a = check("left", false) && check("right", true);
    IO.println("&:");
    boolean b = check("left", false) & check("right", true);
    IO.println("||:");
    boolean c = check("left", true) || check("right", false);
    IO.println(a + " " + b + " " + c);
}

It prints:

&&:
  checked left
&:
  checked left
  checked right
||:
  checked left
false false true

With &&, once the left side is false the whole thing is false, so right is never checked. & gives the same answer but runs both. || stops as soon as the left side is true.

Short-circuiting is what makes a null check followed by a method call safe. Swap && for & and the guard stops guarding:

String name = null;

void main() {
    if (name != null && name.length() > 3) {
        IO.println("long name");
    }
    IO.println("the && version is fine");
    if (name != null & name.length() > 3) {
        IO.println("long name");
    }
}

It prints, then stops:

the && version is fine
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "this.name" is null

The & version calls name.length() even though name != null was false. In everyday code, use && and ||. Save & and | for bit operations on integers.

while, do-while and the classic for

Java has three loops that repeat while a condition holds, and they differ in when the condition is checked. A while checks before each pass, a do-while checks after, and a for puts the start, the check and the step on one line:

void main() {
    int n = 3;
    while (n > 0) {
        IO.println("while: " + n);
        n--;
    }

    int tries = 10;
    do {
        IO.println("do-while ran with tries = " + tries);
        tries++;
    } while (tries < 5);

    for (int i = 0; i < 3; i++) {
        IO.println("for: i = " + i);
    }
}

It prints:

while: 3
while: 2
while: 1
do-while ran with tries = 10
for: i = 0
for: i = 1
for: i = 2

The do-while body ran once even though tries < 5 was false from the start. That’s the only thing a do-while does differently. It fits “do this, then ask whether to go again”, such as prompting for input until it’s valid.

The for loop’s i exists only inside the loop. Use it after the closing brace and javac reports cannot find symbol.

The enhanced for walks every element

The enhanced for, written for (var x : things), visits each element of an array or a collection in order, with no index to manage:

void main() {
    int[] scores = {72, 95, 88};
    int total = 0;
    for (int score : scores) {
        total += score;
    }
    IO.println("total: " + total);

    var names = List.of("Ana", "Ben", "Chen");
    for (var name : names) {
        IO.println("hello, " + name);
    }
}

It prints:

total: 255
hello, Ana
hello, Ben
hello, Chen

Read the colon as “in”: for each score in scores. It works on arrays and on anything that implements Iterable, which covers List, Set and the other collections. When you need the position as well as the value, go back to the classic for.

An off-by-one bug, and the fix

An off-by-one bug is a loop that runs one time too many or one time too few. With arrays, the usual cause is <= where you needed <:

void main() {
    String[] days = {"Mon", "Tue", "Wed"};
    for (int i = 0; i <= days.length; i++) {
        IO.println(i + ": " + days[i]);
    }
}

It prints, then stops:

0: Mon
1: Tue
2: Wed
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3

An array of length 3 has indexes 0, 1 and 2. The condition i <= days.length lets i reach 3, and Java checks every array access, so it throws instead of reading whatever memory sits past the end. C doesn’t check, so the same loop there can quietly read garbage.

The fix is <:

void main() {
    String[] days = {"Mon", "Tue", "Wed"};
    for (int i = 0; i < days.length; i++) {
        IO.println(i + ": " + days[i]);
    }
}

It prints:

0: Mon
1: Tue
2: Wed

Start at 0 and stop before the length. If you don’t need i, the enhanced for can’t get this wrong, because it has no index.

break, continue and labeled break

break leaves a loop right away, and continue skips the rest of the current pass and starts the next one. Both act on the innermost loop, which is a problem when you’re two loops deep. A label names an outer loop so break can leave that one instead:

void main() {
    for (int i = 1; i <= 6; i++) {
        if (i % 2 == 0) {
            continue;
        }
        if (i == 5) {
            break;
        }
        IO.println("odd: " + i);
    }

    int[][] grid = {{1, 2, 3}, {4, 42, 6}, {7, 8, 9}};

    for (int[] row : grid) {
        for (int value : row) {
            if (value == 42) {
                IO.println("plain break: found 42");
                break;
            }
        }
        IO.println("plain break: still scanning after a row");
    }

    search:
    for (int r = 0; r < grid.length; r++) {
        for (int c = 0; c < grid[r].length; c++) {
            if (grid[r][c] == 42) {
                IO.println("labeled break: found 42 at row " + r + ", column " + c);
                break search;
            }
        }
    }
    IO.println("done");
}

It prints:

odd: 1
odd: 3
plain break: still scanning after a row
plain break: found 42
plain break: still scanning after a row
plain break: still scanning after a row
labeled break: found 42 at row 1, column 1
done

The first loop skips even numbers with continue and stops at 5 with break, so only 1 and 3 print.

The plain break found 42 in the middle row and left the inner loop only. The outer loop carried on, printed its line, and scanned the last row for nothing. A plain break can’t reach further out than one loop.

search: labels the outer loop, so break search leaves both loops at once. The next line to run is the one after the outer loop, which prints done. continue takes a label too: continue search would jump to the next row.

Labels are rare in practice. If you need one, it’s often cleaner to move the nested loops into a method and return when you find the value.

The old switch statement falls through

The old switch statement jumps to the case that matches and then keeps running every line below it, through the following cases, until it hits a break. That’s called fall-through, and a missing break turns it into a bug:

@SuppressWarnings("fallthrough")
String dayType(int day) {
    String result = "";
    switch (day) {
        case 6:
            result += "Saturday ";
        case 7:
            result += "Sunday ";
            break;
        default:
            result += "weekday ";
    }
    return result;
}

void main() {
    IO.println("6 -> " + dayType(6).strip());
    IO.println("7 -> " + dayType(7).strip());
    IO.println("2 -> " + dayType(2).strip());
}

It prints:

6 -> Saturday Sunday
7 -> Sunday
2 -> weekday

Day 6 matched case 6, added "Saturday ", then ran straight on into case 7 and added "Sunday " too. Nothing stopped it until the break. Day 7 started lower, so it only picked up "Sunday ".

The @SuppressWarnings("fallthrough") line is there because every program in this series is built with javac -Xlint:all -Werror. Take it out and that build catches the bug:

String dayType(int day) {
    String result = "";
    switch (day) {
        case 6:
            result += "Saturday ";
        case 7:
            result += "Sunday ";
            break;
        default:
            result += "weekday ";
    }
    return result;
}

void main() {
    IO.println(dayType(6));
}

The build fails with:

Main.java:6: warning: [fallthrough] possible fall-through into case
        case 7:
        ^
error: warnings found and -Werror specified

That warning is off by default. Plain javac Main.java prints nothing, and neither does java Main.java, which ran the buggy version above without a word. If you maintain code with old switches, turn on -Xlint:fallthrough.

Fall-through is occasionally what you want. case 6: case 7: with nothing in between is the old way to share one block between two labels. The new switch has a cleaner way to say that.

Switch expressions: -> and no fall-through

A switch with -> runs exactly one case and never falls into the next one. It can also be an expression: the whole switch produces a value you can assign or return. Switch expressions became final in Java 14. With javac --release 13, javac rejects them with switch expressions are not supported in -source 13.

enum Day { MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY }

String dayType(Day day) {
    return switch (day) {
        case SATURDAY, SUNDAY -> "weekend";
        case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "weekday";
    };
}

void main() {
    IO.println("SATURDAY -> " + dayType(Day.SATURDAY));
    IO.println("SUNDAY -> " + dayType(Day.SUNDAY));
    IO.println("TUESDAY -> " + dayType(Day.TUESDAY));
}

It prints:

SATURDAY -> weekend
SUNDAY -> weekend
TUESDAY -> weekday

Compare it with the old version:

  • No break. The right side of -> runs, and the switch is done. There’s nothing to forget.
  • Several labels in one case. case SATURDAY, SUNDAY replaces the old case 6: case 7: stack.
  • A value comes out. return switch (...) { ... }; returns whatever the chosen case produced. Note the semicolon after the closing brace, because the switch is part of a return statement.
  • No default. The cases name all seven days, and the compiler checked that. More on that below.

You can’t mix the two styles. A case 6: and a case 7 -> in the same switch fail with different case kinds used in the switch.

The arrow works in a switch statement too, with no value, when each case just does something. You still get no fall-through.

yield returns a value from a block

When a case needs more than one expression, give it a block in braces and end the block with yield:

enum Day { MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY }

int openingHour(Day day) {
    return switch (day) {
        case SATURDAY -> 10;
        case SUNDAY -> {
            IO.println("  (Sunday: checking the holiday rota)");
            yield 12;
        }
        default -> 9;
    };
}

void main() {
    IO.println("MONDAY opens at " + openingHour(Day.MONDAY));
    IO.println("SUNDAY opens at " + openingHour(Day.SUNDAY));
}

It prints:

MONDAY opens at 9
  (Sunday: checking the holiday rota)
SUNDAY opens at 12

yield hands a value back to the switch, the way return hands one back from a method. You can’t use return for this, because return would leave openingHour itself. Leave yield out of the block and javac reports switch rule completes without providing a value.

default is fine here, because “every other day opens at 9” is the actual rule. The next section shows when default hides a bug instead.

Explain it like I’m ten

Picture the old switch as a tall slide with a platform at every level: a level for Saturday, one below it for Sunday, one below that for weekdays. You climb on at your level. Then you slide, and you keep sliding past every level underneath, collecting whatever is on each one, until a break catches you like a net. Forget the net and you slide all the way to the bottom.

The new switch is a hallway of separate doors. There’s a Saturday door, a Sunday door and a weekday door. You open the one with your name on it and walk into that room. The room has no hole in the floor, so you can’t end up in the room next door.

The precise version

In an old switch statement, the case labels are only entry points into a single block of statements. The switch jumps to the matching label and runs the statements in order from there. A break jumps out of the block. Without one, execution carries on into the statements under the next label, because to the compiler they’re just the next lines.

In a switch with ->, each case is a separate rule. The right side is one expression, one block or one throw, and after it finishes, control leaves the switch. In a switch expression, every rule must also produce a value, either directly or with yield, or throw.

Where the analogy breaks: you can build a slide with a platform shared by two levels on purpose, and the new switch covers that with case SATURDAY, SUNDAY, one door with two names on it. The doors also have a rule the analogy doesn’t show: in a switch expression, the compiler checks that there’s a door for every possible value before the program runs.

Switching on strings and enums

Without patterns, a switch works on int, short, byte, char, their wrapper classes, String and enums. A long isn’t on the list: in Java 25, javac rejects switch on a long with primitive patterns are a preview feature and are disabled by default. Strings are compared with equals, so the case matches on the text, not on the object:

String run(String command) {
    return switch (command) {
        case "start", "go" -> "starting";
        case "stop" -> "stopping";
        default -> "unknown command: " + command;
    };
}

void main() {
    IO.println(run("go"));
    IO.println(run("stop"));
    IO.println(run("jump"));
    IO.println(run(null));
}

It prints, then stops:

starting
stopping
unknown command: jump
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.hashCode()" because "<local2>" is null

The first three calls behave as you’d expect. The null doesn’t reach default. It throws, and the message gives away how a string switch works. javac copies command into a hidden variable, calls hashCode() on it to pick a candidate case, then confirms the match with equals. The hidden variable has no name, so the message calls it <local2>. If null is a real input, check for it before the switch. The part on sealed types and pattern matching shows case null for pattern switches.

An enum switch expression must cover every constant

A switch expression over an enum with no default has to list every constant. If one is missing, the code doesn’t build. Here SUNDAY has been left out:

enum Day { MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY }

String dayType(Day day) {
    return switch (day) {
        case SATURDAY -> "weekend";
        case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "weekday";
    };
}

void main() {
    IO.println(dayType(Day.SUNDAY));
}

The build fails with:

Main.java:4: error: the switch expression does not cover all possible input values
    return switch (day) {
           ^

A switch expression must produce a value for every input, and the compiler knows the full list of Day constants, so it can see that SUNDAY has nowhere to go. That’s why the earlier dayType needed no default. It’s also why you should usually leave default out of an enum switch expression. Add a HOLIDAY constant next year, and every switch that forgot it stops compiling. A default would quietly give HOLIDAY the default answer.

This check applies to switch expressions. An old-style switch statement over an enum, with -> or with :, may still skip constants, and a skipped value simply runs nothing. The part on sealed types and pattern matching covers switches over types, where the same exhaustiveness check does more work.

A worked example: a grade calculator

A grade calculator puts most of this post into one program. It loops over a list of scores, turns each into a letter with a switch expression, rejects scores outside 0 to 100, and counts how many of each letter it saw:

char grade(int score) {
    if (score < 0 || score > 100) {
        throw new IllegalArgumentException("score out of range: " + score);
    }
    return switch (score / 10) {
        case 10, 9 -> 'A';
        case 8 -> 'B';
        case 7 -> 'C';
        case 6 -> 'D';
        default -> 'F';
    };
}

void main() {
    var scores = List.of(91, 78, 100, 59, 84, 67, 88, 73, 45, 95);
    var counts = new TreeMap<Character, Integer>();
    int passed = 0;

    for (int score : scores) {
        char letter = grade(score);
        counts.put(letter, counts.getOrDefault(letter, 0) + 1);
        if (letter != 'F') {
            passed++;
        }
    }

    for (var entry : counts.entrySet()) {
        IO.println(entry.getKey() + ": " + "#".repeat(entry.getValue()));
    }
    IO.println(passed + " of " + scores.size() + " passed");

    try {
        grade(101);
    } catch (IllegalArgumentException e) {
        IO.println("rejected: " + e.getMessage());
    }
}

It prints:

A: ###
B: ##
C: ##
D: #
F: ##
8 of 10 passed
rejected: score out of range: 101

Now the pieces:

  • score / 10 is integer division, so 91 becomes 9 and 78 becomes 7. That turns 101 possible scores into 11 cases.
  • case 10, 9 -> 'A' gives 100 an A without a separate if.
  • default -> 'F' is right here. An int has billions of values, and “everything below 60” is a genuine catch-all. The range check above the switch makes sure nothing silly reaches it.
  • The for loop counts into a TreeMap, which keeps its keys sorted. That’s why the letters print from A to F.
  • The second for loop walks the map and draws one # per score with String.repeat.
  • grade(101) fails the range check, throws, and the catch prints the message. Without the check, 101 / 10 is 10, and 101 would have quietly earned an A.

Iterating a Map when order matters

You loop over a map with for (var entry : map.entrySet()), and the order you get depends on which kind of map it is. A HashMap returns entries in an order based on hash codes, which is not the order you inserted them. Map.of(...) is worse for printing: its order changes from one JVM run to the next, and four runs of the same program printed four different orders when we tried it. When order matters, choose the map that promises one. A LinkedHashMap keeps insertion order, and a TreeMap keeps its keys sorted:

void main() {
    var byInsertion = new LinkedHashMap<String, Integer>();
    byInsertion.put("pears", 3);
    byInsertion.put("apples", 5);
    byInsertion.put("figs", 1);

    for (var entry : byInsertion.entrySet()) {
        IO.println("inserted: " + entry.getKey() + " = " + entry.getValue());
    }

    var sorted = new TreeMap<>(byInsertion);
    for (var entry : sorted.entrySet()) {
        IO.println("sorted:   " + entry.getKey() + " = " + entry.getValue());
    }
}

It prints:

inserted: pears = 3
inserted: apples = 5
inserted: figs = 1
sorted:   apples = 5
sorted:   figs = 1
sorted:   pears = 3

The part on equals, hashCode and collections explains why a HashMap has no useful order.

What to remember

  • An if condition must be a boolean. if (x = 5) doesn’t compile, but if (done = true) does, so write if (done).
  • && and || skip the right side when the left side decides the answer. & and | always run both sides.
  • Loop over arrays with i < array.length, not <=, or use the enhanced for and skip the index.
  • A plain break leaves only the innermost loop. Label the outer loop to leave both.
  • The old switch statement falls through to the next case without break, and nothing warns you unless you turn on -Xlint:fallthrough.
  • A switch with -> never falls through. As an expression, it returns a value, uses yield inside a block, and must cover every enum constant, so leave out default when the enum is the whole list.
  • Use a LinkedHashMap or TreeMap when the order you iterate a map in matters.

Prefer the switch with ->: each case runs alone, and the compiler checks that none are missing.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.