CSSE2002 — Full Course Notes

Week 1

Course Overview and Java Basics

See csse2002 for staff, assessment, and course logistics — this note covers the technical content from Lecture 1 (the live lecture; there’s also a recorded lecture this week, see 2026-02-26-java-basics-part-02-collections-and-strings).

Today’s outline

  • Programming in the large vs. programming in the small — why this course isn’t just “a Java course”
  • Java vs Python: compiled vs interpreted, statically vs dynamically typed
  • Java’s primitive and reference types
  • Scope
  • Sequence, selection, and iteration in Java
  • Java arithmetic

Programming in the large

Code from an intro course (CSSE1001/ENGG1001) has likely been small, written solely by you, for a limited purpose, and to exist for a limited time — but large software projects are usually none of these. CSSE2002 teaches the individual discipline and programming practices needed to write software suitable for integration with large software systems: documenting, debugging, and testing code so that programming scales.

This is not just a Java course — Java is the vehicle, not the destination.

Java overview

Java is a general-purpose programming language that is:

  • Compiled
  • Statically typed
  • Object-oriented
  • Memory safe

Java vs Python: compiled vs interpreted

Python is interpreted: you run the Python interpreter directly on the source.

$ python hello_world.py

Java is compiled: the source is first compiled to bytecode, then run on the JVM.

$ javac HelloWorld.java
$ java HelloWorld

Homework (from the slides). What is the benefit of having bytecode? What are the differences between bytecode and machine code?

Java vs Python: static vs dynamic typing

Python is dynamically typed — whether an appropriate type is used is determined when the program runs. Java is statically typed — types are checked for every scenario at compile time. Static-typed languages require an explicit type declaration for every piece of data (variable, parameter, return value); dynamic languages instead infer (or guess) the type in use.

>>> x = 1
>>> print(x, type(x))
1 <class 'int'>
>>> x = 1.7
>>> print(x, type(x))
1.7 <class 'float'>
>>> x = "Hello"
>>> print(x, type(x))
Hello <class 'str'>

Python doesn’t restrict a variable to one type over its lifetime, even though it tracks each value’s own (runtime) type. Java is the opposite:

int x;
x = 1;      // OK
x = -5000;  // OK
x = 1.0;    // illegal: not a whole number
x = "15";   // illegal: cannot convert from a string to a number

Python also uses indentation to delimit code blocks, while the Java compiler uses {} to delimit blocks and ; to terminate statements.

Data types in Java

See java-primitive-and-reference-types for the full primitive-vs-reference-types table introduced in this lecture. In short: Java distinguishes primitive types (built-in, fixed representations like int and boolean) from reference types — everything else, i.e. classes (including String and arrays).

Scope

A variable’s scope is where it can be used — in Java, a variable’s scope ends at the end of the block in which it’s declared.

Question. What happens when this executes?

public static void main(String[] args) {
    if (5 > 4) {
        int z = 2;
    } else {
        int z = 3;
    }
    System.out.println(z);
}

This is a compile error: z is declared inside the if/else blocks, so it’s out of scope by the time System.out.println(z) runs.

Answer. Declare the variable inside the same (or a higher) block as where it’s used — e.g. declare z before the if/else (outer scope), or move the println inside each branch.

Control flow: sequence, selection, and iteration

Sequence

  • Each line must end with a semicolon (;).
  • Declarations must come before other assignments.
  • Instructions are executed sequentially.

Methods are Java’s equivalent of Python functions — but all code must live inside a class:

def add_two_numbers(num1, num2):
    result = num1 + num2
    return result
class Arithmetic {           // All code must be inside a class.
    int add_two_numbers(int num1, int num2) {
        int result = num1 + num2;
        return result;
    }
}

Calling a method looks the same as calling a Python function: add_two_numbers(10, 20).

Selection: if / switch statements

Java if/else and switch work like their Python counterparts, just with Java’s block/statement syntax ({}, ;, and case/break for switch rather than Python’s match).

Exercise. Write a method printGrade that takes a student’s mark and determines their grade:

Mark Grade
83.00 – 100.00 High Distinction
73.00 – 82.99 Distinction
63.00 – 72.99 Credit
50.00 – 62.99 Pass
0.00 – 49.99 Fail

Iteration: while and for loops

A while loop has an initialiser (set up a counter), a test/condition (check the counter), and an update (increment/decrement the counter):

int i = 0;
while (i < 10) {
    System.out.println(i);
    i++;
}
i = 0
while i < 10:
    print(i)
    i += 1

A for loop bundles all three parts together:

for (int i = 0; i < 10; i++) {
    System.out.println(i);
}
for i in range(10):
    print(i)

Exercise. A factorial of a number \(n\), written \(n!\), is the product of all numbers less than or equal to \(n\). Write a method factorial that calculates it.

Exercise. For all positive integers, \(n! = n \times (n-1)!\). Write an equivalent factorialRec method that uses recursion to calculate the result.

Java arithmetic

  • +, -, * work the same way as Python — except integer division: / between two ints in Java truncates towards zero (integer division), rather than always returning a float like Python’s /.
  • Comparison and logical operators are semantically identical to Python, just different syntax: ==, !=, <, >, <=, >= are the same spelling, but Java’s logical operators are &&, ||, ! where Python uses and, or, not.
  • Augmented assignment (+=, -=, *=, /=) and increment/decrement (++, --) work as in many C-like languages — increment/decrement only apply to primitive types.

Summary

  • The entry point for all Java programs is the main method: public static void main(String[] args).
  • Java is a compiled and statically typed language, compared to Python.
  • Most arithmetic operations in Java function identically to Python.
  • The same core principles of sequence, selection, and iteration apply to Java, but the syntax is different.

Reminders

  • Your first Ed Lessons exercise is due by 1pm next Wednesday.
  • Applied and practical classes start next week (Week 2).

Tasks this week:

Java Basics Part 02 - Collections and Strings

See csse2002 for staff, assessment, and course logistics. This is the recorded lecture flagged as a “Tasks this week” item in 2026-02-26-course-overview-and-java-basics — watch/read this alongside the live Lecture 1.

Today’s outline

  • Java’s memory model: stack vs heap
  • Arrays and their limitations
  • Strings: indexing, substrings, equality, immutability
  • The Stack, List, Set, and Map collections

Java’s memory model

Runtime memory splits into:

  • The stack — local variables and method parameters.
  • The heap — values with a dynamic size (objects and shared data).

Worked example: stack frames

public class StackHeap {
    public static void main(String[] args) {
        double hourlyRate = 54.0;
        int hoursWorked = 16;

        double salary = calculateSal(hourlyRate, hoursWorked);

        printSalary(salary);
    }
    public static double calculateSal(double hourlyRate, int hoursWorked) {
        return hourlyRate * hoursWorked;
    }
    public static void printSalary(double sal) {
        System.out.println("Salary: " + sal);
    }
}

Tracing the stack: main pushes a frame holding hourlyRate = 54.0 and hoursWorked = 16. Calling calculateSal pushes a new frame on top (with its own hourlyRate/hoursWorked parameters); it computes 864.0 and returns, popping its frame and storing the result in main’s salary. main then calls printSalary, which pushes a frame holding sal = 864.0, prints Salary: 864.0, and pops. Each method call gets its own frame, and frames are popped in the reverse order they were pushed (last in, first out).

Worked example: heap allocation

static float lastMark(int n) {
    float[] marks = new float[n];
    marks[0] = 75.5f;
    marks[n - 1] = 88.0f;
    return marks[n - 1];
}

Called as lastMark(5) from main: the array itself (float[5], initially [0.0, 0.0, 0.0, 0.0, 0.0]) is allocated on the heap. The stack frame for lastMark only holds a reference (marks) pointing at that heap object, plus the parameter n = 5. Assigning marks[0] = 75.5f and marks[n-1] = 88.0f mutates the heap array directly through the reference, giving [75.5, 0.0, 0.0, 0.0, 88.0].

Java never clears heap memory just because a method ends — it’s only reclaimed once no references to it exist and the garbage collector decides to run.

Arrays

Recall: an array is an ordered, fixed-length, mutable sequence of homogeneous items.

int[] numbers = {1, 2, 3, 4, 5};
numbers.length;        // 5
numbers[0] = 0;        // OK
numbers[0] = "zero";   // illegal -- all elements must be the same type (int)

Two ways to create an array:

// Approach 1: array literal
float[] marks = {60.3, 62, 70.1, 65.8, 80.3};

// Approach 2: allocate then assign each index
float[] marks = new float[5];
marks[0] = 60.3;
marks[1] = 62;
marks[2] = 70.1;
marks[3] = 65.8;
marks[4] = 80.3;

Querying: marks[3] and marks[0] are valid, but marks[marks.length] throws ArrayIndexOutOfBoundsException — valid indices are 0 to length - 1.

Iterating: an indexed for loop, or an enhanced for-each loop:

for (int i = 0; i < marks.length; i++) {
    System.out.println(marks[i]);
}
for (float mark : marks) {
    System.out.println(mark);
}

Try at home.

  • max(int[]) — returns the maximum value in an array of integers.
  • contains(int[], int) — returns true if the array contains the given integer.
  • reverse(int[]) — returns a new array with the elements in reverse order.

Limitations of arrays

Arrays have a fixed size at creation (you must know the space you need up-front), and don’t automatically close gaps when an element is removed from the middle. This motivates the built-in collections below.

Strings

A String is a sequence of characters:

String course = "Programming in the Large";
System.out.println(course.length());     // 24
System.out.println(course.charAt(0));     // 'P'
System.out.println(course.charAt(11));
System.out.println(course.charAt(23));    // 'e'

substring(start, end) returns a substring with an inclusive start index and an exclusive end index (if end is omitted, it defaults to the end of the string):

System.out.println(course.substring(0, 11));  // "Programming"

Equality and immutability

Equality means something different for primitive types vs reference types:

Primitive types Reference types
x = y make x store a copy of y’s value make x refer to the same object y refers to
x == y check if x stores the same value as y check if x refers to the same object as y
x != y check if x’s value differs from y’s check if x and y refer to different objects
String name  = "Jack";
String name2 = "Jack";
String name3 = new String("Jack");

System.out.println(name == name2);        // true  -- same pooled literal
System.out.println(name == name3);        // false -- different object

System.out.println(name.equals(name2));   // true
System.out.println(name.equals(name3));   // true  -- .equals() compares content

Object obj1 = new Object();
Object obj2 = new Object();
System.out.println(obj1.equals(obj2));    // false -- Object's default .equals() is identity

String objects are immutable. Reassigning name = "Jill" doesn’t mutate the original "Jack" object — it just repoints the name reference to a different (or newly pooled) string. String literals with the same value are shared via the string pool (name and name2 above both point at the same pooled "Jack"); new String("Jack") opts out of the pool and allocates a distinct object.

The Collections framework

See java-collections-framework for the full Stack/List/Set/Map interface reference — the rest of this section walks through the lecture’s worked traces. All of these live in java.util.*.

Stack

LIFO (Last In, First Out): empty(), peek(), pop(), push(obj).

letters.empty();       // true
letters.push("A");
letters.empty();       // false
letters.push("B");
letters.push("C");
letters.peek();        // "C"
letters.push("D");

letters.pop();         // "D"
letters.pop();         // "C"
letters.pop();         // "B"
letters.pop();         // "A"
letters.pop();         // EmptyStackException

Creating a stack (must import java.util.Stack):

Stack<Integer> stacks = new Stack<>();
Stack<String> stacks = new Stack<>();
Stack<Cat> stacks = new Stack<>();

Collections only store objects, so Stack<int> is illegal — Java provides a wrapper class for each primitive type (Boolean, Byte, Character, Double, Float, Integer, Long, Short; see java-primitive-and-reference-types).

Exercise. Implement int sum(Stack) that returns the sum of all integers in the given stack.

List

Lists hold items in sequential order like an array, but grow/shrink automatically, have no fixed size limit, support inserting/removing an item anywhere, and are indexed from zero.

List is an interface, not a particular implementation — you can declare a variable as List, but can’t do new List(). Popular implementations: ArrayList (better for random access) and LinkedList (better for modifying the middle of the list).

List<String> courses = new ArrayList<>();
courses.add("CSSE1001");          // [CSSE1001]
courses.add("DECO3801");          // [CSSE1001, DECO3801]
courses.get(0);                   // "CSSE1001"
courses.add(1, "CSSE2310");       // [CSSE1001, CSSE2310, DECO3801]
courses.add(1, "CSSE2002");       // [CSSE1001, CSSE2002, CSSE2310, DECO3801]
courses.remove(2);                // removes/returns "CSSE2310" -> [CSSE1001, CSSE2002, DECO3801]

Set

Sets store unique items (no duplicates); don’t assume any iteration order.

Set<String> farm = new HashSet<>();
farm.add("Fox");       // true
farm.add("Farmer");    // true
farm.add("Chicken");   // true
farm.add("Fox");       // false -- already present
farm.add("Grain");     // true
farm.size();           // 4

Implementations: TreeSet<E> (E must implement Comparable, e.g. String) and HashSet<E> (E must have sensible hashCode()/equals()).

Map

Maps store key → value pairs, like a Python dict.

Map<String, Integer> farm = new HashMap<>();
farm.put("Fox", 5);
farm.put("Farmer", 10);
farm.put("Chicken", 0);
farm.get("Farmer");                          // 10
farm.put("Fox", 18);                         // overwrites Fox's value
farm.put("Grain", 15);
farm.put("Grain", farm.get("Grain") + 5);    // Grain -> 20
// final map: {Fox: 18, Farmer: 10, Chicken: 0, Grain: 20}

Implementations: TreeMap<K,V> (K must implement Comparable) and HashMap<K,V> (K must have sensible hashCode()/equals()).

The hashCode/equals contract

For HashSet/HashMap to behave correctly, elements/keys need:

  1. x.equals(y) \(\iff\) y.equals(x) (symmetric).
  2. x.equals(y) \(\implies\) x.hashCode() == y.hashCode().

hashCode() returns an integer generated by a hashing algorithm for the object, e.g. "Thilina".hashCode()318621125.

Week 2

Object Oriented Programming I

See csse2002 for staff, assessment, and course logistics.

Today’s outline

  1. Objects, Classes, & Object-Oriented Programming
  2. Encapsulation
  3. Inheritance
  4. Abstraction
  5. Polymorphism (next week)

Why OOP?

A single bank account example, grown in stages, motivates the four pillars below.

Stage 1 — one global balance. A single static bankBalance shared by the whole “bank” — fine for one account, but there’s no way to represent more than one account or customer at once.

Stage 2 — parallel arrays of accounts. Adding more accounts naively means adding a parallel array for every piece of account data (accountNumbers, balances, …) plus a manually-maintained accountCount, and a findAccountIndex helper to keep them in sync by position.

Stage 3 — parallel arrays of account holders too. Adding a holder’s name/id alongside each account means yet more parallel arrays (holderIds, holderNames) and another manually-maintained linkedHolderIds array to associate accounts with holders — the bookkeeping to keep every array in sync by index grows with every new piece of data.

This is the motivation for OOP: instead of scattering related data across parallel arrays and free functions, bundle a variable and the functions that act on it together — e.g. instead of a free function f(x), a method that belongs to x. This bundling is exactly what a class does.

The four pillars of object orientation

  1. Encapsulation — see java-encapsulation
  2. Inheritance — see java-inheritance
  3. Abstraction — see java-abstraction
  4. Polymorphism — next week

Java Basics Practical - Palindromes

Practical exercises for week 2. The practical also introduces IntelliJ IDEA (the course’s supported IDE — see the install guide on Blackboard) and the Java 21 API docs (in particular the String class in java.lang, needed below) — neither has gradable content, so isn’t repeated here.

On the HTML site, fill in each blank with your answer (as a quoted string, e.g. "true"), then click Run Code to check it. In the PDF, the Working callout is shown as a static answer key instead (interactive checking needs a browser).

Question 1 — Palindromes

A palindrome reads the same forwards and backwards, e.g. "AaaA", "madamimadam", "racecar".

Implement four methods, all with the signature public static boolean isPalindromeN(String word) (for \(N=1..4\)), each using a different technique:

  1. isPalindrome1 — a for loop.
  2. isPalindrome2 — a while loop.
  3. isPalindrome3 — recursive, with no helper methods.
  4. isPalindrome4 — recursive, using a private helper method.

All four should agree on at least these cases. What does each return?

isPalindromeN("AaA")

isPalindromeN("A")

isPalindromeN("")

isPalindromeN("Abbb")

Task 0 — isPalindrome1 (for loop):

public static boolean isPalindrome1(String word) {
    int len = word.length();
    for (int i = 0; i < len / 2; i++) {
        if (word.charAt(i) != word.charAt(len - i - 1)) {
            return false;
        }
    }
    return true;
}

Task 1 — isPalindrome2 (while loop):

public static boolean isPalindrome2(String word) {
    int len = word.length();
    int i = 0;
    while (i < len / 2) {
        if (word.charAt(i) != word.charAt(len - i - 1)) {
            return false;
        }
        i++;
    }
    return true;
}

Task 2 — isPalindrome3 (recursive, no helper methods):

public static boolean isPalindrome3(String word) {
    if (word.length() < 2) {
        return true; // base case
    }
    if (word.charAt(0) != word.charAt(word.length() - 1)) {
        return false; // base case
    }
    return isPalindrome3(word.substring(1, word.length() - 1)); // recursive step
}

Task 3 — isPalindrome4 (recursive, with a private helper):

public static boolean isPalindrome4(String word) {
    return helper(word, 0);
}

private static boolean helper(String word, int i) {
    if (i >= word.length() / 2) {
        return true; // base case
    }
    if (word.charAt(i) != word.charAt(word.length() - i - 1)) {
        return false; // base case
    }
    return helper(word, i + 1); // recursive step
}

All four agree on the required test cases: "AaA"true, "A"true, ""true, "Abbb"false.

Code review

The practical’s second half is a paired activity, not a gradable exercise: swap solutions with a partner, and for each other’s code —

  • Understand it, and ask questions about anything unclear.
  • Check correctness against the test cases above (and a few of your own).
  • Check it follows the course style guide.
  • Discuss/suggest improvements if you find issues.

Then discuss which of the four isPalindromeN techniques you each preferred, and why.

Java Basics Applied Class

Applied class exercises for week 2, reviewing week 1’s 2026-02-26-course-overview-and-java-basics content (switch statements, arrays, recursion). Meant to be done without a computer — you’re welcome to look up Java/library information, but your group knowing the answer isn’t a substitute for individual competence.

On the HTML site, fill in each blank with your answer (as a quoted string, e.g. "46368"), then click Run Code to check it. In the PDF, the Working callout is shown as a static answer key instead (interactive checking needs a browser).

Question 1 — Numbers to Numbers

Implement a method, nameOf, with the signature public static String nameOf(int value). If 0 < value < 10, return the name of the number, otherwise return "??".

What does each call return?

nameOf(4)

nameOf(7)

nameOf(24)

Task 0 — using switch:

public static String nameOf(int value) {
    return switch (value) {
        case 1 -> "One";
        case 2 -> "Two";
        case 3 -> "Three";
        case 4 -> "Four";
        case 5 -> "Five";
        case 6 -> "Six";
        case 7 -> "Seven";
        case 8 -> "Eight";
        case 9 -> "Nine";
        default -> "??";
    };
}

The modern -> switch expression syntax is preferred over a traditional switch statement because it avoids one case accidentally falling through into the next if a break is omitted.

Task 1 — using an array of strings (with an early-exit variant):

public static String nameOf(int value) {
    if (value <= 0 || value >= 10) {
        return "??";
    }
    String[] numbers = {"One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine"};
    return numbers[value - 1];
}

Bonus — extending to 10-99, using tiered helpers:

/** Requires: value > 0 && value < 10 **/
private static String ones(int value) {
    String[] numbers = {"One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine"};
    return numbers[value - 1];
}

/** Requires: value >= 10 && value < 20 **/
private static String teens(int value) {
    String[] numbers = {"Ten", "Eleven", /* ... */};
    return numbers[value - 10];
}

/** Requires: value >= 20 && value < 100 **/
private static String tens(int value) {
    String[] numbers = {"Twenty", "Thirty", /* ... */};
    return numbers[value / 10 - 2];
}

public static String nameOf(int value) {
    if (value > 0 && value < 10) {
        return ones(value);
    }
    if (value >= 10 && value < 20) {
        return teens(value);
    }
    if (value >= 20 && value < 100) {
        if (value % 10 == 0) {
            return tens(value);
        } else {
            return tens(value) + " " + ones(value % 10);
        }
    }
    return "??";
}

Question 2 — Fibonacci Sequence

The Fibonacci sequence (\(0, 1, 1, 2, 3, \ldots\)) is defined as \(\mathcal{F}(0)=0\), \(\mathcal{F}(1)=1\), \(\mathcal{F}(n) = \mathcal{F}(n-1) + \mathcal{F}(n-2)\) for \(n \geq 2\): 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, ...

Implement public static int fib(int n), returning the \(n\)th element. What is fib(24)?

public static int fib(int n) {
    if (n <= 1) {
        return n;
    }
    return fib(n - 1) + fib(n - 2);
}

Desk check (manual, pen-and-paper trace of the recursive calls) for fib(6):

graph RL
    F6["F(6)"] -->|5| F5["F(5)"]
    F6 -->|3| F4a["F(4)"]
    F5 -->|3| F4b["F(4)"]
    F5 -->|2| F3a["F(3)"]
    F4a -->|2| F3b["F(3)"]
    F4a -->|1| F2a["F(2)"]
    F4b -->|2| F3c["F(3)"]
    F4b -->|1| F2b["F(2)"]
    F3a -->|1| F2c["F(2)"]
    F3a -->|1| F1a["F(1)"]
    F3b -->|1| F2d["F(2)"]
    F3b -->|1| F1b["F(1)"]
    F3c -->|1| F2e["F(2)"]
    F3c -->|1| F1c["F(1)"]
    F2a -->|1| F1d["F(1)"]
    F2a -->|0| F0a["F(0)"]
    F2b -->|1| F1e["F(1)"]
    F2b -->|0| F0b["F(0)"]
    F2c -->|1| F1f["F(1)"]
    F2c -->|0| F0c["F(0)"]
    F2d -->|1| F1g["F(1)"]
    F2d -->|0| F0d["F(0)"]
    F2e -->|1| F1h["F(1)"]
    F2e -->|0| F0e["F(0)"]

Question 3 — The Collatz Conjecture

Start with any positive integer \(n\): if even, divide by 2; if odd, multiply by 3 and add 1; repeat. The (unproven, but checked below \(2075 \times 2^{60}\) as of 2025) conjecture is that this always reaches 1.

Implement public static int collatz(int n), returning the number of steps to reach 1. What is collatz(3)?

public static int collatz(int n) {
    int steps = 0;
    while (n != 1) {
        if (n % 2 == 0) {
            n = n / 2;
        } else {
            n = 3 * n + 1;
        }
        steps++;
    }
    return steps;
}

Trace for collatz(3): 3 → 10 → 5 → 16 → 8 → 4 → 2 → 1, which is 7 steps.

Question 4 — Ackermann function (extra)

The Ackermann–Péter function: \(\mathcal{A}(m,n) = n+1\) if \(m=0\); \(\mathcal{A}(m-1, 1)\) if \(m>0, n=0\); \(\mathcal{A}(m-1, \mathcal{A}(m, n-1))\) if \(m>0, n>0\).

Implement public long ackermann(short m, short n), and explain why it takes short parameters but returns a long.

public long ackermann(short m, short n) {
    if (m == 0) {
        return n + 1;
    }
    if (m > 0 && n == 0) {
        return ackermann((short) (m - 1), (short) 1);
    }
    if (m > 0 && n > 0) {
        return ackermann((short) (m - 1),
                          (short) ackermann(m, (short) (n - 1)));
    }
    return -1; // Impossible case but required for coverage.
}

Note the explicit (short) casts on every recursive call — Java’s arithmetic (m - 1, n - 1) implicitly promotes short operands to int, so without the casts this wouldn’t compile against a short-typed parameter.

Why short in, long out? The Ackermann function grows extremely rapidly even for small inputs, so the parameters are restricted to short (deliberately small inputs) while the result is a long, so the (much larger) output can be represented without overflow.

Week 3

Object Oriented Programming II

See csse2002 for course logistics. Continues 2026-03-05-object-oriented-programming-i.

Today’s outline

  1. Object Oriented Programming II
  2. Specification

Recap: the four (five) pillars

Last week covered Objects/Classes/OOP, Encapsulation, Inheritance, and Abstraction. This week adds Polymorphism, plus revisits/extends a few Week 2 topics in more depth.

Interface vs abstract class

Week 2 left the “when to use which” comparison as homework — now covered properly in java-abstraction.

Revising encapsulation

A quick recap of java-encapsulation’s Person example — bundling fields with the methods that access them, restricting direct access via private + getters.

Mutable vs immutable classes

