By the end of this module, you will be able to:
instanceof operator to check an object's type safelyPolymorphism 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.
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.
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
Animaltype have amakeSound()method?" — yes, it does. What actually runs is determined at runtime, not compile time.
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.
instanceof OperatorWhen 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");
}
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
}
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
instanceofand 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.
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.
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.
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?
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());
}
}
instanceof OveruseThe 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.
Create the following in package com.shapes:
Shape (abstract class or interface — your choice, justify it)
area() → doubleperimeter() → doubledescribe() → String — returns something like "Circle with radius 5.0"Three implementing classes:
Circle — fields: radiusRectangle — fields: width, heightTriangle — fields: sideA, sideB, sideC (use Heron's formula for area)In Main:
Shape objects — a mix of all three typesinstanceofExtend 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: