Polymorphism: dynamic binding, upcasting, downcasting
Which method runs when a superclass-typed variable holds a subclass object, what the compiler accepts, and when a cast blows up.
Polymorphism means “the same call, different behavior on a different object”. You put a circle, a square and a triangle in a Shape array; you call area() on all of them and each computes its own area. The hardest output questions in the exam come from this topic: a variable of the superclass type, a subclass object, and code with super and casts sprinkled in. You solve all of them by asking two questions: what does the compiler allow and which version runs at run time.
Golden rules:
- The compiler looks at the type of the variable (static type). You cannot call a method that is not in the type of the variable.
- The JVM looks at the type of the object (dynamic type). For overridden methods, the object’s version runs.
- Upcasting is automatic, downcasting needs a cast. If the cast is wrong,
ClassCastExceptionat run time. - Dynamic binding applies only to overridden instance methods. Fields, static methods and private methods are chosen by the type of the variable.
Static type and dynamic type
Animal a = new Lion();
There are two types in this line. The type of the variable a is Animal (static type, known at compile time). The type of the object it points to is Lion (dynamic type, known at run time). This assignment is valid because every Lion is an Animal (is-a). Conversely, Lion l = new Animal(); does not compile.
The hierarchy we will use in the examples:
class Animal {
void speak() { System.out.println("Animal speaks"); }
}
class Cat extends Animal {
@Override void speak() { System.out.println("Meow"); }
void purr() { System.out.println("Purr"); }
}
class Lion extends Cat {
@Override void speak() { System.out.println("Roar"); }
}
Dynamic binding
When you call an overridden method, which version runs is decided while the program is running, by looking at the object. This is called dynamic binding (or late binding).
Animal a = new Lion();
a.speak();
Animal b = new Cat();
b.speak();
((Cat) b).purr();
Roar
Meow
Purr
You see the real power in arrays and method parameters. Below, Shape.toString() is written only once, but the name() and area() inside it behave differently on each object:
class Shape {
String name() { return "Shape"; }
double area() { return 0; }
public String toString() { return name() + " area=" + area(); }
}
class Circle extends Shape {
double r;
Circle(double r) { this.r = r; }
@Override String name() { return "Circle"; }
@Override double area() { return Math.PI * r * r; }
double diameter() { return 2 * r; }
}
class Square extends Shape {
double s;
Square(double s) { this.s = s; }
@Override String name() { return "Square"; }
@Override double area() { return s * s; }
}
Shape[] shapes = { new Circle(1), new Square(2), new Shape() };
for (Shape s : shapes) System.out.println(s);
Circle area=3.141592653589793
Square area=4.0
Shape area=0.0
If you add a new Triangle class, you do not need to touch this loop. That is the real benefit of polymorphism: the code is written against the superclass type and keeps working as new subtypes are added.
Mixed calls with super
super.a() redirects only that one call to the superclass version. If another method is called inside the superclass method (b() is really this.b()), this is still the subclass object and dynamic binding applies again.
class First {
void a() { System.out.println("First.a"); b(); }
void b() { System.out.println("First.b"); }
}
class Second extends First {
@Override void a() { System.out.println("Second.a"); super.a(); }
@Override void b() { System.out.println("Second.b"); }
}
First f = new Second();
f.a();
Second.a
First.a
Second.b
f.a() object is Second → Second.a "Second.a"
super.a() explicitly First.a "First.a"
this.b() this = Second object → Second.b "Second.b"
Upcasting and downcasting
| Upcasting | Downcasting | |
|---|---|---|
| Direction | Sub → super (Cat → Animal) | Super → sub (Animal → Cat) |
| Syntax | Animal a = cat; | Cat c = (Cat) a; |
| Is a cast needed? | No, automatic | Yes |
| Risk | None | ClassCastException if the object is not that type |
| Why is it done? | To work with a common type (array, parameter) | To call a method specific to the subclass |
Downcasting involves two separate checks:
- Compile time: are the types on the same inheritance line?
(Cat) animalRefcompiles. Between sibling classes, for example(Dog) catRef, it does not compile:incompatible types: Cat cannot be converted to Dog. - Run time: is the object really of that type (or a subtype of it)? If not,
ClassCastException.
try {
Animal an = new Animal();
Cat ct = (Cat) an; // compiles, but the object is not a Cat
ct.purr();
} catch (ClassCastException e) {
System.out.println("ClassCastException");
}
ClassCastException
PITFALL · do not assume it will run just because it compiles. In “Find the error” questions, first decide whether it is a compile error or a run-time error. A cast from Animal to Cat always compiles; whether it blows up depends on the object.
instanceof
x instanceof T returns true if the object x points to is of type T or a subtype of T. It is used as a check before a downcast.
Animal l = new Lion();
System.out.println(l instanceof Animal);
System.out.println(l instanceof Cat);
System.out.println(l instanceof Lion);
Animal plain = new Animal();
System.out.println(plain instanceof Cat);
Animal nul = null;
System.out.println(nul instanceof Animal);
true
true
true
false
false
The classic pattern and its short form available since Java 16 (pattern matching):
static void describe(Shape s) {
System.out.print(s.name());
if (s instanceof Circle) { // classic
Circle c = (Circle) s;
System.out.print(" d=" + c.diameter());
}
System.out.println();
}
describe(new Circle(1.5));
describe(new Square(3));
Shape sh = new Circle(2);
if (sh instanceof Circle cc) // check + cast in one line
System.out.println("r=" + cc.r);
Circle d=3.0
Square
r=2.0
TIP · an instanceof chain smells bad. If you are writing
if (s instanceof Circle) ... else if (s instanceof Square) ..., you should usually put that job in Shape as a method and override it. The phrase “use polymorphism” in the exam asks for exactly this.
Overload and override together
The hardest type of output question. Solve it in two steps:
- Compile time, signature choice: the static types of the arguments decide which parameter list is called (overload).
- Run time, version choice: the overridden version of that signature in the object’s actual class runs.
class X {
void m(Object o) { System.out.println("X.m(Object)"); }
void m(String s) { System.out.println("X.m(String)"); }
}
class Y extends X {
@Override void m(Object o) { System.out.println("Y.m(Object)"); }
}
X obj = new Y();
obj.m("hi"); // signature m(String); Y did not override it
Object o = "hi";
obj.m(o); // signature m(Object); Y overrode it
new X().m(o);
X.m(String)
Y.m(Object)
X.m(Object)
Even though o actually holds a String, the type of the variable (Object) is used when choosing the signature.
Things without dynamic binding
| Member | Who decides? |
|---|---|
| Overridden instance method | The actual type of the object |
Field (ref.x) | The type of the variable |
| static method | The type of the variable (hiding, not overriding) |
| private method | Cannot be overridden, always the one of the class where it is defined |
| Overload choice | The static types of the arguments |
Animal.info(); // static
Animal c2 = new Cat();
c2.info(); // the object is a Cat but static: Animal's
Animal info
Animal info
Common mistakes
| Wrong idea | The truth |
|---|---|
Animal a = new Cat(); a.purr(); works | It does not compile; the compiler looks at Animal |
| If the variable is Animal, Animal’s speak runs | If it is overridden, the object’s version runs |
| A cast converts the object | The object does not change, only the type of the reference does |
| A cast that compiles is safe | ClassCastException if the object does not fit |
(Dog) cat blows up at run time | It does not even compile: sibling classes |
null instanceof X throws an error | It returns false |
| Every call inside super.a() goes to the superclass | Only that call; the b() inside still goes to the subclass’s |
| A static method is also chosen by the object | By the type of the variable |
| The type of the object matters in overload choice | The static type of the argument matters |
Test yourself
Exam-style questions. Solve them on paper first, then reveal the answer.
What is printed?
class Animal { void speak() { System.out.println("Animal speaks"); } }
class Cat extends Animal { @Override void speak() { System.out.println("Meow"); } }
class Lion extends Cat { @Override void speak() { System.out.println("Roar"); } }
// in main:
Animal a = new Lion();
a.speak();Show answer
The type of the variable is Animal but the object is a Lion. An overridden method is chosen at run time by the actual type of the object (dynamic binding). Lion's speak runs.
Find the compile error.
class Animal { void speak() { } }
class Cat extends Animal { void purr() { } }
// in main:
Animal a = new Cat();
a.purr();Show answer
The compiler only looks at the type of the variable (static type). Animal has no purr, and the compiler does not know the object is a Cat. javac: cannot find symbol ... method purr() ... variable a of type Animal. Fix: ((Cat) a).purr();.
Find the compile error.
Animal a = new Animal();
Cat c = a;Show answer
Upward conversion (Cat → Animal) is automatic, downward conversion is not. javac: incompatible types: Animal cannot be converted to Cat. If you write (Cat) a it compiles, but since the object here really is an Animal, a ClassCastException is thrown at run time.
What happens?
Animal an = new Animal();
Cat ct = (Cat) an;
ct.purr();Show answer
With a cast you tell the compiler "trust me, this is a Cat"; since Cat is a subclass of Animal, the compiler accepts it. At run time the JVM looks at the object: a plain Animal, not a Cat. A ClassCastException is thrown and purr never runs.
Find the compile error.
class Animal { }
class Cat extends Animal { }
class Dog extends Animal { }
// in main:
Cat c = new Cat();
Dog d = (Dog) c;Show answer
A cast compiles only if the types are on the same inheritance line. A Cat variable can never hold a Dog object, and the compiler knows it. javac: incompatible types: Cat cannot be converted to Dog. (A cast from an Animal variable to Dog would compile.)
What is printed?
Animal l = new Lion(); // Lion extends Cat extends Animal
System.out.println(l instanceof Cat);
Animal plain = new Animal();
System.out.println(plain instanceof Cat);
Animal nul = null;
System.out.println(nul instanceof Animal);Show answer
instanceof looks at the actual type of the object and counts inheritance: a Lion is a Cat, true. A plain Animal object is not a Cat, false. null is not an instance of any type, so instanceof always returns false for null (it does not throw an error).
What is printed?
class First {
void a() { System.out.println("First.a"); b(); }
void b() { System.out.println("First.b"); }
}
class Second extends First {
@Override void a() { System.out.println("Second.a"); super.a(); }
@Override void b() { System.out.println("Second.b"); }
}
// in main:
First f = new Second();
f.a();Show answer
The object is a Second: f.a() runs Second's a. super.a() goes to First's a and prints First.a. The b() inside First.a is really this.b(); this is still the Second object, so Second's b runs. super redirects only that one call, it does not change the type of the object.
What is printed?
class X {
void m(Object o) { System.out.println("X.m(Object)"); }
void m(String s) { System.out.println("X.m(String)"); }
}
class Y extends X {
@Override void m(Object o) { System.out.println("Y.m(Object)"); }
}
// in main:
X obj = new Y();
obj.m("hi");
Object o = "hi";
obj.m(o);Show answer
There are two stages. (1) Which signature, at compile time: the static type of the argument is looked at. "hi" is a String, the best signature is m(String). o is an Object, the signature is m(Object). (2) Which version of that signature, at run time: the object is a Y. Y only overrode m(Object). The first call goes to X's m(String), the second to Y's m(Object).
What is printed?
class Animal { static void info() { System.out.println("Animal info"); } }
class Cat extends Animal { static void info() { System.out.println("Cat info"); } }
// in main:
Animal c = new Cat();
c.info();Show answer
static methods are not overridden, they are hidden. The object is not looked at; the type of the variable (Animal) decides. That is why dynamic binding applies only to instance methods. Calling a static method with the class name (Cat.info()) avoids the confusion.
What is printed?
Shape[] shapes = { new Circle(1), new Square(2), new Shape() };
for (Shape s : shapes) System.out.println(s);
// Shape.toString() returns name() + " area=" + area();
// Circle and Square override name() and area(), Shape returns "Shape" and 0Show answer
toString is written only in Shape, but the name() and area() inside it run each object's own version. Circle(1): π · 1² = 3.141592653589793. Square(2): 2 · 2 = 4.0. Shape: the value 0 is returned as a double, so 0.0.
Given Animal a = new Cat(); which line compiles AND runs without exception? (a) Cat c = a; (b) Cat c = (Cat) a; (c) Lion l = (Lion) a;
- (a)
- (b)
- (c)
Show answer
(a) a downcast without a cast does not compile. (b) compiles, and it runs because the object really is a Cat. (c) compiles (Lion is a subclass of Animal) but the object is not a Lion, so a ClassCastException is thrown at run time.
Write a static method totalArea(Shape[] shapes) that returns the sum of the areas of all shapes, using polymorphism (no instanceof). For a Square(2), a Square(3) and a Circle(1) it should return about 16.14.
Show answer
Each object brings its own version of area(); the method never deals with types. Even if a new shape class is added, this method does not change, which is the real benefit of polymorphism:
public static double totalArea(Shape[] shapes) {
double sum = 0;
for (Shape s : shapes) {
sum += s.area(); // dynamic binding
}
return sum;
}4 + 9 + π = 16.14159..., which is 16.14 with printf("%.2f", ...).Verification: All code examples and answers were run with JDK 26 (java verify/SEN1002/W03.java); the compile errors were tried with javac (verify/SEN1002/err/E03a-c.java).