What is Polymorphism?
Polymorphism, from Greek meaning "many forms," is a core concept in object-oriented programming that allows objects of different classes to be treated as objects of a common superclass. The most common use of polymorphism is when a parent class reference is used to refer to a child class object.
This powerful feature enables you to write more generic and flexible code. You can create methods that can operate on objects of various types, as long as they share a common superclass. The specific behavior that gets executed is determined at runtime.
Think of a real-world example: a start() button. Clicking it starts a Car, a Motorcycle, or a Computer. The action "start" is the same, but the underlying implementation is different for each object. Polymorphism allows this to happen seamlessly in code.
Compile-time vs. Runtime
In Java, polymorphism can be categorized into two types, depending on when the method to be executed is determined.
Compile-time Polymorphism
Also known as static polymorphism or method overloading. The compiler knows which method to call based on the method signature (name and parameters) at compile time.
class Calculator {
public int add(int a, int b) {
return a + b;
}
public double add(double a, double b) {
return a + b;
}
public int add(int a, int b, int c) {
return a + b + c;
}
}
Runtime Polymorphism
Also known as dynamic polymorphism or method overriding. The method to be executed is determined at runtime based on the actual type of the object. This is achieved through virtual methods.
class Animal {
public void makeSound() {
System.out.println("Animal makes a sound");
}
}
class Dog extends Animal {
@Override
public void makeSound() {
System.out.println("Dog barks");
}
}
This chapter focuses on runtime polymorphism, which is one of the most powerful features of OOP in Java.
The Engine of Runtime Polymorphism
A virtual method is a method whose implementation is determined at runtime. In many programming languages, you need to explicitly declare a method as virtual. However, in Java, all non-static, non-final, and non-private methods are virtual by default.
This means that when you call a method on a reference variable, the Java Virtual Machine (JVM) will look up the actual method to invoke in the object's class, not in the reference's type. This is the mechanism that enables dynamic method dispatch.
Methods that are NOT Virtual:
- Static methods: Belong to the class, not an instance. They are hidden, not overridden.
- Final methods: Cannot be overridden by subclasses, so the JVM knows exactly which method to call at compile time.
- Private methods: Are not inherited by subclasses and therefore cannot be overridden.
- Constructors: Are not methods in the traditional sense and are not inherited.
class Parent {
// This is a VIRTUAL method (default in Java)
public void display() {
System.out.println("Parent's display method");
}
// This is NOT a virtual method (it's final)
public final void show() {
System.out.println("Parent's final show method");
}
}
class Child extends Parent {
// This OVERRIDES the virtual method from Parent
@Override
public void display() {
System.out.println("Child's display method");
}
// This would cause a COMPILE ERROR
// public void show() {
// System.out.println("Cannot override a final method");
// }
}
public class VirtualMethodExample {
public static void main(String[] args) {
// Parent reference pointing to a Child object
Parent myObj = new Child();
// The JVM calls Child's display() method at runtime
myObj.display(); // Output: Child's display method
// The JVM calls Parent's final show() method
myObj.show(); // Output: Parent's final show method
}
}
Runtime Polymorphism in Action
Dynamic Method Dispatch is the mechanism by which a call to an overridden method is resolved at runtime. This is how Java implements runtime polymorphism. When you call a method through a superclass reference, the JVM determines which version of the method to execute based on the actual type of the object being referenced, not the type of the reference.
This allows for highly flexible and decoupled systems. You can write code that works with a general type, and it will automatically adapt to specific implementations without needing to be recompiled.
// Superclass
class Animal {
public void makeSound() {
System.out.println("Some generic animal sound");
}
}
// Subclass 1
class Dog extends Animal {
@Override
public void makeSound() {
System.out.println("Woof! Woof!");
}
}
// Subclass 2
class Cat extends Animal {
@Override
public void makeSound() {
System.out.println("Meow");
}
}
// Main class to demonstrate dynamic dispatch
public class DynamicDispatchDemo {
public static void main(String[] args) {
// A single reference variable of type Animal
Animal myAnimal;
// 1. Reference points to a Dog object
myAnimal = new Dog();
myAnimal.makeSound(); // Calls Dog's makeSound() method
// 2. Reference now points to a Cat object
myAnimal = new Cat();
myAnimal.makeSound(); // Calls Cat's makeSound() method
// 3. Using polymorphism in a collection
Animal[] animals = { new Dog(), new Cat(), new Animal() };
for (Animal animal : animals) {
// The correct makeSound() is called for each object at runtime
animal.makeSound();
}
}
}
Navigating the Inheritance Hierarchy
Casting is a way to temporarily treat an object of one type as another. In the context of inheritance, we have two main types of casting: upcasting and downcasting.
Upcasting
Upcasting is casting a subclass type to a superclass type. This is always safe and can be done implicitly by the Java compiler. We've been doing this throughout our examples when we write Animal myAnimal = new Dog();. The Dog object is being "upcast" to an Animal reference. You lose access to subclass-specific methods but gain the ability to use the object in a more general context.
Downcasting
Downcasting is casting a superclass reference down to a subclass type. This is not always safe and must be done explicitly. If you try to downcast a reference to an object that is not actually of that subclass type, you will get a ClassCastException at runtime.
class Animal { public void eat() { System.out.println("Animal eats"); } }
class Dog extends Animal { public void bark() { System.out.println("Dog barks"); } }
public class CastingExample {
public static void main(String[] args) {
Dog myDog = new Dog();
// Upcasting: Safe, implicit
Animal myAnimal = myDog;
myAnimal.eat(); // OK
// myAnimal.bark(); // COMPILE ERROR: bark() is not in Animal class
// Downcasting: Must be explicit, potentially unsafe
// We are telling the compiler "trust me, this Animal is actually a Dog"
Dog anotherDog = (Dog) myAnimal;
anotherDog.bark(); // OK now
// --- Unsafe Downcasting Example ---
Animal genericAnimal = new Animal();
// Dog riskyDog = (Dog) genericAnimal; // This would compile...
// riskyDog.bark(); // ...but would throw a ClassCastException at runtime
}
}
Safe Downcasting
To avoid the risk of a ClassCastException when downcasting, Java provides the instanceof operator. This operator checks if an object is an instance of a specific class (or a subclass thereof) before you attempt to cast it. It returns true if the object can be cast to the specified type without causing an exception, and false otherwise.
Using instanceof is a best practice for performing safe downcasts.
class Animal { public void eat() { System.out.println("Animal eats"); } }
class Dog extends Animal { public void bark() { System.out.println("Dog barks"); } }
class Cat extends Animal { public void meow() { System.out.println("Cat meows"); } }
public class InstanceofExample {
public static void checkAnimalType(Animal animal) {
animal.eat(); // Always safe to call Animal methods
// Safe downcasting using instanceof
if (animal instanceof Dog) {
Dog dog = (Dog) animal; // Safe to cast
dog.bark();
} else if (animal instanceof Cat) {
Cat cat = (Cat) animal; // Safe to cast
cat.meow();
} else {
System.out.println("Unknown animal type.");
}
}
public static void main(String[] args) {
Animal myDog = new Dog();
Animal myCat = new Cat();
Animal genericAnimal = new Animal();
checkAnimalType(myDog); // Checks and casts to Dog
checkAnimalType(myCat); // Checks and casts to Cat
checkAnimalType(genericAnimal); // Finds no specific type
}
}
Test Your Skills
- Create a base class
Shapewith a virtual methoddraw(). - Create three subclasses:
Circle,Square, andTriangle. Override thedraw()method in each to print a unique message (e.g., "Drawing a Circle"). - In a
mainmethod, create an array of typeShapeand populate it with instances of your three subclasses. - Loop through the array and call the
draw()method on each shape. Observe how dynamic method dispatch calls the correct method for each object.
🏆 Challenge: Advanced Payment System
Create a payment processing system that uses polymorphism to handle different payment types:
- Create an abstract class
Paymentwith an abstract methodprocessPayment(double amount)and a concrete methodprintReceipt(). - Create three subclasses:
CreditCardPayment,PayPalPayment, andBitcoinPayment. Each should implementprocessPaymentwith a unique message (e.g., "Processing $XX.XX via Credit Card"). - In a
mainmethod, create a list of different payment objects. - Loop through the list and process each payment polymorphically.
- After processing, use the
instanceofoperator to check the type of each payment and print a specific, non-polymorphic message for each type (e.g., "Sending email receipt for Credit Card payment").
abstract class Payment {
protected double amount;
public Payment(double amount) {
this.amount = amount;
}
// Abstract method to be implemented by subclasses
public abstract void processPayment();
// Concrete method available to all subclasses
public void printReceipt() {
System.out.printf("Receipt: Payment of $%.2f processed.%n", amount);
}
}
class CreditCardPayment extends Payment {
private String cardNumber;
public CreditCardPayment(double amount, String cardNumber) {
super(amount);
this.cardNumber = cardNumber;
}
@Override
public void processPayment() {
System.out.printf("Processing $%.2f via Credit Card ending in %s%n", amount, cardNumber.substring(cardNumber.length() - 4));
}
}
class PayPalPayment extends Payment {
private String email;
public PayPalPayment(double amount, String email) {
super(amount);
this.email = email;
}
@Override
public void processPayment() {
System.out.printf("Processing $%.2f via PayPal account %s%n", amount, email);
}
}
public class PaymentProcessor {
public static void main(String[] args) {
// List of different payment types
Payment[] payments = {
new CreditCardPayment(150.75, "1234-5678-9876-5432"),
new PayPalPayment(99.99, "customer@example.com"),
new CreditCardPayment(25.50, "5555-6666-7777-8888")
};
// Process each payment polymorphically
for (Payment payment : payments) {
payment.processPayment(); // Calls the correct implementation
payment.printReceipt(); // Calls the method from the superclass
// Use instanceof for specific post-processing logic
if (payment instanceof CreditCardPayment) {
System.out.println("-> Sending SMS receipt for Credit Card payment.");
} else if (payment instanceof PayPalPayment) {
System.out.println("-> Sending email receipt for PayPal payment.");
}
System.out.println("---");
}
}
}