Skip to content
Part IA Michaelmas Term

2023 Paper 1 Question 4 — Type Erasure, Immutability, LSP and SRP

Question

(a) Explain the concept of type erasure in Java. [2 marks]

(b) What do these types erase to?

  • (i) List<List<Integer>> [1 mark]
  • (ii) List<String>[] [1 mark]
  • (iii) Map<String, List<Map<String, Integer>>> [1 mark]

(c) This question covers the concept of immutability.

  • (i) Provide an example of a built-in immutable class in Java. [1 mark]
  • (ii) Explain what is required to declare an immutable class in Java. [2 marks]
  • (iii) Provide a code example of a declared immutable class called ProductInfo which contains two fields storing an id and a description. [2 marks]

(d)

  • (i) Explain the meaning of the Liskov-Substitution principle. [1 mark]
  • (ii) Explain the meaning of the Single Responsibility principle. [1 mark]
  • (iii) Imagine that you are working in the insurance context. You can issue a Policy to insure a policy holder. There are two types of policies available: a life insurance policy and a car insurance policy. Both policies take into consideration the age of the policy holder. You need to calculate the premium of each policy based on:
    • Life insurance takes 5% of the total sought coverage amount if the policy holder is under 35, or 10% otherwise.
    • For adults only, car insurance takes 10% of the car value if the policy holder is over 30, or 20% otherwise. Define four classes PolicyHolder, Policy, LifeInsurancePolicy extends Policy, CarInsurancePolicy extends Policy such that they encapsulate this system and demonstrate a violation of the Single Responsibility Principle in the Policy class and a Liskov-Substitution pre-condition violation in the CarInsurancePolicy class. [6 marks]
  • (iv) Describe what steps you would need to take to adhere to both the Liskov-Substitution and Single Responsibility principles. [2 marks]

Worked Solution

(a) Type erasure — 2 marks

Model answer: Type erasure is the process by which the Java compiler removes all generic type parameter information and replaces it with the type parameter’s bound (or Object if unbounded). This means List<String> and List<Integer> have the same runtime type: List. The JVM never sees the generic type argument.

The compiler inserts casts where needed. For example, list.get(0) on a List<String> compiles to (String) list.get(0) in bytecode — the cast is performed after erasure.

(b) Erasure of specific types

(b)(i) List<List<Integer>> — erases to List (1 mark)

The outer List’s type parameter is List<Integer>, which erases to List (its bound is Object, and List is a reference type). So the whole thing erases to List.

At runtime: List<List<Integer>>List (raw).

List<List<Integer>> matrix = new ArrayList<>();
System.out.println(matrix.getClass()); // class java.util.ArrayList

(b)(ii) List<String>[] — erases to List[] (1 mark)

The array type List<String>[] erases to List[] (raw List array). The generic component type List<String> erases to List. However, the creation of generic arrays is prohibited in Java — you cannot write new List<String>[10] because type erasure would lose the String constraint, allowing heap pollution.

// List<String>[] arr = new List<String>[10];  // COMPILE ERROR
List<?>[] arr = new List<?>[10];               // OK with wildcard

(b)(iii) Map<String, List<Map<String, Integer>>> — erases to Map (1 mark)

All generic type arguments are erased:

  • StringObject
  • List<Map<String, Integer>>List (the inner Map<String, Integer> also erases to Map)
  • So the full type erases to Map

At runtime, it is just a raw Map. The compiler inserts casts as needed.

Key pattern: Erasure removes all type arguments recursively, replacing each with its erasure. The erasure of a type parameter is its leftmost bound (or Object if unbounded).

(c)(i) Built-in immutable class — 1 mark

String is the canonical example. Others include Integer, Double, Boolean, LocalDate, BigInteger, BigDecimal. All wrapper classes for primitives are immutable.

(c)(ii) Requirements for an immutable class — 2 marks

Model answer:

  1. Declare the class final (or make the constructor private and provide a static factory method) — prevents subclassing that could add mutable state.
  2. Make all fields private and final — prevents modification after construction and prevents direct external access.
  3. No setter methods (or any method that mutates state after construction).
  4. Initialise all fields in the constructor — establishing invariants once.
  5. Defensive copying — If a field holds a reference to a mutable object (e.g., a Date or a List), do not expose that reference directly. Instead: (a) in the constructor, make a defensive copy of any mutable parameter; (b) in getters, return a copy (or an unmodifiable view) of any mutable field.

(c)(iii) Immutable ProductInfo example — 2 marks

Model answer:

public final class ProductInfo {
    private final int id;
    private final String description;

    public ProductInfo(int id, String description) {
        this.id = id;
        this.description = description;
    }

    public int getId() { return id; }
    public String getDescription() { return description; }
}

Why this is correct:

  • final class — cannot be subclassed.
  • private final fields — cannot be reassigned after construction.
  • String is already immutable, and int is a primitive — no defensive copying needed.
  • No setters — state cannot change after construction.

(d)(i) Liskov-Substitution Principle — 1 mark

