Skip to content

VOL 01 / CH 07 / LESSON 06

7.6 Lab: Object Invariants and Common Pitfalls ​

Prerequisites: Chapter 7 Object Model, Construction, Encapsulation, and static

Estimated time: 40 minutes

Exercise environment: JDK 17+ and Bash; complete experiment written to a temporary directory

You are ready to continue when: You can define object invariants and failure behavior, test aliases, and complete the integer-cent account exercise

Check Four Common Object Problems ​

The first two excerpts use Book from the encapsulation lesson. Run each group separately so different class versions do not collide.

1. Assignment Creates Aliases, Not Object Copies ​

java
Book first = new Book("ISBN-DEMO-001", "Java Study Notes", 1000);
Book second = first;
second.rename("New version Java Core technology");
System.out.println(first.title());

Two variables point to the same object. If a copy is needed, define the copy semantics first, and then create it using copy constructors, static factories, or specialized mappers. Do not assume that all fields should be deeply copied.

2. Object Parameters Are Still Passed by Value ​

java
static void replace(Book book) {
    book = new Book("ISBN-DEMO-002", "Local Book", 1000);
}

Reassigning the parameter does not replace the caller's variable. Calling book.rename(...) through the parameter will modify the shared object.

3. Calling Overridable Methods from Constructors ​

java
class Parent {
    Parent() {
        printLength();
    }

    void printLength() {}
}

class Child extends Parent {
    private String value = "ready";

    @Override
    void printLength() {
        System.out.println(value.length());
    }
}

new Child() first enters the parent constructor, but dynamic dispatch calls the overridden version in the child class. At this point, value is still the default value null, so it throws NullPointerException. During construction, only call logic for private, final, static, or explicitly non-overridable methods, and keep them simple.

4. Getter Leaks Mutable Internal Objects ​

java
public java.util.List<String> tags() {
    return java.util.List.copyOf(tags);
}

Snapshots prevent callers from modifying the internal list structure. If the elements are mutable, also decide whether to copy the elements; if only a read-only view is chosen, also explain whether the view changes with the internal structure.

Warm-up: Student Invariants ​

Implement Student:

  • name cannot be null or blank after creation;
  • age is between 0 and 150;
  • score is finite and between 0 and 100;
  • Only the grade is allowed to be modified through updateScore;
  • The static counter only counts successfully constructed objects and notes that the example is not thread-safe.

Put the reference implementation in its own source file. Prepare the test directory:

bash
chapter7_lab_root=$(mktemp -d)
readonly chapter7_lab_root
mkdir -p "$chapter7_lab_root/out"
cd "$chapter7_lab_root"

Save as Student.java:

java
final class Student {
    private static int count;

    private final String name;
    private final int age;
    private double score;

    Student(String name, int age, double score) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("blank name");
        }
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException("invalid age");
        }
        this.name = name;
        this.age = age;
        updateScore(score);
        count++;
    }

    void updateScore(double score) {
        if (!Double.isFinite(score) || score < 0.0 || score > 100.0) {
            throw new IllegalArgumentException("invalid score");
        }
        this.score = score;
    }

    String name() { return name; }
    int age() { return age; }
    double score() { return score; }
    static int count() { return count; }
}

Test with blank names, age -1/151, grade NaN/Infinity/-1/101, and ensure that failed constructions do not increment the counter.

Challenge: BankAccount with Integer Cents ​

This text uses long to represent the smallest monetary unit, "cent," and does not handle currency conversion, rounding, interest, or concurrent transactions in the examples.

Save as BankAccount.java:

java
import java.util.Objects;

final class BankAccount {
    private final String accountNumber;
    private final String ownerName;
    private long balanceCents;

    BankAccount(String accountNumber,
                String ownerName,
                long initialCents) {
        this.accountNumber = requireText(accountNumber, "accountNumber");
        this.ownerName = requireText(ownerName, "ownerName");
        if (initialCents < 0) {
            throw new IllegalArgumentException("negative initial balance");
        }
        balanceCents = initialCents;
    }

    long balanceCents() {
        return balanceCents;
    }

    void deposit(long cents) {
        requirePositive(cents);
        balanceCents = Math.addExact(balanceCents, cents);
    }

    boolean withdraw(long cents) {
        requirePositive(cents);
        if (cents > balanceCents) {
            return false;
        }
        balanceCents -= cents;
        return true;
    }

