Verified for the 2025 AP Computer Science A exam•Citation:
Imagine you have a remote control that works differently depending on which device you point it at - the same button might turn on the TV, start a DVD player, or adjust the volume on a sound system. In Java, polymorphism works in a similar way, allowing the same method call to behave differently depending on the object it's acting on. This powerful feature is at the heart of object-oriented programming, making code more flexible and reusable. This guide explores how polymorphism works in inheritance relationships, how Java determines which method to execute at runtime, and how you can leverage this concept to write more elegant and maintainable code.
Polymorphism comes from Greek words meaning "many forms." In programming, it refers to the ability of different objects to respond to the same method call in different ways. Java supports polymorphism through inheritance and method overriding.
The key idea is that a reference variable of a superclass type can refer to an object of any subclass type, and the method that gets executed depends on the actual object type, not the reference type.
All Java classes inherit from the Object class, either directly or indirectly. This means that every class you create automatically inherits methods like:
toString()
equals()
hashCode()
getClass()
Because of this inheritance relationship, any object in Java can be referred to using an Object reference variable:
Object obj1 = new String("Hello"); Object obj2 = new ArrayList<Integer>(); Object obj3 = new Scanner(System.in);
This is polymorphism in action - a single reference type (Object) can refer to objects of many different types.
To understand polymorphism, we need to understand how Java binds method calls to method implementations:
Static binding occurs during compilation. The compiler determines which method to call based on:
This applies to:
Dynamic binding occurs at runtime. Java determines which method to execute based on:
This is what enables polymorphic behavior.
For a non-static method call, Java follows these steps:
At compile time, the compiler checks if the method exists in the declared reference type
At runtime, Java looks for the method in the actual object type
Consider this class hierarchy:
public class Animal { public void makeSound() { System.out.println("Some generic animal sound"); } } public class Dog extends Animal { @Override public void makeSound() { System.out.println("Woof!"); } public void fetch() { System.out.println("Fetching..."); } } public class Cat extends Animal { @Override public void makeSound() { System.out.println("Meow!"); } public void climb() { System.out.println("Climbing..."); } }
Here's polymorphism in action:
public class Main { public static void main(String[] args) { // Animal reference, Animal object Animal animal1 = new Animal(); // Animal reference, Dog object Animal animal2 = new Dog(); // Animal reference, Cat object Animal animal3 = new Cat(); animal1.makeSound(); // Outputs: Some generic animal sound animal2.makeSound(); // Outputs: Woof! animal3.makeSound(); // Outputs: Meow! // This won't compile - fetch() is not in Animal class // animal2.fetch(); // This works - casting to Dog ((Dog) animal2).fetch(); } }
In this example:
Polymorphism offers several key benefits:
Code Flexibility: Write methods that work with superclass references but can handle any current or future subclass
Extensibility: Add new subclasses without changing existing code
Simplification: Treat different objects uniformly when they share behavior
Maintenance: Modify subclass implementations without affecting code that uses them
A common use of polymorphism is in method parameters:
public class VetClinic { public void examineAnimal(Animal animal) { System.out.println("Examining animal..."); animal.makeSound(); // The makeSound method will behave differently // depending on what kind of animal is passed in } public static void main(String[] args) { VetClinic clinic = new VetClinic(); Dog fido = new Dog(); Cat whiskers = new Cat(); clinic.examineAnimal(fido); // Will output "Woof!" clinic.examineAnimal(whiskers); // Will output "Meow!" } }
The examineAnimal
method can work with any Animal or Animal subclass. This makes the code more reusable and extensible.
Polymorphism is particularly useful with arrays and collections:
// Create an array of Animal references Animal[] pets = new Animal[3]; // Fill with different types of animals pets[0] = new Dog(); pets[1] = new Cat(); pets[2] = new Animal(); // Process them all the same way for (Animal pet : pets) { pet.makeSound(); // Calls the appropriate version for each animal }
This allows you to:
Java enforces strict compile-time type checking. This means:
Animal myDog = new Dog(); myDog.makeSound(); // Works - makeSound() is in Animal class // myDog.fetch(); // Won't compile - fetch() is not in Animal class
To call methods specific to the subclass, you need to cast the reference:
Animal myDog = new Dog(); ((Dog) myDog).fetch(); // Works after casting
But be careful! Casting to an incompatible type causes a ClassCastException:
Animal myCat = new Cat(); ((Dog) myCat).fetch(); // Runtime error: ClassCastException
To safely cast, use the instanceof operator:
Animal pet = getAnimalFromSomewhere(); if (pet instanceof Dog) { Dog dog = (Dog) pet; dog.fetch(); } else if (pet instanceof Cat) { Cat cat = (Cat) pet; cat.climb(); }
When a method is called through a reference variable, Java uses a process called "dynamic method dispatch" to determine which method implementation to execute:
This happens at runtime, which is why it's called "runtime polymorphism."
// Base class public class Shape { public double getArea() { return 0.0; // Default implementation } public void draw() { System.out.println("Drawing a shape"); } } // Subclasses public class Circle extends Shape { private double radius; public Circle(double radius) { this.radius = radius; } @Override public double getArea() { return Math.PI * radius * radius; } @Override public void draw() { System.out.println("Drawing a circle with radius " + radius); } } public class Rectangle extends Shape { private double width; private double height; public Rectangle(double width, double height) { this.width = width; this.height = height; } @Override public double getArea() { return width * height; } @Override public void draw() { System.out.println("Drawing a rectangle with width " + width + " and height " + height); } } // Using polymorphism public class DrawingApp { public static void drawShape(Shape shape) { System.out.println("Area: " + shape.getArea()); shape.draw(); } public static void main(String[] args) { Shape shape1 = new Circle(5.0); Shape shape2 = new Rectangle(4.0, 6.0); drawShape(shape1); // Works with Circle drawShape(shape2); // Works with Rectangle // Array of shapes Shape[] shapes = { new Circle(3.0), new Rectangle(2.0, 8.0), new Circle(1.0) }; // Process all shapes for (Shape s : shapes) { s.draw(); // Polymorphic behavior } } }
// Base class public class Vehicle { protected double speed; public void accelerate() { speed += 5; System.out.println("Vehicle accelerating to " + speed + " mph"); } public void brake() { speed = Math.max(0, speed - 5); System.out.println("Vehicle braking to " + speed + " mph"); } public void displayInfo() { System.out.println("Generic vehicle traveling at " + speed + " mph"); } } // Subclasses public class Car extends Vehicle { private int numDoors; public Car(int numDoors) { this.numDoors = numDoors; } @Override public void accelerate() { speed += 10; System.out.println("Car accelerating to " + speed + " mph"); } @Override public void displayInfo() { System.out.println(numDoors + "-door car traveling at " + speed + " mph"); } public void honk() { System.out.println("Beep beep!"); } } public class Motorcycle extends Vehicle { private boolean hasSidecar; public Motorcycle(boolean hasSidecar) { this.hasSidecar = hasSidecar; } @Override public void accelerate() { speed += 15; System.out.println("Motorcycle accelerating to " + speed + " mph"); } @Override public void displayInfo() { System.out.println("Motorcycle" + (hasSidecar ? " with sidecar" : "") + " traveling at " + speed + " mph"); } public void wheelie() { if (!hasSidecar) { System.out.println("Doing a wheelie!"); } else { System.out.println("Cannot do a wheelie with a sidecar!"); } } } // Using polymorphism public class TrafficSimulator { public static void testVehicle(Vehicle vehicle) { vehicle.accelerate(); vehicle.displayInfo(); vehicle.brake(); System.out.println(); } public static void main(String[] args) { Vehicle car = new Car(4); Vehicle motorcycle = new Motorcycle(false); testVehicle(car); // Works with Car testVehicle(motorcycle); // Works with Motorcycle // Using type checking and casting for specialized methods if (car instanceof Car) { ((Car) car).honk(); } if (motorcycle instanceof Motorcycle) { ((Motorcycle) motorcycle).wheelie(); } } }
Confusing reference type with object type: Remember that the reference type determines which methods you can call, but the object type determines which implementation is executed.
Forgetting to override properly: For polymorphism to work, methods must be properly overridden (same name, same parameters, same or compatible return type).
Trying to call subclass-specific methods without casting: Methods not defined in the reference type need casting to be called.
Unsafe casting:
Always use instanceof
before casting to avoid ClassCastException.
Design with polymorphism in mind:
Use interfaces for maximum flexibility: Interfaces are another way to achieve polymorphism and are especially useful when classes don't share an inheritance relationship.
Keep the "is-a" relationship meaningful: Only use inheritance when there's a true "is-a" relationship between classes.
Don't override methods marked final: Final methods cannot be overridden and thus don't participate in polymorphism.
Polymorphism is a powerful feature that allows objects of different types to be treated uniformly through a common interface. By understanding how Java resolves method calls at runtime, you can write more flexible and maintainable code. Remember that at compile time, Java checks if the method exists in the reference type, while at runtime, it executes the method that matches the actual object type. This dynamic binding is what makes polymorphism possible, allowing the same method call to produce different behaviors depending on the object it's acting on. By designing your classes with inheritance and method overriding, you can leverage polymorphism to create code that's both flexible and extensible.