Week 2

Inheritance

Interfaces

Polymorphism

Error Handling in Java

Practice

Assignment

Back end Track

🎯 Learning Objectives

By the end of this module, you will be able to:


1. What is Polymorphism?

Polymorphism means "many forms". In Java, it describes the ability to treat objects of different types through a common type, and have each respond in its own way.

You've already seen the building blocks in the last two modules:

Polymorphism is what happens when you put these to work. Instead of writing separate code for every type, you write code against a common type and let Java figure out at runtime which version to call.

A real-world analogy: a TV remote has a pressPlay() button. Whether you're playing a DVD, a streaming app, or a Blu-ray, you press the same button. Each device responds differently, but the remote doesn't care — it just calls pressPlay().

🔍 JavaScript is dynamically typed, so polymorphism happens implicitly — any object with a matching method name works. Java is statically typed, which means polymorphism is explicit and checked at compile time: the common type must be declared, and the compiler verifies the contract is met before the program even runs.


2. Compile-Time Polymorphism — Method Overloading

You already know this from Module 5 (Methods) and saw it again in Module 1 (Inheritance). Compile-time polymorphism is resolved by the compiler based on the method signature — the number and types of parameters.

public class Formatter {
    public String format(int value) {
        return "INT: " + value;
    }

    public String format(double value) {
        return String.format("DOUBLE: %.2f", value);
    }

    public String format(String value) {
        return "STRING: " + value.toUpperCase();
    }
}
Formatter f = new Formatter();
System.out.println(f.format(42));       // INT: 42
System.out.println(f.format(3.14));     // DOUBLE: 3.14
System.out.println(f.format("hello"));  // STRING: HELLO

The compiler looks at the argument type at the call site and wires it to the correct method version — all resolved before the program runs. This is why it's called compile-time.


3. Runtime Polymorphism — Dynamic Dispatch

This is the more powerful and more important form. Runtime polymorphism happens when a method call is resolved at runtime based on the actual type of the object — not the declared type of the variable.

This is enabled by method overriding.

public class Animal {
    public String makeSound() {
        return "...";
    }
}

public class Dog extends Animal {
    @Override
    public String makeSound() { return "Woof!"; }
}

public class Cat extends Animal {
    @Override
    public String makeSound() { return "Meow!"; }
}

public class Duck extends Animal {
    @Override
    public String makeSound() { return "Quack!"; }
}

Now observe what happens when all three are stored as the parent type Animal:

Animal a1 = new Dog();   // declared as Animal, actual object is Dog
Animal a2 = new Cat();   // declared as Animal, actual object is Cat
Animal a3 = new Duck();  // declared as Animal, actual object is Duck

System.out.println(a1.makeSound()); // Woof!  — Dog's version runs
System.out.println(a2.makeSound()); // Meow!  — Cat's version runs
System.out.println(a3.makeSound()); // Quack! — Duck's version runs

The declared type is Animal for all three — but Java doesn't call Animal.makeSound(). At runtime, Java looks at the actual object (Dog, Cat, Duck) and calls that class's version. This mechanism is called dynamic dispatch.

💡 The compiler only checks: "does the Animal type have a makeSound() method?" — yes, it does. What actually runs is determined at runtime, not compile time.


4. Programming to an Interface or Superclass Type

The real power of polymorphism comes from writing code that talks to a common type — either an interface or a superclass — rather than a specific class. This pattern is called programming to an interface.

// ❌ Specific type — tightly coupled to one implementation
Dog dog = new Dog();
dog.makeSound();

// ✅ Common type — works with any Animal subclass
Animal animal = new Dog();
animal.makeSound();

This becomes especially powerful with collections. A single array or list can hold many different types, as long as they share a common supertype:

Animal[] animals = {
    new Dog(),
    new Cat(),
    new Duck(),
    new Dog(),
    new Cat()
};

for (Animal animal : animals) {
    System.out.println(animal.makeSound());
}
// Woof!
// Meow!
// Quack!
// Woof!
// Meow!

The loop doesn't know — and doesn't need to know — what specific type each animal is. It calls makeSound() on every one, and each responds correctly.

The same principle works with interfaces:

public interface Payable {
    double calculatePay();
}

public class FullTimeEmployee implements Payable {
    private double monthlySalary;

    public FullTimeEmployee(double monthlySalary) {
        this.monthlySalary = monthlySalary;
    }

    @Override
    public double calculatePay() {
        return monthlySalary;
    }
}

public class FreelanceContractor implements Payable {
    private double hourlyRate;
    private int    hoursWorked;

    public FreelanceContractor(double hourlyRate, int hoursWorked) {
        this.hourlyRate   = hourlyRate;
        this.hoursWorked  = hoursWorked;
    }

    @Override
    public double calculatePay() {
        return hourlyRate * hoursWorked;
    }
}
Payable[] staff = {
    new FullTimeEmployee(3500.00),
    new FreelanceContractor(75.00, 32),
    new FullTimeEmployee(4200.00),
    new FreelanceContractor(90.00, 20)
};

double totalPayroll = 0;

for (Payable person : staff) {
    double pay = person.calculatePay();
    totalPayroll += pay;
    System.out.printf("Pay: €%.2f%n", pay);
}

System.out.printf("Total payroll: €%.2f%n", totalPayroll);
// Pay: €3500.00
// Pay: €2400.00
// Pay: €4200.00
// Pay: €1800.00
// Total payroll: €11900.00

FullTimeEmployee and FreelanceContractor are completely unrelated classes — they share no inheritance. But the loop treats them identically because they both honour the Payable contract.


5. The instanceof Operator

When you hold objects as a superclass or interface type, you sometimes need to check what the actual type is before accessing type-specific behaviour. The instanceof operator does this:

Animal animal = new Dog();

if (animal instanceof Dog) {
    System.out.println("This is a dog");
}

Casting to the Specific Type

After confirming the type with instanceof, you can downcast to access methods that only exist on the subclass:

public class Dog extends Animal {
    @Override
    public String makeSound() { return "Woof!"; }

    public void fetch() { System.out.println("Fetching the ball!"); }
}
Animal animal = new Dog();

// animal.fetch(); ❌ — Animal type doesn't have fetch()

if (animal instanceof Dog) {
    Dog dog = (Dog) animal;  // downcast
    dog.fetch();             // ✅ now accessible
}

Pattern Matching with instanceof (Java 16+)

Java 16 introduced a cleaner syntax that combines the check and the cast in one step:

// Classic — two steps
if (animal instanceof Dog) {
    Dog dog = (Dog) animal;
    dog.fetch();
}

// Pattern matching — one step (Java 16+)
if (animal instanceof Dog dog) {
    dog.fetch();  // dog is already cast and available
}

⚠️ Use instanceof and downcasting sparingly. If you find yourself checking types frequently, it's a signal that your design may need revisiting — perhaps a method belongs in the superclass or interface instead. Polymorphism works best when each type handles its own behaviour.


6. Why Polymorphism Matters

Flexibility — new types slot in without changing existing code

Imagine you need to add a Parrot to the payroll system. With polymorphism:

public class Parrot extends Animal {
    @Override
    public String makeSound() { return "Polly wants a cracker!"; }
}

The loop that iterates over Animal[] doesn't change at all. Parrot just slots in. Without polymorphism, you'd need to add an if/else branch for every new type — and update it every time you add another.

Extensibility — the Open/Closed Principle

Polymorphism is the mechanism behind one of software's most important design principles: code should be open for extension, but closed for modification. New behaviour is added by creating new classes, not by editing existing working code.

// BAD — every new animal type requires editing this method
public void processAnimal(Animal animal) {
    if (animal instanceof Dog) {
        ((Dog) animal).bark();
    } else if (animal instanceof Cat) {
        ((Cat) animal).meow();
    } else if (animal instanceof Duck) {
        ((Duck) animal).quack();
    }
    // Every new type = another else if = editing existing code
}

// GOOD — new animal types add zero changes here
public void processAnimal(Animal animal) {
    System.out.println(animal.makeSound());
    // Works for Dog, Cat, Duck, Parrot, and any future Animal
}

The "bad" version grows forever. The "good" version never changes.


✏️ Exercises

Exercise 1 — Concept: Runtime vs Compile-time

Classify each of the following as compile-time polymorphism (overloading) or runtime polymorphism (overriding). Explain your reasoning.

// A
public class Logger {
    public void log(String message) { ... }
    public void log(String message, int level) { ... }
    public void log(Exception e) { ... }
}

// B
public class Shape {
    public double area() { return 0; }
}
public class Circle extends Shape {
    @Override
    public double area() { return Math.PI * radius * radius; }
}

// C
public class Shape {
    public double area() { return 0; }
}
public class Circle extends Shape {
    public double area(double radius) { return Math.PI * radius * radius; }
}
// Is this overriding or overloading? What does @Override do here?

Exercise 2 — Concept: What Runs?

Without running the code, predict the exact output line by line. Then explain why each line prints what it does.

public class Vehicle {
    public String describe() { return "I am a vehicle."; }
    public String fuelType()  { return "Unknown fuel"; }
}

public class Car extends Vehicle {
    @Override
    public String describe() { return "I am a car."; }
    @Override
    public String fuelType()  { return "Petrol"; }
}

public class ElectricCar extends Car {
    @Override
    public String fuelType() { return "Electric"; }
}

public class Main {
    public static void main(String[] args) {
        Vehicle v1 = new Vehicle();
        Vehicle v2 = new Car();
        Vehicle v3 = new ElectricCar();
        Car     c1 = new ElectricCar();

        System.out.println(v1.describe());
        System.out.println(v2.describe());
        System.out.println(v3.describe());
        System.out.println(c1.describe());
        System.out.println(v1.fuelType());
        System.out.println(v2.fuelType());
        System.out.println(v3.fuelType());
        System.out.println(c1.fuelType());
    }
}

Exercise 3 — Bug Fixing: instanceof Overuse

The following method works but is poorly designed. Explain what's wrong with it, then refactor it to use polymorphism properly — eliminating all instanceof checks.

public class NotificationSender {
    public void send(Object notification) {
        if (notification instanceof EmailNotification) {
            EmailNotification email = (EmailNotification) notification;
            System.out.println("[EMAIL] " + email.getRecipient());
        } else if (notification instanceof SmsNotification) {
            SmsNotification sms = (SmsNotification) notification;
            System.out.println("[SMS] " + sms.getPhoneNumber());
        } else if (notification instanceof PushNotification) {
            PushNotification push = (PushNotification) notification;
            System.out.println("[PUSH] " + push.getDeviceToken());
        }
    }
}

Your refactored version should allow a new notification type to be added without changing NotificationSender at all.


Exercise 4 — Coding: Shape Calculator

Create the following in package com.shapes:

Shape (abstract class or interface — your choice, justify it)

Three implementing classes:

In Main:

  1. Create an array of at least 6 Shape objects — a mix of all three types
  2. Loop over them and print each shape's description, area, and perimeter — all formatted to 2 decimal places
  3. Calculate and print the combined total area of all shapes
  4. Find and print the shape with the largest area — without sorting

Exercise 5 — Coding: Mixed Collection with instanceof

Extend Exercise 4. Add a RegularPolygon class (all sides equal — fields: sides (int), sideLength (double)) to the shapes array.

Then write a separate method summarise(Shape[] shapes) that: