Skip to content
elephantoo

Polymorphism: overriding & overloading

Lesson 15 of 43 16 min read

Dynamic dispatch, @Override rules, overloading resolution, upcasting, downcasting and instanceof.


Polymorphism means "many forms". In Java it means one variable, parameter or array of a general type can hold objects of many specific types, and each object responds to the same method call in its own way. It is what lets you write code against Shape or PaymentMethod today and have it work with subclasses you will invent next year.

Java has two kinds:

  • Runtime polymorphism: method overriding, resolved while the program runs.
  • Compile-time polymorphism: method overloading, resolved by the compiler.

Upcasting: a child fits in a parent variable#

Because a Dog is an Animal, you can store it in an Animal variable. This is called upcasting, and it is automatic:

Java
Animal a = new Dog();        // upcast: no cast needed
Object o = new Dog();        // also fine: every class extends Object

The declared type (Animal) decides what you are allowed to call. The runtime type (Dog) decides which version runs.

Overriding and dynamic dispatch#

Shapes.java
public class Shapes {
    public static void main(String[] args) {
        Shape[] shapes = { new Circle(1), new Rectangle(2, 3), new Square(2) };

        double total = 0;
        for (Shape s : shapes) {
            System.out.printf("%-9s area = %.2f%n", s.name(), s.area());
            total += s.area();
        }
        System.out.printf("Total area = %.2f%n", total);
    }
}

class Shape {
    double area() { return 0; }
    String name() { return "Shape"; }
}

class Circle extends Shape {
    private final double r;
    Circle(double r) { this.r = r; }

    @Override double area() { return Math.PI * r * r; }
    @Override String name() { return "Circle"; }
}

class Rectangle extends Shape {
    private final double w, h;
    Rectangle(double w, double h) { this.w = w; this.h = h; }

    @Override double area() { return w * h; }
    @Override String name() { return "Rectangle"; }
}

class Square extends Rectangle {
    Square(double side) { super(side, side); }

    @Override String name() { return "Square"; }   // area() is inherited from Rectangle
}
Output
Circle    area = 3.14
Rectangle area = 6.00
Square    area = 4.00
Total area = 13.14

The loop only knows about Shape, yet each object runs its own area(). This is dynamic dispatch: at runtime, the JVM looks at the actual class of the object and finds the most specific override, walking up the hierarchy if needed (Square uses Rectangle.area()).

The big win: adding a Triangle class requires no changes to the loop. Code that depends on the general type is open to new subclasses.

Rules for a valid override#

To override, the subclass method must have:

  1. The same name and parameter list as the parent method.
  2. A compatible return type: the same type, or a subtype (a covariant return, e.g. parent returns Animal, child returns Dog).
  3. Equal or wider access: a public method can't become protected or private in the child.
  4. No new or broader checked exceptions than the parent declares (exceptions get their own lesson).

And some things cannot be overridden at all:

  • private methods (they are invisible to the child, so a same-named method is just a new method).
  • final methods.
  • static methods. A static method with the same signature in a child hides the parent's; there is no dynamic dispatch for statics.
  • Fields. Fields are never polymorphic; they are chosen by the declared type.

Always use @Override

@Override asks the compiler to confirm you really are overriding something. Without it, a typo silently creates a brand-new method:

Java
// Without @Override: compiles, but this OVERLOADS equals; it does not override equals(Object)!
public boolean equals(Point other) { ... }

// With @Override: the compiler catches the mistake
@Override
public boolean equals(Point other) { ... }   // error: method does not override or implement a method from a supertype

// Correct:
@Override
public boolean equals(Object other) { ... }

With @Override, the mistake becomes a compile error instead of a subtle bug.

Fields and static methods are not polymorphic#

NotPolymorphic.java
public class NotPolymorphic {
    public static void main(String[] args) {
        Parent p = new Child();
        System.out.println(p.label);          // field: chosen by declared type
        System.out.println(p.describe());     // instance method: chosen by runtime type
        Parent.greet();                       // static: always call via the class
        Child.greet();
    }
}

class Parent {
    String label = "parent field";
    String describe() { return "Parent.describe"; }
    static void greet() { System.out.println("Parent.greet"); }
}

class Child extends Parent {
    String label = "child field";             // hides Parent.label (confusing, avoid)
    @Override String describe() { return "Child.describe"; }
    static void greet() { System.out.println("Child.greet"); }   // hides, not overrides
}
Output
parent field
Child.describe
Parent.greet
Child.greet

Keep fields private and you will never be bitten by field hiding.

Overloading: same name, different parameters#

Overloading means several methods in the same class (or inherited) share a name but have different parameter lists. The compiler picks one based on the declared types of the arguments:

Printer.java
public class Printer {
    static void print(int n)      { System.out.println("int: " + n); }
    static void print(long n)     { System.out.println("long: " + n); }
    static void print(double d)   { System.out.println("double: " + d); }
    static void print(String s)   { System.out.println("String: " + s); }
    static void print(Object o)   { System.out.println("Object: " + o); }

