Java Inheritance & Polymorphism

Series: Java Core for Beginners Lecture: 05 of 12 Topics: extends · Method overriding · super · Polymorphism · Abstract classes · final


The Problem Inheritance Solves

Imagine you are building a system for a zoo. You need to model different kinds of animals — dogs, cats, birds, fish. Each animal has a name, an age, and a species. Each animal can eat and sleep. But each animal also has unique behavior: a dog can fetch, a bird can fly, a fish can swim.

Without inheritance, you would write separate classes for each animal, duplicating the name, age, species fields and the eat(), sleep() methods in every single class. When requirements change — say, you need to add a weight field to all animals — you must update every class individually.

Inheritance solves this by letting you define shared data and behavior in one place (a parent class, also called a superclass or base class) and then extend it in specialized classes (called child classes, subclasses, or derived classes) that add or customize behavior.

The relationship is described as “is-a”: a Dog is-a Animal. This is the test you should apply before using inheritance — if the “is-a” relationship does not hold, inheritance is probably the wrong tool.


Extending a Class with extends

Use the extends keyword to declare that one class inherits from another:

// Parent class
public class Animal {

    private String name;
    private int age;
    private String species;

    public Animal(String name, int age, String species) {
        this.name = name;
        this.age = age;
        this.species = species;
    }

    public void eat() {
        System.out.println(name + " is eating.");
    }

    public void sleep() {
        System.out.println(name + " is sleeping.");
    }

    public String getName()    { return name; }
    public int getAge()        { return age; }
    public String getSpecies() { return species; }

    @Override
    public String toString() {
        return species + " named " + name + " (age " + age + ")";
    }
}
// Child class
public class Dog extends Animal {

    private String breed;

    public Dog(String name, int age, String breed) {
        super(name, age, "Canis lupus familiaris");   // calls Animal's constructor
        this.breed = breed;
    }

    public void fetch() {
        System.out.println(getName() + " fetches the ball!");
    }

    public String getBreed() { return breed; }
}
// Using the classes
Dog rex = new Dog("Rex", 3, "Labrador");

rex.eat();           // inherited from Animal — "Rex is eating."
rex.sleep();         // inherited from Animal — "Rex is sleeping."
rex.fetch();         // defined in Dog — "Rex fetches the ball!"
System.out.println(rex.getName());    // "Rex"
System.out.println(rex.getBreed());   // "Labrador"
System.out.println(rex);             // calls toString() from Animal

What is inherited

A child class inherits all non-private members of its parent: public and protected fields, methods, and nested types. Private members are not inherited — they remain exclusively within the class that declares them. However, the child class can still access private fields indirectly through inherited public methods (getters, setters).

What is not inherited

  • Constructors are not inherited. Every class must define its own constructors.
  • Private members are not accessible in child classes.
  • Static members are inherited but belong to the class level — they are not truly “inherited” in the object-oriented sense.

Java supports single inheritance only

A class can extend only one parent class. Java does not support multiple inheritance of classes (unlike C++). This avoids the notorious “diamond problem” — ambiguity when two parent classes define the same method.

If you need to inherit behavior from multiple sources, Java provides interfaces (covered in Lecture 6), which support multiple implementation.


Method Overriding and @Override

A child class can override a method it inherits from the parent — replacing the parent’s implementation with its own. The overriding method must have the same name, return type, and parameter list as the parent method.

public class Cat extends Animal {

    private boolean isIndoor;

    public Cat(String name, int age, boolean isIndoor) {
        super(name, age, "Felis catus");
        this.isIndoor = isIndoor;
    }

    @Override
    public void eat() {
        System.out.println(getName() + " delicately nibbles food.");   // custom behavior
    }

    @Override
    public String toString() {
        return super.toString() + (isIndoor ? " [indoor]" : " [outdoor]");
    }
}
Cat luna = new Cat("Luna", 2, true);
luna.eat();           // "Luna delicately nibbles food." — uses Cat's version
luna.sleep();         // "Luna is sleeping." — uses Animal's version (not overridden)
System.out.println(luna);   // "Felis catus named Luna (age 2) [indoor]"

The @Override annotation

Always use @Override when overriding a method. It tells the compiler your intent, and the compiler will report an error if no matching method exists in the parent. Without it, a typo in the method name silently creates a new method instead of overriding the parent’s:

// Without @Override — this is a NEW method, not an override!
public void Eat() { }   // capital E — completely different method

