SEN1002 · Week 3 · Polymorphism

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.

polymorphismdynamic bindinglate bindingstatic typedynamic typeupcastingdowncastinginstanceofClassCastExceptionpattern matching

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:

  1. 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.
  2. The JVM looks at the type of the object (dynamic type). For overridden methods, the object’s version runs.
  3. Upcasting is automatic, downcasting needs a cast. If the cast is wrong, ClassCastException at run time.
  4. 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

UpcastingDowncasting
DirectionSub → super (Cat → Animal)Super → sub (Animal → Cat)
SyntaxAnimal a = cat;Cat c = (Cat) a;
Is a cast needed?No, automaticYes
RiskNoneClassCastException 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:

  1. Compile time: are the types on the same inheritance line? (Cat) animalRef compiles. Between sibling classes, for example (Dog) catRef, it does not compile: incompatible types: Cat cannot be converted to Dog.
  2. 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:

  1. Compile time, signature choice: the static types of the arguments decide which parameter list is called (overload).
  2. 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

MemberWho decides?
Overridden instance methodThe actual type of the object
Field (ref.x)The type of the variable
static methodThe type of the variable (hiding, not overriding)
private methodCannot be overridden, always the one of the class where it is defined
Overload choiceThe 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 ideaThe truth
Animal a = new Cat(); a.purr(); worksIt does not compile; the compiler looks at Animal
If the variable is Animal, Animal’s speak runsIf it is overridden, the object’s version runs
A cast converts the objectThe object does not change, only the type of the reference does
A cast that compiles is safeClassCastException if the object does not fit
(Dog) cat blows up at run timeIt does not even compile: sibling classes
null instanceof X throws an errorIt returns false
Every call inside super.a() goes to the superclassOnly that call; the b() inside still goes to the subclass’s
A static method is also chosen by the objectBy the type of the variable
The type of the object matters in overload choiceThe static type of the argument matters

Test yourself

Exam-style questions. Solve them on paper first, then reveal the answer.

Question 1easy

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
Roar

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.

Question 2easy

Find the compile error.

class Animal { void speak() { } }
class Cat extends Animal { void purr() { } }
// in main:
Animal a = new Cat();
a.purr();
Show answer
a.purr(): type Animal has no method purr

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

Question 3medium

Find the compile error.

Animal a = new Animal();
Cat c = a;
Show answer
Animal → Cat does not convert automatically, a cast is needed

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.

Question 4medium

What happens?

Animal an = new Animal();
Cat ct = (Cat) an;
ct.purr();
Show answer
It compiles, ClassCastException at run time

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.

Question 5medium

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
Cat and Dog are sibling classes, they cannot be cast to each other

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

Question 6medium

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
true, false, false

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

Question 7hard

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
Second.a / First.a / Second.b

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.

Question 8hard

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
X.m(String) and Y.m(Object)

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

Question 9hard

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
Animal info

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.

Question 10medium

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 0
Show answer
Circle area=3.141592653589793 / Square area=4.0 / Shape area=0.0

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.

Question 11medium

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;

  1. (a)
  2. (b)
  3. (c)
Show answer
(b)

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

Question 12medium

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
add up s.area() in a for-each loop

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

Review card

Super ref = new Sub(); → upcasting, AUTOMATIC, always safe. Static type (type of the variable): the COMPILER uses this to decide what you can call. Dynamic type (type of the object): AT RUN TIME this decides which overridden version runs. ref.subOnlyMethod() → compile error (cannot find symbol). Cast first. Sub a = (Sub) ref; → downcasting, cast REQUIRED. ClassCastException if the object is not really a Sub. Cast between sibling classes (Cat → Dog): compile error. instanceof: actual type + inheritance. null instanceof X → false. Safe pattern: if (s instanceof Circle c) { c.diameter(); } super.m() redirects only that call; the this.b() inside still goes to the subclass's b. Overload choice is at compile time (static type of the argument), override choice is at run time. static method, field, private method: NO dynamic binding.