One Reference, Many Behaviours
Polymorphism is a Greek-derived word meaning "many forms". In Java it names a specific, very practical ability: a variable declared as the parent type can hold an object of any child type, and when you call an overridden method on it, the version belonging to the actual object runs — not the version belonging to the declared type.
So Shape s = new Circle(5); is legal, because a Circle is a Shape. Calling s.area() runs Circle's area(), even though the compiler only knows s as a Shape. The JVM looks at the object sitting in memory at that moment and picks its method. This mechanism has two names you will see in exams: dynamic method dispatch and late binding.
Assigning a child object to a parent reference is called upcasting, and it never needs a cast because it is always safe — every Circle really is a Shape. It looks like you are losing information, and in one sense you are: through a Shape reference you may only call methods that Shape declares. A method that exists only on Circle is not visible, and trying to call it is a compile error.
That restriction is the point rather than a limitation. It means you can write code that deals with shapes in general, without knowing or caring which kinds exist, while each kind still behaves correctly.
public class Shape {
public double area() { return 0; }
public String name() { return "Shape"; }
}
public class Circle extends Shape {
private final double radius;
public Circle(double radius) { this.radius = radius; }
@Override public double area() { return Math.PI * radius * radius; }
@Override public String name() { return "Circle"; }
public double diameter() { return 2 * radius; } // Circle-only method
}
public class Rectangle extends Shape {
private final double w, h;
public Rectangle(double w, double h) { this.w = w; this.h = h; }
@Override public double area() { return w * h; }
@Override public String name() { return "Rectangle"; }
}
// Upcasting — no cast needed, always safe
Shape s1 = new Circle(5);
Shape s2 = new Rectangle(4, 6);
System.out.printf("%.2f%n", s1.area()); // 78.54 <- Circle's version ran
System.out.printf("%.2f%n", s2.area()); // 24.00 <- Rectangle's version ran
// But through a Shape reference, only Shape's methods are visible
// s1.diameter();
// error: cannot find symbol: method diameter() in class Shape - The compiler decides whether a call is legal, using the declared type. The JVM decides which version actually runs, using the real object. Keeping those two jobs separate in your head explains every polymorphism question you will be asked.
The Real Payoff: Code That Does Not Change
The one-line examples above make polymorphism look like a curiosity. Its value shows up when you write a method that accepts the parent type.
Consider a payment screen supporting UPI, card and net banking. Without polymorphism you write a chain of if statements checking which kind of payment this is and calling the matching method. It works. Then the business adds wallets, and you have to find every one of those chains scattered through the codebase and add a branch. Miss one and payments silently fail for the new method.
With polymorphism you declare a PaymentMethod parent with a pay(amount) method, and each kind overrides it. The checkout code takes a PaymentMethod and calls pay(). Adding wallets means writing one new class. The checkout code does not change at all — it never mentioned UPI or cards in the first place.
This is the practical meaning of a design principle you will hear repeatedly: software should be open to extension but closed to modification. Polymorphism is the language feature that makes it achievable, and it is the reason object-oriented design is worth the extra ceremony you have been putting up with for the last few lessons.
The same idea powers collections. A List<Shape> can hold circles, rectangles and every shape invented next year; looping over it and calling area() gives the right answer for each without a single type check.
public abstract class PaymentMethod {
protected final String holder;
protected PaymentMethod(String holder) { this.holder = holder; }
public abstract void pay(double amount); // each kind decides how
}
public class UpiPayment extends PaymentMethod {
private final String upiId;
public UpiPayment(String holder, String upiId) {
super(holder);
this.upiId = upiId;
}
@Override public void pay(double amount) {
System.out.printf("Rs %.2f paid via UPI (%s)%n", amount, upiId);
}
}
public class CardPayment extends PaymentMethod {
private final String last4;
public CardPayment(String holder, String last4) {
super(holder);
this.last4 = last4;
}
@Override public void pay(double amount) {
System.out.printf("Rs %.2f paid by card ending %s%n", amount, last4);
}
}
// This method never changes, no matter how many payment types are added
public static void checkout(PaymentMethod method, double amount) {
System.out.println("Processing order...");
method.pay(amount);
System.out.println("Order confirmed");
}
checkout(new UpiPayment("Ananya", "ananya@bank"), 1499.00);
checkout(new CardPayment("Rahul", "4421"), 2350.50);
// A collection of mixed types, handled uniformly
List<Shape> shapes = List.of(new Circle(3), new Rectangle(5, 2), new Circle(7));
double total = 0;
for (Shape s : shapes) {
System.out.printf("%-10s %8.2f%n", s.name(), s.area());
total += s.area();
}
System.out.printf("Total area: %.2f%n", total); - A useful smell test: if you find yourself writing a long chain of
if (x instanceof A) ... else if (x instanceof B), that logic probably wants to be a method on A and B instead. The chain is polymorphism written by hand.
Downcasting, instanceof and ClassCastException
Occasionally you genuinely need the child-specific behaviour back. Going from a parent reference to a child reference is downcasting, and unlike upcasting it is not automatically safe — the object might not be of that type at all. Java therefore requires an explicit cast, which is you telling the compiler you are sure.
If you are wrong, the program throws ClassCastException at run time. The message is clear enough — class Rectangle cannot be cast to class Circle — but it is a crash, so check first with instanceof, which asks whether an object is of a given type and answers false rather than throwing.
Since Java 16 you can combine the test and the cast in one step, called pattern matching for instanceof. Writing if (s instanceof Circle c) both tests the type and, when it succeeds, declares c already cast and ready to use inside the block. It removes a line of noise and removes the chance of casting to the wrong type by mistake.
One helpful detail: instanceof returns false for null rather than throwing, so it doubles as a null check. And be aware it is true for subclasses too — a Circle is instanceof Shape. When you need an exact type match rather than "this or any subtype", compare getClass() instead, which is exactly what a well-written equals() does.
Shape s = new Circle(5);
// Unsafe: compiles, but throws if the object is not a Circle
Circle c = (Circle) s;
System.out.println(c.diameter()); // 10.0
Shape r = new Rectangle(4, 6);
// Circle bad = (Circle) r;
// ClassCastException: class Rectangle cannot be cast to class Circle
// Safe, the old way
if (s instanceof Circle) {
Circle circle = (Circle) s;
System.out.println(circle.diameter());
}
// Safe, the modern way (Java 16+): test and cast in one step
if (s instanceof Circle circle) {
System.out.println("Diameter: " + circle.diameter());
}
// instanceof is null-safe
Shape none = null;
System.out.println(none instanceof Circle); // false, no exception
// instanceof is true for subtypes too
System.out.println(s instanceof Shape); // true
System.out.println(s.getClass() == Circle.class); // true — exact type
System.out.println(s.getClass() == Shape.class); // false — exact type - Needing to downcast often means the parent type is missing a method. Before reaching for a cast, ask whether the behaviour you want could be a method on the parent that each child overrides. That version needs no cast and cannot throw.
What Is Not Polymorphic: Fields and static Methods
Polymorphism applies to instance methods only. Two things that look like they should behave the same way do not, and both are staple interview questions because they catch people who have memorised the rule without understanding the mechanism.
Fields are not polymorphic. If a child declares a field with the same name as one in the parent, both fields exist, and which one you read is decided at compile time from the declared type of the reference. So a Parent p = new Child(); reading p.value gives you the Parent's field even though the object is a Child. This is field hiding, mentioned in the last lesson, and it is a strong argument for keeping fields private and reaching them only through methods — methods are polymorphic, so a getter gives the answer you expect.
Static methods are not polymorphic either. A static method belongs to the class, so declaring one with the same signature in a child hides the parent's rather than overriding it, and again the declared type decides. Calling a static method through an object reference is legal but misleading for exactly this reason; always call it as ClassName.method(), which makes the behaviour obvious.
The consistent rule underneath both: only overridden instance methods are resolved from the actual object at run time. Everything else is resolved from the declared type at compile time.
class Parent {
String label = "Parent field";
static String tag() { return "Parent static"; }
String describe() { return "Parent instance"; }
}
class Child extends Parent {
String label = "Child field"; // HIDES, does not override
static String tag() { return "Child static"; } // HIDES, does not override
@Override String describe() { return "Child instance"; }
}
Parent p = new Child(); // declared Parent, actually a Child
System.out.println(p.label); // Parent field <- declared type wins
System.out.println(p.describe()); // Child instance <- actual object wins
System.out.println(Parent.tag()); // Parent static
System.out.println(Child.tag()); // Child static
Child c = new Child();
System.out.println(c.label); // Child field <- same object, different
System.out.println(((Parent) c).label); // Parent field reference type!
// The lesson: keep fields private and expose a getter, which IS polymorphic
class BetterParent {
private String label = "Parent";
public String getLabel() { return label; }
}
class BetterChild extends BetterParent {
private String label = "Child";
@Override public String getLabel() { return label; }
}
BetterParent bp = new BetterChild();
System.out.println(bp.getLabel()); // Child — as you would expect - You cannot put
@Overrideon a static method — the compiler rejects it. That refusal is the language telling you that hiding and overriding are different things.
Compile-Time vs Runtime Polymorphism
Indian placement interviews almost always phrase this topic as two kinds of polymorphism, so it is worth having the vocabulary ready even though the underlying ideas are the two you have already met.
Compile-time polymorphism — also called static polymorphism — is method overloading. The compiler picks which version to call by looking at the declared types of the arguments, and that decision is fixed before the program runs.
Runtime polymorphism — also called dynamic polymorphism — is method overriding. The JVM picks the version by looking at the actual object, so the same line of code can run different implementations on different passes through a loop. This is the one people mean when they say "polymorphism" without qualification, and it is the one that makes flexible design possible.
Polymorphism is not limited to classes. An interface reference behaves exactly the same way — List<String> list = new ArrayList<>(); is polymorphism, and it is why professional code declares variables by the interface type. Swapping the implementation to LinkedList then requires changing one line rather than every line that touches the variable.
- Compile-time / static polymorphism = overloading; resolved from declared argument types
- Runtime / dynamic polymorphism = overriding; resolved from the actual object, via dynamic method dispatch
- Upcasting (child to parent) is implicit and always safe; downcasting (parent to child) needs a cast and can throw
ClassCastException - Through a parent reference you may call only the methods the parent declares — but the child's implementations of them are what run
- Fields and
staticmethods are hidden, not overridden, and always follow the declared type - Declare variables and parameters by the most general type that still does the job — usually an interface
- Some textbooks say Java has no true polymorphism for overloading because the decision is made by the compiler. That is a terminology argument, not a technical one. In an interview, describe the mechanism — declared type at compile time, actual object at run time — and the label matters much less.