// With @Override — compiler catches the mistake immediately
@Override
public void Eat() { }   // ERROR: method does not override a method from Animal

Rules for overriding

  1. The method signature (name + parameters) must match exactly.
  2. The return type must be the same or a subtype (called covariant return type).
  3. The access modifier must be the same or more permissive — you can widen access but not restrict it. A public method in the parent cannot be overridden as protected or private.
  4. static methods cannot be overridden (they can be hidden, but that is a different concept).
  5. final methods cannot be overridden (covered in section 8).

The super Keyword

super is the complement of this: while this refers to the current object, super refers to the parent class. It has two main uses.

Use 1: Calling the parent constructor

Every child class constructor must call a parent constructor — either explicitly with super(...) or implicitly (Java inserts super() automatically if the parent has a no-argument constructor). If the parent has no no-argument constructor, you must call super(...) explicitly as the first statement:

public class Dog extends Animal {

    private String breed;

    public Dog(String name, int age, String breed) {
        super(name, age, "Canis lupus familiaris");   // MUST be the first statement
        this.breed = breed;
    }
}

Why is this required? The parent constructor initializes the parent’s fields (name, age, species). If it did not run, the inherited fields would be uninitialized.

Use 2: Calling the parent’s overridden method

When you override a method but still want to include the parent’s behavior, use super.methodName():

public class Cat extends Animal {

    @Override
    public void eat() {
        super.eat();   // calls Animal's eat() first
        System.out.println("...and then grooms itself.");
    }
}
Cat luna = new Cat("Luna", 2, true);
luna.eat();
// "Luna is eating."
// "...and then grooms itself."

This pattern is common in toString() overrides — call super.toString() to get the parent’s output, then append additional information.

super can only go one level up — you cannot write super.super.method() to jump two levels up the hierarchy.


Polymorphism

Polymorphism — from Greek, meaning “many forms” — is the ability to treat objects of different types through a common interface. It is arguably the most powerful concept in object-oriented programming.

The core idea

Because Dog and Cat both extend Animal, a variable of type Animal can hold a reference to a Dog object or a Cat object. The declared type of the variable (Animal) is called the static type. The actual type of the object at runtime (Dog or Cat) is called the dynamic type.

Animal a1 = new Dog("Rex", 3, "Labrador");   // static: Animal, dynamic: Dog
Animal a2 = new Cat("Luna", 2, true);         // static: Animal, dynamic: Cat
Animal a3 = new Animal("Nemo", 1, "Nemo fish"); // static: Animal, dynamic: Animal

Dynamic method dispatch

When you call an overridden method through a parent-type reference, Java calls the version defined in the actual runtime type, not the declared type. This is called dynamic method dispatch (or late binding):

Animal a1 = new Dog("Rex", 3, "Labrador");
Animal a2 = new Cat("Luna", 2, true);

a1.eat();   // calls Dog's eat() if overridden, otherwise Animal's — "Rex is eating."
a2.eat();   // calls Cat's eat() — "Luna delicately nibbles food."

The decision of which eat() to call is made at runtime, based on what object the variable actually points to — not at compile time based on the variable’s declared type.

Polymorphism with arrays and collections

This is where polymorphism becomes genuinely powerful. You can store different subtypes in a single collection and process them uniformly:

Animal[] animals = {
    new Dog("Rex", 3, "Labrador"),
    new Cat("Luna", 2, true),
    new Dog("Buddy", 5, "Poodle"),
    new Cat("Whiskers", 4, false)
};

for (Animal animal : animals) {
    animal.eat();    // calls the correct eat() for each actual type
    animal.sleep();
    System.out.println(animal);
}

Without polymorphism, you would need separate arrays for dogs and cats, separate loops, and your code would break every time you added a new animal type. With polymorphism, you add a new Bird class that extends Animal, and the loop above works without any changes.

Upcasting and downcasting

Moving from a child type to a parent type is called upcasting — it is always safe and happens automatically:

Dog rex = new Dog("Rex", 3, "Labrador");
Animal animal = rex;   // upcasting — automatic, always safe

Moving from a parent type back to a child type is called downcasting — it requires an explicit cast and can fail at runtime if the object is not actually of that type:

Animal animal = new Dog("Rex", 3, "Labrador");
Dog dog = (Dog) animal;        // downcasting — explicit cast, safe here
dog.fetch();                   // works — Rex is a Dog

Animal animal2 = new Cat("Luna", 2, true);
Dog dog2 = (Dog) animal2;      // ClassCastException at runtime! Luna is not a Dog

Always verify the type before downcasting using instanceof (next section).


The instanceof Operator and Pattern Matching

instanceof checks whether an object is an instance of a given class (or any of its subclasses):

Animal animal = new Dog("Rex", 3, "Labrador");

System.out.println(animal instanceof Animal);   // true
System.out.println(animal instanceof Dog);      // true
System.out.println(animal instanceof Cat);      // false

Use it before downcasting to avoid ClassCastException:

// Traditional style (Java 8 and earlier)
if (animal instanceof Dog) {
    Dog dog = (Dog) animal;   // safe — we verified the type
    dog.fetch();
}

Pattern matching with instanceof (Java 16+)

Since Java 16, instanceof supports pattern matching — the cast and variable declaration are combined into one step:

// Modern style (Java 16+)
if (animal instanceof Dog dog) {
    dog.fetch();   // dog is already cast — no explicit cast needed
}

if (animal instanceof Cat cat) {
    System.out.println(cat.getName() + " is " + (cat.isIndoor() ? "indoor" : "outdoor"));
}

The variable (dog, cat) is only in scope inside the if block. This eliminates the boilerplate of the traditional two-step check-then-cast, and the code is harder to get wrong.


Abstract Classes

Sometimes a parent class represents a concept that is too general to be instantiated on its own. You would never create a bare Animal — you always want a specific Dog or Cat. In these cases, declare the class abstract:

public abstract class Animal {

    private String name;
    private int age;

    public Animal(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // Concrete method — inherited as-is
    public void sleep() {
        System.out.println(name + " is sleeping.");
    }

    // Abstract method — subclasses MUST implement this
    public abstract void makeSound();

    // Abstract method — subclasses MUST implement this
    public abstract String getDiet();

    public String getName() { return name; }
    public int getAge()     { return age; }
}

An abstract class:

  • Cannot be instantiated directly — new Animal(...) is a compile error
  • Can have abstract methods — methods with no body, declared with abstract; subclasses must implement every abstract method (or be abstract themselves)
  • Can have concrete methods — regular methods with implementations that subclasses inherit
public class Dog extends Animal {

    private String breed;

    public Dog(String name, int age, String breed) {
        super(name, age);
        this.breed = breed;
    }

    @Override
    public void makeSound() {
        System.out.println(getName() + " says: Woof!");
    }

    @Override
    public String getDiet() {
        return "omnivore";
    }
}

public class Bird extends Animal {

    public Bird(String name, int age) {
        super(name, age);
    }

    @Override
    public void makeSound() {
        System.out.println(getName() + " says: Tweet!");
    }

    @Override
    public String getDiet() {
        return "herbivore";
    }

    public void fly() {
        System.out.println(getName() + " is flying.");
    }
}
// Animal animal = new Animal("X", 1);   // COMPILE ERROR — Animal is abstract

Animal dog = new Dog("Rex", 3, "Labrador");
Animal bird = new Bird("Tweety", 1);

dog.makeSound();    // "Rex says: Woof!"
bird.makeSound();   // "Tweety says: Tweet!"
dog.sleep();        // inherited — "Rex is sleeping."
bird.sleep();       // inherited — "Tweety is sleeping."

Abstract class vs concrete class — when to use each

Use an abstract class when:

  • The class represents a concept that should never be instantiated on its own
  • You want to enforce that all subclasses implement certain methods
  • You want to provide some shared concrete implementation alongside the abstract methods

If all methods have implementations and the class can meaningfully be instantiated, keep it concrete.


The final Keyword

The final keyword has three distinct meanings depending on where it is used.

final variable

A final variable (covered in Lecture 2 and 4) can be assigned only once:

final double PI = 3.14159;
PI = 3.0;   // COMPILE ERROR

final method

A final method cannot be overridden by subclasses:

public class Animal {

    public final void breathe() {
        System.out.println(getName() + " is breathing.");
    }
}

public class Dog extends Animal {

    @Override
    public void breathe() { }   // COMPILE ERROR — cannot override final method
}

Use final on a method when overriding it would break a critical invariant of the parent class — for example, a method that manages internal state in a way that must not be changed.

final class

A final class cannot be extended at all:

public final class ImmutablePoint {
    private final double x;
    private final double y;

    public ImmutablePoint(double x, double y) {
        this.x = x;
        this.y = y;
    }
    // ...
}

public class SpecialPoint extends ImmutablePoint { }   // COMPILE ERROR

String in Java is a final class — no one can subclass it and change its behavior. This is central to String‘s safety and why it can be stored in the string pool without risk.

Use final on a class when:

  • The class is a value type that should not be modified or extended (like String, Integer)
  • You want to guarantee the class’s behavior for security or correctness reasons

The Object Class — Root of All Classes

Every class in Java implicitly extends java.lang.Object if it does not extend any other class. Object is the root of the entire Java class hierarchy — every object is an instance of Object.

This means every class inherits these key methods from Object:

Method Description
toString() Returns a string representation; override for readable output
equals(Object o) Checks equality; default compares references (==)
hashCode() Returns an integer hash; must be consistent with equals()
getClass() Returns the runtime class of the object

Why override equals and hashCode

By default, equals() uses reference comparison (same as ==). To compare objects by content, override it:

public class Point {

    private double x;
    private double y;

    public Point(double x, double y) {
        this.x = x;
        this.y = y;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Point other)) return false;
        return Double.compare(x, other.x) == 0 &&
               Double.compare(y, other.y) == 0;
    }

    @Override
    public int hashCode() {
        return java.util.Objects.hash(x, y);
    }
}
Point p1 = new Point(3.0, 4.0);
Point p2 = new Point(3.0, 4.0);

System.out.println(p1 == p2);        // false — different objects
System.out.println(p1.equals(p2));   // true — same content

Important: whenever you override equals(), you must also override hashCode(). Objects that are equal must have the same hash code — this is a contract relied on by HashMap, HashSet, and other collections (covered in Lecture 8).


Putting It Together — A Complete Example

Let us model a payment system to tie together all the concepts:

// Abstract parent class
public abstract class Payment {

    private final String paymentId;
    private final double amount;
    private final String currency;

    public Payment(String paymentId, double amount, String currency) {
        if (amount <= 0) throw new IllegalArgumentException("Amount must be positive.");
        this.paymentId = paymentId;
        this.amount = amount;
        this.currency = currency;
    }

    // Abstract — every payment type must implement its own processing logic
    public abstract boolean process();

    // Abstract — describe the payment method
    public abstract String getPaymentMethod();

    // Concrete — shared across all payment types
    public void printReceipt() {
        System.out.printf("Receipt [%s]%n", paymentId);
        System.out.printf("Method : %s%n", getPaymentMethod());
        System.out.printf("Amount : %.2f %s%n", amount, currency);
        System.out.printf("Status : %s%n", process() ? "Approved" : "Declined");
    }

    public double getAmount()    { return amount; }
    public String getCurrency()  { return currency; }
    public String getPaymentId() { return paymentId; }

    @Override
    public String toString() {
        return String.format("Payment[id=%s, method=%s, amount=%.2f %s]",
                paymentId, getPaymentMethod(), amount, currency);
    }
}
// Concrete subclass 1
public class CreditCardPayment extends Payment {

    private final String cardNumber;
    private final String cardHolder;

    public CreditCardPayment(String paymentId, double amount,
                             String cardNumber, String cardHolder) {
        super(paymentId, amount, "USD");
        this.cardNumber = cardNumber;
        this.cardHolder = cardHolder;
    }

    @Override
    public boolean process() {
        // Simulate approval: approve if amount < 10,000
        return getAmount() < 10_000;
    }

    @Override
    public String getPaymentMethod() {
        return "Credit Card (**** " + cardNumber.substring(cardNumber.length() - 4) + ")";
    }
}
// Concrete subclass 2
public class CryptoPayment extends Payment {

    private final String walletAddress;
    private final String coin;

    public CryptoPayment(String paymentId, double amount,
                         String coin, String walletAddress) {
        super(paymentId, amount, coin);
        this.walletAddress = walletAddress;
        this.coin = coin;
    }

    @Override
    public boolean process() {
        // Simulate: always approved for crypto
        return true;
    }

    @Override
    public String getPaymentMethod() {
        return coin + " wallet (" + walletAddress.substring(0, 6) + "...)";
    }
}
// Demonstrating polymorphism
public class Main {

