<< Home

Chapter 7: Inheritance and Polymorphism

Inheritance and polymorphism are two of the core ideas that give object-oriented programming its power and flexibility. Together, they let you define new classes that build on existing ones, share common behaviour, and adapt that behaviour to new contexts without rewriting code. Inheritance expresses relationships between classes, while polymorphism allows those related classes to be used interchangeably in consistent and predictable ways.

In Java, every class except Object inherits from exactly one superclass. This single-inheritance model keeps hierarchies clear and avoids the ambiguity that can arise when multiple parents define overlapping members. A subclass automatically gains the accessible fields and methods of its parent, can extend or replace them, and can add its own specialisations. Through this mechanism, large systems evolve as trees of related types that share a common foundation but differ in detail.

💡 You can think of inheritance as defining what something is, and polymorphism as defining how it behaves in context. Both work together to keep programs modular and adaptable.

Polymorphism complements inheritance by letting a reference to a superclass hold an object of any subclass. When a method defined in the superclass is overridden in the subclass, Java automatically selects the correct version at runtime. This behaviour, known as dynamic dispatch, allows programs to remain general at the top level yet specific in execution. It is what enables a single interface or abstract type to represent many concrete implementations.

Together, inheritance and polymorphism form the architectural glue that connects every object-oriented Java program. They allow code to be structured for reuse, clarity, and evolution, so that systems can grow without duplication or chaos. Mastering these concepts is essential to writing code that scales naturally from small examples to real-world applications.

⚠️ Overuse of inheritance can lead to rigid designs that are hard to modify. Prefer composition (building classes from reusable components) when it expresses intent more clearly than deep class hierarchies.

Extending classes and using superclasses

Inheritance in Java is achieved by defining a new class that extends an existing one. The new class, known as the subclass or derived class, inherits all the accessible fields and methods of its superclass (also called the base class). This means you can reuse and expand on existing functionality instead of duplicating it.

The syntax for inheritance uses the extends keyword, followed by the name of the superclass. For example:

class Animal {
  void speak() {
    System.out.println("The animal makes a sound.");
  }
}

class Dog extends Animal {
  void bark() {
    System.out.println("The dog barks.");
  }
}

Here, the Dog class inherits the speak() method from Animal and also defines its own bark() method. This allows an instance of Dog to use both methods:

Dog d = new Dog();
d.speak();  // Inherited from Animal
d.bark();   // Defined in Dog

All classes in Java (except Object) implicitly extend a single superclass. If you do not specify one, Java automatically assumes extends Object, which gives every class access to methods like toString(), equals(), and hashCode().

💡 The Object class sits at the root of Java’s class hierarchy. Every class, directly or indirectly, inherits from it, which means you can treat any object as a generic Object reference when needed.

Accessing superclass members

A subclass can call methods or access fields from its superclass using the super keyword. This is useful when you want to invoke a superclass implementation that has been overridden, or when you need to initialise inherited fields through the parent’s constructor.

class Animal {
  void speak() {
    System.out.println("The animal makes a sound.");
  }
}

class Dog extends Animal {
  void speak() {
    super.speak();  // call the superclass version
    System.out.println("The dog barks.");
  }
}

When speak() is called on a Dog object, both versions run in sequence, demonstrating how subclass methods can extend behaviour rather than completely replace it.

Calling superclass constructors

If a superclass defines one or more constructors, the subclass must explicitly call one of them as the first statement of its own constructor using super(…). This ensures that the inherited part of the object is properly initialised before the subclass adds its own setup.

class Animal {
  String name;

  Animal(String name) {
    this.name = name;
  }
}

class Dog extends Animal {
  Dog(String name) {
    super(name);  // call Animal constructor
  }
}

If the superclass has a default (no-argument) constructor, the compiler inserts a call to it automatically when you do not specify one. However, if no such constructor exists, you must explicitly call one of the available constructors using super(…).

⚠️ The call to super(…) must always appear as the first statement in a subclass constructor. Placing it later will cause a compilation error, because superclass initialisation must occur before any subclass code executes.

Overriding methods and using the super keyword

When a subclass defines a method with the same name, return type, and parameters as one in its superclass, the subclass version overrides the parent version. This is a cornerstone of polymorphism, allowing a subclass to provide behaviour that better fits its specific role while keeping the same external interface.

class Animal {
  void speak() {
    System.out.println("The animal makes a sound.");
  }
}

