Skip to content
Part IA Michaelmas Term

2024 Paper 1 Question 4 — Student Hierarchy, Exceptions as Flow Control, and State Pattern

Question

A university manages its students using a program that has a class Student with subclasses FirstYear, SecondYear, and ThirdYear for year-specific state and behaviour. The program has a List<Student> that contains all Students.

(a) Should Student be a class, an abstract class or an interface? Explain your answer. [2 marks]

(b) Write a Comparator that can be used to sort the List<Student> by year group and then by name, both ascending, and show how it would be used. You should assume the existence of a String getName() method within Student. [4 marks]

(c) At the end of each year, the third year students graduate and must be removed. This is done by passing the list to the following method:

void removeThirdYears(List<Student> students) {
    for (Student student : students) {
        try {
            ThirdYear thirdyear = (ThirdYear) student;
            students.remove(thirdyear);
        }
        catch(ClassCastException cce) { }
    }
}
  • (i) What will happen when the call to remove() is made? Explain why and fix the code. [4 marks]
  • (ii) Comment on this use of exceptions and propose an alternative that does not rely on them. [3 marks]

Full marks required correct use of an iterator or equivalent. Copying the list did not earn the mark for the solution.

(d) Also at the end of the academic year, the first and second year students move up a year.

  • (i) Explain why this class design makes this problematic. [3 marks]
  • (ii) Propose an alternative design and explain in detail how it addresses the problems you identified in (d)(i). [4 marks]

Worked Solution

(a) Student: class, abstract class, or interface? — 2 marks

Model answer: Student should be an abstract class.

Reasoning:

  • Student should NOT be a regular class because there is no such thing as a generic “student” without a year — every student is a FirstYear, SecondYear, or ThirdYear. You should not be able to instantiate new Student().
  • Student should NOT be an interface because it likely has shared state that all students possess (e.g., name, id, college) and shared behaviour (e.g., getName(), getCollege()). Interfaces cannot hold non-static state (fields), so an interface would force every subclass to duplicate the common fields. An abstract class provides both a common state template and the “cannot instantiate” constraint.
  • Student SHOULD be abstract because it defines the common contract (getName(), year-specific methods declared abstract) and holds the shared state, while preventing direct instantiation of a Student that does not belong to any year.

Why 2 marks: Correct answer (abstract class) and clear justification (shared state + cannot instantiate). Mention why the alternative choices are wrong.

(b) Comparator by year group then name — 4 marks

Model answer:

The Comparator needs to sort by year group (First → Second → Third) and then alphabetically by name within each year group.

Comparator<Student> byYearThenName = new Comparator<Student>() {
    @Override
    public int compare(Student a, Student b) {
        int yearCompare = Integer.compare(getYearGroup(a), getYearGroup(b));
        if (yearCompare != 0) {
            return yearCompare;
        }
        return a.getName().compareTo(b.getName());
    }

    private int getYearGroup(Student s) {
        if (s instanceof FirstYear) return 1;
        if (s instanceof SecondYear) return 2;
        if (s instanceof ThirdYear) return 3;
        return 0;
    }
};

// Usage
List<Student> students = new ArrayList<>();
// ... populate ...
students.sort(byYearThenName);

Lambda alternative (more concise):

Comparator<Student> byYearThenName = Comparator
    .comparingInt(s -> {
        if (s instanceof FirstYear) return 1;
        if (s instanceof SecondYear) return 2;
        return 3;
    })
    .thenComparing(Student::getName);

students.sort(byYearThenName);

Why 4 marks: Write a complete Comparator (anonymous class or lambda) that sorts by year first, then by name. Show how it is used (students.sort(...)). Handle the year-group ordering correctly. The instanceof chain is acceptable because the subclasses are the source of the year information — but note that this is a code smell (see part d).

(c)(i) What happens with remove() and fix — 4 marks

Model answer: A ConcurrentModificationException will be thrown.

Why: The enhanced for-each loop (for (Student student : students)) internally uses an Iterator. When you call students.remove(thirdyear) directly on the list while the iterator is iterating, the list’s internal modification counter is incremented. On the next iteration, the iterator detects that the list was modified outside of its own remove() method and throws ConcurrentModificationException.

This happens as soon as the first ThirdYear is removed — the iterator’s hasNext() or next() call will detect the structural modification and throw.

Fix — use the iterator’s own remove():

void removeThirdYears(List<Student> students) {
    Iterator<Student> iter = students.iterator();
    while (iter.hasNext()) {
        Student student = iter.next();
        if (student instanceof ThirdYear) {
            iter.remove();  // safe — iterator knows about the removal
        }
    }
}

Or using the removeIf method (simpler):

void removeThirdYears(List<Student> students) {
    students.removeIf(student -> student instanceof ThirdYear);
}

Why 4 marks: Identify ConcurrentModificationException as the runtime result, explain that the for-each loop uses an iterator internally and that direct list.remove() bypasses the iterator’s modification tracking, and provide a correct fix using Iterator.remove() or removeIf.

(c)(ii) Comment on exception use and alternative — 3 marks