    public static void main(String[] args) {
        Payment[] payments = {
            new CreditCardPayment("PAY-001", 250.00, "4111111111111234", "Alice"),
            new CryptoPayment("PAY-002", 0.05, "BTC", "1A2B3C4D5E6F7890"),
            new CreditCardPayment("PAY-003", 15000.00, "5500005555555559", "Bob"),
        };

        for (Payment payment : payments) {
            payment.printReceipt();   // calls each subclass's process() and getPaymentMethod()
            System.out.println("---");
        }

        // Downcasting with pattern matching
        for (Payment payment : payments) {
            if (payment instanceof CryptoPayment cp) {
                System.out.println("Crypto coin used: " + cp.getCurrency());
            }
        }
    }
}

Output:

Receipt [PAY-001]
Method : Credit Card (**** 1234)
Amount : 250.00 USD
Status : Approved
---
Receipt [PAY-002]
Method : BTC wallet (1A2B3C...)
Amount : 0.05 BTC
Status : Approved
---
Receipt [PAY-003]
Method : Credit Card (**** 5559)
Amount : 15000.00 USD
Status : Declined
---
Crypto coin used: BTC

Notice how printReceipt() in the abstract Payment class calls process() and getPaymentMethod() — both abstract methods. At runtime, Java dispatches to the correct subclass implementation. The printReceipt() method does not know or care whether it is dealing with a credit card or crypto — that is polymorphism at work.


Summary

  • Inheritance (extends) lets a child class reuse and specialize the fields and methods of a parent class. The relationship must satisfy the “is-a” test. Java supports single inheritance only.
  • A child class inherits all non-private members. It must call a parent constructor using super(...) as the first statement.
  • Method overriding replaces a parent method with a child-specific implementation. Always use @Override to let the compiler catch mistakes. The overriding method must match the signature and be equally or more accessible.
  • super calls the parent constructor (super(...)) or the parent’s version of an overridden method (super.method()).
  • Polymorphism allows a parent-type variable to hold a child-type object. Method calls are resolved at runtime based on the actual object type — this is dynamic dispatch.
  • Upcasting (child → parent) is automatic and safe. Downcasting (parent → child) requires an explicit cast and must be guarded by instanceof.
  • Abstract classes (abstract) cannot be instantiated and may declare abstract methods that subclasses must implement. They are ideal for representing general concepts with mandatory behavior.
  • final prevents: a variable from being reassigned, a method from being overridden, and a class from being extended.
  • Every Java class implicitly extends Object. Override toString() for readable output, and always override both equals() and hashCode() together when comparing by content.

Exercises

Exercise 1 — Shape hierarchy Create an abstract class Shape with a color field (String), a constructor, a getter, and two abstract methods: area() (returns double) and perimeter() (returns double). Also add a concrete method describe() that prints the shape’s color, area, and perimeter. Then create concrete subclasses Circle (radius), Rectangle (width, height), and Triangle (three sides — use Heron’s formula for area). Instantiate all three in main and call describe() on each.

Exercise 2 — Method overriding and super Create a class Vehicle with fields make, model, year, and a method getInfo() that returns a formatted string. Create subclasses Car (adds numDoors) and Truck (adds payloadTons). Override getInfo() in each subclass to call super.getInfo() and append the subclass-specific fields. Demonstrate that the correct getInfo() is called for each object through a Vehicle[] array.

Exercise 3 — Polymorphic processing Create an abstract class Employee with name, baseSalary, and an abstract method calculateBonus(). Create subclasses Manager (bonus = 20% of salary), Engineer (bonus = 15% of salary), and Intern (no bonus). Add a concrete method totalCompensation() in Employee that returns baseSalary + calculateBonus(). Create a mixed array of employees, iterate over it, and print total compensation for each. Then compute and print the total payroll.

Exercise 4 — instanceof and downcasting Using the hierarchy from Exercise 3, iterate over the employee array and:

  • Use pattern matching instanceof to identify each type
  • For Manager objects, print an additional line: "[Manager] Oversees a team"
  • For Engineer objects, print their specialization (add a specialization field to Engineer)
  • Avoid ClassCastException by always checking type before casting

Exercise 5 — final in practice Create a final class Money that holds an amount (double) and a currency (String). Make both fields private final. Provide a constructor, getters, add(Money other) (returns a new Money), subtract(Money other) (returns a new Money), and toString(). Verify that Money cannot be extended. Verify that add() and subtract() do not modify the original objects (immutability).


Up next: Lecture 6 — Interfaces & Encapsulation — where you will learn Java’s most flexible abstraction tool, how to implement multiple behaviors through interfaces, and how to design truly robust encapsulated classes.

Leave a Reply

Your email address will not be published. Required fields are marked *