See java-mutability — object state that can (mutable) or cannot (immutable) change after construction, and the arrays/lists gotcha where final only protects the reference, not the contents.

Polymorphism

See java-polymorphism — compile-time (overloading), runtime (overriding/dynamic dispatch), and subtype polymorphism.

Casting

See java-casting — primitive widening/narrowing casts, and reference upcasting/downcasting.

Specification

See java-specification — Javadoc, restrictiveness, generality, clarity, formality, contracts, and defensive programming.

Java Classes - Point Line Polynomial

Practical exercises for week 3, building up a small geometry class hierarchy (PointLinePolynomial) to practice java-encapsulation and java-mutability (note that movePoint/flipPoint/scale-style methods return a new object rather than mutating this).

On the HTML site, fill in each blank with your answer (as a quoted string, e.g. "5.0"), then click Run Code to check it. In the PDF, the Working callout is shown as a static answer key instead (interactive checking needs a browser).

Question 1 — Points

Write a Point class representing an x,y-coordinate on a Cartesian plane, with:

  • Point(float x, float y) and a default Point() (both 0).
  • getX()/getY().
  • Point movePoint(float deltaX, float deltaY) — returns a new Point at (getX() + deltaX, getY() + deltaY), without modifying this.
  • static double distance(Point p, Point q) — Euclidean distance between two points.

If p1 = new Point() and p2 = p1.movePoint(4, 5), what does p2 print (assuming a toString of "(" + x + ", " + y + ")")?

public class Point {
    private float x;
    private float y;

    public Point() {
        this(0, 0);
    }

    public Point(float x, float y) {
        this.x = x;
        this.y = y;
    }

    public float getX() { return this.x; }
    public float getY() { return this.y; }

    public Point movePoint(float deltaX, float deltaY) {
        return new Point(this.x + deltaX, this.y + deltaY);
    }

    public static double distance(Point p, Point q) {
        double x = p.x - q.x;
        double y = p.y - q.y;
        return Math.sqrt(x * x + y * y);
    }

    @Override
    public String toString() {
        return "(" + x + ", " + y + ")";
    }
}

movePoint deliberately returns a new Point rather than mutating thisp1 is left unchanged at (0.0, 0.0).

Question 2 — Lines

Write a Line class storing a start Point, an end Point, and the (precomputed) double length between them, with:

  • Line(Point start, Point end) and a default Line() (both ends at the origin, length 0).
  • getStart()/getEnd()/getLength().
  • void moveStart(float deltaX, float deltaY) / void moveEnd(float deltaX, float deltaY)mutate the line’s start/end in place and recompute the length.

For a Line from (0, 0) to (3, 4), what is getLength()?

public class Line {
    private Point start;
    private Point end;
    private double length;

    public Line() {
        this.start = new Point();
        this.end = new Point();
        this.length = 0;
    }

    public Line(Point start, Point end) {
        this.start = start;
        this.end = end;
        this.length = Point.distance(start, end);
    }

    public Point getStart() { return start; }
    public Point getEnd() { return end; }
    public double getLength() { return length; }

    public void moveStart(float deltaX, float deltaY) {
        start = start.movePoint(deltaX, deltaY);
        length = Point.distance(start, end);
    }

    public void moveEnd(float deltaX, float deltaY) {
        end = end.movePoint(deltaX, deltaY);
        length = Point.distance(start, end);
    }

    @Override
    public String toString() {
        return start.toString() + " " + end.toString() + " " + this.getLength();
    }
}

(0,0) to (3,4) is a 3-4-5 right triangle, so the length is exactly 5.0 — unlike Point, moveStart/moveEnd mutate the Line in place (a deliberately different, mutable design from Point’s immutable movePoint).

Question 3 — Testing and toString

Unless overridden, printing an object prints its class name and memory location, e.g. Point@30f39991. Overriding toString() (as used above) replaces this with something readable — always worth adding when testing a class by eye.

Writing a quick main to sanity-check as you go (rather than writing a large class fully before ever running it) catches mistakes early:

Point p1 = new Point();
Point p2 = p1.movePoint(4, 5);
System.out.println(p1); // (0.0, 0.0)
System.out.println(p2); // (4.0, 5.0)

Question 4 — Additional methods

Add to Point:

  • Line createLine(Point end) — a Line from this to end.
  • Point flipPoint() — negates both coordinates, e.g. (-1, 2) becomes (1, -2).

Add to Line:

  • Point middle() — the midpoint of the start and end.
  • Line flipLine() — a Line with both endpoints flipped.

What does new Point(-1, 2).flipPoint() print?

public Line createLine(Point end) {
    return new Line(this, end);
}

public Point flipPoint() {
    return new Point(x * -1, y * -1);
}

public Point middle() {
    return new Point((start.getX() + end.getX()) / 2, (start.getY() + end.getY()) / 2);
}

public Line flipLine() {
    return new Line(start.flipPoint(), end.flipPoint());
}

Question 5 — Polynomials (bonus)

A polynomial function of the restricted form \(f(x) = a + bx + cx^2\) can be represented by its coefficients. Write a Polynomial class with constructors Polynomial(), Polynomial(float a), Polynomial(float a, float b), Polynomial(float a, float b, float c), plus:

  • float valueAt(float x) — evaluates the polynomial at x.
  • Polynomial add(Polynomial other) — adds two polynomials coefficient-wise, returning a new Polynomial.

For \(f(x) = 1 + 2x\), what is \(f(3)\)?

public class Polynomial {
    private final float[] coefficients;

    public Polynomial() { coefficients = new float[]{0}; }
    public Polynomial(float a) { coefficients = new float[]{a}; }
    public Polynomial(float a, float b) { coefficients = new float[]{a, b}; }
    public Polynomial(float a, float b, float c) { coefficients = new float[]{a, b, c}; }
    public Polynomial(float[] coefficients) { this.coefficients = coefficients; }

    private static float index(float[] coefficients, int index) {
        // Coefficients beyond the polynomial's degree are implicitly 0.
        if (index >= coefficients.length) {
            return 0;
        }
        return coefficients[index];
    }

    public Polynomial add(Polynomial other) {
        int max = Math.max(coefficients.length, other.coefficients.length);
        float[] newCoefficients = new float[max];
        for (int i = 0; i < max; i++) {
            newCoefficients[i] = index(coefficients, i) + index(other.coefficients, i);
        }
        return new Polynomial(newCoefficients);
    }

    public float valueAt(float x) {
        float v = 0;
        float xpower = 1;
        for (int i = 0; i < coefficients.length; i++) {
            v += coefficients[i] * xpower; // xpower == x^i
            xpower *= x;
        }
        return v;
    }
}

\(f(3) = 1 + 2 \times 3 = 7\).

Challenge — representing the unrestricted polynomial \(f(x) = a_0 + a_1x + \dots + a_nx^n\) for any \(n \geq 0\) (rather than fixing the degree at 2) just needs the internal representation generalised to a float[] coefficients array of arbitrary length, as already used by add’s newCoefficients array and the extra Polynomial(float[]) constructor above — valueAt and add already work unchanged for any length.

Stack Heap and Class Design

Applied class exercises for week 3, covering 2026-03-12-object-oriented-programming-ii and reinforcing java-mutability (why reference types behave differently from primitives when passed to methods).

On the HTML site, fill in each blank with your answer (as a quoted string, e.g. "1"), then click Run Code to check it. In the PDF, the Working callout is shown as a static answer key instead (interactive checking needs a browser).

Question 1 — Tracing the call stack and heap

Understanding the runtime call stack and object heap clarifies unexpected behaviour of reference types. Consider:

class Counter {
    private int number;
    public Counter() { this.reset(); }
    public void increment(int amount) { this.number += amount; }
    public void increment() { this.number++; }
    public void reset() { this.number = 0; }
    public int getValue() { return this.number; }
}

class DebugMe {
    static void f(Counter counter, int param) {
        counter.increment();
        param++;
    }

    public static void main(String[] args) {
        Counter first = new Counter();
        int second = 0;
        f(first, second);
        System.out.println(first.getValue());
        System.out.println(second);
    }
}

What does main print (first line, then second line)?

First line →

Second line →

State of the call stack/heap just before f finishes executing:

main
args {}
first Counter{number: 1}
second 0
f
counter (same Counter object as first)
param 1

first and counter are two references to the same Counter object on the heap, so counter.increment() inside f is visible through first after f returns — first.getValue() prints 1. But param is a int, passed by value: param++ only changes f’s local copy, not main’s second — so second still prints 0. This is why mutating an object through a reference parameter is visible to the caller, but reassigning/incrementing a primitive parameter is not.

Question 2 — Designing a Vertex class

The Triangle class below represents a triangle as a 2D array of coordinates (vertexes[0] = x-coordinates, vertexes[1] = y-coordinates) — leading to a fiddly implementation:

public class Triangle {
    private double[][] vertexes;

    public Triangle(double a, double b, double c) {
        double dividend = (square(a) - square(b) - square(c));
        double cx = dividend / (-2 * c);
        double cy = Math.sqrt(square(b) - square(cx));
        this.vertexes = new double[][]{
            new double[]{0, c, cx},
            new double[]{0, 0, cy}
        };
    }

    public double perimeter() {
        double x0 = vertexes[0][0], x1 = vertexes[0][1], x2 = vertexes[0][2];
        double y0 = vertexes[1][0], y1 = vertexes[1][1], y2 = vertexes[1][2];
        return distance(x0, y0, x1, y1) + distance(x1, y1, x2, y2) + distance(x2, y2, x0, y0);
    }

    public Triangle scale(double multiplier) {
        double[][] scaledUp = new double[2][3];
        for (int x = 0; x < 2; x++) {
            for (int y = 0; y < 3; y++) {
                scaledUp[x][y] = vertexes[x][y] * multiplier;
            }
        }
        return new Triangle(scaledUp);
    }
}

Design a Vertex class (signatures only, no implementation needed) that could simplify this — a triangle is really a collection of three vertices, each with an x and y coordinate.

class Vertex {
    Vertex(); // Construct a new vertex at (0, 0)
    Vertex(double, double);
    double distance(Vertex); // distance to another vertex
    Vertex scale(double); // multiply the coordinates by the given amount
}

Rewriting Triangle to use three Vertex fields instead of a raw 2D array:

public class Triangle {
    private Vertex a;
    private Vertex b;
    private Vertex c;

    public Triangle(double a, double b, double c) {
        double dividend = (square(a) - square(b) - square(c));
        double cx = dividend / (-2 * c);
        double cy = Math.sqrt(square(b) - square(cx));
        this.a = new Vertex();
        this.b = new Vertex(c, 0);
        this.c = new Vertex(cx, cy);
    }

    public Triangle(Vertex a, Vertex b, Vertex c) {
        this.a = a;
        this.b = b;
        this.c = c;
    }

    public double perimeter() {
        return a.distance(b) + b.distance(c) + c.distance(a);
    }

    public Triangle scale(double multiplier) {
        return new Triangle(a.scale(multiplier), b.scale(multiplier), c.scale(multiplier));
    }
}

Introducing Vertex moves the x/y bookkeeping into a single-responsibility class, so Triangle’s own methods read as “distance between vertices” and “scale each vertex” rather than juggling raw array indices.

Question 3 — Recursive call stack (bonus)

class Reverser {
    static void reverse(int[] arr) {
        int n = arr.length;
        if (n == 1) {
            return;
        }
        int[] rest = new int[n - 1];
        for (int i = 1; i < n; i++) {
            rest[i - 1] = arr[i];
        }
        arr[n - 1] = arr[0];
        reverse(rest);
    }

    public static void main(String[] args) {
        int[] numbers = {42, 24, 11};
        reverse(numbers);
    }
}

Sketch the state of the call stack and heap just prior to the third call to reverse.

main
args {}
numbers {42, 24, 42}
reverse (1st call)
arr {42, 24, 42}
n 3
rest {24, 24}
reverse (2nd call)
arr {24, 24}
n 2
rest {11}

Each recursive call copies everything except the first element into a new, smaller array (rest), and overwrites the last slot of the current array with its own first element — so by the time the base case (n == 1) is reached, the original array has been rebuilt back-to-front in place.

Week 4

Exceptions

See csse2002 for course logistics.

Today’s outline

  1. Exceptions
  2. Specification (recap)

Exceptions

See java-exceptions — the exception hierarchy, checked vs unchecked exceptions, try/catch/throw/throws/finally, and custom exceptions.

Specification

This part of the lecture repeats Week 3’s java-specification material near-verbatim (Javadoc, restrictiveness, generality, clarity, formality, contracts, defensive programming) — see that note rather than duplicating it here. Two small additions from this week’s repeat have been folded into it: an early-return variant of the search generality example, and an added Effective Java citation for defensive programming/doc comments.

Method Dispatch and Casting

Applied class exercises for week 4, covering java-polymorphism (dynamic method dispatch) and java-casting in more depth.

On the HTML site, fill in each blank with your answer (as a quoted string, e.g. "Car.topSpeed()"), then click Run Code to check it. In the PDF, the Working callout is shown as a static answer key instead (interactive checking needs a browser).

Question 1 — Apparent type vs actual type

Every object has an apparent type (compile-time type, from variable/method declarations) and an actual type (runtime type, fixed by whichever class’s new constructed it). The compiler always ensures the apparent type is the actual type or one of its superclasses, decides whether a call is legal using the apparent type, but dispatches the call to the method as implemented by the actual type.

class Animal { void eat() {...} }
class Lion extends Animal {}
class Dog extends Animal implements Pet {
    void eat() {...}
    void eat(int howMuch) {...}
}
class Poodle extends Dog {}
interface Pet {}

What happens when you call simba.eat(200), given Lion simba = new Lion();?

Lion’s apparent AND actual type is Lion, which has no eat(int) method (only the no-arg eat() inherited from Animal) — the compiler rejects the call outright, since legality is checked against the apparent type’s available methods.

Full worked example, one line at a time:

Statement Outcome
Dog evie = new Dog(); OK
Animal animal = evie; OK (upcast, implicit)
Lion mufasa = animal; Must be cast. Will fail (an Animal referencing a Dog is never a Lion).
Dog harlee = animal; Must be cast. Will succeed (the actual object really is a Dog).
Poodle bruiser = evie; Must be cast. Will fail (evie’s actual type is Dog, not Poodle).
Lion simba = new Lion(); OK
Lion scar = new Poodle(); Will not compile — Poodle and Lion share no inheritance relationship.
Pet charley = new Poodle(); OK (Poodle extends Dog, which implements Pet).
Dog lassie = charley; Must be cast. Will succeed.
Animal scooby = charley; Will not compile — Pet is not related to Animal (an interface doesn’t know about unrelated classes).
simba.eat(); OK
simba.eat(200); No method found — compile error.

Question 2 — A larger hierarchy

class Location {...}
interface RoadLegal { int topSpeed(); boolean licenceRequired(); }
interface PublicTransport { double calculateFare(Location start, Location end); }

class Vehicle {
    int travelTime(Location start, Location end) {...}
    int topSpeed() {...}
}
class HumanPowered extends Vehicle {
    int getWeight() {...}
    int topSpeed() {...}
}
class Motorised extends Vehicle implements RoadLegal {
    int travelTime(Location start, Location end) {...}
    int topSpeed() {...}
    boolean licenceRequired() {...}
}
class Skateboard extends HumanPowered {}
class Bicycle extends HumanPowered implements RoadLegal {
    boolean licenceRequired() {...}
}
class Motorbike extends Motorised {
    int topSpeed() {...}
    int travelTime(Location location) {...} // overloaded, single-Location version
}
class Car extends Motorised { int topSpeed() {...} }
class Taxi extends Car {}
class Bus extends Motorised implements PublicTransport {
    int topSpeed() {...}
    double calculateFare(Location start, Location end) {...}
}
classDiagram
    Vehicle <|-- HumanPowered
    Vehicle <|-- Motorised
    HumanPowered <|-- Skateboard
    HumanPowered <|-- Bicycle
    RoadLegal <|.. Bicycle
    Motorised <|-- Motorbike
    Motorised <|-- Car
    Motorised <|.. RoadLegal
    Car <|-- Taxi
    Motorised <|-- Bus
    Bus <|.. PublicTransport

For each, what method implementation is actually called?

Vehicle vehicle = new Taxi(); vehicle.topSpeed();

Vehicle vehicle = new Bicycle(); vehicle.topSpeed();

RoadLegal roadLegal = new Bicycle(); roadLegal.topSpeed();

Vehicle vehicle = new Motorbike(); vehicle.travelTime(location1, location2);

Even though roadLegal’s apparent type is the RoadLegal interface, dispatch always goes to the actual type’s implementation — Bicycle doesn’t override topSpeed() itself, so it’s inherited from HumanPowered. Similarly Motorbike doesn’t override the two-Location travelTime, only the single-Location overload, so the two-argument call dispatches to Motorised’s version.

Some other statements to check yourself against — whether each compiles, and why:

  • Vehicle bus = new Bus(); — compiles (upcast).
  • Motorised skateboard = new Skateboard(); — does not compile: no relationship between Motorised and Skateboard.
  • Motorbike vehicle = new Vehicle(); — does not compile: downcast needs an explicit cast.
  • Motorised motorbike = new Motorbike(); motorbike.travelTime(destination); — does not compile: neither Motorised nor its parents declare a single-argument travelTime(Location).
  • Car car = new Taxi(); Taxi taxi = car; — does not compile: downcast needs an explicit cast.
  • Vehicle bike = new Bicycle(); bike.getWeight(); — does not compile: Vehicle has no getWeight() method (it’s declared on HumanPowered).
  • Motorised bus = new Bus(); PublicTransport trip = bus; — does not compile: Motorised can’t be implicitly cast to the unrelated PublicTransport interface.

Question 3 — Casting rules

When changing a value’s apparent type:

  1. Casting to a superclass of the current apparent type can be implicit.
  2. Casting to a subclass should be checked via instanceof (a runtime check).
  3. If the two types share no inheritance relationship, Java refuses to compile at all.

For each instanceof pattern-match below (assuming each variable holds an instance of its own class, e.g. bus is a Bus), does it compile, and if so, could the match be guaranteed true at compile time ("implicit")?

Expression Compiles? Guaranteed true?
bus instanceof Vehicle vehicle Yes Yes — Vehicle is a superclass of Bus
car instanceof Taxi taxi Yes No — Taxi is a subclass of Car, needs a runtime check
motorised instanceof Bicycle bike No Motorised and Bicycle share no relationship (siblings under Vehicle)
motorbike instanceof RoadLegal legal Yes Yes — Motorbike extends Motorised, which implements RoadLegal
taxi instanceof Vehicle vehicle Yes Yes — Vehicle is a superclass all the way up the TaxiCarMotorisedVehicle chain

Where would an electric scooter fit into this hierarchy? (Discussion question — not every real-world model fits neatly into single inheritance.)

Week 5

Testing

See csse2002 for course logistics.

Today’s outline

  1. Levels of Testing
  2. Test Frameworks
  3. Black Box Testing
  4. White Box Testing
  5. Test Driven Development (TDD)

Testing

See java-testing — levels of testing, regression testing, black-box vs white-box testing (smoke tests, boundary tests, equivalence classes, code coverage), and TDD.

JUnit

See java-junit@Test, @Before/@After, Assert methods, and testing for expected exceptions.

Debugging

Practical for week 5: using the IntelliJ debugger to find and fix bugs in a small provided codebase of chat bots (ChatBot implementations) — reinforcing java-junit (the provided tests are what reveal each bug) and java-exceptions (RecursiveFibonacci’s bug manifests as a StackOverflowError).

This practical is hands-on IntelliJ-debugger work rather than a computable Q&A, so there are no interactive checks here — just each bug and its fix, as a static reference/answer key.

Setup: a provided .zip (from Blackboard) contains ChatBot.java (the shared interface) plus several bot implementations and their JUnit tests, to be placed under a debugging/ package in src//test/ source roots. Running the tests initially shows 37 tests total, 24 failing. Ground rule: no modifying code (implementation or tests) until the bug is actually found via the debugger — no debug-print-statement shortcuts.

public interface ChatBot {
    String replyTo(String username, String message);
}

Bug 1 — MockBot

MockBot should reply to any message with the message repeated in alternating caps — letters up to and including 'O' lowercase, letters after 'O' uppercase:

private String mock(String message) {
    StringBuilder result = new StringBuilder();
    for (char letter : message.toCharArray()) {
        if (letter > 'O') {
            result.append(Character.toUpperCase(letter));
        } else {
            result.append(Character.toLowerCase(letter));
        }
    }
    return result.toString();
}

The bug is that the original message’s case is never normalised first — letter > 'O' compares the character’s existing case-sensitive code point directly, so any already-lowercase letter after 'o' (lowercase) in the alphabet compares incorrectly against the uppercase 'O' boundary. Fixing it means normalising the message to uppercase before iterating, so the comparison is always against consistent-case letters:

private String mock(String message) {
    StringBuilder result = new StringBuilder();
    for (char letter : message.toUpperCase().toCharArray()) {
        if (letter > 'O') {
            result.append(Character.toUpperCase(letter));
        } else {
            result.append(Character.toLowerCase(letter));
        }
    }
    return result.toString();
}

Bug 2 & 3 — GuessingGameBot

A bot that replies “yes”/“no” to guess higher <n> / guess lower <n> against a secret number, and “you win!”/“you lose!” for guess equal <n>. Two independent bugs:

Bug 2 — message splitting. split(String) breaks a message into words on spaces:

protected static List<String> split(String input) {
    List<String> output = new ArrayList<>();
    int index = 0, newIndex;
    while (index != -1) {
        newIndex = input.indexOf(' ', index + 1);
        if (newIndex == -1) {
            output.add(input.substring(index));
        } else {
            output.add(input.substring(index, newIndex));
        }
        index = newIndex;
    }
    return output;
}

Bug 3 — comparison logic. The higher/lower cases compare the wrong way:

case "higher" -> {
    int guess = Integer.parseInt(words.get(2));
    return guess > SECRET_NUMBER ? "yes" : "no";
}
case "lower" -> {
    int guess = Integer.parseInt(words.get(2));
    return guess < SECRET_NUMBER ? "yes" : "no";
}

Bug 2 fix: substring(index) includes the character at index itself, but after finding a space, index is reassigned directly to that space’s position (newIndex) — so the next word’s substring starts with a leading space. Fix: advance past the space (newIndex + 1), and break once the last word has been added (since index would otherwise become -1 from newIndex, which is already handled by the while condition, but the assignment ordering still needs correcting):

while (index != -1) {
    newIndex = input.indexOf(' ', index + 1);
    if (newIndex == -1) {
        output.add(input.substring(index));
        break;
    } else {
        output.add(input.substring(index, newIndex));
    }
    index = newIndex + 1;
}

Bug 3 fix: the >/< are swapped — a "guess higher" should return "yes" when the guess is lower than the secret (i.e. the secret is higher than the guess), and vice versa:

case "higher" -> {
    int guess = Integer.parseInt(words.get(2));
    return guess < SECRET_NUMBER ? "yes" : "no";
}
case "lower" -> {
    int guess = Integer.parseInt(words.get(2));
    return guess > SECRET_NUMBER ? "yes" : "no";
}

Bug 4 & 5 — MultiplayerGuessingGameBot

Extends GuessingGameBot to let multiple users each submit one guess, with the game’s host finishing it to reveal a ranked leaderboard. Two independent bugs:

Bug 4 — host check inverted:

if (message.equals("finish game")) {
    if (username.equals(host)) {
        return "You do not have a game running.\n" +
                "Host must finish game.";
    }
    return results();
}

Bug 5 — leaderboard rank off-by-one:

private String results() {
    // ...
    int rank = 0;
    for (Map.Entry<String, Integer> entry : entries) {
        builder.append("\n").append(rank).append(") ")...
        rank++;
    }
    return builder.toString();
}

Bug 4 fix: the condition is backwards — a non-host finishing the game should get the “you do not have a game running” message, not the host:

if (!username.equals(host)) {
    return "You do not have a game running.\n" +
            "Host must finish game.";
}
return results();

Bug 5 fix: rankings should start at 1, not 0:

int rank = 1;

Bug 6 & 7 — CommitBot

Lets a user generate a random commit message (commit new), save the last-generated one (commit save), and reload it later (commit load). Two independent bugs:

Bug 6 — generated message never recorded as “last”:

if (message.endsWith("new")) {
    try {
        String commit = generateMessage();
        return commit;
    } catch (IOException | URISyntaxException e) {
        return "IOException trying to fetch commit message";
    }
}

Bug 7 — load reads from the wrong map:

if (message.endsWith("load")) {
    if (!lastMessages.containsKey(username)) {
        return "No saved message to load";
    }
    return lastMessages.get(username);
}

Bug 6 fix: commit new never stores the generated message into lastMessages, so commit save (which reads from lastMessages) always sees a stale (or missing) value:

String commit = generateMessage();
lastMessages.put(username, commit);
return commit;

