The Problem Inheritance Solves
Suppose you are modelling a school system. A Student has a name, an age and an ID, and can be enrolled. A Teacher has a name, an age and an ID, and can be assigned a subject. A Librarian has a name, an age and an ID too. Written separately, the same three fields and the same getters appear three times. Add a phone number later and you edit three files, and forget the fourth.
Inheritance lets you write the shared part once. Put name, age and ID into a Person class, then declare class Student extends Person. Student now has all of Person's fields and methods without repeating them, and adds only what is specific to a student. Person is called the superclass or parent; Student is the subclass or child.
The relationship this expresses is is-a. A Student is a Person, so inheritance fits. A Car is a Vehicle. Say the sentence out loud before you write extends, because if it sounds wrong, inheritance is the wrong tool. A Car is not an Engine — a car has an engine, which is a field, not a parent class. Getting this backwards produces designs that seem clever and then fight you for the rest of the project.
Java allows a class to extend exactly one class. This is deliberate. Languages permitting multiple inheritance run into the problem of a class inheriting the same method by two different routes, with no clear rule for which wins. Java's answer is single inheritance for classes plus multiple interfaces, which the Abstraction lesson covers.
// The shared part, written once
public class Person {
protected String name; // protected: visible to subclasses
protected int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public void introduce() {
System.out.println("I am " + name + ", age " + age);
}
}
// Student IS-A Person
public class Student extends Person {
private final int rollNo;
public Student(String name, int age, int rollNo) {
super(name, age); // initialise the Person part first
this.rollNo = rollNo;
}
public void study() {
System.out.println(name + " is studying"); // 'name' is inherited
}
}
// Teacher IS-A Person
public class Teacher extends Person {
private final String subject;
public Teacher(String name, int age, String subject) {
super(name, age);
this.subject = subject;
}
public void teach() {
System.out.println(name + " teaches " + subject);
}
}
Student s = new Student("Ananya", 20, 101);
s.introduce(); // inherited from Person
s.study(); // its own
Teacher t = new Teacher("Mr Rao", 45, "Physics");
t.introduce(); // the same inherited method, no duplication protectedmeans "visible inside this package and to subclasses anywhere". It is the access level that exists specifically for inheritance. Use it for fields a subclass genuinely needs; keep the restprivateand expose them through methods.
What Is Inherited, and What Is Not
A subclass inherits every public and protected member of its parent, and package-private members when both classes are in the same package. It does not inherit anything private. A private field still exists inside every child object — it is part of the Person portion of a Student — but the child's own code cannot touch it directly and must go through a public or protected method.
Constructors are also not inherited. Every subclass declares its own, and calls super(...) to initialise the parent part. If you omit that call, the compiler inserts super() with no arguments, which fails to compile when the parent has no no-argument constructor.
super has a second use beyond the constructor. Inside any method, super.methodName() calls the parent's version rather than your own. That is how a child extends behaviour instead of replacing it: do the parent's work, then add to it. A toString() in a subclass that starts with super.toString() and appends the extra fields is a common and useful pattern.
One thing to avoid: declaring a field in the child with the same name as one in the parent. That is field hiding, not overriding. Both fields exist, and which one you get depends on the declared type of the reference rather than on the actual object — the opposite of how methods behave. It is a genuine source of confusion, and there is never a good reason to do it deliberately.
public class Account {
private double balance; // private: NOT accessible in child
protected String accountNo; // protected: accessible in child
public Account(String accountNo, double balance) {
this.accountNo = accountNo;
this.balance = balance;
}
public double getBalance() { return balance; } // the child's only route in
@Override
public String toString() {
return "Account " + accountNo + ": " + balance;
}
}
public class SavingsAccount extends Account {
private final double interestRate;
public SavingsAccount(String accountNo, double balance, double rate) {
super(accountNo, balance);
this.interestRate = rate;
}
public double yearlyInterest() {
// return balance * interestRate; // compile error: balance is private
return getBalance() * interestRate; // use the inherited public method
}
@Override
public String toString() {
// extend the parent's version instead of rewriting it
return super.toString() + " (savings @ " + interestRate + ")";
}
}
SavingsAccount sa = new SavingsAccount("SB1001", 25000, 0.04);
System.out.println(sa);
// Account SB1001: 25000.0 (savings @ 0.04)
System.out.printf("%.2f%n", sa.yearlyInterest()); // 1000.00 - Inheritance can go deeper than one level:
C extends BandB extends AmeansCinherits from both. Keep hierarchies shallow — two or three levels at most. Deep chains make it very hard to answer the simple question of where a method actually comes from.
Method Overriding and Its Rules
Overriding means a subclass replaces an inherited method with its own version. This is what makes inheritance powerful rather than merely tidy: every Shape can be asked for its area(), and each kind of shape answers in its own way.
To override, the child method must have the same name and the same parameter list as the parent's. Differ in either and you have not overridden anything — you have added an overload, and the parent's version still runs when it is called with the original arguments. This silent failure is why @Override matters so much: it asks the compiler to check that a matching parent method really exists, turning a bug you would find at 2 a.m. into an error you see immediately. Put it on every override, every time.
The remaining rules follow from one principle: code written against the parent type must keep working when handed a child object. So the access modifier cannot become more restrictive — a public method cannot be overridden as private, though the reverse widening is allowed. The return type must be the same or a subtype of the original (called a covariant return). And an overriding method cannot declare broader checked exceptions than the one it replaces.
Three kinds of method cannot be overridden at all. A final method is explicitly sealed by its author. A private method is invisible to the child, so a same-named method there is simply a new method. And a static method belongs to the class, not the object — declaring one with the same signature in a child hides rather than overrides it, and which version runs depends on the reference type. Putting @Override on a static method is a compile error, which is the compiler catching that confusion for you.
public class Shape {
public double area() {
return 0;
}
public String describe() {
return "A shape with area " + area();
}
}
public class Circle extends Shape {
private final double radius;
public Circle(double radius) { this.radius = radius; }
@Override // checked by the compiler
public double area() {
return Math.PI * radius * radius;
}
}
public class Rectangle extends Shape {
private final double width, height;
public Rectangle(double w, double h) { this.width = w; this.height = h; }
@Override
public double area() {
return width * height;
}
}
System.out.println(new Circle(5).describe());
// A shape with area 78.53981633974483
// describe() lives in Shape but calls the CHILD's area()
// ---- Why @Override matters ----
public class Square extends Shape {
private final double side;
public Square(double side) { this.side = side; }
// Typo: 'Area' not 'area'. Without @Override this compiles fine
// and Shape.area() keeps returning 0 forever.
// public double Area() { return side * side; }
// @Override
// public double Area() { ... }
// error: method does not override or implement a method from a supertype
@Override
public double area() { return side * side; }
} - You cannot reduce visibility when overriding. Marking an override
privategivesattempting to assign weaker access privileges. The reason is that a caller holding aShapereference is entitled to callarea(), and a private override would break that promise.
Overriding vs Overloading
These two words look alike, are examined constantly, and mean almost opposite things. Fixing the difference now will save you in both interviews and debugging.
Overloading is several methods with the same name in the same class, distinguished by their parameter lists, chosen by the compiler from the declared types at the call site. Overriding is one method redefined in a subclass with an identical signature, chosen by the JVM at run time from the actual type of the object.
The consequence that matters in practice: with overriding, a variable declared as Shape holding a Circle runs Circle's area(). With overloading, a variable declared as Object holding a String selects the Object overload, because the compiler decided before the program ever ran. The next lesson, on polymorphism, builds entirely on the first half of that sentence.
- Overloading: same class (or inherited) — Overriding: parent and child class
- Overloading: parameter lists must differ — Overriding: parameter lists must be identical
- Overloading: return type is free to differ — Overriding: same type, or a subtype of it
- Overloading: resolved at compile time from declared types — Overriding: resolved at run time from the actual object
- Overloading: access modifier is unrestricted — Overriding: cannot be made more restrictive
- Overloading:
staticandprivatemethods can be overloaded — Overriding: neither can be overridden @Overrideapplies only to overriding, and using it is the easiest way to be sure which one you have written
- A quick memory hook: overloading gives one name a bigger load of versions in one class; overriding rides over the parent's version and replaces it.
final, Object, and Preferring Composition
final on a method means no subclass may override it. final on a class means it cannot be extended at all — String, Integer and the other wrapper classes are final, which is part of why String can be relied on never to change behaviour underneath you.
Every class you write, whether you say so or not, extends java.lang.Object. That is where toString(), equals(), hashCode() and getClass() come from, and it is why you can put any object into a List<Object>. Java 17 also added sealed classes, which permit inheritance but only by a named list of subclasses — useful when you want a fixed, closed set of variants.
Finally, a warning that experienced developers repeat constantly: prefer composition over inheritance. Inheritance creates the tightest coupling in the language. The child depends not only on what the parent does but on how it does it, so a harmless-looking change in the parent can break every subclass — the so-called fragile base class problem. And because Java allows only one parent, spending it unwisely is expensive.
Composition means holding another object as a field and delegating to it, rather than extending it. A Car has an Engine; it does not extend Engine. Composition is more flexible — you can swap the engine at run time, hold several, and change either class without breaking the other. Use inheritance when the is-a relationship is genuinely true and you want the subclass to be usable anywhere the parent is expected. Otherwise, use a field.
// final: sealed against extension
public final class Constants {
public static final double GST_RATE = 0.18;
}
// class More extends Constants { } // error: cannot inherit from final
public class Payment {
public final void audit() { // subclasses must not change this
System.out.println("Audit log written");
}
}
// ---- Inheritance: an is-a relationship ----
class ElectricCar extends Car { } // an electric car IS-A car ✓
// ---- Composition: a has-a relationship ----
class Engine {
private final int cc;
Engine(int cc) { this.cc = cc; }
void start() { System.out.println(cc + "cc engine starting"); }
}
class Car {
private final Engine engine; // a car HAS-A engine
Car(Engine engine) { this.engine = engine; }
void start() {
engine.start(); // delegate
System.out.println("Car ready");
}
}
Car c = new Car(new Engine(1200));
c.start();
// 1200cc engine starting
// Car ready - Say "X is a Y" out loud. If it sounds wrong, do not use
extends. - Ask whether every method of the parent makes sense on the child. If some would have to throw "not supported", the hierarchy is wrong.
- Ask whether code holding the parent type could be handed the child and still behave correctly. If not, use composition.
- Prefer inheriting from an abstract class or interface designed for it over inheriting from a concrete class that was not.
- Keep hierarchies shallow — two or three levels — and mark classes
finalwhen you do not intend them to be extended.
- A famous cautionary example: a
Stackthat extendsArrayListinheritsadd(int index, E element), so anyone can insert an item into the middle of your stack and break the last-in-first-out guarantee entirely. AStackthat holds anArrayListexposes onlypushandpop, and the guarantee holds.
