Lesson 15 — Many Forms

Polymorphism

Polymorphism — 'many forms' — allows different objects to respond to the same method call in their own appropriate way. It is the mechanism that makes code extensible without modification.

What Polymorphism Means in Practice

You have an array of Person objects — some are Learners, some are Facilitators. You call introduce() on each. Without polymorphism, you would need to check each object's type and call a different method. With polymorphism, you call the same method and Java automatically uses the correct version for each object. This is runtime (dynamic) dispatch.

Method Overriding

Overriding means a subclass defines a method with the same name, same parameter list, and same return type as a parent method. At runtime, Java looks at the actual type of the object and calls the most specific version. The @Override annotation asks the compiler to verify you are actually overriding something — a typo in the method name would otherwise silently create a new method instead of overriding.

JAVA — Overriding
public class Person {
    protected String name;
    public Person(String name) { this.name = name; }

    public void introduce() {
        System.out.println("Hello, I am " + name + ".");
    }
}

public class Learner extends Person {
    private String course;
    public Learner(String name, String course) {
        super(name); this.course = course;
    }

    @Override
    public void introduce() {
        System.out.println("I am " + name + ", studying " + course + " at Your IT Tutor.");
    }
}

public class Facilitator extends Person {
    private String specialisation;
    public Facilitator(String name, String spec) {
        super(name); this.specialisation = spec;
    }

    @Override
    public void introduce() {
        System.out.println("I am " + name + ", a facilitator specialising in " + specialisation + ".");
    }
}

Polymorphism in Action

JAVA — Polymorphic dispatch
// A Learner IS-A Person. A Facilitator IS-A Person.
// So we can store both in a Person array:
Person[] people = {
    new Learner("Thandi",  "Java"),
    new Learner("Sipho",   "Python"),
    new Facilitator("Ms Dube", "Software Development"),
    new Learner("Lerato",  "SQL"),
    new Facilitator("Mr Khumalo", "Data Engineering")
};

// One loop, one method call — Java selects the right version for each object:
for (Person p : people) {
    p.introduce();
}

// Output:
// I am Thandi, studying Java at Your IT Tutor.
// I am Sipho, studying Python at Your IT Tutor.
// I am Ms Dube, a facilitator specialising in Software Development.
// I am Lerato, studying SQL at Your IT Tutor.
// I am Mr Khumalo, a facilitator specialising in Data Engineering.

Casting and instanceof

When you hold an object through a parent-type reference, you can only call parent-type methods. To access subclass-specific methods, you must cast down — but first check with instanceof to avoid a ClassCastException.

JAVA — Casting
for (Person p : people) {
    p.introduce(); // always works

    // Access Learner-specific methods:
    if (p instanceof Learner) {
        Learner l = (Learner) p;    // downcast — safe because we checked
        System.out.println("  Course: " + l.getCourse());
    } else if (p instanceof Facilitator) {
        Facilitator f = (Facilitator) p;
        System.out.println("  Specialisation: " + f.getSpecialisation());
    }
}

// Java 16+ pattern matching instanceof (cleaner):
if (p instanceof Learner l) {
    System.out.println(l.getCourse()); // l is already cast — no explicit cast needed
}

Overriding equals() and hashCode()

Inherited from Object, the default equals() compares references (same object in memory). Override it when two objects should be considered equal based on their data.

JAVA — equals and hashCode
@Override
public boolean equals(Object obj) {
    if (this == obj) return true;               // same reference
    if (!(obj instanceof Learner)) return false; // different type
    Learner other = (Learner) obj;
    return this.name.equals(other.name) && this.course.equals(other.course);
}

// Always override hashCode when overriding equals:
@Override
public int hashCode() {
    return java.util.Objects.hash(name, course);
}

Learner a = new Learner("Thandi", "Java");
Learner b = new Learner("Thandi", "Java");
System.out.println(a == b);       // false — different objects
System.out.println(a.equals(b));  // true  — same data

Practice Task

Your Turn

Create a Shape hierarchy: Shape (colour, getArea(), getPerimeter(), describe()), Circle (radius), Rectangle (width, height), Triangle (base, height, hypotenuse). Override getArea(), getPerimeter(), and describe() in each subclass. Store all three in a Shape[], loop through, and for each: print its colour, area, perimeter. Also print the total combined area of all shapes.

Common Mistakes

  • Forgetting @Override — without it, a typo creates a new method instead of overriding. The annotation makes the compiler catch this.
  • Overriding with a more restrictive access modifier — you can widen access (protected → public) but not narrow it.
  • Overriding a private method — private methods are not visible to subclasses; you are creating a new method, not overriding.
  • Overriding equals without overriding hashCode — collections like HashMap rely on both being consistent.
  • Confusing overriding (same signature, subclass) with overloading (same name, different parameters, same class).

Professional Tip

Design your superclass methods with overriding in mind. If you know subclasses will need to specialise behaviour, make those methods overridable and give them sensible defaults. Polymorphism is what lets you add a new shape to the system without touching any existing code.

Mini Quiz

Which annotation should you always use when overriding a method?