Bug 7 fix: commit load should read the saved message, not the last generated one — otherwise load just repeats whatever was most recently generated, ignoring commit save entirely:

if (message.endsWith("load")) {
    if (!savedMessages.containsKey(username)) {
        return "No saved message to load";
    }
    return savedMessages.get(username);
}

Bug 8 — RecursiveFibonacci (bonus)

Tests throw a StackOverflowError — a recursive method’s base case is either missing or unreachable, so it recurses forever (until the call stack runs out of memory).

The recursive calculate(n, x) only has a base case for n == 1. When x > 1, the recursive subtraction steps by more than 1 at a time, so n can skip straight past 1 into negative numbers — n == 1 is never reached, and the recursion never terminates.

Two possible fixes (given \(\mathcal{F}(1) = \mathcal{F}(2) = 1\)):

// Preferred: widen the existing base case so it can't be skipped over.
public static int calculate(int n, int x) {
    if (n <= 2) {
        return 1;
    }
    // ...
}
// Alternative: add an explicit n == 0 (or n < 1) base case.
public static int calculate(int n, int x) {
    if (n == 0) { // or n < 1
        return 0;
    }
    // ...
}

The first (n <= 2) is preferred: the second approach still lets recursive calls that violate the method’s own precondition (n >= 1) run to completion instead of eliminating them.

Bug 9 — CachedFibonacci (challenge)

Gives incorrect results for larger Fibonacci numbers. The cache moves the most-recently-used entry to the front of two parallel arrays (cacheKeys/cacheValues) each lookup:

int key = cacheKeys[index], value = cacheValues[index];
for (int i = index - 1; i >= 0; --i) {
    cacheKeys[i + 1] = cacheKeys[i];
    cacheValues[i + 1] = cacheValues[i];
}
cacheKeys[0] = key;
cacheValues[0] = value;

After shuffling the found entry to the front of the cache, index (the variable tracking where the entry was found) is never updated to reflect its new position (0) — so subsequent logic that relies on index still thinks the entry lives at its old position:

cacheKeys[0] = key;
cacheValues[0] = value;
index = 0;

Exceptions

Applied class exercises for week 5, tracing execution through nested try/catch/finally blocks — reinforcing java-exceptions. Uses this exception hierarchy throughout:

class E1 extends Exception {};
class E2 extends E1 {};
class E3 extends E1 {};
class E4 extends E3 {};
class E5 extends E3 {};

class F1 extends Exception {};
class F2 extends F1 {};
class F3 extends F1 {};
class F4 extends F1 {};

On the HTML site, fill in each blank with your answer (as a quoted string, in the form x, y, e.g. "12, 20"), then click Run Code to check it. In the PDF, the Working callout is shown as a static answer key instead (interactive checking needs a browser).

Question 1

x and y are member variables, both starting at 0:

public void exercise1() {
    try {
        try {
            f();
            y += 10;
        } catch (F3 e) {
            x += 1;
        } catch (F1 e) {
            x += 10;
        } catch (F4 e) {
            x += 100;
        } catch (E5 e) {
            y += 1;
            throw e;
        } finally {
            x += 2;
        }
        y += 20;
    } catch (Exception e) {
        y += 100;
    }
}

What are x, y after exercise1() is called, if f() throws each of the following?

f() throws F2

f() throws F3

f() throws F4

f() throws E4

f() throws E5

  • F2: not caught by the inner catch (F3), catch (F4), or catch (E5), but is an F1 (F2 extends F1) → caught by catch (F1 e): x += 10. finally always runs: x += 2x = 12. No exception escapes the inner try, so y += 20 still runs → y = 20.
  • F3: directly caught by catch (F3 e): x += 1. finally: x += 2x = 3. y += 20 runs → y = 20.
  • F4: matches catch (F1 e) before reaching catch (F4 e), since catch clauses are checked in order and F4 extends F1 — so it’s caught by the F1 handler: x += 10. finally: x += 2x = 12. y += 20 runs → y = 20.
  • E4: none of the inner catch clauses match (F3/F1/F4 are all F-hierarchy, E5 is a sibling of E4’s ancestor E3, not a match) — only finally runs (x += 2x = 2), then the exception propagates to the outer catch (Exception e): y += 100y = 100.
  • E5: matches catch (E5 e): y += 1, then re-throws it. finally still runs: x += 2x = 2. The re-thrown exception escapes the inner try entirely (skipping y += 20) and is caught by the outer catch (Exception e): y += 100y = 1 + 100 = 101.

Does exercise1 need a throws declaration? No — every exception f() might throw is either caught by the inner try or (for the re-thrown E5) by the outer try. A throws declaration is only needed for checked exceptions that escape uncaught.

Question 2 — bonus

public void exercise2() throws ... {
    try {
        y += g();
    } catch (E3 e) {
        x += 100;
    }
}

public int g() throws ... {
    try {
        f();
        y += 10;
        return 400;
    } catch (E1 e) {
        x += 1;
    } finally {
        x += 10;
    }
    return 1;
}

f() throws E1

f() throws E2

f() throws E3

Whichever of E1/E2/E3 is thrown, g()’s catch (E1 e) matches (all are E1 or subclasses), so x += 1, then finally runs x += 10x = 11g() never reaches return 400 or return 1 explicitly inside the catch, so it falls through to the final return 1. Back in exercise2, y += g() adds that 1 to yy = 1. The exception never escapes g(), so exercise2’s own catch (E3 e) never runs (x stays at 11, not 111).

The throws declarations on exercise2 and g would differ depending on which exception type f() actually throws (only checked exceptions that can escape uncaught need declaring).

Question 3 — bonus

g() throws F2 (unconditionally, when called):

public void exercise3() throws ... {
    try {
        try {
            try {
                f();
                x += 100;
            } catch (F2 e) {
                x += 5;
                g();
            } catch (F1 e) {
                x += 1;
            } catch (E1 e) {
                y += 2;
                throw e;
            }
            y += 10;
        } catch (F2 f) {
            y += 100;
        }
    } catch (Exception e) {
        x += 1000;
    }
}

f() throws F3

f() throws F2

f() throws E1

f() throws F1

  • F3: not caught by catch (F2 e), but is caught by catch (F1 e) (F3 extends F1): x += 1. No further exception, y += 10 runs. Neither outer catch triggers → x = 1, y = 10.
  • F2: caught directly by catch (F2 e): x += 5, then calls g(), which itself throws F2 — this propagates out of the innermost try (skipping y += 10) to the middle catch (F2 f): y += 100. → x = 5, y = 100.
  • E1: none of F2/F1 catch it, but catch (E1 e) does: y += 2, then re-throws. This isn’t an F2, so the middle catch (F2 f) doesn’t match — it propagates to the outermost catch (Exception e): x += 1000. → x = 1000, y = 2.
  • F1: caught by catch (F1 e) directly (an F1 isn’t an F2): x += 1. y += 10 runs. → x = 1, y = 10 (same result as F3, since both only match the F1 handler).

Week 6

Refactoring

See csse2002 for course logistics.

Today’s outline

  1. Good Practice Refactoring, When to Refactor, Code Smells
  2. Cohesion and Coupling
  3. SOLID Principles

Refactoring

See java-refactoring — what refactoring is, why it’s needed, good practice, and code smells (bad naming, duplication, feature envy).

Cohesion and coupling

See java-cohesion-and-coupling — modularization, the levels of coupling (content → data) and cohesion (coincidental → functional), and why high cohesion + low coupling is the goal.

SOLID principles

See java-solid-principles — why large software becomes rigid/fragile, and the first two SOLID principles: Single Responsibility and Open-Closed (the rest are covered in a later lecture).

Week 7

SOLID Principles II

See csse2002 for course logistics. Continues 2026-04-02-refactoring (Week 6) — Single Responsibility and Open-Closed were covered there; this lecture covers the remaining three SOLID principles.

Today’s outline

  1. Liskov Substitution Principle (and substitution with contracts)
  2. Interface Segregation Principle
  3. Dependency Inversion Principle

All content, including the worked examples, is in java-solid-principles (updated this week with the L, I, D sections).

Applied class

No separate applied-class topic on SOLID this week — see week7-tutorial-black-box-and-glass-box-testing for this week’s applied class (black-box/glass-box testing, a continuation of java-testing from Week 5).

Black-box and Glass-box Testing

Applied class for 2026-04-16-solid-principles-ii (Week 7). Applies the black-box/white-box testing concepts from java-testing (Week 5) to a worked example. All of these tasks ask for a set of inputs achieving some coverage goal — many different answers are equally correct, so answers here are reveal-only worked examples rather than checkable exercises.

The Bus class

public class Bus {
    public Bus(int capacity) {...}
    public int getCurrent() {...}
    public int getAverageCount() {...}
    public void stop(int on, int off) {...}
}

Task 0 — write a smoke test

A smoke test just exercises normal/expected operation:

Bus bus = new Bus(10);
bus.stop(5, 0);
bus.stop(3, 1);
assertEquals(7, bus.getCurrent());
assertEquals(6, bus.getAverageCount());

Task 1 — develop boundary scenarios (with justification)

Scenario Justification
Construct a bus with capacity 0 A bus that can have no passengers is an uncommon scenario we may expect bugs from.
Construct a bus with a negative capacity Unclear how this should behave — worth clarifying and testing.
Ask for the average after no stops Averages usually involve division by count; want to confirm no exception when count is 0.
Negative values for on/off Unclear behaviour — does a negative on remove passengers?
More passengers get off than are currently on the bus Results in undefined behaviour.
More passengers on the bus than capacity Behaviour isn’t specified, so worth observing.

Task 2 — how can we make Bus’s behaviour fully specified?

Most of the boundary scenarios above come from unspecified/unclear behaviour. If we declare getCurrent() <= capacity an invariant of Bus (see java-specification), we must protect it everywhere:

  1. Constructor throws IllegalArgumentException if capacity < 0.
  2. stop gets a precondition that both on and off are >= 0.
  3. stop throws IllegalStateException if getCurrent() + on - off would be negative or exceed capacity.

Glass-box testing: code coverage levels

See java-testing for the three levels (statement, branch, path). Consider:

public static int countOccurrences(int[] array, int target) {
    int count = 0;
    for (int num : array) {
        if (num == target) {
            count++;
        }
    }
    return count;
}

A single array containing the target at least once (e.g. [2, 3, 2], target 2) gives statement coverage (the if body runs); adding an array where the target never appears (e.g. [3], target 2) gives branch coverage too.

Worked example: ascending

/**
 * @require numbers != null
 * @ensure \result is true iff for all indices j such that
 *         0 <= j < numbers.length - 1, numbers[j] <= numbers[j + 1]
 */
public boolean ascending(int[] numbers) {
    boolean result = true;
    for (int i = 0; i < numbers.length - 1; i++) {
        if (numbers[i] > numbers[i + 1]) {
            result = false;
        }
    }
    return result;
}

Statement coverage: [2, 1] (loop runs once, if body executes).

Branch coverage: [2, 1, 2], or the pair [2, 1] and [1, 2] (need the if to be both true and false).

Path coverage (0, 1, and 2-iteration cases, crossed with the if outcome each time):

Input Justification
[] or [3] 0 times through loop
[3, 4] 1 time through loop, if false
[4, 3] 1 time through loop, if true
[3, 4, 5] 2 times through loop, false, false
[3, 4, 2] 2 times through loop, false, true
[4, 3, 5] 2 times through loop, true, false
[5, 4, 3] 2 times through loop, true, true

Worked example: maximum (bonus)

/**
 * @require numbers != null
 * @ensure numbers.length > 0 ==> (
 *     (\forall int i; 0 <= i < numbers.length ==> \result >= numbers[i]) &&
 *     (\exists int i; 0 <= i < numbers.length && \result == numbers[i]))
 * @ensure numbers.length == 0 ==> \result = Integer.MIN_VALUE
 */
public int maximum(int[] numbers) {
    int currentMaximum = Integer.MIN_VALUE;
    for (int number : numbers) {
        if (number > currentMaximum) {
            currentMaximum = number;
        }
    }
    return currentMaximum;
}

Statement coverage: [1].

Branch coverage: [2, 1] (first element sets a new max, second doesn’t).

Path coverage:

Input Justification
[] 0 times through loop
[Integer.MIN_VALUE] 1 time through loop, if false
[1] 1 time through loop, if true
[Integer.MIN_VALUE, Integer.MIN_VALUE] 2 times through loop, false, false
[Integer.MIN_VALUE, 1] 2 times through loop, false, true
[2, 1] 2 times through loop, true, false
[1, 2] 2 times through loop, true, true

Worked example: indexOf (bonus)

/**
 * @require numbers != null
 * @ensure numbers[\result] = number ||
 *     (\result == -1 &&
 *      \forall int j; 0 <= j < numbers.length ==> numbers[j] != number)
 */
public int indexOf(int[] numbers, int number) {
    for (int i = 0; i < numbers.length; i++) {
        if (numbers[i] == number) {
            return i;
        }
    }
    return -1;
}

Statement coverage: indexOf([], 1) and indexOf([1], 1).

Branch coverage: indexOf([], 1) and indexOf([2, 1], 1).

Path coverage — note that with an early return inside the loop, “2 iterations, if true on the first” is unreachable (the method returns immediately), so two of the theoretically-possible paths for a 2-element input can’t actually be tested:

Input Justification
indexOf([], 42) 0 times through loop
indexOf([1], 2) 1 time through loop, if false
indexOf([1], 1) 1 time through loop, if true
indexOf([1, 2], 3) 2 times through loop, false, false
indexOf([1, 2], 2) 2 times through loop, false, true
Cannot be tested 2 times through loop, true, false
Cannot be tested 2 times through loop, true, true

Week 8

Java I/O

See csse2002 for course logistics.

Today’s outline

  1. Streams (java.io, since Java 1.0)
  2. Readers & Writers (java.io, since Java 1.1)
  3. Scanner (java.util, since Java 1.5)
  4. New I/O (java.nio.file, since Java 1.7)

All content is in java-io.

Applied class

See week8-tutorial-class-invariants — protecting class invariants (a continuation of java-specification and java-encapsulation from earlier weeks).

Practical

See week8-lab-junit-testing — Test Driven Development with JUnit, extending java-junit from Week 5.

JUnit Testing

Practical for 2026-04-23-java-io (Week 8). Extends java-junit (Week 5) with a full Test Driven Development (TDD) worked example.

Test Driven Development (TDD)

Cycle (see java-testing):

  1. Write a test for some piece of functionality.
  2. Write just enough functionality for the test to pass.
  3. Refactor implementation while preserving the passing test.
  4. Repeat.

Stubs: a stub class has all public members, but methods return dummy values based on their return type — void methods are empty, primitives return a default (e.g. 0), reference types normally return null. This lets you write an outline of a class that compiles but doesn’t yet work — in pure TDD, not compiling counts as a test failure, so a stub is often the very first thing written to make a not-yet-existing class’s test compile.

DistinctCounter

Tracks a collection of distinct strings, retrievable in lexicographical order:

  • DistinctCounter()
  • void add(String element)
  • int getDistinctCount()
  • String[] getStrings() — in lexicographical order
DistinctCounter distinct = new DistinctCounter();
distinct.add("Z");
distinct.add("Hello");
distinct.add("Z");
distinct.add("Hello ");
distinct.getDistinctCount(); // 3
distinct.getStrings();       // {"Hello", "Hello ", "Z"}

Stub:

class DistinctCounter {
    public DistinctCounter() {}
    void add(String element) {}
    int getDistinctCount() { return 0; }
    String[] getStrings() { return null; }
}

JUnit 4 test class skeleton:

import org.junit.Test;
import static org.junit.Assert.*;

class DistinctCounterTest {
    @Test
    public void testEmpty() {}
}

Implementation (one of several equally valid designs — a HashSet naturally rejects duplicates, so getStrings() just needs to sort on the way out):

public class DistinctCounterHashSet implements DistinctCounter {
    private final Set<String> distinct = new HashSet<>();

    public void add(String word) { distinct.add(word); } // set won't add duplicates

    public int getDistinctCount() { return distinct.size(); }

    public String[] getStrings() {
        String[] elements = distinct.toArray(new String[]{});
        Arrays.sort(elements);
        return elements;
    }
}

(Other equally valid implementations: a TreeSet, which keeps elements sorted automatically without an explicit Arrays.sort; or an ArrayList-backed version that checks contains() before adding, sorting either on every getStrings() call or by inserting in sorted position on every add().)

Representative tests (@Before constructs a fresh counter before each test, avoiding duplicated setup code in every method):

public class DistinctCounterTest {
    private DistinctCounter counter;

    @Before
    public void setup() { counter = new DistinctCounter(); }

    @Test
    public void testEmptyCounterCount() {
        assertEquals("Empty counter does not have a count of zero", 0, counter.getDistinctCount());
    }

    @Test
    public void testTwoIdenticalCount() {
        counter.add("A");
        counter.add("A");
        assertEquals("Counter with two identical elements does not have count of one",
                1, counter.getDistinctCount());
    }

    @Test
    public void testTwoDistinctArray() {
        counter.add("A");
        counter.add("B");
        assertArrayEquals("Counter with two distinct elements does not have an array of two",
                new String[]{"A", "B"}, counter.getStrings());
    }
}

The full test suite (not reproduced in full here) mirrors this pattern across every case worth naming: empty / one element / two distinct / two identical / two identical + one other / N distinct (for both getDistinctCount() and getStrings(), plus that the returned array is sorted). TDD like this tends to produce a very thorough test suite, but one that mostly targets “happy path” execution — it’s still worth adding boundary cases (see java-testing) on top, e.g.:

@Test
public void testNullStringCount() {
    counter.add(null);
    assertEquals(0, counter.getDistinctCount());
}

PalindromeCounter

Extends DistinctCounter with:

  1. int getPalindromeCount() — number of distinct palindromes
  2. String[] getPalindromes() — distinct palindromes
  3. String[] getNonPalindromes() — distinct non-palindromes
public class PalindromeCounter extends DistinctCounterTreeSet {
    private static boolean isPalindrome(String word) {
        if (word.length() < 2) {
            return true;
        }
        if (word.charAt(0) != word.charAt(word.length() - 1)) {
            return false;
        }
        return isPalindrome(word.substring(1, word.length() - 1));
    }

    public int getPalindromeCount() { return getPalindromes().length; }

    public String[] getPalindromes() {
        List<String> palindromes = new ArrayList<>();
        for (String word : getStrings()) {
            if (isPalindrome(word)) {
                palindromes.add(word);
            }
        }
        return palindromes.toArray(new String[]{});
    }

    public String[] getNonPalindromes() {
        List<String> distincts = new ArrayList<>(Arrays.asList(getStrings())); // wrap to allow removeAll
        distincts.removeAll(Arrays.asList(getPalindromes()));
        return distincts.toArray(new String[]{});
    }
}

Representative test (built up incrementally via TDD, one case at a time — empty, single palindrome, single non-palindrome, one of each, then several of each):

public class PalindromeCounterTest {
    private PalindromeCounter counter;

    @Before
    public void setup() { counter = new PalindromeCounter(); }

    @Test
    public void testManyOfEachCounter() {
        counter.add("car");
        counter.add("racecar");
        counter.add("mamma mia");
        counter.add("AbbA");
        counter.add("palindrome");
        counter.add("rufus");
        assertEquals("Counter with two palindromes has incorrect count",
                2, counter.getPalindromeCount());
        assertArrayEquals("Counter with multiple palindromes does not have all in array",
                new String[]{"AbbA", "racecar"}, counter.getPalindromes());
        assertArrayEquals("Counter with multiple non-palindromes does not have all in array",
                new String[]{"car", "mamma mia", "palindrome", "rufus"}, counter.getNonPalindromes());
    }
}

Note isPalindrome treats strings shorter than 2 characters as palindromes by definition (the base case), and is case-sensitive ("AbbA" is a palindrome as written — comparing first/last characters directly, not after case-folding).

Class Invariants

Applied class for 2026-04-23-java-io (Week 8). Applies java-encapsulation’s class invariants (and the preconditions/postconditions from java-specification) to worked examples.

XFiles

XFiles maintains the invariant that every stored string must be prefixed with 'X':

\[\forall\, 0 \le i < \texttt{getFiles().size()} \implies \texttt{getFiles().get(i).startsWith("X")}\]

/**
 * @invariant
 *     \forall i; 0 <= i < getFiles().size(); getFiles().get(i).startsWith("X")
 */
public class XFiles {
    /**
     * @ensures getFiles().contains(newFile)
     * @ensures getFiles().size() == \old(getFiles()).size() + 1
     */
    public void add(String newFile) {...}

    /**
     * @ensures !getFiles().contains(file)
     */
    public void remove(String file) {...}

    public List<String> getFiles() {...}
}

As specified, add allows the invariant to be broken (nothing stops a non-'X' string being added). Fix: add a precondition

/**
 * @requires newFile.startsWith("X")
 */

Even with that precondition documented, a naive implementation still has two further leaks (see java-encapsulation — Protecting invariants):

public class XFiles {
    public List<String> files = new ArrayList<>(); // (1) public field

    public void add(String newFile) {
        if (newFile.startsWith("X")) {
            files.add(newFile);
        }
    }
    public void remove(String file) { files.remove(file); }

    public List<String> getFiles() {
        return files; // (2) leaks the internal reference
    }
}
  1. files is public — callers can grab the list directly and mutate it, bypassing add’s check entirely: xFiles.files.add("Doesn't start with X!"). Fix: make it private.
  2. getFiles() returns the internal reference — even with files made private, the returned list is still the real one: xFiles.getFiles().add("Doesn't start with X!") still breaks the invariant. Fix: return a defensive copy, return new ArrayList<>(files);.

Cinema and Screening

public class Cinema {
    /**
     * @ensures getCapacity() == capacity
     */
    public Cinema(int capacity) {...}
    public int getCapacity() {...}
}

A useful invariant: getCapacity() >= 0.

Precondition to preserve it: the constructor needs capacity >= 0.

/**
 * @invariant getEndTime() > getStartTime()
 */
public class Screening {
    /** @ensures \result > 946648800 */
    public int getStartTime() {...}
    /** @ensures \result > 946648800 */
    public int getEndTime() {...}
    /** @ensures getEndTime() == \old(getEndTime()) + amount */
    public int extendRuntime(int amount) {...}
}

(Timestamps are unix time; 946648800 = 1st January 2000, when the cinema opened.)

Do the methods preserve the invariant? No — a negative amount passed to extendRuntime could push getEndTime() below (or equal to) getStartTime(). Two sensible fixes:

  1. amount >= 0
  2. getEndTime() + amount > getStartTime()

The method’s name (extendRuntime) implies (a) is the more likely intended precondition.

Now extend Screening with ticket sales:

public class Screening {
    // getStartTime(), getEndTime(), extendRuntime(int) as before.
    public Cinema getCinema() {...}

    /**
     * @ensures getSoldTickets().contains(\result)
     * @ensures getSoldTickets().size() == \old(getSoldTickets()).size() + 1
     */
    public Ticket sellTicket() {...}

    public Set<Ticket> getSoldTickets() {...}
}

Invariant to prevent overselling:

/**
 * @invariant getSoldTickets().size() <= getCinema().getCapacity()
 */

Precondition to preserve it — add to sellTicket(): getSoldTickets().size() < getCinema().getCapacity(). (Technically, since the invariant can be assumed as a precondition too, getSoldTickets().size() != getCinema().getCapacity() is enough — the invariant plus that inequality together imply the strict <.)

Now consider a concrete (simplified) implementation:

public class Screening {
    private Set<Ticket> tickets = new HashSet<>();

    public Ticket sellTicket() {
        Ticket ticket = new Ticket(...);
        tickets.add(ticket);
        return ticket;
    }

    public Set<Ticket> getSoldTickets() {
        return tickets;
    }
}

Does this protect the invariant? Not really, for two reasons:

  1. In pure programming-by-contract, sellTicket() doesn’t check the precondition at all — under a pure contract it’s allowed to do anything if the caller violates the precondition, but that’s fragile if sellTicket() is exposed to code you don’t control. Defensive programming (see java-specification) is safer here:
public Ticket sellTicket() {
    if (tickets.size() >= getCinema().getCapacity()) {
        throw new IllegalArgumentException("Screening full!");
    }
    ...
}

(Returning null instead of throwing protects the invariant too, but risks a hard-to-trace NullPointerException later in the caller — throwing immediately at the point of misuse is clearer.)

  1. The real bug is that getSoldTickets() leaks the internal tickets reference — external code can oversell the screening directly: screening.getSoldTickets().add(new Ticket()), bypassing sellTicket() entirely. Fix with a defensive copy:
public Set<Ticket> getSoldTickets() {
    return new HashSet<>(tickets);
}

Week 9

Generics in Java

See csse2002 for course logistics.

Today’s outline

  1. Generics
  2. Bounded Generics
  3. Bounds through Wildcards
  4. Type Erasure
  5. Practical Example

All content is in java-generics.

Applied class