Model answer: Using ClassCastException as a flow control mechanism is an anti-pattern for several reasons:

  1. Exceptions are for exceptional situations, not normal control flow — Finding a FirstYear or SecondYear in the list is completely expected; it is not an error condition. Throwing and catching exceptions is expensive (stack trace generation) and obscures the logic.

  2. Readability — The intent of the code (“remove only ThirdYear students”) is buried inside a try-catch. A reader must mentally parse “try to cast, if that fails then do nothing” to understand “do this only for ThirdYears.” This is a roundabout way to express a type check.

  3. Silent catching of ClassCastException — The empty catch block hides any genuine ClassCastException that might arise from a bug elsewhere in the loop body. If ThirdYear’s methods throw a ClassCastException internally (unlikely, but possible with complex inheritance), the bug is silently swallowed.

Alternative — use instanceof:

void removeThirdYears(List<Student> students) {
    Iterator<Student> iter = students.iterator();
    while (iter.hasNext()) {
        Student student = iter.next();
        if (student instanceof ThirdYear) {
            iter.remove();
        }
    }
}

instanceof is the correct tool for checking an object’s runtime type. It is designed for this purpose, is fast (a simple type check), and clearly communicates the intent.

Why 3 marks: Explain why exception-based flow control is bad (performance, readability, hides real errors), and provide the correct alternative using instanceof. The question note says “Full marks required correct use of an iterator or equivalent” — make sure your solution uses an iterator (not copying the list).

(d)(i) Why moving up a year is problematic — 3 marks

Model answer: The problem is that the class hierarchy encodes the student’s year statically as part of the object’s type, but the year needs to change dynamically over the object’s lifetime. A FirstYear student becomes a SecondYear after one year, but you cannot change an object’s class in Java — a FirstYear object is forever a FirstYear.

To “move up” a year, you would need to:

  1. Create a brand new SecondYear object.
  2. Copy all the data from the old FirstYear object (name, ID, grades, etc.).
  3. Replace the reference in the List<Student> with the new object.
  4. Discard the old FirstYear object.

This is:

  • Error-prone — you must remember to copy every field. If someone adds a new field to Student, you must update the year-transition code.
  • Inefficient — you allocate a new object and copy data for every student, every year.
  • Breaks object identity — any other code holding a reference to the old FirstYear object now holds a stale reference to a “ghost” student that is no longer in the list.

Why 3 marks: Clearly explain that class-based year encoding is static (type) vs the dynamic nature of year progression. Describe the copy-and-replace workaround and its problems.

(d)(ii) Alternative design — 4 marks

Model answer: Use the State pattern — represent the year as a mutable field rather than a type hierarchy.

Instead of subclassing Student into FirstYear, SecondYear, ThirdYear, have a single Student class with a YearState field that changes:

interface YearState {
    int yearNumber();
    String yearName();
    // year-specific behaviour methods
}

class FirstYearState implements YearState {
    public int yearNumber() { return 1; }
    public String yearName() { return "First Year"; }
}

class SecondYearState implements YearState {
    public int yearNumber() { return 2; }
    public String yearName() { return "Second Year"; }
}

class ThirdYearState implements YearState {
    public int yearNumber() { return 3; }
    public String yearName() { return "Third Year"; }
}

class Student {
    private String name;
    private YearState year;

    Student(String name) {
        this.name = name;
        this.year = new FirstYearState();
    }

    String getName() { return name; }

    int yearNumber() { return year.yearNumber(); }

    void advanceYear() {
        if (year.yearNumber() == 1) {
            year = new SecondYearState();
        } else if (year.yearNumber() == 2) {
            year = new ThirdYearState();
        }
        // ThirdYears do not advance further
    }

    boolean isThirdYear() {
        return year.yearNumber() == 3;
    }
}

Now, advancing a year is a single method call — no object copying, no identity change, no type mutation:

for (Student s : students) {
    s.advanceYear();
}
students.removeIf(Student::isThirdYear);

How this addresses the problems:

  1. Object identity preserved — The same Student object remains; only its internal year reference changes. Code holding references to the student is not invalidated.
  2. No data copyingname, id, grades, and all other fields stay in the same object. No risk of missing a field during copy.
  3. Efficient — Changing a reference is O(1)O(1) per student. No object allocation for every transition (or minimal, if you reuse singleton state instances).
  4. Year-specific behaviour is still polymorphic — The YearState interface can declare year-specific methods (e.g., getModules(), getFees()). The student delegates to the current state. This gives the same polymorphic dispatch as the subclass approach but with dynamic mutability.
  5. Comparator is cleanerComparator.comparingInt(Student::yearNumber) — no instanceof chain.
  6. Easier to add new years — Add a FourthYearState by implementing YearState. With the subclass approach, you would need FinalYear extends Student plus updating every instanceof chain in the codebase.

Why 4 marks: Name the State pattern explicitly. Show the interface and state classes. Show the Student class with the mutable year field. Explain how it solves the transition problem (no copy, identity preserved). Connect back to the specific problems identified in (d)(i).