Layers of Logic

3.2

Conditionals

Boolean branches, braces, ordered tests, and switch statements and expressions without hidden fallthrough.

Core18 min read4 exercises
01

Previously on

Section 3.1 gave you the relational operators, which turn numbers into true or false, and the logical operators that combine them. You also saw that && stops early, which is what makes a null check safe.

That short circuit becomes load bearing in this section. Nearly every condition you write from here on relies on it.

02

The problem

Everything you have written so far runs top to bottom, every line, every time. Useful programs also select work based on data.

A real program chooses. Show a different message for a failed login. Charge a different price on a weekend. Skip the record that has no email address.

Java gives you three ways to write a choice, and they are not interchangeable. Picking the wrong one gives you code that works and is painful to read six months later.

Two comparisons need special care. Both compile, but neither expresses a content comparison:

if (0.1 + 0.2 == 0.3)      // false. Always.
if (name == "admin")       // sometimes true, sometimes false, for the same text
03

The idea

if, else if, else

public class GradeDemo {
    public static void main(String[] args) {
        int score = 84;
        char grade;

        if (score >= 90) {
            grade = 'A';
        } else if (score >= 80) {
            grade = 'B';
        } else {
            grade = 'C';
        }

        System.out.println(grade);
    }
}

Output:

B

The condition in the brackets must give a boolean. Not a number, not null, not an object. In C, if (5) is legal and means true. In Java it does not compile.

Java is refusing a whole category of bug there, the classic being if (x = 5), which in C assigns 5 and is always true. In Java that line is a type error, because assignment gives back a number and if wants a boolean.

switch

When you are checking one value against many fixed options, a ladder of if statements gets noisy. switch says it directly.

switch (day) {
    case 6:
    case 7:
        type = "weekend";
        break;
    case 1:
        type = "monday";
        break;
    default:
        type = "midweek";
}

Read the control flow from top to bottom:

  • case 6: and case 7: stacked together means “either of these”.
  • default: catches everything else. It is optional.
  • Without break, execution continues into the statements under the next label.
04

Under the hood

Going deeper

Fallthrough, and why it exists

Without break, switch does not stop at the end of a case. It carries on.

switch (day) {
    case 1:
        result = "monday";       // no break
    case 2:
        result += " tuesday";
        break;
}
// day = 1 gives you "monday tuesday"

This behaviour is called fallthrough. It is almost always a bug when it happens by accident, and Java gives you no warning at all.

So why keep it? Because of the stacked-label shape:

case 6:
case 7:
    type = "weekend";
    break;

case 6: has an empty body, so it falls straight into case 7:. That useful pattern and the dangerous bug are the same mechanism.

Arrow labels and switch expressions

Java 14 made arrow labels and switch expressions permanent language features.

String type = switch (day) {
    case 6, 7 -> "weekend";
    case 1    -> "monday";
    default   -> "midweek";
};
Colon labelsArrow labels
Fallthroughyes, and silentnone. Each case is separate.
Leaving a statement caseusually break, return or throwthe arm ends by itself
Several valuesstack the case labelscase 6, 7 ->
Usecommon in older switch statementspreferred when fallthrough is not intended

Label syntax and construct type are separate choices. A switch statement performs work. A switch expression produces a value and must cover every possible selector value. Arrow labels work in either construct. The example above is an expression because its result is assigned to type.

What classic switch accepts

AllowedNot allowed
Whole numbersbyte, short, char, int and their wrapperslong and Long
Decimalsnonefloat, double
OtherString, enumboolean

These are language rules. They do not promise one bytecode or machine-code strategy. A compiler can use a dense table for dense integer cases or a lookup for sparse cases. String switching must still distinguish different strings that have the same hash code.

Use switch because it states a fixed choice over one selector. Use if for ranges, unrelated conditions, or conditions that depend on several values.

Passing null to a classic String, wrapper, or enum switch throws NullPointerException. Check for null first when it is a valid input state.

Floating-point equality

System.out.println(0.1 + 0.2 == 0.3);    // false

You already know why, from Section 2.2. Those three numbers have no exact form in binary, so the closest-to-0.1 plus the closest-to-0.2 is not bit for bit the closest-to-0.3.

If the domain means “close enough,” define a tolerance that matches its units and scale:

double tolerance = 0.000001;
if (Math.abs(a - b) <= tolerance) { }   // domain-specific absolute tolerance

One fixed epsilon is not correct for every magnitude. Some programs use a relative tolerance, some require exact bit-level equality, and money often needs decimal arithmetic such as BigDecimal. Choose the comparison contract before choosing the code.

Text identity and text content

Literal pooling can make an identity comparison appear to compare text:

String a = "hi";
String b = "hi";
String c = new String("hi");

System.out.println(a == b);         // true
System.out.println(a == c);         // false
System.out.println(a.equals(c));    // true

Same three letters in all three variables. == says yes for one pair and no for the other.

The reason is that == on objects does not compare contents. It compares whether the two variables point at the same object.

Java interns matching String literals, so these two literal expressions designate one pooled object. That is why a == b is true. new String("hi") creates a distinct object, and runtime input commonly produces another distinct object. Compile-time concatenation can also be interned, so the construction syntax is not a reliable content test.

Scanner scanner = new Scanner(System.in);
String typed = scanner.nextLine();       // user types: admin

if (typed == "admin")        { }   // asks whether both references designate one object
if ("admin".equals(typed))   { }   // compares text and is safe when typed is null

Two later sections finish this story. Section 8.3 explains what equals() actually is and why you often have to write your own. Phase IX explains the String pool and why it exists.

The ternary, used well

String label = (count == 1) ? "item" : "items";

Use it to choose between two short value expressions. If either branch performs several actions, or nesting makes the types and grouping hard to read, use an if statement.

05

What it costs

Deep if nesting stops being readable fast. Four levels of indentation and nobody can hold the conditions in their head. The usual fix is to deal with the failure cases first and return early, so the main path stays flat:

if (user == null) return;
if (!user.isActive()) return;
// the real work, at one level of indentation

The constant-label switches in this lesson match one selector against fixed alternatives. Use if for ranges or unrelated boolean conditions. Modern Java also has pattern switch, which belongs after classes and inheritance.

Classic switch permits fallthrough, which can be useful for grouped behaviour but is easy to introduce accidentally. Arrow labels do not fall through.

== compares primitive values and reference identity. Identity is correct when you need to know whether two references designate one object. Value-like classes such as String normally need equals() for content.

06

Check yourself

Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.

  1. You write `if (a == b)` where both are `String` variables holding the text "hi". Sometimes it is true and sometimes it is false, with no obvious pattern. What is `==` actually comparing?

    Show the answer

    == is comparing whether they are the same object, not whether they hold the same text.

    Java keeps a pool of String literals. Write "hi" twice in your source and both point at one shared object, so == is true. Build one with new String("hi"), or read it from a file or the keyboard, and you get a separate object with identical contents, so == is false.

    A test built only from literals can therefore pass while a test using runtime input fails.

    Use .equals() for text content, with an explicit null policy. Section 8.3 explains what equals() really is, and Phase IX explains the pool.

  2. Why does `switch` refuse a `long`, when it happily accepts `byte`, `short`, `char` and `int`?

    Show the answer

    That is a rule of the Java language, not a portable conclusion about one mandatory machine layout. Classic switch selectors support char, byte, short, int, their wrappers, String and enum types. They do not support long, float, double or boolean.

    A compiler may emit a table switch, a lookup switch, or other code depending on the cases. Do not choose switch because you assume it is faster. Choose it when one selector is being matched against fixed alternatives.

  3. In the old style `switch`, what happens if you forget a `break`, and why was that ever considered a good idea?

    Show the answer

    Execution carries on into the next case and keeps going until it hits a break or the end. This is called fallthrough.

    It is a bug almost every time it happens by accident, and it is silent. No warning, just extra code running.

    It was kept because of one useful shape: stacking labels so several values share one block, like case 6: case 7: return "weekend";. That works because of fallthrough.

    Modern arrow switch (case 6, 7 -> ...) gives you that shape directly and prevents accidental fallthrough. Prefer it when fallthrough is not part of the design.

07

Exercises

Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.

4 exercises85 pointsabout 95 minutes

The Layers of Logic VS Code extension runs the checks for exercises marked checked. For a manual exercise, run the program and compare its behaviour with the stated requirements and sample output.
A

String Identity and Content

Warm up·20 min·15 points

checkedex-3-2-a

Reproduce the bug on purpose, so it never catches you by accident.

The first three lines already show the strange part: == says yes for one pair of identical strings and no for another. Run it and see.

Read admin from the keyboard and compare it with the literal using both operators. Explain why == can be false while equals is true by naming identity and content as different relations.

This is a bug that passes every test you write with hardcoded values, and fails the first time a real user types something.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Trap {
    static boolean sameObject(String a, String b)
    static boolean sameText(String a, String b)
    static boolean twoLiteralsAreOneObject()
    static boolean literalEqualsNew()
    static boolean builtAtRuntimeIsSameObject(String typed)
    static String safeCompare(String typed)
}

safeCompare returns "match" or "no match" and must not throw when given null. Put the literal on the left.