See week9-tutorial-substitution-principle-and-pre-post-conditions — more practice with the Liskov Substitution Principle (from java-solid-principles, Week 7) and pre/postconditions (from java-specification, Week 3).

Practical

See week9-lab-refactoring — refactoring in the small and in the large, extending java-refactoring (Week 6).

Refactoring

Practical for 2026-04-30-generics-in-java (Week 9). Extends java-refactoring (Week 6) with a full worked refactoring of a Connect 4 implementation, plus a “refactoring in the large” exercise using java-cohesion-and-coupling and java-solid-principles (SRP, DIP).

Setup

Starting point: a Connect4 class (one play method implementing most of the game), a small Library helper, and a test suite. Set up version control before refactoring, so each refactoring step can be committed separately and reverted if it breaks something.

Refactoring in the small

Goal: make an existing, working class more readable and understandable without changing its behaviour — tests must keep passing after every change. “In the small” refactorings touch one method/class’s internals, not the overall class structure.

Style guide fixes (whitespace) — trivial with a linter like Checkstyle:

// Before
int x=0;
while (x < 7){out.print(x); x++;}

// After
int x = 0;
while (x < 7) {
    out.print(x);
    x++;
}

Bad naming — replace cryptic names with meaningful ones:

// Before
int[] n = new int[7 * 6];
int[] m = new int[7 * 6];
boolean t = false;

// After
int[] boardX = new int[7 * 6];
int[] boardO = new int[7 * 6];
boolean isTurnX = false;

while loops that run a fixed number of times — replace with for:

// Before
int x = 0;
while (x < 7) { out.print(x); x++; }

// After
for (int x = 0; x < 7; x++) { out.print(x); }

Magic numbers — replace with named constants:

private static final int ROW_SIZE = 7;
private static final int COL_SIZE = 6;
// for (int i = 0; i < ROW_SIZE; i++) { ... }

Decomposition — even code that isn’t duplicated can be pulled into a helper method if it has a single, nameable purpose (here, printing the board):

private static void printBoard(int[] boardX, int[] boardO, PrintWriter out) {
    for (int i = 0; i < ROW_SIZE; i++) { out.print(i); }
    // ...
}

Comments — explain complex lines or the purpose of a block, not what’s already obvious from the code:

// Check horizontal win
for (int q = 0; q < 4; q++) {
    if ((s + q) / 7 != s / 7) {
        // Found row out of bounds, not a win
        nf = false;
        break;
    }
    if (boardX[s + q] == 0)
        // Found position that isn't an X, not a win
        nf = false;
}

Duplication — the original win-checking logic repeats a near-identical horizontal/vertical check for both players. This is refactored in two stages:

Stage 1 — extract a checkWin(int[] playerBoard) helper parameterised on which player’s board to check, removing the player-specific duplication:

private static boolean checkWin(int[] playerBoard) {
    boolean win = true;
    for (int q = 0; q < 4; q++) {                 // horizontal
        if ((s + q) / 7 != s / 7) { return false; }
        if (playerBoard[s + q] == 0) win = false;
    }
    if (win) { return true; }

    win = true;
    for (int q = 0; q < 4; q++) {                  // vertical
        if (s + (q * 7) >= 42) { return false; }
        if (playerBoard[s + (q * 7)] == 0) win = false;
    }
    return win;
}
// if (checkWin(boardX)) { out.println("Player X wins"); exit = true; }
// if (checkWin(boardO)) { out.println("Player O wins"); exit = true; }

Stage 2 — further split checkWin into checkHorizontalWin/checkVerticalWin, each returning as soon as a check fails (removing the need for the win bookkeeping variable entirely):

private static boolean checkHorizontalWin(int[] playerBoard) {
    for (int q = 0; q < 4; q++) {
        if ((s + q) / 7 != s / 7) { return false; }
        if (playerBoard[s + q] == 0) { return false; }
    }
    return true;
}
private static boolean checkVerticalWin(int[] playerBoard) {
    for (int q = 0; q < 4; q++) {
        if (s + (q * 7) >= 42) { return false; }
        if (playerBoard[s + (q * 7)] == 0) { return false; }
    }
    return true;
}
private static boolean checkWin(int[] playerBoard) {
    return checkHorizontalWin(playerBoard) || checkVerticalWin(playerBoard);
}

Data structures — the flat 42-slot array representing the board is clumsy; three options (a matter of preference):

  1. A nested array of 6 rows × 7 columns instead of a flat 42-slot array (int[][] boardX = new int[7][6]) — but this breaks all existing indexing code, so needs a gradual rewrite.
  2. Combine boardX/boardO into a single board (1 = X, 2 = O), avoiding the risk of the two arrays getting out of sync.
  3. Since boardX/boardO only ever store 1 or 0, make them boolean[] instead of int[].

Refactoring in the large

Goal: given a reasonably well-styled class that has low cohesion and too many responsibilities, split it into smaller, more cohesive classes with clear responsibilities — to make the code reusable and extensible, not just readable. Starting point: a monolithic Library class handling everything.

Considerations when doing this:

  • Each class should serve one single-minded purpose (high cohesion).
  • Each class should have only one reasonable reason to change (SRP, see java-solid-principles) — e.g. changing the classification system shouldn’t require touching bookshelf-rendering code.
  • Each class’s interface should be designed for reasonable programmatic interaction (not too large — see Interface Segregation in java-solid-principles).
  • Components should depend on abstractions so sub-components can be substituted/extended later (DIP, see java-solid-principles).

A possible split of Library (not exhaustive — a real system could expand on this considerably):

  • Book — data for a single book (title, author, id).
  • BookShelf — tracks all Books in the library and their availability status.
  • Borrower — data for a borrower (name, id) and the books they’re currently borrowing.
  • BorrowingSystem — the “bridge” that handles borrowing/returning, passing data between BookShelf and Borrower rather than either of those two classes depending directly on each other.

Substitution Principle and Pre/Post Conditions

Applied class for 2026-04-30-generics-in-java (Week 9). More practice applying the Liskov Substitution Principle (see java-solid-principles) and pre/postconditions (see java-specification).

Predicate strength

A B
1 a < 0 && b < 0 a != 0 && b < 0
2 a instanceof Animal a instanceof String
3 a instanceof Animal \|\| b instanceof Zebra a instanceof Animal && b instanceof Zebra
4 a instanceof Animal && b instanceof Zebra a instanceof Animal
5 a instanceof Animal && b instanceof Object a instanceof Animal
6 a instanceof Zebra a instanceof Tiger

For each row, identify which column is a stronger (more restrictive) condition, or state that there’s no relation.

  1. A is stronger than Ba < 0 && b < 0 implies a != 0 && b < 0 (every a < 0 is also a != 0), but not vice versa.
  2. No relationAnimal and String are unrelated types; neither instance check implies the other.
  3. B is stronger than A — A only needs either condition to hold (a looser OR), while B requires both (a stricter AND) — satisfying B always satisfies A, not the reverse.
  4. A is stronger than B — A requires everything B requires, plus more (b instanceof Zebra on top of a instanceof Animal).
  5. A looks stronger than B, but they’re equivalent — since everything is an instance of Object, b instanceof Object is always true, so it adds no actual restriction.
  6. No relationZebra and Tiger are siblings (both presumably subtypes of Animal, say), so neither instance check implies the other.

Now consider two possible class structures:

// Option 1: Preconditions
class ClassA {
    /** @requires A */
    void f(Object a, Object b) {}
}
class ClassB extends ClassA {
    /** @requires B */
    void f(Object a, Object b) {}
}
// Option 2: Postconditions
class ClassA {
    /** @ensures A */
    void f(Object a, Object b) {}
}
class ClassB extends ClassA {
    /** @ensures B */
    void f(Object a, Object b) {}
}

For each row above, if column A and column B are inserted as the specification text, which option (if any) satisfies the substitution principle?

Recall: a subclass must not strengthen preconditions (they may only stay the same or weaken) and must not weaken postconditions (they may only stay the same or strengthen).

  1. Option 1 (precondition)ClassB’s precondition (B) is weaker than ClassA’s (A), which is allowed for preconditions.
  2. Neither — unrelated conditions satisfy neither the “no stronger precondition” nor “no weaker postcondition” rule.
  3. Option 2 (postcondition)ClassB’s postcondition (B) is stronger than ClassA’s (A), which is allowed for postconditions.
  4. Option 1 (precondition) — same reasoning as row 1: B is weaker than A.
  5. Both — since A and B are equivalent here, substituting either as precondition or postcondition preserves the (unchanged) strength relationship.
  6. Neither — unrelated conditions again satisfy neither rule.

Substitution principle

class X {
    /**
     * @require fontSize >= 5
     * @ensure \result >= 0
     */
    int detexify(Object symbol, float fontSize) { ... }
}

class Y extends X {
    /**
     * @require fontSize > 0
     * @ensure \result > 0
     */
    int detexify(Object symbol, float fontSize) { ... }
}

class Z extends X {
    /**
     * @require fontSize > 5
     * @ensure \result > 0
     */
    int detexify(Object symbol, float fontSize) { ... }
}

Why would a programmer choose to use a precondition at all?

As a restriction on the allowable range of inputs, to avoid having to handle huge positive/negative numbers (or other awkward edge cases) inside the method body.

Does Y violate the substitution principle?

No. Y’s precondition (fontSize > 0) is weaker than X’s (fontSize >= 5 implies fontSize > 0, but not vice versa — the range of acceptable inputs expanded), and Y’s postcondition (\result > 0) is stronger than X’s (\result >= 0 — the range of possible outputs contracted). Both changes are allowed by the substitution principle.

Does Z violate the substitution principle?

Yes. Z’s postcondition is correctly stronger (\result > 0 vs. \result >= 0), but its precondition (fontSize > 5) is also stronger than X’s (fontSize >= 5) — it forbids fontSize == 5, which X explicitly allowed. Strengthening a precondition violates the principle, regardless of what happens to the postcondition.

Writing pre/postconditions

public boolean q2(String[] strArray, int firstIndex, int secondIndex) {
    return strArray[firstIndex] == strArray[secondIndex];
}

Write a Javadoc comment for q2.

/**
 * Determines if two indicated elements of an array of Strings
 * refer to the same String object.
 *
 * @param strArray an array of Strings
 * @param firstIndex index of the first element to compare
 * @param secondIndex index of the second element to compare
 * @return true if the indicated elements in the array refer to
 *         the same string, false otherwise
 */

Add @requires/@ensures tags.

/**
 * ...
 * @requires strArray != null && 0 <= firstIndex < strArray.length
 *               && 0 <= secondIndex < strArray.length
 * @ensures \result == true
 *              <==> strArray[firstIndex] == strArray[secondIndex]
 */

Specify that q2 does not change any elements of strArray.

/**
 * ...
 * @ensures \forall int i; 0 <= i && i < strArray.length
 *              ==> \old(strArray)[i] == strArray[i]
 */

Week 10

Java I/O

Practical for Week 10 (no lecture this week). Extends java-io (Week 8) with saving/loading a data model to/from files, plus a file-format design exercise.

Setup

Provided classes: Schedule, Airport, Flight, DayOfWeek, BadScheduleException (an airline schedule of flights between airports), plus an unrelated Maze class.

Airline schedule

Goal: make a Schedule’s data persistent by saving/loading it to/from a file.

Task 0 — implement Schedule.toString() per its Javadoc spec.

@Override
public String toString() {
    StringJoiner joiner = new StringJoiner(System.lineSeparator());
    joiner.add(String.valueOf(getConnectedAirports().size()));
    joiner.add(String.valueOf(this.flights.size()));
    for (Airport airport : getConnectedAirports()) {
        joiner.add(airport.toString());
    }
    for (Flight flight : this.flights) {
        joiner.add(flight.toString());
    }
    return joiner.toString();
}

Task 1 — implement void save(String filename), propagating any exceptions rather than handling them:

public void save(String filename) throws IOException {
    BufferedWriter writer = new BufferedWriter(new FileWriter(filename));
    writer.write(this.toString());
    writer.close();
}

Task 2 — implement static Schedule load(String filename), using two private helpers (readFlight, readAirport) and throwing BadScheduleException on a failed numeric parse or an unknown airport code:

public static Schedule load(String filename) throws IOException, BadScheduleException {
    BufferedReader reader = new BufferedReader(new FileReader(filename));

    String airportsLine = reader.readLine();
    String flightsLine = reader.readLine();
    if (airportsLine == null || flightsLine == null) {
        throw new BadScheduleException();
    }

    int numAirports, numFlights;
    try {
        numAirports = Integer.parseInt(airportsLine);
        numFlights = Integer.parseInt(flightsLine);
    } catch (NumberFormatException e) {
        throw new BadScheduleException();
    }

    List<Airport> airports = new ArrayList<>();
    for (int i = 0; i < numAirports; ++i) {
        airports.add(readAirport(reader));
    }
    List<Flight> flights = new ArrayList<>();
    for (int i = 0; i < numFlights; ++i) {
        flights.add(readFlight(reader, airports));
    }

    return new Schedule(flights);
}

private static Flight readFlight(BufferedReader reader, List<Airport> airports)
        throws IOException, BadScheduleException {
    String line = reader.readLine();
    if (line == null) {
        throw new BadScheduleException();
    }

    String[] lineParts = line.split("\\|");
    if (lineParts.length != 4) {
        throw new BadScheduleException();
    }

    int flightNumber;
    try {
        flightNumber = Integer.parseInt(lineParts[0]);
    } catch (NumberFormatException e) {
        throw new BadScheduleException();
    }

    Airport origin = null, destination = null;
    for (Airport airport : airports) {
        if (airport.getCode().equals(lineParts[1])) {
            origin = airport;
        }
        if (airport.getCode().equals(lineParts[2])) {
            destination = airport;
        }
    }
    if (origin == null || destination == null) {
        throw new BadScheduleException();
    }

    DayOfWeek dayOperating = DayOfWeek.valueOf(lineParts[3]);
    return new Flight(flightNumber, origin, destination, dayOperating);
}

private static Airport readAirport(BufferedReader reader) throws IOException, BadScheduleException {
    String line = reader.readLine();
    if (line == null) {
        throw new BadScheduleException();
    }

    String[] lineParts = line.split("\\|");
    if (lineParts.length != 2) {
        throw new BadScheduleException();
    }

    return new Airport(lineParts[0], lineParts[1]);
}

Note readFlight/readAirport both throw BadScheduleException for a malformed line — this is a form of defensive programming (see java-specification) protecting the Schedule created by load() from corrupt input, rather than letting a NullPointerException/ArrayIndexOutOfBoundsException leak out from deeper in the parsing logic.

Maze — designing a file format

Maze maps (row, column) positions to tile types (start, end, wall, empty). Unlike Schedule, there’s no existing format to follow — the task is to design one that’s sufficient to save and reload a Maze’s full state.

Task 3 — design a file format for Maze. (Hint: the class’s invariants may let you store less than a full grid dump.)

