Inheritance in Java
Learn how to build class hierarchies using extends, super, method overriding, and the Liskov Substitution Principle with real Java examples.
Inheritance allows a child class (subclass) to acquire the fields and methods of a parent class (superclass), then extend or specialise that behaviour. It models “is-a” relationships and enables code reuse across related types. Without inheritance, every related class would have to duplicate all shared logic — any change to that logic would have to be made in every copy.
Basic Inheritance with extends
The extends keyword establishes the parent-child relationship. The child class inherits everything that is public or protected from the parent. The first thing a child constructor must do is call super(...) — this initialises the parent’s fields before the child adds its own.
// Parent class — defines shared state and behaviour for all vehicles
public class Vehicle {
protected String brand;
protected int year;
protected double speed; // km/h
public Vehicle(String brand, int year) {
this.brand = brand;
this.year = year;
this.speed = 0;
}
public void accelerate(double amount) {
speed += amount;
System.out.printf("%s accelerates to %.1f km/h%n", brand, speed);
}
public void brake(double amount) {
speed = Math.max(0, speed - amount);
System.out.printf("%s slows to %.1f km/h%n", brand, speed);
}
public String getInfo() {
return String.format("%d %s (%.1f km/h)", year, brand, speed);
}
}
// Child class — inherits everything from Vehicle, adds its own behaviour
public class Car extends Vehicle {
private int doors;
private boolean trunkOpen;
public Car(String brand, int year, int doors) {
super(brand, year); // MUST call parent constructor first
this.doors = doors;
this.trunkOpen = false;
}
// New behaviour unique to Car — not present in Vehicle
public void openTrunk() { trunkOpen = true; System.out.println("Trunk opened."); }
public void closeTrunk() { trunkOpen = false; System.out.println("Trunk closed."); }
@Override // Extend the parent's info string with Car-specific details
public String getInfo() {
return super.getInfo() + ", " + doors + " doors";
}
}
Usage:
Car car = new Car("Toyota", 2023, 4);
car.accelerate(60); // Toyota accelerates to 60.0 km/h
car.openTrunk(); // Trunk opened.
System.out.println(car.getInfo()); // 2023 Toyota (60.0 km/h), 4 doors
Multi-Level Hierarchy
Inheritance chains can extend multiple levels deep. Each level adds specialisation while reusing everything above it. Keep hierarchies shallow — more than 2-3 levels deep becomes hard to understand and maintain.
public class ElectricCar extends Car {
private double batteryLevel; // 0.0 to 1.0
public ElectricCar(String brand, int year, int doors, double batteryLevel) {
super(brand, year, doors);
this.batteryLevel = batteryLevel;
}
public void charge(double amount) {
batteryLevel = Math.min(1.0, batteryLevel + amount);
System.out.printf("Charged. Battery at %.0f%%%n", batteryLevel * 100);
}
@Override
public void accelerate(double amount) {
if (batteryLevel <= 0) {
System.out.println("Battery empty — cannot accelerate.");
return;
}
batteryLevel -= amount * 0.01; // each km/h costs 1% battery
super.accelerate(amount); // call Vehicle's accelerate for the speed update
}
@Override
public String getInfo() {
return super.getInfo() + String.format(", battery %.0f%%", batteryLevel * 100);
}
}
ElectricCar tesla = new ElectricCar("Tesla", 2024, 4, 0.9);
tesla.accelerate(100); // Tesla accelerates to 100.0 km/h
System.out.println(tesla.getInfo()); // 2024 Tesla (100.0 km/h), 4 doors, battery 89%
Method Overriding
Override a parent method to change its behaviour in the child. Always add @Override — it tells the compiler to verify you are actually overriding an existing method, not accidentally creating a new one with a slightly different signature. Without it, a typo in the method name would silently create a new method rather than overriding.
public class Shape {
public double area() {
return 0;
}
public String describe() {
return "Shape with area " + area(); // calls whichever area() is in effect
}
}
public class Circle extends Shape {
private double radius;
public Circle(double radius) { this.radius = radius; }
@Override
public double area() {
return Math.PI * radius * radius; // replaces Shape's area() for Circle instances
}
}
public class Rectangle extends Shape {
private double width, height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() {
return width * height;
}
}
Shape c = new Circle(5);
Shape r = new Rectangle(4, 6);
System.out.println(c.describe()); // Shape with area 78.53...
System.out.println(r.describe()); // Shape with area 24.0
The parent’s describe() method calls area(), and Java automatically dispatches to the overriding version in the child. This is polymorphic dispatch.
Calling the Parent with super
super gives you access to the parent class’s constructor and methods. Use super.methodName() when you want to extend (not replace) the parent’s behaviour — add extra steps before or after calling the parent’s logic.
public class Animal {
private String name;
public Animal(String name) { this.name = name; }
public String getName() { return name; }
public String speak() {
return name + " makes a sound.";
}
}
public class Dog extends Animal {
private String breed;
public Dog(String name, String breed) {
super(name); // initialise the Animal part first
this.breed = breed;
}
@Override
public String speak() {
return super.speak() + " Woof!"; // reuse parent's output, then append
}
public String getBreed() { return breed; }
}
Dog d = new Dog("Rex", "Labrador");
System.out.println(d.speak()); // Rex makes a sound. Woof!
Preventing Inheritance: final
Mark a class final to stop it from being subclassed, or mark a method final to stop it from being overridden. Use final on classes when uncontrolled subclassing would break correctness guarantees — for example, an immutable value type where a subclass could add mutable state.
public final class ImmutablePoint {
private final double x, y;
public ImmutablePoint(double x, double y) {
this.x = x;
this.y = y;
}
public double getX() { return x; }
public double getY() { return y; }
}
// COMPILE ERROR: Cannot subclass a final class
// class ThreeDPoint extends ImmutablePoint { ... }
The Object Class
Every class in Java implicitly extends java.lang.Object. This is why all objects have toString(), equals(), and hashCode() — they are inherited from Object. Overriding them is one of the most important things you can do when writing a value-type class, because the defaults are almost never what you want.
public class Product {
private String name;
private double price;
public Product(String name, double price) {
this.name = name;
this.price = price;
}
// Without @Override toString(), printing a Product shows "Product@3c7a835a" — useless
@Override
public String toString() {
return String.format("Product(%s, $%.2f)", name, price);
}
// Without @Override equals(), two Products with the same data would not be "equal"
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Product other)) return false;
return Double.compare(price, other.price) == 0 && name.equals(other.name);
}
// hashCode must be consistent with equals — objects that are equal must have equal hash codes
@Override
public int hashCode() {
return Objects.hash(name, price);
}
}
Product p1 = new Product("Laptop", 999.99);
Product p2 = new Product("Laptop", 999.99);
System.out.println(p1); // Product(Laptop, $999.99)
System.out.println(p1.equals(p2)); // true
Liskov Substitution Principle
A child class should always be substitutable for its parent without breaking the program. If your subclass changes behaviour in a way that violates the parent’s contract — the rules and guarantees that callers depend on — you have a design problem that no amount of @Override annotations can fix.
// GOOD — ElectricCar can be used anywhere Car is expected
Car myCar = new ElectricCar("Tesla", 2024, 4, 0.8);
myCar.accelerate(50); // works correctly via polymorphism
// BAD example — a subclass that breaks the parent's contract
class NegativeBalance extends BankAccount {
// Allows negative balance — violates BankAccount's invariant
@Override
public void withdraw(double amount) {
balance -= amount; // ignores insufficient-funds check — callers can't trust this
}
}