AP Computer Science A
Find gaps with guided practice
Guided practice grid visualization
Table of Contents

💻ap computer science a review

9.6 Polymorphism

Verified for the 2025 AP Computer Science A examCitation:

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.

What is Polymorphism?

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.

Polymorphism and the Object Class

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.

Static vs. Dynamic Binding

To understand polymorphism, we need to understand how Java binds method calls to method implementations:

Static Binding (Compile-time Binding)

Static binding occurs during compilation. The compiler determines which method to call based on:

  • The declared type of the reference variable
  • The method signature (name and parameters)

This applies to:

  • Private methods
  • Static methods
  • Final methods
  • Methods called using super

Dynamic Binding (Runtime Binding)

Dynamic binding occurs at runtime. Java determines which method to execute based on:

  • The actual type of the object (not the reference type)
  • This applies to overridden methods

This is what enables polymorphic behavior.

Method Call Resolution Process

For a non-static method call, Java follows these steps:

  1. At compile time, the compiler checks if the method exists in the declared reference type

    • If it doesn't exist, compilation fails
    • If it exists, compilation succeeds, but the actual method to be called is determined at runtime
  2. At runtime, Java looks for the method in the actual object type

    • If the method is overridden in the actual object type, that version is called
    • If not, Java moves up the inheritance hierarchy until it finds an implementation

Polymorphism Example

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:

  • All three reference variables are of type Animal
  • The actual objects are of types Animal, Dog, and Cat
  • When makeSound() is called, Java executes the version appropriate for each actual object type
  • This is polymorphism - the same method call behaves differently based on the object type

Benefits of Polymorphism

Polymorphism offers several key benefits:

  1. Code Flexibility: Write methods that work with superclass references but can handle any current or future subclass

  2. Extensibility: Add new subclasses without changing existing code

  3. Simplification: Treat different objects uniformly when they share behavior

  4. Maintenance: Modify subclass implementations without affecting code that uses them

Polymorphism in Method Parameters

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 in Arrays and Collections

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:

  • Group related objects together
  • Process them with the same code
  • Get different behaviors based on the specific object types

Compile-Time Type Checking

Java enforces strict compile-time type checking. This means:

  • You can only call methods that are defined in the reference type (or its superclasses)
  • Even if the actual object would have the method, the code won't compile if the reference type doesn't have it
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

The instanceof Operator

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();
}

Method Dispatch at Runtime

When a method is called through a reference variable, Java uses a process called "dynamic method dispatch" to determine which method implementation to execute:

  1. Look at the actual object type (not the reference type)
  2. Search for the method in that class
  3. If not found, search in the parent class
  4. Continue up the inheritance hierarchy until found

This happens at runtime, which is why it's called "runtime polymorphism."

Practical Examples

Example 1: Shape Hierarchy

// 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
        }
    }
}

Example 2: Vehicle Hierarchy

// 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();
        }
    }
}

Common Mistakes and Best Practices

Common Mistakes

  1. 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.

  2. Forgetting to override properly: For polymorphism to work, methods must be properly overridden (same name, same parameters, same or compatible return type).

  3. Trying to call subclass-specific methods without casting: Methods not defined in the reference type need casting to be called.

  4. Unsafe casting: Always use instanceof before casting to avoid ClassCastException.

Best Practices

  1. Design with polymorphism in mind:

    • Place common behaviors in superclasses
    • Override methods in subclasses to provide specialized behavior
    • Use superclass reference types in method parameters and return types
  2. Use interfaces for maximum flexibility: Interfaces are another way to achieve polymorphism and are especially useful when classes don't share an inheritance relationship.

  3. Keep the "is-a" relationship meaningful: Only use inheritance when there's a true "is-a" relationship between classes.

  4. Don't override methods marked final: Final methods cannot be overridden and thus don't participate in polymorphism.

Summary

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.

Key Terms to Review (7)

Constructor: A constructor is a special method within a class that is used to initialize objects of that class. It is called automatically when an object is created and helps set initial values for its attributes.
Dynamic Type: The dynamic type refers to the actual type of an object at runtime. It may differ from its static (declared) type and is determined based on the specific instance assigned to it during program execution.
Method Calling: Method calling refers to invoking or executing a method in Java code. It involves using the method's name followed by parentheses and passing any required arguments inside those parentheses.
Override: Overriding refers to providing a different implementation for a method inherited from a superclass or interface in its subclass or implementing class respectively. It allows the subclass to customize the behavior of inherited methods.
Polymorphism: Polymorphism refers to the ability of objects to take on multiple forms or have multiple types. In programming, it allows different objects to be treated as instances of a common superclass, enabling flexibility and extensibility.
Static Type: The static type refers to the declared type of a variable or expression at compile-time. It determines the set of operations that can be performed on the variable or expression.
Superclass: A superclass, also known as a parent class or base class, is a class that is extended by another class (subclass). It provides common attributes and behaviors that can be inherited by its subclasses.