Several reasonable designs, each with different tradeoffs:

  1. Visual dump — store the same characters render() would print (e.g. # for wall, S/E for start/end). Human-readable, but more complex to parse back than necessary.
  2. Linear encoding of every tile — e.g. #S#E for a 2×2 grid means wall at (0,0), start at (0,1), wall at (1,0), end at (1,1) (with or without an outer border wall included).
  3. One line per map entry — e.g. 1,2,# means “wall at (1, 2)”. Simple, but verbose for large mazes with many walls.
  4. Only relevant tiles — the first two stored positions are always start/end, and every position after that is implicitly a wall; positions can be (row, column) pairs or a single linear index (rows * row + column).
  5. Start/end positions + a bitmap — store start and end explicitly, then a bit per remaining tile (1 = wall, 0 = empty).
  6. Start/end positions + wall runs — store start and end, then walls as (start, end) position pairs, so a run of adjacent wall tiles can be stored as a single entry instead of one entry per tile.

Options 4-6 exploit the fact that most of a maze’s tiles are either walls or empty (i.e. an invariant/regularity in the data), letting the format avoid storing every single tile individually.

Coupling and Cohesion

Applied class for Week 10 (no lecture this week). More practice applying java-cohesion-and-coupling (Week 6).

Coupling

Class coupling: the strength of the connection or dependence between classes — to what extent does this class depend on other classes? How many methods are called on how many other classes? Can another object influence the flow of control in this object?

Assume the following classes are all in separate files in the same package:

public class X {
    public int num = 5;
    protected Z z;

    public X(Z z) { this.z = z; }

    public void doThis() { sayHello(); }

    public void sayHello() { System.out.println("Hello"); }
}
public class Y extends X {
    public Y(Z z) { super(z); }

    public void doThat() { this.z.sayHello(); }

    @Override
    public void sayHello() { super.sayHello(); }
}
public class Z {
    private X x = new X();

    public void setNum(int num) { x.num = num; }

    public void sayHello() { System.out.println("Hello"); }
}

These classes are (unrealistically) tightly coupled. Identify the points of coupling between: (0) X and Y; (1) X and Z; (2) Y and Z — you don’t need to name the type/level of coupling, just where it occurs.

Class Coupling Implication
X stores a Z object X is coupled to Z
X constructor takes a Z X is coupled to Z
X doThis() just calls sayHello(), which is overridden in Y forwarding behaviour
Y constructor takes a Z Y is coupled to Z
Y constructor calls super(z) Y is coupled to X
Y doThat() accesses the protected this.z Y is coupled to Z and X
Y sayHello() calls X.sayHello() via super Y is coupled to X
Z stores an X object Z is coupled to X
Z setNum() accesses the public X.num Z is coupled to X

Cohesion — Customer

Class cohesion: how well components support a central purpose — how focused the components of a unit are. How well do the parts of the class (state and methods) fit together? Do they all contribute to a single, clear purpose?

public class Customer {
    private String name;
    private String streetAddress;
    private String suburb;
    private String postCode;
    private List<Item> orders; // the products that have been ordered

    public Customer(String name, String streetAddress, String suburb, String postCode) {
        this.name = name;
        this.streetAddress = streetAddress;
        this.suburb = suburb;
        this.postCode = postCode;
        this.orders = new ArrayList<>();
    }

    public String getName() { return name; }
    public String getMailingAddress() { return streetAddress + suburb + postCode; }
    public void newOrder(List<Item> order) { orders.addAll(order); }
    public List<Item> getAllOrder() { return orders; }
}

Does Customer exhibit high or low cohesion? Justify your answer.

Low cohesion:

  • name is only used once, in a single getter.
  • streetAddress/suburb/postCode are only used once, in a single getter — and these aren’t unique to a Customer, so belong in a separate class.
  • orders (with its getter and add method) isn’t cohesive with the rest of Customer’s representation, and could live in a dedicated Order-related class.

The grouping of member variables looks coincidental, and the methods have no clear relationship to each other’s functionality — the state and methods don’t contribute to a single, clear purpose.

If Customer doesn’t have high cohesion, design replacement classes with higher cohesion.

Customer is really trying to be at least three concepts at once: a customer, a mailing address, and a customer’s order history. Extract a postal-address abstraction (that Customer stores an instance of), and an order-history abstraction (stored by Customer, or managed separately).

Cohesion — Employee

public class Employee {
    private String firstName;
    private String surname;
    private String homeAddress;
    private String suburb;
    private String postCode;
    private String currentRole;
    private int currentRoleSecurityLevel;
    private int hourlyWage;

    public Employee(String fName, String lName, String homeAddress,
                     String suburb, String postCode, int hourlyWage) {
        this.firstName = fName;
        this.surname = lName;
        this.homeAddress = homeAddress;
        this.suburb = suburb;
        this.postCode = postCode;
        this.hourlyWage = hourlyWage;
    }

    public String getName() { return surname + ", " + firstName; }
    public String getMailingAddress() {
        return String.format("%s%n%s%n%s", homeAddress, suburb, postCode);
    }
    public void setRole(String newRole, int securityLevel) {
        currentRole = newRole;
        currentRoleSecurityLevel = securityLevel;
    }
    public String getCurrentRole() { return currentRole; }
    public boolean accessAllowed(int requiredSecurityLevel) {
        return currentRoleSecurityLevel >= requiredSecurityLevel;
    }
    public int getPay(int hoursWorked) { return hourlyWage * hoursWorked; }
    public void setHourlyWage(int newWage) { hourlyWage = newWage; }
}

Does Employee exhibit high or low cohesion? Justify your answer.

Low cohesion:

  • firstName/surname are only set in the constructor and returned by a single getter — better as a dedicated personal-details class, or simply a single name string.
  • The address fields are assigned once and used in a single getter — they appear arbitrarily grouped in this class and could live in a separate object.
  • The role fields (currentRole, currentRoleSecurityLevel) are used across several methods, but never interact with the name or address fields — this functionality could move to a Role class, shrinking Employee’s constructor and letting other objects reuse Role.

Employee is really trying to wrap the functionality of at least two other objects (address, role) inside itself; since the grouping of state appears coincidental rather than purposeful, Employee has low cohesion.

Week 11

Lambdas, Streams and Events

See csse2002 for course logistics. Guest lecture by James Baker (Principal Software Engineer, School of EECS).

Today’s outline

  1. Lambdas in Java (briefly)
  2. Streams in Java (briefly)
  3. Events, including a toy event system built from scratch

All content is in java-lambdas-and-streams and java-events.

Applied class

See week11-tutorial-dependency-inversion — more practice with Dependency Inversion, extending java-solid-principles (Week 7).

Aside: anecdotal local industry survey

The lecture closed with an informal, small-sample survey (~14 Queensland-based dev teams from the lecturer’s own professional network) on AI tool usage and graduate hiring — explicitly framed as anecdotal rather than representative data, so not reproduced here as course content.

Dependency Inversion

Applied class for 2026-05-14-lambdas-streams-and-events (Week 11). More practice applying Dependency Inversion and Dependency Injection (see java-solid-principles).

Dependencies

A class A has a dependency on class B if A refers to B in code — an import B;, or (if in the same package) simply referring to B anywhere in the code.

import furniture.Lamp;

class Desk {
    HardwoodFloor placedOn;
    List<Junk> items = new ArrayList<>();
    LogitechKeyboard keyboard = new LogitechKeyboard();

    public Desk() {
        placedOn = new HardwoodFloor();
        items.add(new Book("Pragmatic Programmer"));
        items.add(new Monitor());
    }

    public void rotate(Angle angle) {
        if (angle == Angle.LEFT) {
            placedOn.scratch("clockwise");
        }
        // implementation
    }

    public boolean clean() {
        // implementation
    }
}

List the classes Desk depends upon.

Lamp, HardwoodFloor, Junk, List, ArrayList, Book, Monitor, Angle, LogitechKeyboard.

Not all dependencies are equally bad. Two properties determine how good/bad a dependency is:

  • Stability — how likely the dependency is to change. ArrayList is a good dependency because it’s very unlikely to change; in general, treat classes you write as unstable until proven otherwise. Interfaces are usually assumed more stable than concrete classes (unless they’re poorly designed and change often).
  • Exposure — how much of the class uses the dependency. Book (used only in the constructor) is a better dependency than HardwoodFloor (used in any method) simply because less of the class is entangled with it.

Roughly order Desk’s dependencies from best to worst.

  1. List (interface, low exposure)
  2. ArrayList
  3. Lamp (assuming it isn’t actually used anywhere — the import is unused)
  4. Book
  5. Monitor
  6. Angle — likely an enum, so reasonably assumed stable
  7. LogitechKeyboard
  8. HardwoodFloor
  9. Lamp (assuming it actually is used somewhere, contradicting the “unused import” assumption above)

Dependency Inversion

Dependency Inversion Principle (DIP): depend on abstractions, not concretions (implementations).

Minimising concrete dependencies (the simple form)

The simplest step towards DIP: change a field’s compile-time type from a concrete class to whatever interface it implements (assuming the interface exposes everything the field is used for):

- HardwoodFloor placedOn;
+ Floor placedOn;
public class Warrior {
    private BronzeSword weapon = new BronzeSword();
    private BronzeShield shield = new BronzeShield();

    public void attack(Opponent opponent) { weapon.use(opponent); }
    public void defend(Attack attackType) { shield.block(attackType); }
}

Given a hierarchy Weapon (interface) ← SwordBronzeSword/GoldSword, and a standalone concrete BronzeShield (no interface):

Minimise concrete dependencies for Warrior.

- private BronzeSword weapon = new BronzeSword();
+ private Weapon weapon = new BronzeSword();

(BronzeShield can’t be minimised this way yet — there’s no interface for it, see the proper form below.)

The proper form of DIP

Minimising concrete dependencies only helps where an abstraction already exists. The proper form of DIP is:

Remove dependencies on low-level components from high-level components — make both depend on an abstraction.

High-level vs low-level isn’t a strict distinction: high-level components implement application logic (e.g. a browser’s back/forward navigation history); low-level components do the grunt work the high-level logic depends on (e.g. actually requesting a webpage’s data). The goal is for high-level components to talk to low-level components exclusively through an abstraction, so a low-level component changing doesn’t force a change to the high-level component depending on it.

Applying this to Desk’s LogitechKeyboard dependency (LogitechKeyboard has no existing interface to fall back on):

  1. Create an abstraction of the low-level component:

    interface Keyboard {
        void type(char key);
        // include other ways to interface with the low-level component;
        // multiple interfaces can be used if it has multiple responsibilities
    }
  2. Modify the low-level component to depend on (implement) the abstraction:

    - class LogitechKeyboard {
    + class LogitechKeyboard implements Keyboard {
  3. Minimise concrete dependencies in the high-level component:

    - LogitechKeyboard keyboard = new LogitechKeyboard();
    + Keyboard keyboard = new LogitechKeyboard();

Apply the proper form of DIP to Warrior’s BronzeShield dependency.

interface Shield {
    void block(Attack attack);
}
- class BronzeShield {
+ class BronzeShield implements Shield {
- private BronzeShield shield = new BronzeShield();
+ private Shield shield = new BronzeShield();

Dependency Injection

Even after minimising concrete dependencies, Desk still depends on the concrete types when constructing them. Dependency Injection (DI) completes DIP: provide concrete instances to a class as parameters, rather than having the class instantiate them itself:

- public Desk() {
-     placedOn = new HardwoodFloor();
+ public Desk(Floor floor) {
+     placedOn = floor;

Desk no longer needs to change to support a different floor type — the caller just passes a different Floor implementation in. A constructor isn’t the only injection point — a setter is appropriate when the dependency is likely to change over the object’s lifetime:

public changeKeyboard(Keyboard keyboard) {
    this.keyboard = keyboard;
}

Modify Warrior so its concrete types are dependency injected.

public class Warrior {
    private Weapon weapon;
    private Shield shield;

    public Warrior(Weapon weapon, Shield shield) {
        this.weapon = weapon;
        this.shield = shield;
    }

    // it's quite likely a warrior will change their weapon
    public void equip(Weapon weapon) { this.weapon = weapon; }

    // overloading gives a nice consistent interface for this action
    public void equip(Shield shield) { this.shield = shield; }

    public void attack(Opponent opponent) { weapon.use(opponent); }
    public void defend(Attack attackType) { shield.block(attackType); }
}

weapon/shield are injected via the constructor (initial equipment) and can be swapped later via the overloaded equip(...) setters (since it’s plausible a warrior changes weapon or shield mid-game) — this is setter injection layered on top of constructor injection.

Dependency injection frameworks

Something (typically an entry-point method, e.g. main) still has to construct the concrete dependencies before injecting them. DI frameworks let you defer that construction to runtime, specifying which concrete classes to build via the framework’s own configuration rather than in code. Unless a project needs multiple levels of injected dependencies, good design alone can avoid the extra conceptual overhead of adopting a DI framework.

Week 12

Correct Programming

See csse2002 for course logistics. Lecture by Dr. Brae Webb.

Correctness is the prime quality. If a system does not do what it is supposed to do, everything else about it […] matters little.

— Bertrand Meyer, Object-Oriented Software Construction, §1.2 p.4, 1997

Today’s outline

  1. Specification — what does it mean for a program to be correct?
  2. Deriving preconditions — propagating a postcondition backward through code
  3. Loop invariants — reasoning about (and designing) loops
  4. Pragmatic considerations — when is this worth the effort?

All content is in java-hoare-logic and java-loop-invariants.

Applied class

See week12-tutorial-lambdas-and-streams — more practice with lambdas and streams, extending java-lambdas-and-streams (Week 11).

Practical

See week12-lab-generics — more practice with bounded generics and wildcards, extending java-generics (Week 9).

Next steps

  • CSSE3100 — Reasoning About Programs
  • Floyd, 1967 — Assigning Meanings to Programs
  • Hoare, 1969 — introduced the triple \(\{P\}\,S\,\{Q\}\)
  • Dijkstra, 1975 — weakest-precondition reasoning

Generics

Practical for 2026-05-21-correct-programming (Week 12). Extends java-generics (Week 9) with practice designing generic classes and using bounded wildcards.

Setup

A simple Bus domain models a class hierarchy of things that can ride a bus:

Bus <>--- Passenger
              ^
      +-------+-------+
      |               |
     Pet             Person
                       ^
              +--------+--------+
              |                 |
       TransportWorker   GeneralPublic
              ^                 ^
      +-------+-------+         |
      |               |         |
MaintenanceStaff   BusDriver  Concession

Passenger is the common supertype every rider implements/extends; Pet and Person are its two direct subtypes; TransportWorker (with subtypes MaintenanceStaff, BusDriver) and GeneralPublic (with subtype Concession) are both Persons. A Bus holds a collection of Passengers up to some capacity.

§1 Generic Bus

Task 0. The starting Bus class is not generic — it stores plain Passengers, so a single Bus instance could hold a mix of Pets and BusDrivers with no way to restrict it further:

public class Bus {
    private final List<Passenger> passengers;
    private final int capacity;

    public Bus(int capacity) {
        this.capacity = capacity;
        this.passengers = new ArrayList<>();
    }

    /**
     * @requires passenger != null
     * @ensures passenger is added to this bus and true is returned,
     *          unless this bus is already at capacity, in which case
     *          nothing changes and false is returned
     */
    public boolean addPassenger(Passenger passenger) {
        if (passengers.size() >= capacity) {
            return false;
        }
        passengers.add(passenger);
        return true;
    }

    public List<Passenger> getPassengers() {
        return passengers;
    }
}

Rewrite Bus to be generic, so a single Bus instance can be restricted to carrying just one kind of passenger (e.g. Bus<Pet>, Bus<BusDriver>).

public class Bus<T> {
    private final List<T> passengers;
    private final int capacity;

    public Bus(int capacity) {
        this.capacity = capacity;
        this.passengers = new ArrayList<>();
    }

    /**
     * @requires passenger != null
     * @ensures passenger is added to this bus and true is returned,
     *          unless this bus is already at capacity, in which case
     *          nothing changes and false is returned
     */
    public boolean addPassenger(T passenger) {
        if (passengers.size() >= capacity) {
            return false;
        }
        passengers.add(passenger);
        return true;
    }

    public List<T> getPassengers() {
        return passengers;
    }
}

Task 1. As written, Bus<T> accepts any type argument at all — including types with nothing to do with Passenger (e.g. Bus<String>). Add a bound to T that restricts it to Passenger and its subtypes.

public class Bus<T extends Passenger> {
    // ...unchanged from Task 0...
}

Bounding T extends Passenger restricts Bus’s type argument to Passenger or one of its subtypes (Pet, Person, TransportWorker, …), while a particular Bus instance still only carries one such type (e.g. Bus<Pet> can’t also hold a BusDriver). It also means code inside Bus<T> could safely call any Passenger method directly on a T value, if needed.

Task 2. Write a NoPetsBus<T> subclass of Bus<T> that statically (at compile time, not with a runtime check) excludes Pet as a valid type argument.

public class NoPetsBus<T extends Person> extends Bus<T> {
    public NoPetsBus(int capacity) {
        super(capacity);
    }
}

Bounding the subclass’s type parameter to T extends Person (rather than Passenger) is stricter than Bus’s own bound. Since Pet is a Passenger but not a Person, NoPetsBus<Pet> simply doesn’t compile — the exclusion is enforced entirely by the type system, with no instanceof checks needed anywhere.

§2 Bus Stop

Task 3. Write a trainingBus method that takes a Bus<BusDriver> and a list of TransportWorkers, and adds every BusDriver among them to the bus (stopping early if the bus becomes full):

public static void trainingBus(Bus<BusDriver> bus, List<? extends TransportWorker> trainees) {
    ...
}
/**
 * @requires bus != null && trainees != null
 * @ensures every BusDriver in trainees (in order) is added to bus,
 *          stopping as soon as bus is full
 */
public static void trainingBus(Bus<BusDriver> bus, List<? extends TransportWorker> trainees) {
    for (TransportWorker trainee : trainees) {
        if (trainee instanceof BusDriver busDriver) {
            boolean added = bus.addPassenger(busDriver);
            if (!added) {
                return;
            }
        }
    }
}

trainees only needs to be read from (never written to), so it takes the upper-bounded wildcard List<? extends TransportWorker> — this lets callers pass a List<TransportWorker>, List<BusDriver>, or List<MaintenanceStaff> alike, at the cost of the compiler only guaranteeing each element is at least a TransportWorker (hence the instanceof check before adding).

Task 4. Write a transferStudents method that moves every passenger out of a Bus<Concession> and into another bus that’s allowed to carry Concession passengers (or any of their supertypes):

public static void transferStudents(Bus<Concession> from, Bus<? super Concession> to) {
    ...
}
/**
 * @requires from != null && to != null
 * @ensures passengers are moved from `from` into `to`, in order, until either
 *          `from` is empty or `to` is full (any remaining passengers stay in `from`)
 */
public static void transferStudents(Bus<Concession> from, Bus<? super Concession> to) {
    Iterator<Concession> iterator = from.getPassengers().iterator();
    while (iterator.hasNext()) {
        Concession passenger = iterator.next();
        boolean added = to.addPassenger(passenger);
        if (!added) {
            return;
        }
        iterator.remove();
    }
}

to only needs to be written to, so it takes the lower-bounded wildcard Bus<? super Concession> — this lets callers pass a Bus<Concession>, Bus<GeneralPublic>, Bus<Person>, or Bus<Passenger> alike, since all of them can legally accept a Concession passenger via addPassenger.

Task 5. transferStudents only works for exactly Bus<Concession> as its source. Generalise it into a fully generic method that works for Bus<T> for any T that’s at least a Concession.

/**
 * @requires from != null && to != null
 * @ensures passengers are moved from `from` into `to`, in order, until either
 *          `from` is empty or `to` is full (any remaining passengers stay in `from`)
 */
public static <T extends Concession> void transferAllConcession(Bus<T> from, Bus<? super T> to) {
    Iterator<T> iterator = from.getPassengers().iterator();
    while (iterator.hasNext()) {
        T passenger = iterator.next();
        boolean added = to.addPassenger(passenger);
        if (!added) {
            return;
        }
        iterator.remove();
    }
}

Introducing the type parameter <T extends Concession> on the method itself (rather than fixing T = Concession) means from can be Bus<Concession> or any more specific subtype-bus (e.g. a hypothetical Bus<StudentConcession>), while to’s wildcard bound ? super T is resolved relative to whatever T the call site infers — transferStudents from Task 4 is just the special case where T is fixed to Concession.

Lambdas and Streams

Applied class for 2026-05-21-correct-programming (Week 12). Extends java-lambdas-and-streams (Week 11) with practice reading λ-function traces and standard functional interfaces (Function, BiFunction, Predicate, Supplier, Consumer, BiConsumer, UnaryOperator).

On the HTML site, fill in each blank with your answer, then click Run Code to check it. In the PDF, the Working callout is shown as a static answer key instead (interactive checking needs a browser).

§1 \(\lambda\)-functions

Task 0. What is printed by:

Function<Integer, Integer> f = x -> 2 * x;
System.out.println(f.apply(3));

6

Task 1. What is printed by:

Supplier<String> c = () -> "Hello World";
System.out.println(c.get());

Hello World

Task 2. What is printed by:

int power = 3;
Function<Integer, Double> g = x -> Math.pow(x, power);
System.out.println(g.apply(5));

125.0 (\(5^3\))

Task 3. What is printed by:

int power = 3;
Function<Integer, Double> g = x -> Math.pow(x, power);
power = 2;
System.out.println(g.apply(5));

Does not compile — power is reassigned after the lambda captures it, so it is not effectively final, which lambdas require of any local variable they capture.

Task 4. What is printed by:

Predicate<Integer> p = x -> x % 2 == 0;
System.out.println(p.test(4));

true

Task 5. What is printed by:

BiFunction<Integer, Integer, Integer> add = (x, y) -> x + y;
System.out.println(add.apply(3, 5));

8

Task 6. What is printed by:

Function<String, Integer> strLength = String::length;
System.out.println(strLength.apply("Hello"));

5String::length is a method reference, equivalent to s -> s.length().

Task 7. What is printed by:

UnaryOperator<Integer> square = x -> x * x;
System.out.println(square.apply(4));

16

Task 8. What is printed by:

int base = 2;
Function<Integer, Double> exp = x -> Math.pow(base, x);
System.out.println(exp.apply(3));

8.0 (\(2^3\))

Task 9. What is printed by:

Function<Integer, Integer> factorial = n -> {
    int result = 1;
    for (int i = 2; i <= n; i++) {
        result *= i;
    }
    return result;
};
System.out.println(factorial.apply(4));

24 (\(4! = 4 \\times 3 \\times 2 \\times 1\))

Task 10. What is printed by:

Function<Integer, Integer> f1 = x -> x + 1;
Function<Integer, Integer> f2 = x -> x * 2;
Function<Integer, Integer> composed = f1.andThen(f2);
System.out.println(composed.apply(3));

8andThen runs f1 first (3 + 1 = 4), then f2 on that result (4 * 2 = 8).

Task 11. What is printed by:

List<Integer> numbers = new ArrayList<>();
Consumer<Integer> addTo = (number) -> numbers.add(number);

addTo.accept(24);
addTo.accept(42);
System.out.println(numbers);

{24, 42}

Task 12. What is printed by:

Supplier<StringBuilder> supplier = StringBuilder::new;
BiConsumer<StringBuilder, String> appender = StringBuilder::append;
Consumer<StringBuilder> printer = System.out::println;

StringBuilder sb = supplier.get();
appender.accept(sb, "cher");
appender.accept(sb, "ryp");
appender.accept(sb, "ick");
printer.accept(sb);

cherrypick

Task 13. What code snippet replaces ??? so this prints 10?

BiFunction<Integer, Function<Integer, Integer>, Supplier<Integer>> g =
    (i, f) -> ??? ;
System.out.println(g.apply(5, x -> x * 2).get());

() -> f.apply(i)g returns a Supplier<Integer>, a zero-argument lambda that, when later called via .get(), applies f to i (5 * 2 = 10).

Task 14. What is printed by:

Function<Integer, Integer> f = x -> x * 2;
UnaryOperator<Integer> g = x -> x + 1;
Function<Integer, Integer> h = f.andThen(g).andThen(f).andThen(g);
System.out.println(h.apply(3));

15 — chained left to right: f(3)=6, g(6)=7, f(7)=14, g(14)=15.

§2 Streams

Task 15. What is the value of:

Stream.of(1, 2, 3, 4, 5, 6, 7, 8).filter(x -> x % 2 == 0).toList();

{2, 4, 6, 8}

Task 16. What is the value of:

Stream.of(1, 2, 3, 4, 5, 6, 7, 8).map(x -> x * x).toList();

{1, 4, 9, 16, 25, 36, 49, 64}

Task 17. What is the value of:

List<String> list = Arrays.asList("apple", "banana", "cherry");
list.stream()
    .map(String::toUpperCase)
    .filter(s -> s.startsWith("A"))
    .forEach(System.out::println);

APPLE — only "apple" survives the startsWith("A") filter once upper-cased.

Task 18. What is the value of:

Stream<Integer> stream = Stream.iterate(1, n -> n * 2).limit(5);
Integer result = stream.reduce(0, (a, b) -> a + b);
System.out.println(result);

31Stream.iterate(1, n -> n * 2).limit(5) produces 1, 2, 4, 8, 16, and \(1+2+4+8+16 = 31\).

Task 19. Convert the following method into streams:

static int sumOfSquareNumbers(List<String> words) {
    int sum = 0;
    for (String word : words) {
        if (word.matches("[0-9]+")) {
            int number = Integer.parseInt(word);
            if (Math.sqrt(number) == Math.floor(Math.sqrt(number))) {
                sum += number;
            }
        }
    }
    return sum;
}
static int sumOfSquareNumbers(List<String> words) {
    return words.stream()
        .filter(word -> word.matches("[0-9]+"))
        .mapToInt(Integer::parseInt)
        .filter(number -> Math.sqrt(number) == Math.floor(Math.sqrt(number)))
        .sum();
}

Task 20. What does this print?

List<String> words = Arrays.asList("cat", "dog", "elephant", "giraffe");
String details = words.stream()
        .reduce("", (word1, word2) -> word1 + "-" + word2);
System.out.println(details);

-cat-dog-elephant-giraffe — the identity "" is the first word1, so even the first element gets a leading - prepended.

Task 21. What does this print?

Stream<String> stream = Stream.of("hello", "world", "java");
stream.map(String::toUpperCase)
      .forEach(System.out::print);

HELLOWORLDJAVAprint (not println) concatenates every element with no separator.

Reference material

Java Abstraction

Introduced in 2026-03-05-object-oriented-programming-i (Lecture, Week 2).

What is abstraction?

The ability to hide complex implementation details and expose only the essential behaviours. In Java, abstraction is often implemented using interfaces and abstract classes — both define methods that subclasses must implement, specifying a contract for what operations can be performed on an object without specifying how those operations are implemented.

Interfaces

An interface is a group of related methods with empty bodies (a contract that implementing classes must fulfil). To use an interface’s methods, a class must implement it — a class can implement multiple interfaces.

public interface PaymentProcessor {
    void processPayment(double amount);
}

public class PayPalProcessor implements PaymentProcessor {
    @Override
    public void processPayment(double amount) {
        System.out.println("Processing PayPal payment: " + amount);
    }
}

public class Main {
    public static void main(String[] args) {
        PaymentProcessor processor = new PayPalProcessor();
        processor.processPayment(100);
    }
}

The program only interacts with the PaymentProcessor interface — the implementation details are hidden inside the class that implements it.

Abstract classes

An abstract class cannot be instantiated directly. It can mix abstract methods (no body — declared with the abstract keyword) with fully-implemented methods, and subclasses must implement all its abstract methods.

public abstract class Payment {
    public void validateTransaction() {
        System.out.println("Validating transaction...");
    }
    abstract void processPayment(double amount);
}

public class PayPalPayment extends Payment {
    @Override
    public void processPayment(double amount) {
        System.out.println("Processing PayPal payment: $" + amount);
    }
}

public class Main {
    public static void main(String[] args) {
        Payment payment = new PayPalPayment();
        payment.validateTransaction();
        payment.processPayment(150.0);
    }
}

The abstract class defines the common behaviour of all payments (validateTransaction), while leaving the payment-specific logic (processPayment) to subclasses.

Interfaces vs abstract classes

Covered in 2026-03-12-object-oriented-programming-ii (Lecture, Week 3) — Week 2’s lecture left this comparison as a homework/self-study item.

  • Use an interface to define a capability or contract, e.g. Flyable, Runnable, Payable. A class can implement multiple interfaces.
  • Use an abstract class to define a shared base implementation (common behaviour), e.g. an Animal abstract class shared by Dog and Cat. A class can only extend one (super)class.
abstract class Animal {
    public void eat() {
        System.out.println("Animal is eating");
    }
    public abstract void makeSound();
}

class Dog extends Animal {
    @Override
    public void makeSound() {
        System.out.println("Dog barks");
    }
}

class Cat extends Animal {
    @Override
    public void makeSound() {
        System.out.println("Cat Meows");
    }
}

A class can combine both: extend at most one abstract/concrete class, while implementing as many interfaces as needed:

class Dog extends Animal implements Runnable, Swimmable
class Duck extends Animal implements Flyable, Swimmable

Java Casting

Introduced in 2026-03-12-object-oriented-programming-ii (Lecture, Week 3). See also java-primitive-and-reference-types.

Casting converts a variable from one type to another.

Primitive casting

  • Widening — converting a smaller type to a larger one: byte -> short -> char -> int -> long -> float -> double.
  • Narrowing — converting a larger type to a smaller one: double -> float -> long -> int -> char -> short -> byte.

Reference casting

  • Upcasting (implicit) — casting a subclass reference to a superclass reference.
  • Downcasting (explicit) — casting a superclass reference back to a subclass reference.

Given Object -> Animal -> {Mammal -> {Dog, Cat}, Bird -> Chicken}, upcasting moves up this hierarchy, downcasting moves down it.

Upcasting

Always safe, done implicitly:

public class Animal {
    public void makeSound() { System.out.println("Animal makes a sound"); }
}
public class Cat extends Animal {
    @Override public void makeSound() { System.out.println("Cat meows"); }
}

Animal myAnimal = new Cat(); // upcasting: Cat treated as an Animal
myAnimal.makeSound();        // "Cat meows"

Downcasting

Must check the object is actually an instance of the target subclass with instanceof first, since not every Animal is a Cat:

public class Cat extends Animal {
    @Override public void makeSound() { System.out.println("Cat meows"); }
    public void climb() { System.out.println("I can climb trees"); }
}

Animal myAnimal = new Cat(); // upcasting
if (myAnimal instanceof Cat) {
    Cat myCat = (Cat) myAnimal; // downcasting
    myCat.climb();              // access methods specific to Cat
}

Every class implicitly extends Object

A class with no explicit extends clause (e.g. a plain public class Student { ... }) still has a parent: Object, the root of the Java class hierarchy.

Java Cohesion and Coupling

Introduced in 2026-04-02-refactoring (Lecture, Week 6, Part 2).

Modularization

The process of dividing a large system (a monolith) into smaller, more manageable pieces that can be worked on and tested independently before being reassembled into the final product. This raises a question: what if the resulting modules are highly dependent on each other?

Coupling

The degree of interdependence between different modules, classes, or components of a system.

  • High coupling — strong interconnections, where a change in one module can cascade through others.
  • Low coupling — greater independence and isolation between modules.

Levels of coupling (tightly → loosely coupled)

  1. Content — one class directly manipulates another’s data (e.g. public fields instead of private).
  2. Common — sharing access to the same global data/variables.
  3. External — sharing something imposed by an external source, e.g. a data format.
  4. Control — one class controls what happens in another by passing it information/instructions.
  5. Stamp — sharing a data structure, but each class only needs access to a select part of it.
  6. Data — sharing data through means such as parameter passing in methods.
  7. None — no dependency at all (most loosely coupled).

Worked classification examples

Classifying real code against the levels above (my own reasoned classification, following the definitions above — the lecture posed these as open discussion questions without a printed answer key):

public void sinh(int x) {...}
public void cosh(int x) {...}
public void tanh(int x) {
    return sinh(x) / cosh(x);
}

tanh only interacts with sinh/cosh by passing/receiving simple parameters — data coupling.

public class R { int x, y, z; char a, b, c; }
public static void compute(R r) {
    return r.x * r.y; // only touches x and y
}

compute is handed the whole R object but only needs two of its six fields — stamp coupling.

public static void compute(R r) {
    return r.x * r.y * r.z / r.a + r.b + r.c; // uses every field
}

Here every field of R is actually used, so passing the whole object is fully justified — this is just data coupling via a composite value, not stamp coupling (contrast with the previous example, which only used part of the structure).

public static runReport(String name, int age, String phone, Date birth, String address) {...}

Five separate simple parameters — still data coupling, though a long parameter list like this is itself often a separate code smell (see java-refactoring) worth considering bundling into an object.

Cohesion

The degree of interrelatedness and focus among the elements within a module, class, or component.

  • High cohesion — elements are closely related and contribute collectively to one specific functionality.
  • Low cohesion — elements are less focused, serving multiple unrelated purposes.

Levels of cohesion (low → high)

  1. Coincidental — elements grouped arbitrarily, with no clear relationship.
  2. Temporal — elements grouped because they execute at the same time/phase.
  3. Procedural — elements grouped by their involvement in a specific sequence of steps.
  4. Communicational — elements work together to manipulate a shared data structure.
  5. Sequential — elements organised in a linear sequence, where one’s output is the next’s input.
  6. Functional — elements grouped around a single, specific functionality or task (most cohesive).

Worked example

public void performTasks(int[] numbers, String text) {
    // Task 1: sum the numbers
    int sum = 0;
    for (int num : numbers) { sum += num; }
    System.out.println("Sum of numbers: " + sum);

    // Task 2: reverse the text
    StringBuilder reversedText = new StringBuilder();
    for (int i = text.length() - 1; i >= 0; i--) { reversedText.append(text.charAt(i)); }
    System.out.println("Reversed text: " + reversedText);
}

Summing numbers and reversing text share no real purpose — they’re only in the same method because they happen to run one after another. This is low (coincidental, or at best temporal) cohesion, and a sign performTasks should be split into two focused methods.

Is this class cohesive?

A Car class contains: Fuel, Steering, Speed, Route planner, Public holiday calculator.

Fuel/Steering/Speed are all core to a car’s own physical behaviour, but a route planner and (especially) a public-holiday calculator are unrelated concerns bolted onto the class — this is low cohesion, and a sign the class is trying to do too much (a “God class”); the unrelated pieces should be split into their own classes.

Considerations

  • Classes should be highly cohesive — a single, easily understood concept.
  • Classes should have loose coupling to each other — this reduces overall system complexity.

Good modularization (high cohesion, low coupling) groups tightly-interconnected elements together into the same module and minimises connections between modules, unlike bad modularization, where connections are scattered across module boundaries with no clear grouping.

Java Collections Framework

Introduced in 2026-02-26-java-basics-part-02-collections-and-strings (Lecture 1, Week 1) — see that lecture for the worked push/pop/add/remove traces. All of these live in java.util.*, and (unlike arrays) grow/shrink automatically.

Why collections instead of arrays?

Arrays have a fixed size at creation and don’t automatically close gaps when an element is removed from the middle. Collections solve both problems, at the cost of only being able to store objects (reference types) — see java-primitive-and-reference-types for the primitive wrapper classes (Integer, Double, etc.) used to store primitives inside them.

Stack — LIFO

Method Description
empty() Is this stack empty?
peek() Return the object at the top of the stack
pop() Remove (and return) the object at the top of the stack
push(obj) Put obj on the top of the stack
Stack<Type> stacks = new Stack<>();

pop() on an empty stack throws EmptyStackException.

List

An interface, not a particular implementation — you can declare a variable as List, but can’t do new List().

  • ArrayList — better for random access (get(i)).
  • LinkedList — better for operations that modify the middle of the list.

Holds items in sequential order (like an array), 0-indexed, with no fixed size limit; supports inserting/removing at any position.

Set

Stores unique items (no duplicates); don’t assume any iteration order.

interface Set<E> {
    int size();
    boolean contains(E item);
    boolean add(E e);
    boolean remove(E item);
}
  • TreeSet<E>E must implement Comparable (e.g. String).
  • HashSet<E>E must have sensible hashCode()/equals().

Map

Stores key → value pairs (like a Python dict) — specify a type for both the key and the value, e.g. Map<Integer, String>.

interface Map<K, V> {
    int size();
    boolean containsKey(K key);
    boolean containsValue(V value);
    V get(K key);
    V put(K key, V value);
    V remove(K key);
    Set<K> keySet();
}
  • TreeMap<K,V>K must implement Comparable.
  • HashMap<K,V>K must have sensible hashCode()/equals().

The hashCode/equals contract

HashSet/HashMap rely on their elements/keys satisfying:

  1. x.equals(y) \(\iff\) y.equals(x) (symmetric).
  2. x.equals(y) \(\implies\) x.hashCode() == y.hashCode().

hashCode() returns an integer generated by a hashing algorithm for the object — two objects that are .equals() must hash the same, or a HashSet/HashMap won’t be able to find them correctly.

Java Encapsulation

Introduced in 2026-03-05-object-oriented-programming-i (Lecture, Week 2).

Classes and objects

  • A class describes the contents of the objects that belong to it: an aggregate of data fields (properties) plus the operations (methods) defined on them.
  • An object is an element (instance) of a class; objects have the behaviours of their class. The object is the actual component of a running program, while the class specifies how instances are created and how they behave.

Encapsulation

Encapsulation is the process of bundling code (methods/member functions) and data (member variables) together into a single unit — a class. It restricts direct access to some of an object’s components, preventing the accidental modification of data.

public class Person {
    private String name;
    private int age;
    private String email;
    private String[] phoneNumber;

    public Person(String name, int age, String email, String[] phoneNumber) {
        this.name = name;
        this.age = age;
        this.email = email;
        this.phoneNumber = phoneNumber;
    }

    public String getName() { return name; }
    public int getAge() { return age; }
    public String getEmail() { return email; }
    public String[] getPhoneNumber() { return phoneNumber; }
}

Constructors

A constructor is a special method invoked when an object of the class is created (via new):

public class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public static void main(String[] args) {
        Person person = new Person("John", 20);
        System.out.println("The name of the person is " + person.name);
    }
}

If a class has no constructor, the Java compiler automatically creates a default constructor at runtime.

Notes on constructors:

  • Constructors are invoked implicitly when objects are instantiated.
  • The constructor’s name MUST be the same as the class.
  • A constructor must not have a return type.
  • A constructor can be overloaded but cannot be overridden.

Access modifiers

Access modifiers set the accessibility (visibility) of classes, interfaces, properties, methods, constructors, and data members:

Modifier Accessible from
private Only within the declaring class
public Anywhere
protected Same package, plus subclasses (see java-inheritance)
(default, no keyword) Only within the same package

Attempting to access a private field from outside its class is a compile error (X has private access in Y).

Getters and setters

Getter and setter methods provide controlled access to an object’s private fields, keeping its internal representation hidden from the outside world:

  • Getters (accessors) retrieve the value of a private field from outside the class.
  • Setters (mutators) set/update the value of a private field from outside the class.
public class BankAccount {
    private double balance;
    private String accountNumber;

    public BankAccount(String accountNumber) {
        this.accountNumber = accountNumber;
    }

    public double getBalance() { return balance; }
    public void setBalance(double balance) { this.balance = balance; }
    public String getAccountNumber() { return accountNumber; }
}

The static keyword

static is a non-access modifier for methods and attributes:

  • Static methods/attributes can be accessed without creating an object of the class.
  • Useful in memory management; can be applied to variables, methods, blocks, and nested classes.

Static variables (class variables) are shared among all instances of a class — useful for constants and shared properties, e.g. public static int totalAccounts = 0;.

Static methods can be called without creating an instance of the class, but cannot access non-static (instance) variables or methods directly:

class Bank {
    static double interestRate = 5.0;
    static double calculateInterest(double balance, int years) {
        return (balance * interestRate * years) / 100;
    }
}

Class invariants

A class invariant specifies a condition (or set of conditions) that should always be true throughout the life of an object — used to ensure a system remains in a valid state. A class invariant:

  1. Must be established after the class constructor.
  2. May be assumed as a precondition of each method (excluding the constructor).
  3. Must be established after each method call.
class Counter {
    private int count;
    public Counter() { count = 0; }
    public void increment() { count = count + 1; }
}
// Invariant: count >= 0

Invariants complement preconditions (what must be true before a method executes) and postconditions (what must be true after) — together these define a contract for how a method should behave.

Protecting invariants

A specification can claim an invariant while the implementation still allows it to be broken. Two common leaks:

  1. Public fields — a directly-mutable field lets any caller bypass the class entirely and violate the invariant. Fix: make the field private.
  2. Returning an internal reference — a getter that returns its internal mutable collection/object directly hands the caller a way to mutate it from outside, bypassing any checks the class’s own methods perform. Fix: return a defensive copy:
private List<String> files;

public List<String> getFiles() {
    return new ArrayList<>(files); // copy, not the internal reference
}

Preserving the invariant may also require adding preconditions to methods that could otherwise violate it (e.g. rejecting an addition that wouldn’t satisfy the invariant), or, when a method is likely to be called by code outside your control, defensively throwing an exception (e.g. IllegalArgumentException/IllegalStateException) rather than relying purely on the precondition contract (see java-specification — Defensive programming).

Method signatures

A method signature is a method’s unique identifier: its name plus its parameter types. The return type and parameter names do not count towards the signature, and a signature MUST be unique within a class:

public void print() { ... }          // signature: print()
public void print(int parameter) { ... } // signature: print(int)

Java Events

Introduced in 2026-05-14-lambdas-streams-and-events (Guest lecture, Week 11).

Event-driven programming

A common design pattern for controlling program flow — a different way to glue chunks of code together than direct method calls. Code (often a lambda, see java-lambdas-and-streams) subscribes as a listener for specific events from specific sources; when triggered, the event is dispatched to every subscribed listener. E.g. clicking a button spawns a click event, which is passed to anything listening for clicks on that button. Extremely common in GUI programming (Java’s Swing and JavaFX both have built-in event systems) and network programming (Node.js is event-driven under the hood).

A well-designed event system lets each part of a system stay decoupled (see java-cohesion-and-coupling) — a chunk of code doesn’t need to know about the irrelevant details of whatever eventually reacts to the event it raises, only about the event itself.

The event loop

The event system itself is usually run on a separate thread, or via a task-prioritisation architecture, executing an event loop: a program that repeatedly takes an event off a queue and notifies every subscribed listener, dispatching the event’s information to each of them. Many environments provide an event loop for you to hook into (JavaScript has one built into the language itself).

Building a toy event system

A minimal event system needs only two classes plus lambdas:

class SimpleEvent {
    private String type;
    private String data;

    public SimpleEvent(String type, String data) {
        this.type = type;
        this.data = data;
    }

    public String getType() { return type; }
    public String getData() { return data; }
}
class EventSystem {
    // addListener(type, action): subscribe a Consumer<SimpleEvent> to a named event type.
    public Consumer<SimpleEvent> addListener(String type, Consumer<SimpleEvent> action) { ... }

    // removeListener(action): unsubscribe a previously-added listener.
    public void removeListener(Consumer<SimpleEvent> action) { ... }

    // addEvent(event): queue an event to be dispatched on the next tick().
    public void addEvent(SimpleEvent event) { ... }

    // tick(): the event loop step - dispatch queued events to their listeners.
    public void tick() { ... }
}

SimpleEvent is just a named bundle of data (type + data); EventSystem maintains the queue of pending events and the map of listeners per event type. Calling tick() is what actually drives the event loop — it drains the queue, and for each event, calls every Consumer<SimpleEvent> subscribed to that event’s type, passing the event itself as the argument.

Motivating example: the CSSE2002 game engine

Without events, the game engine’s tick() method is the only mechanism for passing state between parts of the program — every parent tile/manager ticks its children, explicitly passing state down through the whole hierarchy. This tightly links everything around that single tick() call. Introducing an event system lets far-apart parts of the program communicate directly through events instead, cutting down substantially on this “connective tissue” code that exists purely to shuttle state through layers that don’t otherwise care about it.

Java Exceptions

Introduced in 2026-03-19-exceptions (Lecture, Week 4).

What is an exception?

An unexpected event that occurs during program execution, e.g.:

System.out.println(5 / 0); // ArithmeticException: / by zero (NOT Infinity, unlike floating-point division)

Common causes: invalid user input, loss of network connection, physical limitations (e.g. out of disk space), code errors, opening an unavailable file.

When an exception occurs, Java creates an exception object containing information about the failure (Effective Java, Item 75: include failure-capture information in detail messages).

Exception hierarchy

Throwable
├── Error
└── Exception
    ├── RuntimeException (unchecked)
    └── IOException (checked)

RuntimeException — unchecked

Caused by a programming error; the compiler does not force you to handle these:

  • Improper use of an API → IllegalArgumentException
  • Null pointer access → NullPointerException
  • Out-of-bounds array access → ArrayIndexOutOfBoundsException
  • Dividing by 0 → ArithmeticException

IOException — checked

Checked by the compiler at compile-time — the programmer is prompted (via throws) to handle these:

  • Opening a file that doesn’t exist → FileNotFoundException
  • Reading past the end of a file → EOFException
  • A connection attempt to a remote host fails → ConnectException

try/catch

Place code that might throw inside try; every try is followed by a catch:

int result;
try {
    result = 5 / 0;
} catch (ArithmeticException e) {
    System.out.println(e); // print out the error message
    result = Integer.MAX_VALUE; // fallback value
}
  1. When an exception occurs, the rest of the try block is skipped.
  2. The catch block catches it and its statements execute.
  3. If nothing in try throws, catch is skipped entirely.

throw and throws

  • throw explicitly throws a single exception:
public static void checkEntry(int age) {
    if (age < 18) {
        throw new IllegalArgumentException("Access denied");
    }
}
  • throws (in a method declaration) declares the exception types a method might produce, so callers are prompted to handle them:
public static void findFile() throws FileNotFoundException {
    File newFile = new File("file.txt");
    FileInputStream stream = new FileInputStream(newFile);
}

finally

Runs regardless of whether an exception was thrown; optional.

try {
    // code
} catch (ExceptionType1 e1) {
    // catch block
} finally {
    // always executes
}

Worked example — what does this print?

try {
    System.out.println("Good Morning");
    throw new FileNotFoundException();
    System.out.println("the earth says"); // never reached
} catch (Exception e) {
    System.out.println("hello");
} finally {
    System.out.println("world");
}

Prints Good Morning, hello, world — the throw immediately skips the rest of try (so "the earth says" never prints), catch runs since FileNotFoundException is an Exception, and finally always runs last.

Custom exceptions

Extend Exception (or a subclass) to define your own:

public class ItemNotFoundException extends Exception {
    public ItemNotFoundException() {
        super("Item not found");
    }
    public ItemNotFoundException(String message) {
        super(message);
    }
}

public class Store {
    private List<String> items;

    public Store() {
        items = new ArrayList<>();
        items.add("apple");
        items.add("banana");
        items.add("orange");
    }

    public String findItem(String itemName) throws ItemNotFoundException {
        for (String item : items) {
            if (item.equals(itemName)) {
                return item;
            }
        }
        throw new ItemNotFoundException("Item '" + itemName + "' not found in the list.");
    }
}

Java Generics

Introduced in 2026-04-30-generics-in-java (Lecture, Week 9).

Why generics?

Prior to Java 1.5, collection classes could hold any type of object — ArrayList.add(Object o) accepted anything, allowing types to be mixed, but required an explicit cast when retrieving elements:

List list = new ArrayList();
list.add("Hello");
list.add(123);           // allowed
String str = (String) list.get(0);   // OK, but no compile-time guarantee
Integer num = (Integer) list.get(1);

There was no guarantee only one type would end up in the collection, since add accepted any Object — a String could silently get mixed into a list that was only ever meant to hold Cars. Generics let a class/interface/method declare which type(s) it works with, giving:

List<Car> carList = new ArrayList<Car>(); // or new ArrayList<>() - the diamond operator
carList.add("Not a product"); // compile-time error, not a runtime surprise
Car c = carList.get(0);       // no cast needed - the compiler already knows it's a Car

This is called type safety — catching and preventing type-related errors early (at compile time), rather than allowing unintended bugs (a stray ClassCastException) to surface at runtime.

Generic classes

A generic class parameterises a type across its whole body:

public class Print<T> {
    T i;
    Print(T i) { this.i = i; }
    public void print() { System.out.println(i); }
}

Print<Integer> p1 = new Print<>(1);
Print<Double> p2 = new Print<>(1.1);
Print<String> p3 = new Print<>("1.1");

By convention, type parameters are single letters: T (Type), E (Element — used throughout the Collections Framework), K (Key), V (Value), N (Number), and S/U/V… for a 2nd/3rd/4th type parameter.

Generic methods

A single method (rather than a whole class) can be made generic, independently of whether its enclosing class is generic:

public <T> void print(T data) {
    System.out.println("Lets shout " + data + "!!!");
}
// print(124);              // T inferred as Integer
// print("I love CSSE2002"); // T inferred as String

The compiler determines T from the argument’s type automatically — this is type inference. Multiple type parameters can be declared together: public <T, S, R> R genericMethod(T value1, S value2) { ... }.

Bounded generics

A type parameter can normally accept any reference type. Bounded generics restrict it to a specific type (or one of its subtypes) using extends:

public <T extends Number> void print(T data) {
    System.out.println("Lets shout " + data + "!!!");
}
// print(123);    // OK, Integer is a Number
// print(123.2);  // OK, Double is a Number
// print("123");  // compile-time error, String is not a Number

<T extends X> means T can only accept data that are subtypes of X (despite the keyword, this works the same way whether X is a class or an interface).

Bounds through wildcards

Wildcards (?) bound what types can be substituted for a generic type parameter at the use site (e.g. in a method parameter), rather than when a generic class/method is declared.

Unbounded wildcard: List<?>

List<?> means “a list of some unknown type”. Since Java doesn’t know whether it’s actually List<String>, List<Integer>, or something else, it prevents writes:

public static void addSomething(List<?> list) {
    list.add("Hello"); // compile-time error - could break whatever the real element type is
}

Use <T> when the method needs to work with the type; use <?> when the method only needs to read values (e.g. a printList that only calls .get()/iterates never needs to know the concrete type).

Upper bounded wildcard: ? extends T

Represents T or any subclass of T. Used mainly to read data — you can’t safely add to it, because the method doesn’t know the exact subtype:

List<? extends Number> nums = new ArrayList<Integer>();
Number n = nums.get(0); // OK: read as Number
nums.add(10);           // compile-time error - could be a List<Double>, etc.

This matters because generics in Java are invariant: even though Double is a subclass of Number, List<Double> is not a subclass of List<Number> (Effective Java, Item 31: parameterized types are invariant — List<Type1> is neither a subtype nor a supertype of List<Type2>). So a method that should accept a List<Integer> or a List<Double> and only needs to read Numbers from it must be written as:

public static double sum(List<? extends Number> list) {
    double sum = 0;
    for (Number n : list) {
        sum += n.doubleValue();
    }
    return sum;
}

Lower bounded wildcard: ? super T

Represents T or any of its superclasses. Used when you want to write to a generic collection while keeping some flexibility in what type of collection is accepted:

public static void addNumbers(List<? super Integer> list) {
    list.add(1);
    list.add(2);
}
// addNumbers(new ArrayList<Number>());  // OK, Number is a superclass of Integer
// addNumbers(new ArrayList<Object>());  // OK, Object is a superclass of Integer
// addNumbers(new ArrayList<Double>());  // compile-time error, Double isn't a superclass of Integer

Wildcards are usually bounded (? extends T, ? super T) rather than left fully unbounded, to reduce flexibility just enough to improve type safety.

Type erasure

Generics only provide type checking at compile time — Java implements them via type erasure, which removes all type parameter information during compilation. Each type parameter is replaced with:

  • its upper bound (e.g. Number, if declared <T extends Number>), or
  • Object, if unbounded.

As a result, generic type information is not available at runtime — a compiled List<String> and a compiled List<Integer> are the same erased List class. This is why the compiler needs to insert a hidden cast for you:

// Source (compile time)
List<String> list = new ArrayList<>();
list.add("Hello");
String s = list.get(0);

// After erasure (what actually runs)
List list = new ArrayList();
list.add("Hello");
String s = (String) list.get(0); // compiler inserts this cast

For a bounded type parameter, the compiler substitutes the bound itself:

// Source
class SomeClass<T extends Number> {
    private T t;
    public void add(T t) { this.t = t; }
    public T get() { return t; }
}
// After erasure
class SomeClass {
    private Number t;
    public void add(Number t) { this.t = t; }
    public Number get() { return t; }
}

Practical example: a bookshop

A bookshop sells books across genres (Fiction, Action, Fantasy, …), stored on shelves; the shop needs to store and retrieve books of these different genres.

Without generics

A single BookShelf storing the abstract Book type loses genre information on retrieval — every getItem() call needs an explicit (unsafe) cast back to the specific genre:

public class BookShelf {
    private List<Book> inventory = new ArrayList<>();
    public void addItem(Book item) { inventory.add(item); }
    public Book getItem(int index) { return inventory.get(index); }
}
// Fiction f = (Fiction) shelf.getItem(0); // risk of ClassCastException, not type safe

Writing separate FictionShelf/ActionShelf classes fixes the type safety but duplicates the whole class for every genre.

With generics

A single generic BookShelf<T extends Book> gives type safety and reuse in one class:

public class BookShelf<T extends Book> {
    private List<T> inventory = new ArrayList<>();
    public void addItem(T item) { inventory.add(item); }
    public T getItem(int index) { return inventory.get(index); }
    public void displayBooks() {
        for (T book : inventory) {
            System.out.println(book + "(" + book.getGenre() + ")");
        }
    }
}

BookShelf<Fiction> fictionShelf = new BookShelf<>();
BookShelf<Action> actionShelf = new BookShelf<>();
fictionShelf.addItem(new Fiction("The Hobbit", "J.R.R. Tolkien"));
actionShelf.addItem(new Action("Die Hard", "Roderick Thorp"));
fictionShelf.displayBooks(); // getItem() on fictionShelf now returns Fiction directly, no cast

Java Hoare Logic

Introduced in 2026-05-21-correct-programming (Lecture, Week 12). Formalises reasoning about java-specification’s preconditions/postconditions.

What does it mean for a program to be correct?

A correct program satisfies its specification.

Correctness is only meaningful relative to a specification — asking “is this program correct?” without one is unanswerable:

public int indexOf(int[] numbers, int number) {
    for (int i = 0; i < numbers.length; i++) {
        if (numbers[i] == number) {
            return i;
        }
    }
    return -1;
}
  • With no spec at all: maybe correct, maybe not — we can’t say without knowing what it’s meant to do.
  • Spec’d as /** Returns how many times number occurs in numbers. */: incorrect — counter-example indexOf({1, 1, 1, 1}, 1) == 0, but a correct implementation of that spec should return 4.
  • Spec’d as /** Returns the index of number within numbers, if number is in numbers. */: correct, but the spec is underspecified — it says nothing about what happens when number isn’t in numbers.

A specification restricts the space of acceptable implementations: think of all possible input/output pairs as arrows crossing an Implementation/Specification boundary — an implementation is correct exactly when every arrow it draws stays within the region the specification permits. A @requires/@ensures Javadoc comment (see java-specification) states the precondition and postcondition that bound this region.

Hoare triples

Preconditions and postconditions aren’t just for whole methods — every block of code has them. We write this as a Hoare triple:

\[\{P\}\ S\ \{Q\}\]

meaning: if precondition \(P\) holds before statement(s) \(S\) run, postcondition \(Q\) holds afterwards. E.g.:

// {true}
if (x > y) {
    max = x;
} else {
    max = y;
}
// {max >= x && max >= y}

Deriving preconditions by propagating backward

Given a method’s postcondition, we can work out its precondition (or verify a block of code) by propagating the postcondition backward through each statement, substituting as we go.

Straight-line code

/**
 * @requires numbers != null && 2 < numbers.length && numbers[2] == number
 * @ensures numbers[\result] == number
 */
public int indexOf(int[] numbers, int number) {
    // {numbers[0 - 10 + 12] == number}
    int result = 0;
    // {numbers[result - 10 + 12] == number}
    result = result + 12;
    // {numbers[result - 10] == number}
    result = result - 10;
    // {numbers[result] == number}
    return result;
    // {numbers[\result] == number}
}

Each line’s precondition is obtained by substituting the assigned expression into the next line’s already-derived precondition, working from the @ensures postcondition backward to the top of the method — the resulting @requires is whatever’s left once you reach the very first line.

Through if/else

Each branch is handled separately, and the two derived preconditions are combined into a single condition depending on which branch executes:

/**
 * @requires numbers != null && 12 < numbers.length
 *               && numbers[12] == number && number % 2 == 0
 * @ensures numbers[\result] == number
 */
public int indexOf(int[] numbers, int number) {
    // {number % 2 == 0 ==> numbers[0 + 12] == number
    //  && number % 2 != 0 ==> numbers[0 - 10] == number}
    int result = 0;
    if (number % 2 == 0) {
        // {numbers[result + 12] == number}
        result = result + 12;
    } else {
        // {numbers[result - 10] == number}
        result = result - 10;
    }
    // {numbers[result] == number}
    return result;
}

Proving a block correct

The same backward-propagation technique proves an arbitrary block of code satisfies a given Hoare triple — substitute back through each branch/statement and confirm the starting precondition (true, in these examples) is actually implied:

// {true}
if (x > y) {
    // {x > y} ==> {x >= x && x >= y}
    max = x;
    // {max >= x && max >= y}
} else {
    // {y >= x} ==> {y >= x && y >= y}
    max = y;
    // {max >= x && max >= y}
}
// {max >= x && max >= y}

A three-statement swap works the same way, substituting each assignment’s right-hand side backward through \old(...) references:

// {true}
// {y == \old(y) && x == \old(x)}
int tmp = x;
// {y == \old(y) && tmp == \old(x)}
x = y;
// {x == \old(y) && tmp == \old(x)}
y = tmp;
// {x == \old(y) && y == \old(x)}

A trickier variant swaps x/y using only arithmetic (no temporary variable), which still propagates the same way:

// {true}
// {y == \old(y) && x == \old(x)}
// {y - (y - x) + (y - x) == \old(y) && y - (y - x) == \old(x)}
x = y - x;
// {y - x + x == \old(y) && y - x == \old(x)}
y = y - x;
// {y + x == \old(y) && y == \old(x)}
x = y + x;
// {x == \old(y) && y == \old(x)}

(A similar swap is possible using bitwise XOR instead of subtraction, exploiting \(a \oplus b = b \oplus a\), \((a \oplus b) \oplus c = a \oplus (b \oplus c)\), \(a \oplus 0 = a\), \(a \oplus a = 0\).)

Java Inheritance

Introduced in 2026-03-05-object-oriented-programming-i (Lecture, Week 2) — “things you have because your parents have them”.

Subclasses and superclasses

Inheritance allows creating a new class from an existing class:

  • The new class is the subclass (child/derived class).
  • The existing class it’s derived from is the superclass (parent/base class).

extends is the keyword used to implement inheritance in Java:

public class Employee {
    private String name;
    private String address;

    public Employee(String name, String address) {
        this.name = name;
        this.address = address;
    }
    public String getName() { return name; }
    public String getAddress() { return address; }
    public void setAddress(String address) { this.address = address; }
    public String printDetails() { return name + " " + address; }
}

public class ContractEmployee extends Employee {
    private double hourlyRate;
    private int hoursWorked;

    public ContractEmployee(String name, String address, double hourlyRate) {
        super(name, address);
        this.hourlyRate = hourlyRate;
        this.hoursWorked = 0;
    }
    public void logHours(int hours) { this.hoursWorked += hours; }
    public double calculateWeeklyPay() { return hourlyRate * hoursWorked; }
}

The super keyword

super is used in a subclass to access superclass members (attributes, constructors, and methods) — e.g. super(name, address) above calls Employee’s constructor to initialise the inherited fields before the subclass’s own constructor body runs.

Method overriding

The subclass inherits the attributes and methods of its superclass. If the same method signature is defined in both, the subclass’s version overrides the superclass’s version:

public class ContractEmployee extends Employee {
    @Override
    public String printDetails() {
        return "Role: Contract Employee, Hourly Rate: $" + hourlyRate + ", Hours Worked: " + hoursWorked;
    }
}

public class FullTimeEmployee extends Employee {
    @Override
    public String printDetails() {
        return "Role: Full-Time Employee, Monthly Salary: $" + monthlySalary;
    }
}

Both the superclass and subclass method MUST share the same method signature (see java-encapsulation) for this to be overriding rather than a separate overload.

super.methodName(...) can be used inside an override to still call the superclass’s version of the method, e.g. to extend rather than replace its behaviour:

public class FullTimeEmployee extends Employee {
    @Override
    public String printDetails() {
        return super.printDetails() + ", Role: Full-Time Employee, Monthly Salary: $" + monthlySalary;
    }
}

Java I/O

Introduced in 2026-04-23-java-io (Lecture, Week 8).

Java spreads its I/O functionality across many more classes than most other languages, split roughly into three generations of API:

  1. Streams (java.io, since 1.0) — byte-oriented.
  2. Readers & Writers (java.io, since 1.1) — character-oriented, added because byte streams weren’t ideal for text/Unicode.
  3. New I/O (java.nio.file, since 1.7) — file-system operations (paths, directories, attributes), which java.io was never designed for.

Streams

A stream is an abstraction that either produces or consumes information, letting Java perform I/O uniformly regardless of whether data comes from a file, keyboard, console, network socket, or elsewhere. We often don’t want to rewrite our program just because the source or destination changed.

  • An output stream is an abstract destination to be written to.
  • An input stream is an abstract source of input that can be read without concern for how it’s supplied.

Java has two families of streams:

  • Byte streams (InputStream/OutputStream) — raw binary data (files, images, network data).
  • Character streams (Reader/Writer, see below) — characters/text, handling Unicode.

A byte stream reads data as bytes, whereas a character stream reads data as characters. (Schildt, Java: The Complete Reference, 11th ed.)

InputStream / OutputStream

InputStream and its subclasses represent a stream of bytes, drawn from different sources (FileInputStream from a file, ByteArrayInputStream from an array, …). Methods that use streams should accept the superclass type as a parameter, so any concrete stream can be substituted (see java-solid-principles — Liskov Substitution):

private static void sendToStream(OutputStream stream) throws IOException {
    String output = "foo bar baz";
    for (char letter : output.toCharArray()) {
        stream.write(letter);
    }
    stream.flush();
}
// sendToStream(System.out);
// sendToStream(new FileOutputStream("output.txt"));
// sendToStream(new ByteArrayOutputStream());

End of file: all input streams need to consider “end of file” — read() returns -1 at EOF, so a loop reading one byte at a time typically continues while (in != -1).

Buffering

Reading a file a byte at a time is slow. BufferedInputStream wraps another input stream and buffers reads (the buffer is an area of main memory used to temporarily hold data) — on one test VM, reading a 4MB file took 82ms buffered vs. 18,193ms unbuffered:

readAll(new BufferedInputStream(new FileInputStream("output.txt")));

Closing streams

Streams (and Readers) need closure — systems may limit how many files can be open at once, so always close() a stream when finished with it. Wrapping cleanup in try/catch/finally gets verbose fast:

BufferedInputStream input = null;
try {
    input = new BufferedInputStream(new FileInputStream("output.txt"));
    readAll(input);
} catch (IOException e) {
    System.out.println("Error: File Not Found " + e);
} finally {
    try {
        input.close();
    } catch (IOException e) {
        System.out.println("Error closing the stream: " + e);
    }
}

try-with-resources is much cleaner — any resource declared in the try(...) parentheses is automatically closed (Effective Java, 3rd ed., Item 9: “Prefer try-with-resources to try-finally”):

try (BufferedInputStream input = new BufferedInputStream(new FileInputStream("output.txt"))) {
    readAll(input);
} catch (IOException e) {
    System.out.println("Error: File Not Found " + e);
}

Readers & Writers

Reader/Writer are the character-based counterparts of InputStream/OutputStream. InputStreamReader bridges the two: it wraps an InputStream (like System.in) and decodes its bytes into characters.

private static void readAndPrint(Reader reader) throws IOException {
    char[] letters = new char[10];
    for (int i = 0; i < 10; i++) {
        letters[i] = (char) reader.read();
    }
    System.out.println(letters);
}
// readAndPrint(new InputStreamReader(System.in));
// readAndPrint(new FileReader("myfile.txt"));

BufferedReader

Wraps another Reader; as well as buffering, it adds String readLine():

BufferedReader reader = new BufferedReader(new FileReader("readwithbuffer.txt"));
for (int i = 0; i < 5; i++) {
    System.out.println(reader.readLine());
}

PrintWriter

System.out is actually a PrintStream; PrintWriter is a better option for character output — it can write to many destinations and supports formatted text (printf):

try (FileWriter fileWriter = new FileWriter("writeroutput.txt");
     PrintWriter printWriter = new PrintWriter(fileWriter)) {
    printWriter.println("I love CSSE2002");
    printWriter.printf("Formatted number: %.2f%n", 123.45336);
} catch (IOException e) {
    e.printStackTrace();
}

flush()

If an OutputStream/Writer is buffered, output might not be sent immediately — flush() forces any pending output out. This matters for interactive situations (the other end won’t respond if nothing’s actually been sent yet) and for debugging/logging (an up-to-date view of what’s happening). close()-ing a stream flushes it as well.

Scanner

Scanner (java.util, since 1.5) is neither a stream nor a reader — it’s a utility class that wraps an existing InputStream or Reader, added to simplify reading user input and parsing primitive types/strings (a friendlier alternative to BufferedReader). It also supports regex-based scanning for advanced use cases.

Scanner scanner = new Scanner(System.in); // internally wraps its own InputStreamReader
int total = 0;
while (scanner.hasNextInt()) {
    total += scanner.nextInt();
}
System.out.println(total);

Reading strings: scanner.next() reads the next word (skipping leading whitespace, stopping at whitespace); scanner.nextLine() reads the entire line up to Enter.

New I/O (java.nio.file)

java.io is mainly stream-oriented — its goal was reading/writing data, not managing files or directories, so it’s awkward for file attributes, directory traversal, and path manipulation. java.nio.file (since Java 1.7) adds two key abstractions:

  • Path — represents a file location.
  • Files — utility methods for file operations.

Creating a Path

Path p1 = Path.of("docs/output.txt");        // whole path as one string
Path p2 = Path.of("docs", "output.txt");     // separate name elements, joined by Java
Path p3 = Path.of(new URI("file:///docs/output.txt"));

Files operations

Category Methods
Create/delete Files.createFile(Path), Files.createDirectory(Path), Files.delete(Path), Files.deleteIfExists(Path)
Query Files.exists(Path), Files.isDirectory(Path), Files.isRegularFile(Path)
Manipulate Files.copy(Path, Path), Files.move(Path, Path)
Read/write Files.readAllLines(Path), Files.readString(Path), Files.write(Path, Iterable<? extends CharSequence>), Files.writeString(Path, CharSequence)

CharSequence is an interface representing a read-only sequence of characters; String, StringBuilder, StringBuffer, and CharBuffer all implement it.

Path myPath = Path.of("src", "Week08", "NIO", "myfile.txt");
List<String> lines = Files.readAllLines(myPath);
for (String line : lines) {
    System.out.println(line);
}

Summary: which to use?

  • Streams — good for binary data, a byte at a time.
  • Readers & Writers — best for text data.
  • New I/O — when manipulating a file system or file contents (paths, directories, copying/moving files).

Java JUnit

Introduced in 2026-03-26-testing (Lecture, Week 5). See java-testing for the broader testing concepts JUnit is used to implement.

What is JUnit?

An open-source test automation framework for Java — one of many xUnit frameworks (JUnit, NUnit, CPPUnit, PyUnit…). This course uses JUnit 4. Depending on how software is structured, unit-testing frameworks like JUnit can also be used for other kinds of automated testing (the term “unit testing” is sometimes loosely used to mean any automated test).

Writing tests

Tests are defined in classes — conventionally one test class per real class. @Test marks a method as a test JUnit should run; test methods take no parameters and return void, and should be named for what they check.

public class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() { return name; }
    public int getAge() { return age; }
}
import org.junit.Test;
import static org.junit.Assert.assertEquals;

public class PersonTest {
    @Test
    public void testGetAge() {
        Person p1 = new Person("Jack", 10);
        assertEquals(10, p1.getAge());
    }

    @Test
    public void testGetName() {
        Person p1 = new Person("Jack", 10);
        assertEquals("Jack", p1.getName());
    }
}

@Before and @After

Each test method should be independent, but common setup (e.g. object creation) can be factored out:

public class PersonTest {
    private Person person;

    @Before
    public void setUp() {
        person = new Person("Jack", 10);
    }

    @After
    public void tearDown() {
        person = null;
    }
    // ...
}

@Before runs before each test; @After runs after each test.

Assert

Some static methods on Assert:

  • assertEquals — asserts two objects are equal.
  • assertArrayEquals — asserts two object arrays are equal.
  • assertFalse / assertTrue — asserts a condition is false/true.
  • assertSame / assertNotSame — asserts two objects do/don’t refer to the same object.

Checking for exceptions

Don’t catch the exception yourself — tell the test runner which exception type to expect via @Test(expected = ...), and let it catch it:

@Test(expected = EOFException.class)
public void testExceptions() throws EOFException {
    callThatShouldThrowEOFException();
}

Note the .class after the exception type is required.

Black box vs white box JUnit tests

  • Black box: write tests purely from the specification/Javadoc, without looking at the implementation, then apply smoke tests / boundary tests / equivalence classes (see java-testing) to decide what to check — tests must not violate the method’s preconditions.
  • White box: design tests using knowledge of the internal logic, aiming to cover branches, loops, and edge cases (e.g. targeting 100% branch coverage) — e.g. for a calculateDiscount(amount, isMember) method with nested if/else branches on both parameters, white-box tests are written to exercise every branch combination (amount ≤ 0; member above/at-or-below 100; non-member above/at-or-below 100).

Java Lambdas and Streams

Introduced in 2026-05-14-lambdas-streams-and-events (Guest lecture, Week 11).

Lambdas (anonymous functions)

A lambda is a function treated as a value — it isn’t declared with a name, and can be passed around like any other reference value (similar to anonymous functions in Python or JavaScript).

Scope: like in most languages, a Java lambda can hold references to things accessible in scope at the point it’s created.

Functional interfaces

Because Java is statically typed, a lambda can’t exist without a clearly-defined type — the JVM needs to know its shape. Java provides a family of generic functional interfaces (in java.util.function) to describe common lambda shapes; four commonly used ones:

Interface Signature Purpose
Consumer<T> takes a T, returns nothing has some side effect (returning nothing and having no side effect would mean doing nothing at all)
Predicate<T> takes a T, returns boolean useful for filtering
Function<T, R> takes a T, returns an R general transform
BiFunction<T, U, R> takes a T and a U, returns an R deriving one value from two, e.g. distance between two positions, or a custom comparator/sort key
Consumer<String> print = s -> System.out.println(s);
Predicate<Integer> isEven = n -> n % 2 == 0;
Function<String, Integer> length = s -> s.length();
BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;

Streams

Added in Java 8 to enable a more declarative style of programming — particularly effective when you need to run multiple operations over data sequentially, or reduce a sequence of operations down to a single resulting value (a very common real-world task). Streams are Java’s answer to the same idea as JavaScript’s .filter()/.map()/.reduce(): an alternative to writing many near-identical for loops.

int totalHpFromFireEnemies =
    enemies.stream()
        .filter(enemy -> enemy.faction.equals("Fire"))
        .mapToInt(enemy -> enemy.hp)
        .sum();

Anatomy of a stream pipeline

  1. Start a stream from a collection: .stream().
  2. Chain any number of intermediate operations, each producing a new stream: .filter(), .map(), .distinct(), .sorted(), .mapToInt(), …
  3. Resolve the stream into a final value with exactly one terminal operation: .sum(), .count(), .toList(), .collect(), .reduce(), …

In the example above: enemies.stream() starts the pipeline; .filter(...) keeps only Fire-faction enemies; .mapToInt(...) transforms each remaining Enemy into its int hp; .sum() (terminal) reduces the stream of hp values down to a single total.

Java Loop Invariants

Introduced in 2026-05-21-correct-programming (Lecture, Week 12). Extends java-hoare-logic’s backward-reasoning technique to loops, where the number of iterations isn’t known up front.

…proving the correctness of algorithms has another aspect that is even more important: it mirrors the way we understand an algorithm.

— Donald Knuth, The Art of Computer Programming (Vol. 1), 1997

What is an invariant?

An invariant is a property of a system that does not change.

Motivating puzzle

A jar contains 100 red and 100 blue beans. Repeatedly: pick two random beans; if they’re the same colour, discard both; if they’re different colours, discard the blue one. What colour is the last bean?

Let \(r, b\) be the current counts of red/blue beans. Each step does exactly one of:

Pick Action Effect
two reds discard both \(r \mapsto r - 2,\ b \mapsto b\)
two blues discard both \(r \mapsto r,\ b \mapsto b - 2\)
one of each discard blue \(r \mapsto r,\ b \mapsto b - 1\)

What never changes? The parity of \(r\) — every action either leaves \(r\) unchanged or decreases it by exactly 2, so \(r\) stays even throughout (it starts at 100, itself even). When one bean remains, \(r + b = 1\); since \(r\) must be even, \(r \neq 1\), so \(r = 0\) and \(b = 1\)the last bean is blue.

The property “\(r\) is even” is an invariant of the process: it (1) holds before any step, (2) is preserved by every step, and (3), combined with the termination condition (\(r + b = 1\)), implies what we wanted to prove. This is exactly the structure of a loop invariant.

Loop invariants

A loop invariant is a condition that: (1) holds before the loop is entered, (2) is preserved by each iteration, and (3) combined with the negation of the guard, implies the postcondition.

int count = 0;
while (count < n) {
    // invariant: count >= 0
    count = count + 1;
}
// {0 >= 0}
int count = 0;
// {count >= 0}                    <-- invariant holds before the loop
while (count < n) {
    // invariant: count >= 0
    // {count + 1 >= 0}
    count = count + 1;
    // {count >= 0}                <-- preserved by the body
}
// {count >= 0 && count >= n}

Deriving an invariant for power

/**
 * @requires ????
 * @ensures \result == base^exp
 */
int power(int base, int exp) {
    int result = 1;
    int i = 0;
    while (i < exp) {
        result = result * base;
        i = i + 1;
    }
    return result;
}

Step 1 — guess an invariant. Replace exp in the postcondition with the loop variable i: \(I : \text{result} = \text{base}^i\).

Step 2 — verify. Three checks:

  1. Does \(I\) hold before the loop first executes? result = base^i \(\equiv 1 = \text{base}^0 \equiv\) true. ✓
  2. Assuming \(I\) holds at the top of the loop body, does it still hold after? result * base = base^i * base = base^{i+1}, and after i = i + 1, that’s exactly result = base^i again. ✓
  3. Does \(I\) combined with the negated guard (\(i \geq \text{exp}\)) imply the postcondition? result = base^i \(\land\ i \geq \text{exp} \Rightarrow \text{result} = \text{base}^{\text{exp}}\)? Not quite — we can only conclude \(i \geq \text{exp}\), but we need \(i = \text{exp}\).

Step 3 — strengthen. Add the bound \(i \leq \text{exp}\) to the invariant: \(I : \text{result} = \text{base}^i \land i \leq \text{exp}\). Re-checking: \(I\) now holds initially iff \(\text{exp} \geq 0\) (giving us our precondition), is still preserved by the loop body, and combined with \(i \geq \text{exp}\) now forces \(i = \text{exp}\) exactly, giving the postcondition:

/**
 * @requires exp >= 0
 * @ensures \result == base^exp
 */
int power(int base, int exp) {
    // {0 <= exp}
    // {1 == base^0 && 0 <= exp}
    int result = 1;
    int i = 0;
    // {result == base^i && i <= exp}
    while (i < exp) {
        ...
    }
    // {result == base^i && i <= exp && i >= exp}
    // {result == base^exp}
    return result;
}

Designing an algorithm from an invariant: fast exponentiation

The power above runs in \(O(\text{exp})\) multiplications. We can do better using the identity:

\[b^e = \begin{cases} (b^{e/2})^2 & \text{if } e \text{ is even} \\ b \times (b^{(e-1)/2})^2 & \text{if } e \text{ is odd} \end{cases}\]

E.g. computing \(3^{13}\): \(3^{13} = 3 \times 3^{12}\) [13 odd] \(= 3 \times (3^2)^6\) [12 even] \(= 3 \times 9^6 = 3 \times (9^2)^3\) [6 even] \(= 3 \times 81^3 = 3 \times 81 \times 81^2\) [3 odd] \(= 3 \times 81 \times 6561 = 1{,}594{,}323\) — only a handful of multiplications instead of 12 sequential ones.

Design from the invariant: rather than writing code and then finding its invariant, pick the invariant first and let the code follow. We want a loop maintaining:

\[I : \text{result} \times b^e = \text{base}^{\text{exp}}\]

starting from result = 1, b = base, e = exp, terminating when e == 0:

/**
 * @requires exp >= 0
 * @ensures \result == base^exp
 */
int power(int base, int exp) {
    int result = 1;
    int b = base;
    int e = exp;
    while (e > 0) {
        // invariant: result * b^e == base^exp
        if (e % 2 == 1) {
            result = result * b;
            e = e - 1;
        }
        b = b * b;
        e = e / 2;
    }
    return result;
}

Verifying \(I\) (using \(b^e = (b^{e/2})^2\) when even, \(b \times (b^{(e-1)/2})^2\) when odd, and \((a^b)^c = (a^c)^b\)):

  1. Before the loop: result * b^e == base^exp \(\equiv 1 \times \text{base}^{\text{exp}} = \text{base}^{\text{exp}} \equiv\) true. ✓
  2. Through the body: splitting on e % 2, both branches reduce back to exactly result * b^e == base^exp after the update (the if branch peels off one factor of b into result and decrements e first; both branches then square b and halve e). ✓
  3. On exit (e <= 0): result * b^e == base^exp with e <= 0 only concludes the postcondition when e >= 0 too (i.e. e == 0) — which holds as long as exp >= 0, giving the same precondition as before. ✓

This preserves the exact same specification as the slow version — both satisfy @requires exp >= 0 / @ensures \result == base^exp — but the fast version reaches it in \(O(\log \text{exp})\) multiplications instead of \(O(\text{exp})\).

Pragmatic considerations

Proving correctness can be vital, but it’s also time-consuming — you want code running on a plane or controlling trains to be correct; you may not care nearly as much whether a weekend web-scraping script is correct. Where formal reasoning tends to pay off:

  • Combinatorial algorithms — proving a faster/trickier implementation still satisfies the original spec (as with power above).
  • Distributed and concurrent algorithms — notoriously hard to reason about by testing alone (see CSSE3610/7610).

Reasoning about programs this way provides (1) a more rigorous way to think about your software, and (2) a better interface design for others to interact with it — it doesn’t remove the need to think about your programs, but gives a framework to structure that thinking.

Beware of bugs in the above code; I have only proved it correct, not tried it.

— Donald Knuth (memo to Peter van Emde Boas, 1977)

Java Mutability

Introduced in 2026-03-12-object-oriented-programming-ii (Lecture, Week 3).

Mutable vs immutable classes

Objects in Java can either change their state after creation or remain fixed forever:

  • Mutable class — object state can change after creation.
  • Immutable class — object state cannot change after creation.

Mutable

public class Person {
    private String name;
    private int age;

    public void setName(String name) { this.name = name; }
    public void setAge(int age) { this.age = age; }
}

Person p = new Person();
p.setName("Alice");
p.setAge(20);
p.setAge(30); // state changed

Immutable

Made final (fields set once in the constructor, no setters):

public final class Person {
    private final String name;
    private final int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() { return name; }
    public int getAge() { return age; }
}

A subclass can reintroduce mutability into an otherwise-immutable design (e.g. adding a setter, or exposing mutable internal state) — worth reading up on (Effective Java, Item 16) before relying on “final” alone as a guarantee.

Arrays and lists break naive immutability

final on an array/list field only prevents reassigning the reference — it does nothing to protect the array’s contents:

public final class Person {
    private final String name;
    private final String[] phoneNumbers;

    public Person(String name) {
        this.name = name;
        this.phoneNumbers = new String[]{"111", "222"};
    }

    public String[] getPhoneNumbers() { return phoneNumbers; }
}

Person person = new Person("John");
person.getPhoneNumbers()[0] = "999999"; // mutates the "immutable" object!
this.phoneNumbers = new String[]{"123"}; // NOT allowed — final prevents reassignment

Even though the class looks immutable (all fields final, no setters), the caller can still reach into the array returned by the getter and mutate it in place.

Defensive copying

To actually protect mutable internal state, return (and store) copies rather than the original reference (Effective Java, Item 50):

public final class Person {
    private final String name;
    private final String[] phoneNumbers;

    public Person(String name, String[] phoneNumbers) {
        this.name = name;
        this.phoneNumbers = phoneNumbers.clone(); // defensive copy
    }

    public String[] getPhoneNumbers() {
        return phoneNumbers.clone(); // defensive copy
    }
}

Java Polymorphism

Introduced in 2026-03-12-object-oriented-programming-ii (Lecture, Week 3).

What is polymorphism?

From the Greek “poly” (many) and “morphe” (form). In programming, it allows the same method call to produce different behaviour depending on the object that executes it.

Compile-time polymorphism (method overloading)

Multiple methods share a name but differ in parameter list (type, number, or both). Which one runs is decided at compile time, based on the method signature (see java-encapsulation#method-signatures):

public class CompPolymorphism {
    static String print(int value) { return "number"; }
    static String print(String value) { return "text"; }

    public static void main(String[] args) {
        System.out.println(print(21));   // "number"
        System.out.println(print("21")); // "text"
    }
}

Runtime polymorphism (dynamic method dispatch)

Also called dynamic method dispatch. The method that gets executed is determined at runtime, based on the object’s actual type, not the variable’s declared type — implemented through method overriding (see java-inheritance):

The subclass’s overriding method executes, regardless of the compile-time type of the subclass instance. — Effective Java

public class Animal {
    public void makeSound() { System.out.println("Animal makes a sound"); }
}
public class Cat extends Animal {
    @Override public void makeSound() { System.out.println("Cat meows"); }
}
public class Dog extends Animal {
    @Override public void makeSound() { System.out.println("Dog barks"); }
}

Animal myAnimal = new Dog();
myAnimal.makeSound(); // "Dog barks" — even though the variable's type is Animal

Subtype polymorphism

Objects of different subclasses can be treated as objects of a common superclass or interface, since subclasses are subtypes of their superclass. Achieved through inheritance (extends) or interface implementation (implements) — code works with the abstraction (supertype) rather than a specific implementation:

public static void performAnimalSound(Animal animal) {
    animal.makeSound();
}

performAnimalSound(new Dog()); // "Dog barks"
performAnimalSound(new Cat()); // "Cat meows"

The same works via an interface instead of a shared superclass:

public interface Animal { void makeSound(); }
public class Cat implements Animal { ... }
public class Dog implements Animal { ... }

// Can pass any class that implements the interface
performAnimalSound(new Dog());
performAnimalSound(new Cat());

Java Primitive and Reference Types

Introduced in 2026-02-26-course-overview-and-java-basics (Lecture 1, Week 1).

Two categories of types

Java distinguishes two sorts of types:

  1. Primitive types — built-in, fixed representations.
  2. Reference types — everything else (i.e. classes), including String, arrays, and any user-defined class.

Primitive types

Type Size Stores
boolean not specified true or false
byte 1 byte / 8 bits whole numbers from -128 to 127
short 2 bytes / 16 bits whole numbers from -32,768 to 32,767
int 4 bytes / 32 bits whole numbers from -2,147,483,648 to 2,147,483,647
long 8 bytes / 64 bits whole numbers from -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807
float 4 bytes / 32 bits fractional numbers, accurate to 6-7 decimal places
double 8 bytes / 64 bits fractional numbers, accurate to 15 decimal places
char 2 bytes / 16 bits a single character/letter

Reference types

Reference types are everything that isn’t primitive — classes, including String and arrays. A variable of a reference type stores a reference to an object, not the object’s data directly (see 2026-02-26-java-basics-part-02-collections-and-strings for the stack/heap consequences of this, and how it changes the meaning of =, ==, and !=).

Collections (Stack, List, Set, Map) can only store objects/reference types — see java-collections-framework for the wrapper classes (Boolean, Byte, Character, Double, Float, Integer, Long, Short) Java provides so primitives can be stored in them.

Java Refactoring

Introduced in 2026-04-02-refactoring (Lecture, Week 6).

What is refactoring?

A disciplined technique for continuously restructuring an existing body of code, altering its internal structure without changing its external behaviour. (refactoring.guru)

Why refactor?

Over time, as features are added: quick fixes (“hacks”) accumulate, the design becomes messy, and code becomes harder to change.

Good practice

  • Don’t refactor and add functionality at the same time — keep the two activities separate.
  • Have good tests before refactoring, so you’ll know immediately if you’ve broken something (see java-testing).
  • Take short, deliberate steps: move a member variable, split a method, rename a variable. Refactoring is usually many small, localised changes that add up to a larger-scale change — small steps + testing after each one avoids prolonged debugging.
  • Refactor early, refactor often.

Example: making it easier to add a feature

A Vehicle class using a type string and a chain of if/else branches is fragile to extend — adding a "scooter" case means editing travelTime and remembering every other place that branches on type:

public class Vehicle {
    private String type;

    public Vehicle(String type) { this.type = type; }

    public int travelTime(int distance) {
        if (type.equals("car")) {
            return distance / 80;
        } else if (type.equals("bike")) {
            return distance / 20;
        }
        return distance;
    }
}

Refactored to use inheritance/polymorphism (see java-polymorphism), adding a Scooter is just one new class — no existing code needs editing:

abstract class Vehicle {
    public abstract int travelTime(int distance);
}
class Car extends Vehicle {
    @Override
    public int travelTime(int distance) { return distance / 80; }
}
class Bike extends Vehicle {
    @Override
    public int travelTime(int distance) { return distance / 20; }
}
class Scooter extends Vehicle {
    @Override
    public int travelTime(int distance) { return distance / 5; }
}

Example: making it easier to understand

Extracting named boolean-returning methods (isHotAndSunny(), isPleasant()) out of inline compound conditions makes the intent of a branch obvious at a glance, instead of making the reader re-derive it from raw comparisons every time:

// Before
if (temperature > 25 && !isRaining) { ... }
else if (temperature <= 25 && !isRaining) { ... }
else if (isRaining) { ... }

// After
if (isHotAndSunny()) { ... }
else if (isPleasant()) { ... }
else if (isRaining) { ... }

Code smells

A “code smell” is a surface indication that usually corresponds to a deeper problem in the design.

Bad naming — single-letter/cryptic names (r, x, y, g(x)) force the reader to trace logic to understand intent; descriptive names (evenCount, number, isEven(number)) make code self-documenting.

Duplication — near-identical blocks repeated for different data (e.g. computing an average for two separate arrays with two copy-pasted loops) should be extracted into a single shared method (average(int[] numbers)), so a bug fix or improvement only needs to happen once.

Feature envy — a method that spends most of its time reaching into another class’s data (cart.getItems(), cart.getVoucher()) rather than its own is a sign the method’s logic actually belongs on the class it’s “envious” of. Moving checkout() onto ShoppingCart itself (next to getItems/getVoucher) removes the envy and reduces coupling between User and ShoppingCart (see java-cohesion-and-coupling).

Many other code smells exist — see Martin Fowler’s Refactoring (2nd ed., 2019), Robert Martin’s Clean Code (2008), John Ousterhout’s A Philosophy of Software Design (2018), and the refactoring.com catalog.

Java SOLID Principles

Introduced in 2026-04-02-refactoring (Lecture, Week 6, Part 3 — Single Responsibility and Open-Closed) and continued in 2026-04-16-solid-principles-ii (Lecture, Week 7 — Liskov Substitution, Interface Segregation, Dependency Inversion).

Why SOLID?

Large software tends to become:

  • Rigid — difficult to change; even small changes require modifications across many parts of the codebase.
  • Fragile — likely to break when changed, sometimes in parts of the codebase that appear unrelated to the change.

The SOLID principles (S-ingle responsibility, O-pen-closed, L-iskov substitution, I-nterface segregation, D-ependency inversion) are a set of guidelines — not rules — to help prevent rigidity and fragility. Many of the individual ideas predate the acronym; Robert Martin combined them in Design Principles and Design Patterns (2000).

Single Responsibility Principle (SRP)

There should never be more than one reason for a class to change.

Closely related to java-cohesion-and-coupling’s cohesion: cohesion is about how similar a class’s functionality is, while SRP is about the reasons for change. A “responsibility” is a reason for change — if you can think of more than one motive to change a class, it has more than one responsibility.

class Student {
    private String name;
    private List<Course> courses;
    private double gpa;

    public void enrollInCourse(Course course) { courses.add(course); }
    public void calculateGPA() { /* ... */ }
    public void printTranscript() { /* ... */ }
}

This Student class has (at least) three reasons to change: how enrolment works, how GPA is calculated, and how transcripts are generated. Splitting each concern into its own class gives each one a single responsibility:

class Student {
    private String name;
    private List<Course> courses;
    public void enrollInCourse(Course course) { courses.add(course); }
    public List<Course> getCourses() { return courses; }
}
class GPACalculator {
    public double calculateGPA(List<Course> courses) { /* ... */ }
}
class TranscriptPrinter {
    public void printTranscript(Student student) { /* ... */ }
}

Open-Closed Principle (OCP)

Components should be open for extension but closed for modification.

Introduced by Bertrand Meyer in Object Oriented Software Construction (1988): “if the open-closed principle is applied well, then further changes are achieved by adding new code, not by changing old code that already works”.

  • Open for extension — a class’s behaviour can be extended to support changing requirements.
  • Closed for modification — no one may change the behaviour of an existing class; it’s closed once it’s available for other modules to depend on.

How do we keep a class closed for modification? Information hiding (see java-encapsulation).

Example: pluggable grading systems

A university needs to support multiple grading systems (letter grades, numeric grades). A first attempt branches on a gradeType string inside GPACalculator — every new grading system means editing this method (violates OCP):

class GPACalculator {
    public double calculateGPA(List<Course> courses, String gradeType) {
        double totalPoints = 0;
        if (gradeType.equals("Letter")) {
            for (Course course : courses) {
                totalPoints += convertLetterGradeToPoints(course.getLetterGrade());
            }
        } else if (gradeType.equals("Numeric")) {
            for (Course course : courses) {
                totalPoints += course.getNumericGrade();
            }
        }
        return totalPoints / courses.size();
    }
}

Extracting the grading logic behind an interface makes GPACalculator open for extension (new GradingSystem implementations) without ever touching its own code again:

interface GradingSystem {
    double getGradePoints(Course course);
}
class LetterGradingSystem implements GradingSystem {
    @Override
    public double getGradePoints(Course course) { /* convert letter grade to points */ return 0; }
}
class NumericGradingSystem implements GradingSystem {
    @Override
    public double getGradePoints(Course course) { return course.getNumericGrade(); }
}

class GPACalculator {
    private GradingSystem gradingSystem;
    public GPACalculator(GradingSystem gradingSystem) { this.gradingSystem = gradingSystem; }

    public double calculateGPA(List<Course> courses) {
        double totalPoints = 0;
        for (Course course : courses) {
            totalPoints += gradingSystem.getGradePoints(course);
        }
        return totalPoints / courses.size();
    }
}

GPACalculator now depends on the GradingSystem abstraction rather than calculating grade points itself — new grading systems (e.g. pass/fail) can be added just by writing a new class that implements GradingSystem, with zero changes to GPACalculator’s own code.

Liskov Substitution Principle (LSP)

Subclasses should be substitutable for their parent classes.

Defined by Barbara Liskov in a 1987 keynote: an instance of a subclass type can be used wherever an instance of a superclass type is expected, without breaking the correctness of the program:

List<Animal> animals = new ArrayList<>();
animals.add(new Cat());
animals.add(new Mouse());
for (Animal animal : animals) {
    animal.eat(new Food());
}

Example violation

class Student {
    protected List<Course> courses;
    public double calculateGPA() {
        double totalPoints = 0;
        for (Course course : courses) {
            totalPoints += course.getGradePoints();
        }
        return totalPoints / courses.size();
    }
}
class PostgraduateStudent extends Student {
    @Override
    public double calculateGPA() {
        throw new UnsupportedOperationException("Postgraduates have a different GPA system.");
    }
}

Code written against Student (e.g. calling calculateGPA() on every student in a list) will blow up the moment a PostgraduateStudent is substituted in — PostgraduateStudent is not actually usable wherever a Student is expected, so it violates LSP.

Substitution with contracts

Even when a subclass overrides a method with a different implementation, it must still honour the parent’s contract (see java-specification):

class Parent {
    /**
     * @requires x > 0
     * @ensures \result > 'A' && \result < 'Z'
     */
    char f(int x);
}

Preconditions — suppose an overriding Child.f() can also handle negative numbers. Changing its precondition from x > 0 to x != 0 is fine: every input Parent.f() accepted (x > 0) is still accepted by Child.f() (x != 0), since \(x > 0 \Rightarrow x \neq 0\) — so the precondition became weaker (less strict), not stronger. Preconditions in a subclass must be no stronger than the superclass’s — they may be weaker or stay the same.

Postconditions — suppose Child.f() only ever returns 'K', 'L', or 'M'. A postcondition of \result > 'J' && \result < 'N' is fine: every result still satisfies the parent’s promise (\result > 'J' && \result < 'N' \Rightarrow \result > 'A' && \result < 'Z'), so the postcondition became stronger (more restrictive), which is allowed. Postconditions in a subclass must be no weaker than the superclass’s — they may be stronger or stay the same.

A subclass must not strengthen preconditions or weaken postconditions — it should accept everything the parent accepts, and still guarantee everything the parent guarantees.

Worked example

class One {
    /**
     * @require row != null && col != null && 0 < val && val < 100
     * @ensure \result is the list of all of the positive
     *     integers n < val that appear in either row or col
     */
    public List<Integer> calc(int[] row, int[] col, int val) { ... }
}
class Two extends One {
    /**
     * @require row != null && col != null && 0 < val &&
     *     val < 100 && row only contains positive integers
     * @ensure \result is the list of all of the positive
     *     integers n < val that appear in either row or col
     */
    @Override
    public List<Integer> calc(int[] row, int[] col, int val) { ... }
}

Two’s precondition adds an extra requirement (row only contains positive integers) that One never required — so One’s precondition does not imply Two’s: there are inputs One would accept (a row containing a negative number) that Two rejects. This strengthens the precondition and violates LSP: code written against One that happens to pass a negative-containing row would break if a Two were substituted in.

Homework variant: if Two’s postcondition is instead changed to return integers appearing in both row and col (rather than either), does this satisfy LSP? Reasoning it through: the guarantee changes from a union to an intersection, which for most inputs returns strictly fewer elements than One promised — that’s a weaker postcondition (some outputs a caller was guaranteed under One are no longer guaranteed under Two), which also violates LSP.

Interface Segregation Principle (ISP)

Many client-specific interfaces are better than one general-purpose interface.

Shrink interfaces to minimize incidental dependencies — large interfaces should be split into smaller ones, so implementing/using classes only need to be concerned about the methods that actually interest them.

class Z {
    public void a() {...}
    public void b() {...}
    public void c() {...}
    public void d() {...}
    public void e() {...}
}
// A only uses a() and b(), B only uses c(), C only uses d() and e() ...
// ...but all three classes still depend on the whole of Z.

This mirrors java-cohesion-and-coupling’s stamp coupling at the interface level: despite each client using only a small subset of Z, every client depends on all of Z — so a change to any part of Z risks affecting (and forcing a recompile/redeploy of) every client, even ones that never used the changed part. Splitting Z’s surface into focused interfaces removes those incidental dependencies:

interface AI { void a(); void b(); }
interface BI { void c(); }
interface CI { void d(); void e(); }
// Z implements AI, BI, CI; A depends only on AI, B only on BI, C only on CI.

Dependency Inversion Principle (DIP)

Depend upon abstractions. Do not depend upon concretions.

Entities must depend on abstractions, not concretions: high-level modules must not depend on low-level modules — both should depend on abstractions. If A depends directly on the concrete class B, changes to B’s implementation can propagate to A; if A instead depends only on an abstraction (BSpec) that B implements, changes inside B are far less likely to affect A, as long as BSpec itself stays the same.

Even a small step towards this helps — programming against the List interface instead of the concrete ArrayList type means Student no longer depends on which list implementation the caller chose:

class Student {
    private List<Course> courses; // not ArrayList<Course>
    public Student(List<Course> courses) { this.courses = courses; }
    public double calculateTotalGradePoints() { ... }
}

What makes a good dependency? (stability and exposure)

A class A has a dependency on class B if A refers to B in code (an import B;, or, in the same package, simply referring to B anywhere). Not all dependencies are equally risky — two properties determine how good/bad one is:

  • Stability — how likely the dependency is to change. ArrayList is a good dependency because it’s very unlikely to change; treat classes you write as unstable until proven otherwise, and generally assume interfaces are more stable than concrete classes (unless they’re poorly designed and change often).
  • Exposure — how much of the depending class is entangled with it. A dependency only used in a constructor is better than one used across every method, since less of the class would need to change if that dependency changed.

Two forms of DIP

Minimising concrete dependencies (the simple form) — change a field’s compile-time type from a concrete class to whatever interface it already implements, wherever one already exists:

- HardwoodFloor placedOn;
+ Floor placedOn;

This only helps where an abstraction already exists, though. The proper form of DIP goes further:

Remove dependencies on low-level components from high-level components — make both depend on an abstraction.

“High-level” vs “low-level” isn’t a strict distinction: high-level components implement application logic (e.g. a browser’s back/forward navigation history); low-level components do the grunt work that logic depends on (e.g. actually requesting a webpage’s data). The goal is for high-level components to talk to low-level ones exclusively through an abstraction, so a low-level component changing doesn’t force the high-level component depending on it to change too. Applied to a Desk class depending on a concrete LogitechKeyboard with no existing interface:

  1. Create an abstraction of the low-level component: interface Keyboard { void type(char key); ... }.
  2. Modify the low-level component to implement it: class LogitechKeyboard implements Keyboard.
  3. Minimise concrete dependencies in the high-level component: Keyboard keyboard = new LogitechKeyboard();.

Dependency Injection

Dependency Injection (DI) is a design pattern that implements DIP by injecting a class’s dependencies into it (e.g. via the constructor) instead of having the class create them internally, promoting loose coupling (see java-cohesion-and-coupling):

// Before: AlertService is tightly coupled to EmailSender, and creates its own dependency.
class AlertService {
    public void sendAlert(String msg) {
        EmailSender sender = new EmailSender();
        sender.send(msg);
    }
}
// After: AlertService depends on the MessageSender abstraction, injected via the constructor.
interface MessageSender {
    void send(String msg);
}
class EmailSender implements MessageSender {
    @Override
    public void send(String msg) { System.out.println("Sending EMAIL: " + msg); }
}
class SmsSender implements MessageSender {
    @Override
    public void send(String msg) { System.out.println("Sending SMS: " + msg); }
}
class AlertService {
    private MessageSender sender;
    public AlertService(MessageSender sender) { this.sender = sender; }
    public void sendAlert(String msg) { sender.send(msg); }
}

AlertService can now send alerts via email, SMS, or any future MessageSender implementation, without ever changing its own code.

A constructor isn’t the only injection point — a setter is appropriate when the dependency is likely to change over the object’s lifetime, e.g. public changeSender(MessageSender sender) { this.sender = sender; }. Eventually something (typically an entry-point method like main) has to construct the concrete dependencies before injecting them; DI frameworks let you defer that construction to runtime via the framework’s own configuration instead of in code, but unless a project needs multiple levels of injected dependencies, good design alone can avoid the extra conceptual overhead of adopting one.

Summary

  • Single Responsibility — one reason to change per class.
  • Open-Closed — open for extension, closed for modification.
  • Liskov Substitution — subclasses substitutable for their parents; no stronger preconditions, no weaker postconditions.
  • Interface Segregation — many small, client-specific interfaces beat one large general-purpose interface.
  • Dependency Inversion — depend on abstractions, not concretions; inject dependencies rather than constructing them internally.

Java Specification

Introduced in 2026-03-12-object-oriented-programming-ii (Lecture, Week 3, Part 2).

Javadoc

  • Ordinary comments: // and /* ... */.
  • Javadoc comments: begin with /** (note the second *), end with */, must sit immediately above the thing being documented, and use tags beginning with @ (some take parameters, some just text).
/**
 * Calculates a sum by combining the hash code of the provided string
 * and the long value of the provided float.
 *
 * @param inputString the input string whose hash code will be used
 * @param inputFloat the float value to be converted to long and combined with the string hash code
 * @return a long value representing the sum of the string's hash code and the long value of the float
 */
public long doCalculation(String inputString, float inputFloat) { ... }

Common tags:

Tag Meaning
@param varname ... Describe a parameter
@return ... Describe the return value
@throws ExceptionType ... Describe when a particular exception is thrown
@requires Precondition Assumptions for the method to execute properly
@ensures Postcondition Effects of executing the method
@author authorname Author of the class

What makes a good specification

Ideally, a specification should:

  • Allow a method to be used by only reading its specification, not its implementation.
  • Allow a method to be re-implemented without requiring changes to its callers.
  • Be restrictive enough to rule out unacceptable implementations.
  • Be general enough to not preclude acceptable (alternative) implementations.
  • Be clear enough for programmers to understand.

Restrictiveness — keep out incorrect implementations

/**
 * Returns an index (i) of ar such that ar[i] == x, if any.
 */
public int search(int[] ar, int x) { ... }

What happens if x isn’t in ar? The spec doesn’t say — by its silence it allows any return value, so a caller can’t distinguish “found at index 0” from “not found”. Adding else, return -1 fixes that, but if x appears multiple times, nothing requires the lowest index or a consistent answer across calls — the spec is still non-deterministic unless it says smallest index.

Generality — allow acceptable alternative versions

/**
 * Examine ar[0], ar[1], ... in turn and return the index of the
 * first one that is equal to x, if any, else return -1.
 */
public int search(int[] ar, int x) { ... }

This is a bad spec even though it’s restrictive: it describes how (forward iteration order), not what. A backward-iterating implementation that returns the same first-match index would violate this over-specific wording despite being equally acceptable — specs should describe outcomes, not implementation strategy. Prefer wording like “return the smallest index such that…”, which both rules out bad implementations and permits any implementation strategy that achieves the same outcome. Both a backward-iterating and an early-return-on-first-match forward implementation satisfy this better wording equally well:

public int search(int[] ar, int x) {
    for (int i = 0; i < ar.length; i++) {
        if (ar[i] == x) {
            return i; // early return, still finds the smallest index
        }
    }
    return -1;
}

Clarity

A specification should facilitate communication — it can fail either because the reader doesn’t understand, or because the reader only thinks they understand. Clarity improves by being concise (long specs are more likely to contain contradictions, be skipped, or be misread) and by marking any deliberate redundancy explicitly (e.g. with “e.g.” or “i.e.”).

Formality

Specifications range from informal to formal:

  • Informal — plain comments, e.g. // Withdraws an amount and returns how much is left. Leaves open questions (what if amount is negative? bigger than the balance? is the balance changed on failure?).
  • Semi-formal — structured English via Javadoc tags (@param, @return, @throws).
  • Formal — mathematical/boolean constraints, e.g. @require/@ensure using Java boolean-expression syntax:
/**
 * Withdraws an amount from this account.
 *
 * @require amount >= 0
 * @require amount <= getBalance()
 * @ensure getBalance() == \old(getBalance()) - amount
 */
public int withdraw(int amount) { ... }

Contracts

A specification can be written as a contract:

  • If the caller satisfies the precondition, the method guarantees to satisfy the postcondition.
  • If the caller does not satisfy the precondition, the method guarantees nothing — any behaviour is allowed, and the method body doesn’t need to check for it.

This contrasts with a defensive, “no contract” style that instead documents and handles every failure case explicitly (e.g. returning a sentinel value like -1 on invalid input) — contracts push that responsibility onto the caller instead.

Defensive programming

Explicitly checking for invalid inputs and bad situations, ensuring the software does not behave dangerously regardless of input.

Even with a documented precondition, some caller eventually won’t check it. When dealing with external input or guarding critical resources, it’s often safer to validate defensively at the system boundary (throwing on bad input, e.g. IllegalArgumentException for a null/empty array) while relying on well-defined contracts between internal methods:

/**
 * Finds the maximum value in the given array of integers.
 *
 * @throws IllegalArgumentException if the array is null or empty.
 * @requires numbers != null && numbers.length > 0
 * @ensures the method returns the maximum value found in the array.
 */
public int findMax(int[] numbers) {
    if (numbers == null || numbers.length == 0) {
        throw new IllegalArgumentException("Array must not be null or empty.");
    }
    int max = Integer.MIN_VALUE;
    for (int i = 0; i < numbers.length; i++) {
        if (numbers[i] > max) {
            max = numbers[i];
        }
    }
    return max;
}

Further reading: Preconditions, Postconditions, and Class Invariants, Assertions, and Effective Java (3rd ed.), Item 56: Write doc comments for all exposed API elements.

Java Testing

Introduced in 2026-03-26-testing (Lecture, Week 5).

Levels of testing

  1. Unit testing — check that each “unit” in the project behaves correctly.
  2. Integration testing — check that components work together and that the interfaces between components work as expected.
  3. System testing — does the system as a whole work correctly?
  4. (User) acceptance testing — do users of the system agree that the system does what it’s supposed to do?

These form a pyramid, from many low-level unit tests at the base up to a few acceptance tests at the top.

Regression testing

During development, testing helps answer two questions: does the new stuff work, and have we broken things that used to work (regressed)? Regression testing ensures old features keep working as new features are introduced.

Black box vs white/glass box testing

  • Black box — the software has inputs and outputs to test, but the implementation is unknown to you; you test according to the specification (see java-specification) — what it’s supposed to do.
  • White/glass box — testing designed with knowledge of the internal implementation, so tests can pay special attention to cases where the implementation is complicated.

Black box techniques

Smoke tests — test the common-case functionality first (“can it run?”), to decide whether more rigorous testing is worthwhile. The name comes from electronics testing: plug in a board, turn on the power — if you see smoke, stop.

Boundary tests — investigate extremes and corner cases, since that’s where bugs often occur:

Input type Try
Whole number 0, -1, minimum value, maximum value
Floating point 0, -1, NaN, infinity
Collection (array, list, set…) empty, one element, large
Reference type null
Resource (file, link) non-existent resource

Equivalence classes — groups of inputs that the system treats the same way. E.g. for getCurrentPassengers() on a bus with capacity 80: Valid-Empty (0), Valid-Normal (1-79), Valid-Full (80), Invalid (anything else). The corresponding boundary tests probe each class’s edges: -1, 0, 1 (around empty), 79, 80, 81 (around full capacity) — giving a minimal boundary set of -1, 0, 1, 79, 80, 81.

White box technique: code coverage

Of all the ways a program could run, how many are covered by the tests? Three levels, from weakest to strongest:

  1. Statement coverage — every statement is executed at least once.
  2. Branch coverage — every branch is tested for both the true and false case.
  3. Path coverage — every distinct path through the code is traversed.

Example:

public void register(int x) {
    if (x > 0) {
        positives += 1;
    }
    if (x % 2 == 0) {
        evens += 1;
    }
}
  • Statement coverage: x = 2 alone covers every line (both if bodies run).
  • Branch coverage: x = 2 and x = -1 together cover both branches of each if (true/false).
  • Path coverage: needs all 4 combinations of the two conditions: x=2 (true, true), x=1 (true, false), x=-2 (false, true), x=-1 (false, false).

Loops make exhaustive path coverage infeasible — a loop like for (int i = 0; i < 100; i++) { if (f(i, k)) { j++; } } has \(2^{100}\) paths (over a billion tests/second would still take ~40.2 trillion years, about 2900 times the age of the universe). Instead, path coverage for loops is approximated: treat 0, 1, and 2-or-more iterations (of a loop or recursive call) as equivalent (“engineers’ induction: one, two, three — that’s good enough for me”).

Test Driven Development (TDD)

Originates from the Agile manifesto and Extreme Programming. Cycle: write a (failing) testwrite code until the test passesrefactor → write the next test. Developers write small test cases for every feature based on their initial understanding, and only modify/write new code when a test fails (avoiding duplicated test scripts) — the tests determine when code is “ready”.