class Dog extends Animal {
  @Override
  void speak() {
    System.out.println("The dog barks.");
  }
}

Here, Dog overrides the speak() method of Animal. When the method is called on a Dog object, Java automatically invokes the subclass version instead of the parent’s, even if the reference type is Animal:

Animal a = new Dog();
a.speak();  // Outputs: The dog barks.

The @Override annotation is optional but strongly recommended. It tells the compiler that the method is intended to override one from a superclass, and generates an error if no such method exists. This prevents subtle mistakes such as misspelling a method name or mismatching its signature.

💡 The @Override annotation does not change program behaviour. It simply adds safety, ensuring your method truly overrides a superclass method rather than accidentally overloading it.

Calling the superclass version

Sometimes a subclass needs to extend rather than completely replace a method’s behaviour. In such cases, you can call the superclass version inside the overriding method using super.methodName(). This lets you build on the inherited implementation instead of discarding it.

class Animal {
  void speak() {
    System.out.println("The animal makes a sound.");
  }
}

class Dog extends Animal {
  @Override
  void speak() {
    super.speak();  // Call superclass version
    System.out.println("The dog barks.");
  }
}

When speak() is invoked on a Dog object, both lines execute in order, combining the superclass message with the subclass-specific one. This pattern is common in frameworks and libraries that expect subclasses to add to base functionality.

Rules for overriding

When overriding a method in Java, several important rules apply:

class Vehicle {
  public void start() {
    System.out.println("Vehicle starting...");
  }

  public final void stop() {
    System.out.println("Vehicle stopped.");
  }
}

class Car extends Vehicle {
  @Override
  public void start() {
    System.out.println("Car engine starting...");
  }

  // void stop() { } // error: cannot override a final method
}
⚠️ If you accidentally change the parameters of a method while intending to override it, Java treats it as an overloaded method instead. Always use @Override to ensure the signature matches exactly.

Overriding and exception handling

When overriding a method that declares checked exceptions, the subclass version cannot declare broader exceptions than the original. It may, however, declare narrower exceptions or none at all. This ensures that code using the superclass reference remains valid and predictable.

class FileReader {
  void read() throws IOException {
    System.out.println("Reading file...");
  }
}

class SafeFileReader extends FileReader {
  @Override
  void read() {
    System.out.println("Reading safely, no exception thrown.");
  }
}

This rule maintains compatibility: a FileReader reference that expects read() to potentially throw an IOException can still safely call read() on a SafeFileReader instance without additional handling.

💡 Always remember that overriding narrows possibilities rather than broadens them. The subclass must never violate the expectations set by its superclass contract.

Abstract classes and methods

Sometimes you need a class that defines a common framework for its subclasses but should not itself be instantiated. In Java, this is achieved using abstract classes. An abstract class can contain both fully implemented methods and abstract methods (methods declared without a body) that subclasses must provide.

abstract class Shape {
  abstract double area();  // abstract method

  void describe() {
    System.out.println("This is a geometric shape.");
  }
}

class Circle extends Shape {
  double radius;

  Circle(double radius) {
    this.radius = radius;
  }

  @Override
  double area() {
    return Math.PI * radius * radius;
  }
}

Here, Shape defines a common interface for geometric forms but leaves the exact implementation of area() to each subclass. The Circle class provides that implementation while inheriting the concrete describe() method from Shape.

Shape s = new Circle(5);
s.describe();
System.out.println(s.area());

You cannot create an object directly from an abstract class. Attempting to do so results in a compile-time error:

Shape s = new Shape(); // error: Shape is abstract; cannot be instantiated
💡 Abstract classes are ideal when you want to define a shared structure and partial implementation for a family of related classes, while still forcing each subclass to fill in specific details.

Declaring abstract methods

An abstract method has no body and ends with a semicolon instead of braces. It serves as a placeholder for behaviour that subclasses must implement. Any class containing one or more abstract methods must itself be declared abstract.

abstract class Animal {
  abstract void speak(); // must be implemented by subclasses
}

Subclasses of an abstract class must either implement all inherited abstract methods or also be declared abstract themselves. This rule ensures that any class you can instantiate has complete implementations for all its behaviours.

class Dog extends Animal {
  @Override
  void speak() {
    System.out.println("Woof!");
  }
}
⚠️ A class can be abstract even if it contains no abstract methods. This is sometimes done to prevent direct instantiation of a class meant only to be a superclass.

