JAVA / INHERITANCE AND POLYMORPHISM
Upcasting, downcasting and the ClassCastException trap
Distinguish the compiler's view of a reference from the object's real class, and predict which casts are free, checked, or impossible.
What you will learn
- Upcast implicitly: it narrows the compiler's view and costs nothing at runtime.
- Read a downcast as a claim the JVM verifies with checkcast on that exact line.
- Spot sibling casts such as (Dog) someCat: they compile and can never succeed.
- Know that (Dog) null succeeds and that a reference cast converts nothing.
Understanding Upcasting, downcasting and the ClassCastException trap
Every reference variable in Java carries two types at once. The declared type, fixed in the source, is all the compiler consults when deciding which members you may call through that variable; the runtime class, fixed at the moment new ran, decides which overridden body actually executes. Assigning a Dog to an Animal variable is an upcast: it is implicit, it compiles to no instruction at all, and it changes nothing about the object. It only shrinks the set of members the compiler will let you reach.
A downcast goes the other way and must be written out as (Dog) a, because the compiler cannot prove it. All javac checks is plausibility: is the target type somewhere below the declared type in the hierarchy, or an interface that some subclass could still implement? If so, it emits a checkcast instruction and hands the real verification to the JVM, which inspects the object's actual class and either passes the reference through unchanged or throws ClassCastException right there. A downcast is a claim, and the exception is that claim being refused.
That is the trap: (Dog) never turns anything into a Dog. A sibling cast, a Cat reached through an Animal variable and cast to Dog, always compiles and always throws, because no object on the heap is both. Contrast primitive casts, which really do produce a new value: (int) 3.9 yields 3, while (Dog) animal yields either the same object or an exception. Casting null always succeeds, since there is no object whose class could contradict the claim.
public class Casting {
static class Animal {
String name() { return "animal"; }
}
static class Dog extends Animal {
@Override String name() { return "dog"; }
void fetch() { System.out.println("the dog fetches the stick"); }
}
static class Cat extends Animal {
@Override String name() { return "cat"; }
}
public static void main(String[] args) {
Animal a = new Dog(); // upcast: implicit, no runtime check
System.out.println("a.name() -> " + a.name());
// a.fetch(); // will not compile: Animal declares no fetch()
Dog realDog = (Dog) a; // downcast that matches the real object
realDog.fetch();
Animal b = new Cat();
try {
Dog notADog = (Dog) b; // compiles, fails when it runs
notADog.fetch();
} catch (ClassCastException e) {
System.out.println("(Dog) b threw " + e.getClass().getSimpleName()
+ "; b really holds a " + b.getClass().getSimpleName());
}
Animal none = null;
Dog stillNull = (Dog) none; // casting null never throws
System.out.println("(Dog) null -> " + stillNull);
}
}A reference cast changes only the type through which the compiler views an object, so a downcast is an unproven claim that the JVM checks with checkcast and rejects with ClassCastException.
Worked examples
A cast produces no new object
Shows that upcasting and downcasting hand back the very same reference and leave the object's class untouched.
public class SameObject {
static class Shape { }
static class Circle extends Shape { }
public static void main(String[] args) {
Circle c = new Circle();
Shape s = c; // upcast
Circle back = (Circle) s; // downcast
System.out.println("c == s : " + (c == s));
System.out.println("s == back : " + (s == back));
System.out.println("s.getClass(): " + s.getClass().getSimpleName());
System.out.println("same class : " + (s.getClass() == back.getClass()));
}
}Example explained
Line 1Shape s = c; needs no cast because Circle is-a Shape; only a reference is copied, never the object.
Line 2(Circle) s gives the compiler permission to call Circle members again and yields the identical reference, which is why s == back is true.
Line 3s.getClass() reports Circle although the variable is declared Shape, because the class is written into the object when it is allocated, not into the variable.
Casting a class reference to an interface
Demonstrates why javac accepts a cast to an unrelated interface and lets the JVM decide per object.
public class InterfaceCast {
interface Flyer { void fly(); }
static class Bird {
String name() { return "bird"; }
}
static class Penguin extends Bird {
@Override String name() { return "penguin"; }
}
static class Eagle extends Bird implements Flyer {
@Override String name() { return "eagle"; }
@Override public void fly() { System.out.println("eagle takes off"); }
}
public static void main(String[] args) {
Bird[] birds = { new Eagle(), new Penguin() };
for (Bird b : birds) {
try {
((Flyer) b).fly();
} catch (ClassCastException e) {
System.out.println(b.name() + " is not a Flyer");
}
}
}
}Example explained
Line 1(Flyer) b compiles even though Bird declares no relation to Flyer, because Bird is not final and a subclass could implement it.
Line 2The Eagle element passes the checkcast, so fly() runs through a reference whose static type is only Flyer.
Line 3The Penguin element fails the same checkcast, since no supertype guarantee was ever made about it.
Line 4Declare Bird final and javac rejects the cast outright: the same mistake would then be a compile error instead of an exception.
A ClassCastException on a line with no cast
Shows the cast the compiler inserts for you when generic type information has been discarded.
import java.util.ArrayList;
import java.util.List;
public class HiddenCast {
public static void main(String[] args) {
List raw = new ArrayList(); // raw type: no element checking
raw.add("ten");
List<Integer> numbers = raw; // unchecked, compiles with a warning
try {
int n = numbers.get(0);
System.out.println(n);
} catch (ClassCastException e) {
System.out.println("threw on a line with no visible cast");
}
}
}Example explained
Line 1List raw opts out of generic checking, so raw.add("ten") is accepted without complaint.
Line 2List<Integer> numbers = raw; is an unchecked conversion: javac warns and then trusts you.
Line 3After erasure get returns Object, so javac inserts (Integer) at the call site, and that invisible cast is what throws.
Line 4The stack trace points at source that contains no cast at all, which is the practical reason unchecked warnings are worth fixing.
Important notes
A cast never changes which override runs: ((Animal) dog).name() still calls Dog's version, because the object's class was decided at allocation.
Whether a bad cast fails early or late depends on what javac can prove; a cast to an unrelated final class is a compile error, while anything it cannot rule out is deferred to the JVM.
Common mistakes
Treating a cast as a conversion: (Dog) animal where animal holds a Cat compiles cleanly and then throws ClassCastException on that line, because naming a different class changes nothing about the object.
Assuming an upcast weakens the object and downcasting to get the override back: after Animal a = new Dog(); the Dog version of name() already runs, so the extra cast adds noise and starts throwing the day a Cat arrives.
Using catch (ClassCastException e) as a type test: the throw happens before the assignment completes, so the handler has no usable reference and quietly masks which object had the wrong type.
Try it yourself
Change, predict, then run
Write Vehicle, Car with an openTrunk() method, and Motorcycle, put one of each into a Vehicle[], and loop calling ((Car) v).openTrunk() so you see the ClassCastException name Motorcycle. Then change the array to Car[] and confirm the cast is no longer needed at all.
Open the Java workspaceCheck your understanding
Animal is a non-final class and both Dog and Cat extend it. After Animal a = new Cat();, the line Dog d = (Dog) a; compiles but throws when it runs. Why?
- The cast converts the Cat into a Dog, but the new Dog's fields are uninitialised, so the JVM refuses it.
- Generic type erasure removes type information, so every Java cast is postponed until runtime.
- javac only knows the declared type Animal, which could legally hold a Dog, so it emits a runtime checkcast that then meets a Cat.
- javac rejects it too; the runtime error appears only because Dog and Cat live in separate source files.
Show answer
javac type-checks the cast against the declared type of a, which is Animal, and an Animal reference could legally point at a Dog, so the cast is plausible and becomes a checkcast that the JVM evaluates against the real Cat. Option 0 is tempting because the syntax matches (int) 3.9, but a reference cast never builds or copies an object; it passes the same reference through or throws. Erasure in option 1 concerns generics and plays no part in this cast.