    boolean transferTo(BankAccount target, long cents) {
        Objects.requireNonNull(target, "target");
        requirePositive(cents);
        if (target == this) {
            throw new IllegalArgumentException("same account");
        }
        if (cents > balanceCents) {
            return false;
        }

        long targetAfter = Math.addExact(target.balanceCents, cents);
        long sourceAfter = balanceCents - cents;
        target.balanceCents = targetAfter;
        balanceCents = sourceAfter;
        return true;
    }

    private static void requirePositive(long cents) {
        if (cents <= 0) {
            throw new IllegalArgumentException("amount must be positive");
        }
    }

    private static String requireText(String value, String name) {
        Objects.requireNonNull(value, name);
        if (value.isBlank()) {
            throw new IllegalArgumentException("blank " + name);
        }
        return value;
    }
}

Transfers first calculate the destination balance, confirm it does not overflow, and then write both objects; therefore, if the destination addition fails, the source account is not debited. This ensures the writing order within a single-threaded call, but it is not a concurrent transaction. Real transfers require locking order, persistent transactions, idempotency keys, and audit trails.

Put Boundaries into the Same Test Entry ​

Save this as OopLab.java. The () -> ... syntax passes an operation to a test helper. The next chapter explains lambdas and Runnable; for now, follow the contract that a given operation should throw a particular exception:

java
public class OopLab {
    public static void main(String[] args) {
        boolean assertionsEnabled = false;
        assert assertionsEnabled = true;
        if (!assertionsEnabled) {
            throw new IllegalStateException("run with -ea");
        }
        int countBeforeFailures = Student.count();
        expectIllegal(() -> new Student(" ", 20, 80));
        expectIllegal(() -> new Student("a developer", -1, 80));
        expectIllegal(() -> new Student("a developer", 151, 80));
        expectIllegal(() -> new Student("a developer", 20, Double.NaN));
        expectIllegal(() -> new Student("a developer", 20,
                Double.POSITIVE_INFINITY));
        expectIllegal(() -> new Student("a developer", 20, -1));
        expectIllegal(() -> new Student("a developer", 20, 101));
        assert Student.count() == countBeforeFailures;

        Student student = new Student("a developer", 20, 80);
        student.updateScore(92.5);
        assert student.score() == 92.5;
        assert Student.count() == countBeforeFailures + 1;

        BankAccount source = new BankAccount("A", "A", 10_000);
        BankAccount target = new BankAccount("B", "Bi", 2_000);
        source.deposit(500);
        assert source.withdraw(500);
        assert source.transferTo(target, 3_000);
        assert source.balanceCents() == 7_000;
        assert target.balanceCents() == 5_000;
        assert !source.withdraw(8_000);
        expectIllegal(() -> source.transferTo(source, 1));

        BankAccount overflowSource =
                new BankAccount("C", "Bing", 100);
        BankAccount overflowTarget =
                new BankAccount("D", "Ding", Long.MAX_VALUE);
        expectArithmetic(() ->
                overflowSource.transferTo(overflowTarget, 1));
        assert overflowSource.balanceCents() == 100;
        assert overflowTarget.balanceCents() == Long.MAX_VALUE;

        System.out.println("OOP lab passed");
    }

    private static void expectIllegal(Runnable action) {
        try {
            action.run();
            throw new AssertionError("expected IllegalArgumentException");
        } catch (IllegalArgumentException expected) {
            // This use case only cares whether the contract rejects input.
        }
    }

    private static void expectArithmetic(Runnable action) {
        try {
            action.run();
            throw new AssertionError("expected ArithmeticException");
        } catch (ArithmeticException expected) {
            // Overflow must occur before both accounts write.
        }
    }
}

Compile the three files and explicitly enable assertions:

bash
javac --release 17 -encoding UTF-8 -Xlint:all -d out \
  Student.java BankAccount.java OopLab.java
java -ea -cp out OopLab

Expected output is only OOP lab passed. These assertions belong to the test harness, so enable them with -ea. The entry point checks that they are enabled rather than reporting success after skipping them. Production validation remains inside the domain methods.

After saving the required experimental records, clean up:

bash
cd
ls -ld -- "$chapter7_lab_root"
rm -r -- "$chapter7_lab_root"

Before Moving On ​

Object diagrams must explain aliasing and copied parameter references; failed construction cannot increase Student.count. Account tests should at least cover deposits, insufficient balance, same account, target overflow, and normal transfers, and confirm that the source balance is not deducted before the target overflow occurs. This writing order is only valid within the current single-threaded call and cannot be considered as a database transaction or a guarantee of concurrency atomicity.

The next chapter continues to discuss inheritance, composition, and polymorphism.

→ Next: Inheritance, Composition, and Polymorphism

Built with VitePress | Software Systems Atlas