Mixing abstract and concrete members

Abstract classes can freely combine abstract methods with fully defined ones. This flexibility allows you to provide default or shared logic that all subclasses inherit, while still requiring them to implement the unique parts.

abstract class Device {
  void powerOn() {
    System.out.println("Powering on...");
  }

  abstract void operate(); // subclasses define how
}

class Printer extends Device {
  @Override
  void operate() {
    System.out.println("Printing document...");
  }
}

When you call powerOn() on a Printer, the method runs exactly as defined in Device, while operate() runs the subclass-specific version.

💡 Abstract classes can act as semi-complete templates. They provide what all subclasses share and enforce what each must define, which helps maintain consistency across complex systems.

Abstract vs. interface

Abstract classes and interfaces both define contracts for other classes to follow, but they serve slightly different purposes. An abstract class can contain state (fields) and concrete method implementations, while an interface (covered in the next chapter) defines only method signatures and constants.

FeatureAbstract ClassInterface
MethodsCan be abstract or concreteUsually abstract (can include default/static)
FieldsCan be instance or staticAlways public static final
ConstructorsAllowedNot allowed
InheritanceSingleMultiple (a class can implement several)

Choosing between them depends on intent: use an abstract class when you want to share code and structure, and an interface when you want to define capabilities that can apply across unrelated class hierarchies.

⚠️ From Java 8 onward, interfaces can include default and static methods with implementations, which can blur the distinction with abstract classes. The main difference remains that interfaces do not hold instance state.

Interfaces and multiple inheritance

Interfaces in Java define contracts that classes can implement, describing what they must do without specifying how. An interface is similar to an abstract class that contains only abstract methods and constants, but unlike classes, interfaces allow a form of multiple inheritance. A class can implement several interfaces, combining behaviours from different sources while keeping its single superclass relationship intact.

interface Speakable {
  void speak();
}

interface Movable {
  void move();
}

class Dog implements Speakable, Movable {
  public void speak() {
    System.out.println("The dog barks.");
  }

  public void move() {
    System.out.println("The dog runs.");
  }
}

Here, Dog implements two interfaces, Speakable and Movable. Each interface defines a capability that Dog must provide. The methods are declared public because all interface methods are implicitly public abstract, and must be implemented with equal or greater visibility.

💡 Interfaces let unrelated classes share common behaviours. For example, both Dog and Robot could implement Movable, even though they have no inheritance relationship.

Defining and implementing interfaces

An interface is declared with the interface keyword. Unlike classes, interfaces cannot have instance fields or constructors, because they do not represent actual objects. They define method signatures that implementing classes must provide.

interface Shape {
  double area();
}

To use an interface, a class must explicitly declare that it implements it and then supply implementations for all its methods:

class Circle implements Shape {
  double radius;

  Circle(double radius) {
    this.radius = radius;
  }

  public double area() {
    return Math.PI * radius * radius;
  }
}

If a class does not implement all the methods of an interface, it must itself be declared abstract. This ensures that only complete, concrete classes can be instantiated.

Default and static methods

Since Java 8, interfaces can include default methods (which provide an implementation) and static methods (which belong to the interface itself). These additions make interfaces more flexible while keeping backward compatibility with older designs.

interface Logger {
  default void log(String msg) {
    System.out.println("[LOG] " + msg);
  }

  static void info(String msg) {
    System.out.println("[INFO] " + msg);
  }
}

class ConsoleLogger implements Logger { }

ConsoleLogger c = new ConsoleLogger();
c.log("Starting system...");  // uses default method
Logger.info("Ready");         // calls static method

Default methods help evolve interfaces without breaking existing code. You can add new behaviour by defining a default method, so classes that already implement the interface do not need immediate changes.

⚠️ If a class implements multiple interfaces that define the same default method, it must override the method itself to resolve the conflict. Java does not automatically choose one.

Extending interfaces

Interfaces can also extend other interfaces, inheriting all their abstract and default methods. This allows complex contracts to be built from smaller, more focused ones.

interface Readable {
  void read();
}

interface Writable {
  void write();
}

interface FileAccess extends Readable, Writable { }

class FileHandler implements FileAccess {
  public void read() {
    System.out.println("Reading file...");
  }

  public void write() {
    System.out.println("Writing file...");
  }
}

Here, FileAccess combines the capabilities of Readable and Writable. Any class implementing FileAccess must provide both read() and write() methods, effectively inheriting multiple behaviours.