What your program must do

  • Show two literals passing an == comparison
  • Show a literal and a new String failing it
  • Show text built at run time also failing it
  • Write a comparison that works for all three and does not throw on null
Trap.java
public class Trap {

    static boolean sameObject(String a, String b) { return false; }  // TODO: ==
    static boolean sameText(String a, String b)   { return false; }  // TODO: equals

    // Two literals with the same characters. Same object?
    static boolean twoLiteralsAreOneObject() { return false; }  // TODO

    // A literal against new String("admin").
    static boolean literalEqualsNew() { return false; }  // TODO

    // Text built while the program runs, which is what a Scanner gives you.
    static boolean builtAtRuntimeIsSameObject(String typed) { return false; }  // TODO

    // "match" or "no match". Must not throw on null.
    static String safeCompare(String typed) { return ""; }  // TODO

    public static void main(String[] args) {
        // TODO: print all of the above
        // TODO: build a String at run time with a StringBuilder and try == on it
    }
}
Hint 1
Identical literals are pooled into one object, which is exactly why == looks correct in small examples and fails on real input.
Hint 2
new StringBuilder("adm").append("in").toString() gives you a String with the right characters and a different identity, which is what a Scanner produces.
Hint 3almost the answer
Put the literal on the left: "admin".equals(typed). That way a null input answers false instead of throwing, and you never need a null check.
What this is really testing

Whether you can reproduce the == bug deliberately. Seeing it work with a literal and fail with typed input is the moment this rule stops being something you were told.

B

Fallthrough, Found and Fixed

Real work·20 min·20 points

checkedex-3-2-b

There is exactly one missing break in this method. Find it by reading, not by running.

Then work out precisely which months are wrong and what they report instead. Write your prediction down, then run the program and check.

Fix it with a break. Then rewrite the entire method using the arrow form from this section, and say in one sentence why that form makes this bug impossible.

That last part is the real exercise. Knowing a modern feature is nice. Knowing which old bug it deletes is what makes you reach for it.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Fall {
    static String describeBroken(int month)
    static String describeFixed(int month)
    static String describeArrow(int month)
}

Keep describeBroken exactly as it is, missing break and all. describeArrow uses the arrow form of switch, which cannot fall through.

What your program must do

  • Find which months the broken version gets wrong, without changing it
  • Say how many of the twelve it gets right
  • Write the fixed version and the arrow version
  • Say why grouped case labels are not the same thing as fallthrough
Fall.java
public class Fall {

    // Leave this one broken. Find the missing break before you look at anything else.
    static String describeBroken(int month) {
        String season = "";
        switch (month) {
            case 12: case 1: case 2:  season = "winter"; break;
            case 3: case 4: case 5:   season = "spring";
            case 6: case 7: case 8:   season = "summer"; break;
            case 9: case 10: case 11: season = "autumn"; break;
            default: season = "not a month";
        }
        return season;
    }

    // The same thing, fixed.
    static String describeFixed(int month) { return ""; }  // TODO

    // The same again with the arrow form, which cannot fall through at all.
    static String describeArrow(int month) { return ""; }  // TODO

    public static void main(String[] args) {
        // TODO: print all three for months 1 to 13 and find where they disagree
    }
}
Hint 1
March, April and May fall through into summer, because the spring branch has no break. Nine of the twelve months are still correct.
Hint 2
The arrow form is case 12, 1, 2 -> "winter"; and it never falls through, so this bug cannot be written in it at all.
Hint 3almost the answer
Stacked labels with no code between them are not fallthrough. They are one branch with several ways in, and that is the one use of the behaviour worth keeping.
What this is really testing

Whether you can spot a missing break by reading, and whether you can rewrite an old switch in the arrow form. Both are things you will do in real codebases within your first month.

C

Comparing Decimals Safely

Real work·20 min·20 points

checkedex-3-2-c

Write a comparison for decimals that actually works, and then find out where your fix stops working too.

Start with the obvious version: take the difference, drop the sign, compare it against something tiny. Show it gets the 0.1 + 0.2 case right.

Then try it on very large numbers, and watch it behave strangely.

Working out why is the interesting part, and the answer is already in Section 2.2. It is about how far apart two neighbouring doubles are, and how that gap grows as the numbers get bigger.

There is no perfect answer here. That is worth knowing too. Comparing decimals is genuinely hard, and the honest response is to avoid needing to whenever you can.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Compare {
    static boolean closeEnough(double a, double b)
    static boolean closeRelative(double a, double b)
    static double addTenth()
    static boolean naiveEquals()
}

closeEnough uses a fixed tolerance. closeRelative scales the tolerance to the size of the numbers. Both must say a value equals itself, including zero.

