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
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
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
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
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:
namecannot be null or blank after creation;ageis between 0 and 150;scoreis 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:
chapter7_lab_root=$(mktemp -d)
readonly chapter7_lab_root
mkdir -p "$chapter7_lab_root/out"
cd "$chapter7_lab_root"Save as Student.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:
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:
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:
javac --release 17 -encoding UTF-8 -Xlint:all -d out \
Student.java BankAccount.java OopLab.java
java -ea -cp out OopLabExpected 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:
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.