Contents
- extends & the Inheritance Chain
- Method Overriding & @Override
- The super Keyword
- Constructor Chaining
- Dynamic Dispatch — Runtime Polymorphism
- Covariant Return Types
- Method Hiding vs Overriding
- Liskov Substitution Principle
A class uses the extends keyword to inherit from exactly one superclass. Java does not support multiple class inheritance — a class can have only one direct parent. It does, however, support multilevel inheritance: A extends B, B extends C, C implicitly extends Object. Every class in Java ultimately extends Object, which provides toString(), equals(), hashCode(), and a handful of threading methods.
When a subclass is created, it inherits all non-private members of the superclass — fields, methods, and nested types. Private members exist in the superclass but are inaccessible to the subclass directly; they can only be reached through public or protected methods the superclass provides.
// Superclass
public class Animal {
protected String name;
protected int ageYears;
public Animal(String name, int ageYears) {
this.name = name;
this.ageYears = ageYears;
}
public String describe() {
return name + " (age " + ageYears + ")";
}
public String sound() { return "..."; }
}
// Subclass — inherits everything from Animal
public class Dog extends Animal {
private String breed;
public Dog(String name, int age, String breed) {
super(name, age); // must call superclass constructor
this.breed = breed;
}
@Override
public String sound() { return "Woof"; }
public String getBreed() { return breed; }
}
// Sub-subclass — two levels deep
public class GuideDog extends Dog {
private String handler;
public GuideDog(String name, int age, String breed, String handler) {
super(name, age, breed);
this.handler = handler;
}
@Override
public String describe() {
return super.describe() + " — guide dog for " + handler;
}
}
// Usage
GuideDog g = new GuideDog("Rex", 4, "Labrador", "Alice");
System.out.println(g.describe()); // Rex (age 4) — guide dog for Alice
System.out.println(g.sound()); // Woof (inherited from Dog)
System.out.println(g.getBreed()); // Labrador (inherited from Dog)
The inheritance chain is checked at compile time. If you call g.sound(), the compiler confirms that sound() exists somewhere in the hierarchy (Animal → Dog → GuideDog). Which version actually runs is determined at runtime — that is dynamic dispatch.
A subclass overrides a superclass method by declaring a method with the same name, the same parameter types (in the same order), and a compatible return type. The overriding method replaces the superclass version for objects of the subclass type. Four rules govern valid overrides:
- The method signature must match exactly (name + parameter types)
- The return type must be the same or a subtype (covariant return — see below)
- The access modifier must be the same or less restrictive (you can widen, not narrow)
- The overriding method may only declare checked exceptions that are the same or narrower than the overridden method's declared exceptions
public class Shape {
public double area() { return 0.0; }
public String describe() { return "Shape"; }
protected void draw(String colour) { System.out.println("Drawing shape in " + colour); }
}
public class Circle extends Shape {
private final double radius;
public Circle(double radius) { this.radius = radius; }
// Valid override — same signature, same return type
@Override
public double area() { return Math.PI * radius * radius; }
// Valid override — widened access (protected → public is allowed)
@Override
public void draw(String colour) {
System.out.printf("Drawing ● radius=%.1f in %s%n", radius, colour);
}
// INVALID — would narrow access (public → protected is forbidden)
// @Override
// protected String describe() { return "Circle"; } // compile error
// Valid — @Override catches typos at compile time
@Override
public String describe() { return "Circle r=" + radius; }
}
// Without @Override, a typo creates a NEW method instead of overriding
public class Bad extends Shape {
public double Area() { return 42; } // typo — new method, not an override!
// @Override would have caught this: "method does not override"
}
Always annotate overrides with @Override. It is optional but invaluable: the compiler rejects the annotation if the method does not actually override anything, turning silent bugs (typos, wrong parameter types) into compile errors. It also serves as documentation for readers.
The super keyword gives a subclass access to the superclass version of overridden members. It has two main uses: calling the superclass constructor (in the first line of a subclass constructor) and calling the superclass version of an overridden method from within the override.
public class Logger {
private final String prefix;
public Logger(String prefix) {
this.prefix = prefix;
}
public void log(String message) {
System.out.println("[" + prefix + "] " + message);
}
public String format(String msg) {
return prefix + ": " + msg;
}
}
public class TimestampLogger extends Logger {
public TimestampLogger(String prefix) {
super(prefix); // calls Logger(String) — must be first line
}
@Override
public void log(String message) {
// Augment, don't replace — delegate to super then add behaviour
super.log(message);
System.out.println(" ^ logged at " + java.time.Instant.now());
}
@Override
public String format(String msg) {
// Use super's format as a starting point
return java.time.LocalTime.now() + " | " + super.format(msg);
}
}
// super.super is NOT possible in Java — you can only go one level up
// If you need behaviour two levels up, redesign using composition.
TimestampLogger tl = new TimestampLogger("APP");
tl.log("Server started");
// [APP] Server started
// ^ logged at 2025-04-17T...
super.method() always refers to the direct parent's version. You cannot chain super.super.method(). If you find yourself needing grandparent behaviour in a grandchild, that is usually a signal to reconsider the hierarchy design.
When you create an object with new, Java calls the constructors from the top of the hierarchy downward — Object first, then each superclass, ending with the most-derived class. Every constructor must either explicitly call super(...) (superclass constructor) or this(...) (another constructor in the same class) as its first statement. If neither is written, the compiler inserts a no-argument super() call automatically — and if the superclass has no no-argument constructor, you get a compile error.
public class Vehicle {
protected final String make;
protected final int year;
public Vehicle(String make, int year) {
System.out.println("Vehicle(" + make + ", " + year + ")");
this.make = make;
this.year = year;
}
}
public class Car extends Vehicle {
private final int doors;
// Delegates to 4-door overload via this(...)
public Car(String make, int year) {
this(make, year, 4);
System.out.println("Car(make, year) delegate");
}
public Car(String make, int year, int doors) {
super(make, year); // Vehicle constructor first
System.out.println("Car(" + make + ", " + year + ", " + doors + ")");
this.doors = doors;
}
}
public class ElectricCar extends Car {
private final int rangeKm;
public ElectricCar(String make, int year, int rangeKm) {
super(make, year, 4); // Car(make, year, doors)
System.out.println("ElectricCar(" + make + ")");
this.rangeKm = rangeKm;
}
}
new ElectricCar("Tesla", 2024, 500);
// Output (top-down):
// Vehicle(Tesla, 2024)
// Car(Tesla, 2024, 4)
// ElectricCar(Tesla)
Never call overridable (non-final, non-private) methods from a constructor. When the constructor runs, the subclass's fields are still at their default values (0, null, false). If an overridden method reads those fields, it sees uninitialised data. Only call private or final methods from constructors.
Dynamic dispatch is the mechanism by which Java selects the correct method implementation at runtime based on the actual type of the object, not the declared type of the reference. This is what makes polymorphism useful: you can write code against a supertype and it automatically adapts to whatever subtype you hand it.
public abstract class Notification {
protected final String recipient;
public Notification(String recipient) { this.recipient = recipient; }
// Each subclass provides its own delivery mechanism
public abstract void send(String message);
// Concrete method uses the abstract one — dynamic dispatch at work
public void broadcast(java.util.List<String> messages) {
messages.forEach(this::send); // calls whichever send() the runtime type has
}
}
public class EmailNotification extends Notification {
public EmailNotification(String email) { super(email); }
@Override
public void send(String message) {
System.out.println("Email to " + recipient + ": " + message);
}
}
public class SmsNotification extends Notification {
public SmsNotification(String phone) { super(phone); }
@Override
public void send(String message) {
System.out.println("SMS to " + recipient + ": " + message.substring(0, Math.min(160, message.length())));
}
}
public class PushNotification extends Notification {
public PushNotification(String deviceId) { super(deviceId); }
@Override
public void send(String message) {
System.out.println("Push to " + recipient + ": " + message);
}
}
// Dynamic dispatch in action — the variable type is Notification,
// but the method called is determined by the actual object at runtime
public class NotificationService {
public void notifyAll(java.util.List<Notification> channels, String message) {
for (Notification n : channels) {
n.send(message); // dispatched to Email, SMS, or Push — at runtime
}
}
public static void main(String[] args) {
NotificationService svc = new NotificationService();
java.util.List<Notification> channels = java.util.List.of(
new EmailNotification("alice@example.com"),
new SmsNotification("+1-555-0100"),
new PushNotification("device-abc123")
);
svc.notifyAll(channels, "Your order has shipped!");
// Email to alice@example.com: Your order has shipped!
// SMS to +1-555-0100: Your order has shipped!
// Push to device-abc123: Your order has shipped!
}
}
Dynamic dispatch applies to instance methods only. Static methods, fields, and constructors are resolved at compile time based on the declared type — they are never dynamically dispatched. This is why calling a static method on an instance variable (while legal) can produce unexpected results if the variable holds a subtype.
An overriding method is allowed to return a more specific (narrower) type than the overridden method. This is called a covariant return type. It lets subclasses advertise more precise return types without breaking the superclass contract — callers who know only the supertype get the declared supertype; callers who know the subtype get the more specific type without a cast.
public class Animal {
public Animal create() {
return new Animal();
}
public Animal copy() {
return new Animal();
}
}
public class Dog extends Animal {
// Covariant return — Dog is a subtype of Animal, so this is a valid override
@Override
public Dog create() { // return type narrowed from Animal to Dog
return new Dog();
}
@Override
public Dog copy() {
return new Dog();
}
}
// Builder pattern uses covariant returns extensively
public class RequestBuilder {
protected String url;
protected String method = "GET";
public RequestBuilder url(String url) { this.url = url; return this; }
public RequestBuilder method(String method) { this.method = method; return this; }
public String build() { return method + " " + url; }
}
public class AuthRequestBuilder extends RequestBuilder {
private String token;
// Covariant — returns AuthRequestBuilder so callers can chain auth-specific methods
@Override
public AuthRequestBuilder url(String url) { this.url = url; return this; }
@Override
public AuthRequestBuilder method(String method) { this.method = method; return this; }
public AuthRequestBuilder bearerToken(String t) { this.token = t; return this; }
@Override
public String build() { return super.build() + " [Bearer " + token + "]"; }
}
// No cast needed — type system tracks the specific builder type
String req = new AuthRequestBuilder()
.url("https://api.example.com/data")
.method("POST")
.bearerToken("abc123")
.build();
System.out.println(req); // POST https://api.example.com/data [Bearer abc123]
Static methods cannot be overridden — they can only be hidden. If a subclass declares a static method with the same signature as a superclass static method, the subclass version hides the superclass version. The critical difference: overriding uses the runtime type of the object to determine which method runs; hiding uses the compile-time type of the reference. This distinction destroys polymorphism for static methods.
public class Parent {
public static String staticGreet() { return "Hello from Parent (static)"; }
public String instanceGreet(){ return "Hello from Parent (instance)"; }
}
public class Child extends Parent {
// HIDES Parent.staticGreet — not an override
public static String staticGreet() { return "Hello from Child (static)"; }
// OVERRIDES Parent.instanceGreet — true polymorphism
@Override
public String instanceGreet() { return "Hello from Child (instance)"; }
}
Parent ref = new Child(); // compile-time type = Parent, runtime type = Child
// Static method — resolved at COMPILE TIME based on reference type (Parent)
System.out.println(Parent.staticGreet()); // Hello from Parent (static)
System.out.println(Child.staticGreet()); // Hello from Child (static)
System.out.println(ref.staticGreet()); // Hello from Parent (static) ← uses Parent!
// Instance method — resolved at RUNTIME based on actual object type (Child)
System.out.println(ref.instanceGreet()); // Hello from Child (instance) ← uses Child!
Never call static methods through an instance reference — it is misleading. ref.staticGreet() looks like it might dispatch to Child but it doesn't. Always call static methods via the class name: Parent.staticGreet() or Child.staticGreet().
The Liskov Substitution Principle (LSP) states: if S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program. In plain terms: a subclass should honour the full contract of its superclass, not just the method signatures. Violating LSP produces hierarchies that compile fine but break at runtime in subtle ways.
// LSP VIOLATION — the classic Rectangle/Square problem
public class Rectangle {
protected int width;
protected int height;
public void setWidth(int w) { this.width = w; }
public void setHeight(int h) { this.height = h; }
public int area() { return width * height; }
}
public class Square extends Rectangle {
// To stay "square", both sides must be equal — violates Rectangle's contract
@Override public void setWidth(int w) { this.width = this.height = w; }
@Override public void setHeight(int h) { this.width = this.height = h; }
}
// Code written for Rectangle breaks when given a Square
void testArea(Rectangle r) {
r.setWidth(5);
r.setHeight(3);
// With Rectangle: 5 * 3 = 15 — correct
// With Square: height overwrite sets both to 3, so 3 * 3 = 9 — WRONG
System.out.println(r.area()); // 9, not 15 — LSP violated
}
testArea(new Square()); // breaks the contract
// LSP-COMPLIANT design — use interfaces or separate hierarchies
public interface Shape {
int area();
}
// Rectangle and Square are separate, unrelated implementations
public class RectangleLSP implements Shape {
private final int width, height;
public RectangleLSP(int w, int h) { this.width = w; this.height = h; }
@Override public int area() { return width * height; }
}
public class SquareLSP implements Shape {
private final int side;
public SquareLSP(int side) { this.side = side; }
@Override public int area() { return side * side; }
}
A quick LSP check: can every method that works correctly with the supertype also work correctly with the subtype, for every valid input, without knowing which subtype it has? If the answer is no — if you need to special-case the subtype, or if the subtype narrows the accepted inputs or strengthens postconditions — you have an LSP violation and should reconsider the hierarchy.