What your program must do

  • Show that 0.1 + 0.2 does not equal 0.3
  • Write a fixed tolerance version and show it handles that case
  • Find where the fixed tolerance stops working
  • Write a relative version and say when you would use each
Compare.java
public class Compare {

    // A fixed tolerance.
    static boolean closeEnough(double a, double b) { return false; }  // TODO

    // A tolerance that scales with the size of the numbers.
    static boolean closeRelative(double a, double b) { return false; }  // TODO

    static double addTenth()      { return 0; }      // TODO: 0.1 + 0.2
    static boolean naiveEquals()  { return true; }   // TODO: 0.1 + 0.2 == 0.3

    public static void main(String[] args) {
        // TODO: print the naive comparison and the real value of 0.1 + 0.2
        // TODO: try closeEnough on 1e15 and 1e15 + 1. Predict first.
    }
}
Hint 1
A tolerance around 1e-9 is a reasonable fixed choice, and Math.abs(a - b) is the comparison.
Hint 2
At 1e15 the gap between one double and the next is larger than 1, so two neighbouring values are more than your tolerance apart. The tolerance did not change and the numbers did.
Hint 3almost the answer
The relative version divides the difference by the size of the numbers, so the allowance grows with them. Handle a == b first, or comparing zero with zero divides by zero.
What this is really testing

Whether you can write a comparison for decimals that actually works. This connects straight back to Section 2.2, and you should be able to explain the fix using what you learned there.

D

Registry Status Rules

Hard·35 min·30 points·The Registry

checkedex-3-2-d

Five written rules. Turn them into code a human can check against the rules without a debugger.

The naive version of this is four levels of nested if, and it will pass the tests. Do not write that version. Write the version where each refusal is its own line and the method stays flat.

Return a reason for every refusal, not just false. A method that says “no” without saying why is a method somebody will be cursing at 2am.

Then prove your structure was worth it: invent a sixth rule, add it, and see whether you had to rearrange anything. If you did, your structure was wrong. If you added one line, it was right.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Status {
    static String deploymentDecision(boolean isActive, double readiness, boolean onMedicalHold, int clearanceLevel, int supplies)
}

Return exactly "DEPLOY" when the unit can go. Otherwise return a string starting with "NO" that names the rule that stopped it, using the words active, supplies, medical or readiness.

What your program must do

  • Implement all five rules and give a specific reason for every refusal
  • Work out which rules are absolute blockers and check those first
  • Decide what happens at exactly 60.0 and exactly 40.0, and defend it
  • Try a unit that breaks several rules and say which reason you report
Status.java
public class Status {

    // Rules for whether a unit can be deployed:
    //   1. The unit must be active.
    //   2. Readiness must be at least 60.0
    //   3. A unit on medical hold cannot deploy, whatever its readiness.
    //   4. A unit with clearance level 5 can deploy at readiness 40.0 or more.
    //   5. A unit with zero supplies cannot deploy, no matter what.
    //
    // Return "DEPLOY", or a reason starting with "NO" that names the rule.

    static String deploymentDecision(
            boolean isActive, double readiness, boolean onMedicalHold,
            int clearanceLevel, int supplies) {
        return ""; // TODO
    }

    public static void main(String[] args) {
        System.out.println(deploymentDecision(true,  75.0, false, 3, 100));
        System.out.println(deploymentDecision(true,  45.0, false, 5, 100));
        System.out.println(deploymentDecision(true,  95.0, true,  5, 100));
        System.out.println(deploymentDecision(false, 95.0, false, 5, 100));
        System.out.println(deploymentDecision(true,  95.0, false, 5, 0));
    }
}
Hint 1
Three of the rules are absolute: not active, no supplies, medical hold. None of them care about readiness, so check them before you work out any threshold.
Hint 2
Rule 4 does not add a case, it changes a number. double required = clearance >= 5 ? 40.0 : 60.0; and then one comparison covers both.
Hint 3almost the answer
"At least 60" means 60 passes. Decide that deliberately and write it in a comment, because off by one at a boundary is the most common bug in rules like these.
What this is really testing

Whether you can turn a set of written rules into readable conditional code. Anyone can write nested ifs. Writing them so a human can check them against the rules is the actual skill.

08

After the credits

The String example introduced the difference between being the same object and having equal content. Later reference-type lessons develop that distinction.

In Section 4.1 you will work directly with reference values. Java keeps their physical representation opaque.

In Section 6.3 you will find out what that means when you pass an object to a method, and why copying one is harder than it looks.

In Section 8.3 you will write equals() yourself. The inherited implementation from Object uses identity semantics. A class that needs value equality must define it explicitly.

Section 8.4 uses an exhaustive switch expression over an enum, where the compiler can reject a missing case.

switch will return in 8.4 - Enums