Java Tutorial » Chapter 25 — Polymorphism and Virtual Methods

Chapter 25 — Java Polymorphism

Understanding the power of virtual methods and dynamic dispatch for flexible code.

1. Introduction to Polymorphism

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.

2. Types of Polymorphism

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.

3. Virtual Methods 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
    }
}
4. Dynamic Method Dispatch

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();
        }
    }
}
5. Upcasting and Downcasting

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
    }
}
6. The `instanceof` Operator

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
    }
}
7. Practice & Challenge

Test Your Skills

  1. Create a base class Shape with a virtual method draw().
  2. Create three subclasses: Circle, Square, and Triangle. Override the draw() method in each to print a unique message (e.g., "Drawing a Circle").
  3. In a main method, create an array of type Shape and populate it with instances of your three subclasses.
  4. 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 Payment with an abstract method processPayment(double amount) and a concrete method printReceipt().
  • Create three subclasses: CreditCardPayment, PayPalPayment, and BitcoinPayment. Each should implement processPayment with a unique message (e.g., "Processing $XX.XX via Credit Card").
  • In a main method, create a list of different payment objects.
  • Loop through the list and process each payment polymorphically.
  • After processing, use the instanceof operator 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("---");
        }
    }
}