💡 Interface inheritance allows Java to achieve multiple inheritance of type safely, without the ambiguity problems caused by inheriting from multiple classes.

Interfaces and polymorphism

Just like abstract classes, interfaces support polymorphism. A variable declared with an interface type can refer to any object of a class that implements that interface.

Movable m = new Dog();
m.move();  // The dog runs.

This allows programs to treat different objects uniformly based on shared capabilities rather than class hierarchies. For example, a method accepting a Movable parameter can work with any movable object, whether it is an animal, vehicle, or machine.

void startMoving(Movable m) {
  m.move();
}
⚠️ An interface reference can only call methods declared in the interface, even if the actual object implements other interfaces or methods. This is similar to how superclass references behave in class-based polymorphism.

Multiple inheritance of behaviour

While Java does not support multiple inheritance of classes, interfaces make it possible to achieve multiple inheritance of type and behaviour. By combining interfaces, a class can inherit from one superclass and any number of interfaces, allowing clean composition of features.

class SmartDog extends Animal implements Speakable, Movable {
  public void speak() {
    System.out.println("The smart dog talks.");
  }

  public void move() {
    System.out.println("The smart dog walks gracefully.");
  }
}

This model avoids the “diamond problem” that plagues languages with full multiple inheritance. Because interfaces contain no shared instance data, there is no ambiguity over which state or method implementation to use.

💡 Interfaces represent capabilities, not hierarchies. Use them to express what an object can do, and use inheritance to express what an object is.

Dynamic dispatch and polymorphic behavior

Dynamic dispatch is the mechanism that allows Java to decide at runtime which implementation of a method to call when multiple versions exist in a class hierarchy. It is the foundation of polymorphic behavior and one of the main reasons Java programs can remain both flexible and type-safe. When a superclass reference is used to call an overridden method, the Java Virtual Machine determines which version to execute based on the actual type of the object, not the declared type of the reference.

class Animal {
  void speak() {
    System.out.println("The animal makes a sound.");
  }
}

class Dog extends Animal {
  @Override
  void speak() {
    System.out.println("The dog barks.");
  }
}

class Cat extends Animal {
  @Override
  void speak() {
    System.out.println("The cat meows.");
  }
}

public class Demo {
  public static void main(String[] args) {
    Animal a;

    a = new Dog();
    a.speak();  // The dog barks.

    a = new Cat();
    a.speak();  // The cat meows.
  }
}

Although both calls are made through an Animal reference, the correct subclass method runs each time. This dynamic resolution of method calls at runtime is what gives polymorphism its power. It lets programs remain general in structure but specific in behavior.

💡 You can think of dynamic dispatch as Java’s way of sending messages to objects and letting them decide how to respond. The object, not the reference, determines the outcome.

Method tables and runtime resolution

Under the hood, each Java class maintains a method table that maps method names to their actual implementations. When you invoke a method on an object, the JVM looks up the method in that table and jumps to the correct implementation. This process is efficient and fully automatic, providing dynamic behavior without sacrificing performance.

Animal ref = new Dog();
ref.speak();  // JVM dispatches to Dog.speak() via method table

Because this resolution happens at runtime, Java can support flexible architectures such as plugin systems, frameworks, and dependency injection, where the exact class being used may not be known until execution.

Polymorphic assignments

Polymorphism allows variables of a superclass type to reference subclass objects. These references can be reassigned freely among related types, and method calls will continue to resolve correctly according to the actual object type.

Animal animal = new Dog();
animal.speak();  // Dog version

animal = new Cat();
animal.speak();  // Cat version

This ability makes it possible to create collections or arrays of superclass types that hold a mixture of subclass instances, each behaving appropriately when accessed.

Animal[] pets = { new Dog(), new Cat(), new Dog() };

for (Animal pet : pets) {
  pet.speak();
}
⚠️ Overuse of polymorphism can obscure control flow. Always design hierarchies so that overridden methods preserve logical intent and do not surprise other developers relying on superclass behavior.

Benefits of dynamic dispatch

Dynamic dispatch is what turns inheritance hierarchies into living systems. It ensures that objects act according to their real identity, even when viewed through general references, and that programs remain open to extension but closed to modification.

💡 Dynamic dispatch, interfaces, and abstract classes together form Java’s expressive core. By combining these features, you can build systems that are both adaptable and dependable.