Model answer: Subtypes must be substitutable for their base types without altering the correctness of the program. If S is a subtype of T, then objects of type T may be replaced with objects of type S without breaking the program’s behaviour.

In practical terms: a subclass should not strengthen preconditions (require more than the superclass), weaken postconditions (guarantee less than the superclass), or violate the superclass’s invariants.

(d)(ii) Single Responsibility Principle — 1 mark

Model answer: A class should have exactly one reason to change — it should have a single, well-defined responsibility. If a class handles both business logic and data persistence (for example), changes to either concern force modification of the same class, increasing the risk of introducing bugs in the other concern.

(d)(iii) Insurance system with violations — 6 marks

Model answer:

class PolicyHolder {
    private final String name;
    private final int age;

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

    String getName() { return name; }
    int getAge() { return age; }
}

class Policy {
    protected PolicyHolder holder;
    protected double coverageAmount;
    protected double carValue;

    Policy(PolicyHolder holder, double coverageAmount, double carValue) {
        this.holder = holder;
        this.coverageAmount = coverageAmount;
        this.carValue = carValue;
    }

    double calculatePremium() {
        return 0;
    }
}

SRP VIOLATION in Policy: The Policy class holds both coverageAmount (relevant only to life insurance) and carValue (relevant only to car insurance). It tries to be the superclass for two fundamentally different kinds of policies, each needing different fields. This means Policy has two reasons to change: (a) if the life insurance calculation rules change, and (b) if the car insurance calculation rules change. Furthermore, every Policy instance carries a meaningless field — a life insurance Policy has an unused carValue field, and vice versa.

class LifeInsurancePolicy extends Policy {

    LifeInsurancePolicy(PolicyHolder holder, double coverageAmount) {
        super(holder, coverageAmount, 0);  // carValue is meaningless here
    }

    @Override
    double calculatePremium() {
        if (holder.getAge() < 35) {
            return coverageAmount * 0.05;
        } else {
            return coverageAmount * 0.10;
        }
    }
}

class CarInsurancePolicy extends Policy {

    CarInsurancePolicy(PolicyHolder holder, double carValue) {
        super(holder, 0, carValue);  // coverageAmount is meaningless here
        // LSP VIOLATION: strengthening preconditions
        if (holder.getAge() < 18) {
            throw new IllegalArgumentException(
                "Car insurance only available for adults (18+)");
        }
    }

    @Override
    double calculatePremium() {
        if (holder.getAge() > 30) {
            return carValue * 0.10;
        } else {
            return carValue * 0.20;
        }
    }
}

LSP VIOLATION in CarInsurancePolicy: The CarInsurancePolicy constructor adds a new precondition that holder.getAge() >= 18. The Policy superclass’s constructor has no such precondition — you can create a Policy for a holder of any age. Code written to work with Policy would reasonably expect to create a Policy for a 16-year-old:

PolicyHolder teen = new PolicyHolder("Teen Driver", 16);
Policy p = new CarInsurancePolicy(teen, 10000.0); // THROWS! Unexpected.

This is an LSP violation because CarInsurancePolicy is not fully substitutable for Policy — it rejects inputs that Policy accepts. The precondition has been strengthened in the subclass.

Summary of violations:

  • SRP violation in Policy: Holds fields for two different insurance types. Has two reasons to change.
  • LSP pre-condition violation in CarInsurancePolicy: Adds an age restriction that the superclass does not have.

(d)(iv) Steps to adhere to LSP and SRP — 2 marks

Model answer:

  1. Remove the SRP violation: Make Policy an abstract class (or interface) that holds only the common state: PolicyHolder holder. Move coverageAmount to LifeInsurancePolicy and carValue to CarInsurancePolicy. Policy should define the abstract method calculatePremium() with no fields that are specific to any subclass.

  2. Remove the LSP violation: Either (a) move the age validation out of the constructor — validate in a factory method or in the client code that creates policies, not inside the CarInsurancePolicy constructor; or (b) document the adult-age requirement as a class invariant of CarInsurancePolicy rather than a constructor check, and handle it through a separate validation layer. The key principle: a subclass should not surprise its users with stricter construction requirements.

abstract class Policy {
    protected final PolicyHolder holder;

    Policy(PolicyHolder holder) {
        this.holder = holder;
    }

    abstract double calculatePremium();
}

class LifeInsurancePolicy extends Policy {
    private final double coverageAmount;

    LifeInsurancePolicy(PolicyHolder holder, double coverageAmount) {
        super(holder);
        this.coverageAmount = coverageAmount;
    }

    @Override
    double calculatePremium() {
        return coverageAmount * (holder.getAge() < 35 ? 0.05 : 0.10);
    }
}

class CarInsurancePolicy extends Policy {
    private final double carValue;

    CarInsurancePolicy(PolicyHolder holder, double carValue) {
        super(holder);
        this.carValue = carValue;
    }

    @Override
    double calculatePremium() {
        return carValue * (holder.getAge() > 30 ? 0.10 : 0.20);
    }
}

Now each class has a single responsibility (life premium calculation, car premium calculation), and no subclass adds unexpected preconditions.