Designing with inheritance; downcasting & RTTI preview
Substitution vs extension, instanceof-guarded downcasts, ClassCastException, and covariant return types.
Substitution vs extension
Two design intents drive inheritance:
- Pure substitution ("is-a"). The subclass adds *no* new public methods; it only overrides. Anywhere the supertype is expected, the subtype slots in cleanly. This is the safest design — calling code never needs to know the concrete type. - Extension ("is-like-a"). The subclass adds new methods of its own. Now code that wants those extras must know the concrete type — which means downcasting, and a loss of pure polymorphism.
Prefer pure substitution. Reach for extension (and the downcasting it forces) only when you genuinely need subtype-specific behavior.
Downcasting: narrowing back to the subtype
Downcasting moves *down* the hierarchy — treating a supertype reference as a specific subtype. Unlike upcasting it is not automatically safe: the object might not actually be that subtype, so it needs an explicit cast and is checked at run time. A wrong cast throws ClassCastException.
Guard every downcast with instanceof. Crucially, instanceof is false for null (a null reference is not an instance of anything), so the check also rules out a NullPointerException:
Worked example: a guarded downcast
void honkIfCar(Vehicle v) {
if (v instanceof Car) { // false if v is null or not a Car
Car c = (Car) v; // safe: we just checked
c.honk(); // Car-specific method
}
}
Without the instanceof guard, passing a Truck would compile fine but throw ClassCastException at run time. This run-time type query is your first taste of RTTI (Run-Time Type Information) — the JVM tracks every object's real class, which Module 7 develops fully with Class objects and reflection.
Covariant return types
When overriding a method, the subclass may narrow the return type to a subtype of the original. This is a *covariant return type* and is legal because a Circle is-a Shape — any caller expecting a Shape is satisfied:
class Shape { Shape copy() { return new Shape(); } }
class Circle extends Shape {
@Override Circle copy() { return new Circle(); } // narrower return: OK
}
Circle.copy() overrides Shape.copy() yet promises the more specific Circle, sparing callers a downcast. You cannot *widen* a return type this way — only narrow it.