    public static void main(String[] args) {
        print(5);              // exact match: int
        print(5L);             // long
        print('A');            // char widens to int (no char version)
        print(3.5f);           // float widens to double
        print("hi");           // String beats Object (more specific)

        Object o = "hi";
        print(o);              // declared type is Object, so Object version
    }
}
Output
int: 5
long: 5
int: 65
double: 3.5
String: hi
Object: hi

That last line is the key difference from overriding: even though o holds a String, the compiler only sees Object, so print(Object) is chosen. Overloading is decided at compile time; overriding at runtime.

The compiler's preference order, roughly: exact match, then widening primitive conversion (int to long), then autoboxing (int to Integer), then varargs.

OverloadingOverriding
Wheresame class (or inherited)subclass replaces parent method
Parametersmust differmust be the same
Return typecan differsame or covariant
Decidedcompile time, by declared typesruntime, by object's class
Annotationnone@Override

Downcasting and instanceof#

Sometimes you hold an Animal and need a Dog-only method. You must downcast explicitly, and it can fail at runtime with a ClassCastException if the object isn't really a Dog. Check first with instanceof.

Since Java 16, pattern matching for instanceof tests and casts in one step, binding a new variable:

Downcast.java
public class Downcast {
    public static void main(String[] args) {
        Animal[] animals = { new Dog("Bruno"), new Cat("Misty"), new Dog("Rex") };

        for (Animal a : animals) {
            a.speak();                                // polymorphic, no cast needed

            if (a instanceof Dog d) {                 // test + cast + bind
                d.fetch();                            // Dog-only method
            }
        }

        Animal a = new Cat("Tom");
        try {
            Dog d = (Dog) a;                          // compiles, but the object is a Cat
            d.fetch();
        } catch (ClassCastException e) {
            System.out.println("Not a dog: " + a.getName());
        }
    }
}

class Animal {
    private final String name;
    Animal(String name) { this.name = name; }
    String getName() { return name; }
    void speak() { System.out.println(name + " makes a sound"); }
}

class Dog extends Animal {
    Dog(String name) { super(name); }
    @Override void speak() { System.out.println(getName() + ": Woof"); }
    void fetch() { System.out.println(getName() + " fetches the ball"); }
}

class Cat extends Animal {
    Cat(String name) { super(name); }
    @Override void speak() { System.out.println(getName() + ": Meow"); }
}
Output
Bruno: Woof
Bruno fetches the ball
Misty: Meow
Rex: Woof
Rex fetches the ball
Not a dog: Tom

The old style, which you'll see in older code, needs a separate cast:

Java
if (a instanceof Dog) {
    Dog d = (Dog) a;
    d.fetch();
}

Some casts are rejected at compile time because they can never succeed, such as (Integer) "hello", since String and Integer are unrelated classes.

If you find yourself writing long chains of instanceof checks, it is usually a sign the behaviour belongs in an overridden method instead. Let the object decide. (Modern Java's switch pattern matching with sealed types is the exception, covered in the Modern Java lesson.)

Polymorphic parameters#

Methods that accept the parent type work with every subclass:

Payments.java
public class Payments {
    static void checkout(PaymentMethod method, double amount) {
        System.out.println(method.pay(amount));
    }

    public static void main(String[] args) {
        checkout(new UpiPayment("asha@okbank"), 499);
        checkout(new CardPayment("4111111111111111"), 1299.5);
    }
}

class PaymentMethod {
    String pay(double amount) { return "Paid " + amount; }
}

class UpiPayment extends PaymentMethod {
    private final String upiId;
    UpiPayment(String upiId) { this.upiId = upiId; }

    @Override String pay(double amount) {
        return "UPI " + upiId + " paid Rs " + amount;
    }
}

class CardPayment extends PaymentMethod {
    private final String cardNumber;
    CardPayment(String cardNumber) { this.cardNumber = cardNumber; }

    @Override String pay(double amount) {
        String last4 = cardNumber.substring(cardNumber.length() - 4);
        return "Card ****" + last4 + " charged Rs " + amount;
    }
}
Output
UPI asha@okbank paid Rs 499.0
Card ****1111 charged Rs 1299.5

checkout never changes when you add NetBankingPayment. Programming to the general type is one of the most useful habits in object-oriented design.

Common mistakes#

  • Expecting the declared type to choose the overridden method. It is the runtime object that decides.
  • Expecting the runtime type to choose an overloaded method. It is the declared type that decides.
  • Downcasting without an instanceof check, leading to ClassCastException.
  • Overloading equals(Point) instead of overriding equals(Object). Always add @Override.
  • Trying to "override" a static or private method; you only hide it or create a new one.
  • Reducing visibility in an override (protected when the parent is public), which won't compile.

What's next#

Our Shape.area() returned a meaningless 0 just so subclasses had something to override. Next, abstract classes let you declare a method that must be implemented, and stop anyone creating a plain, meaningless Shape.

Check your understanding

Quick quiz

0/3 answered
  1. 1.Animal a = new Dog(); and Dog overrides speak(). Which speak() runs for a.speak()?

  2. 2.What decides which *overloaded* method is called?

  3. 3.Object o = "hello"; Integer i = (Integer) o; What happens?

Finished reading?

Mark this lesson complete to track your progress.