RizTech Academy logo
RizTech Academy
Object-Oriented JavaLesson 3 of 830 min

Inheritance and polymorphism

Inheritance is the feature people learn first and reach for too often. It is genuinely useful, it is the mechanism behind polymorphism, and it is also the tightest coupling Java offers — a subclass depends on its parent's internals, not just its published methods.

So this lesson does two things: teach it properly, and be honest about when not to use it.

The mechanism

class Plan {
    private final int tiffins;

    Plan(int tiffins) {
        this.tiffins = tiffins;
    }

    int tiffins() {
        return tiffins;
    }

    String name() {
        return "Plan";
    }

    int pricePaise() {
        return tiffins * 8_235;
    }
}
class TrialPlan extends Plan {
    TrialPlan(int tiffins) {
        super(tiffins);
    }

    @Override
    String name() {
        return "Trial";
    }

    @Override
    int pricePaise() {
        return 0;
    }
}

class StudentPlan extends Plan {
    StudentPlan(int tiffins) {
        super(tiffins);
    }

    @Override
    String name() {
        return "Student";
    }

    @Override
    int pricePaise() {
        return super.pricePaise() * 90 / 100;
    }
}

extends says "everything Plan has, plus these changes". super(...) calls the parent's constructor; super.pricePaise() calls the parent's version of a method you are overriding.

A subclass constructor must call a superclass constructor first. If you do not write super(...), Java inserts super() — and if the parent has no no-argument constructor, that is a compile error telling you to supply one explicitly.

Java has single inheritance: one extends, no more. That restriction is deliberate, and interfaces are the answer to what you lose.

Polymorphism, which is the actual point

Plan[] plans = {new MonthlyPlan(26), new TrialPlan(3), new StudentPlan(26)};

for (Plan p : plans) {
    System.out.printf("%-14s Rs %d.%02d%n", p.name(), p.pricePaise() / 100, p.pricePaise() % 100);
}
Monthly        Rs 2141.10
Trial          Rs 0.00
Student        Rs 1926.99

The loop variable is declared Plan. The method that runs is the one belonging to the object's actual type, decided at runtime. This is dynamic dispatch, and it is what lets you add a fourth plan without touching the loop.

That is the reason inheritance exists. Not code reuse — reuse is a side effect, and usually a worse way of getting it than composition. The value is being able to write code against Plan that works for plans that do not exist yet.

@Override is not optional

It is technically optional. Treat it as mandatory.

class SilentlyWrong extends Plan {
    // meant to override pricePaise()
    int pricePaise(int discountPercent) {
        return 0;
    }
}
price via Plan reference: 214110

The discount never applied. The parameter makes this a different method — an overload, not an override — so Plan.pricePaise() still runs. It compiles, it runs, and it is wrong.

Add the annotation and the compiler catches it:

error: method does not override or implement a method from a supertype
    @Override
    ^

@Override turns a silent behaviour bug into a build failure. Put it on every method you intend to override, including toString, equals and hashCode.

Overriding rules worth knowing:

Rule Detail
Signature Name and parameter types must match exactly
Return type Same, or a subtype (covariant returns)
Access May widen (protected to public), never narrow
Exceptions May throw fewer or narrower checked exceptions, never broader
final methods Cannot be overridden at all
static methods Are hidden, not overridden — dispatch is by reference type
private methods Are not visible to subclasses, so never overridden

The constructor bug

This is the one that produces a null nobody can explain.

class Broken {
    Broken(int tiffins) {
        report();                       // overridable, called from a constructor
    }

    void report() {
        System.out.println("Broken.report");
    }
}

class BrokenChild extends Broken {
    private final int dailyLimit;
    private final List<String> notes = new ArrayList<>();

    BrokenChild(int tiffins) {
        super(tiffins);
        this.dailyLimit = tiffins;
        System.out.println("after super(), dailyLimit = " + dailyLimit + ", notes = " + notes);
    }

    @Override
    void report() {
        System.out.println("BrokenChild.report sees dailyLimit = " + dailyLimit + ", notes = " + notes);
    }
}
BrokenChild.report sees dailyLimit = 0, notes = null
after super(), dailyLimit = 26, notes = []

notes has a field initialiser and is still null. Here is why.

Construction runs parent-first: BrokenChild's constructor calls super(...) before anything else, so Broken's constructor runs to completion before any of BrokenChild's fields are initialised — including the ones with initialisers written on the declaration. And report() dispatches polymorphically, so the parent's constructor calls the child's override, which reads fields that do not exist yet.

Never call an overridable method from a constructor. Make it private, final, or static, or do the work after construction. This is also why the encapsulation lesson marked setTiffins as final when the constructor called it.

Fields are not overridden — they are hidden

class Parent { String label = "parent"; }
class Child extends Parent { String label = "child"; }

Parent asParent = new Child();
Child asChild = new Child();

System.out.println(asParent.label);
System.out.println(asChild.label);
System.out.println(asParent.describe());
parent
child
Child.describe

The same object reports two different labels depending on the reference type, while the method call dispatches to Child. Methods dispatch on the object; fields resolve on the reference type.

This is a trap with no upside. Do not reuse a field name in a subclass — keep fields private and the question cannot arise.

final, and closing a class

  • final class — cannot be extended. String is one.
  • final method — cannot be overridden.
  • final field — assigned once.

Making a class final is not unfriendly. A class that was never designed to be extended and is extended anyway will break when you change its internals, and then the breakage is your problem. Design for inheritance and document it, or forbid it.

When not to use inheritance

Two tests.

Is it genuinely an "is-a"? A StudentPlan is a Plan. A Subscriber is not a Route, however convenient sharing that code would be.

Would composition do? Usually it would:

// inheritance: SmsNotifier IS A HttpClient, which is untrue
class SmsNotifier extends HttpClient { ... }

// composition: SmsNotifier HAS A HttpClient, which is true
class SmsNotifier {
    private final HttpClient http;

    SmsNotifier(HttpClient http) {
        this.http = http;
    }
}

The composed version exposes only what SmsNotifier chooses, can swap the client for a fake in a test, and does not inherit forty public methods that have nothing to do with sending an SMS.

The costs of inheritance, stated plainly:

  • You get the parent's entire public surface whether it suits you or not.
  • A change to the parent can break the child without either file being edited.
  • Your one extends is spent, forever.
  • A deep hierarchy means reading four files to understand one method call.

Prefer composition. Use inheritance when several types genuinely share an identity and you want to treat them uniformly — which, by the end of this module, you will more often do with an interface or a sealed type.

Check your work

What does super(...) do, and where must it be? Calls a superclass constructor, and it must be the first statement. Java inserts super() automatically when you omit it, which fails to compile if the parent has no no-argument constructor.

Why does the loop over Plan[] print three different prices? Dynamic dispatch: the method belonging to the object's actual runtime type runs, not the one belonging to the reference type.

Why is @Override important when it is optional? Without it, a mistyped name or a wrong parameter list silently creates a new overloaded method and the original still runs. With it, the compiler reports method does not override or implement a method from a supertype.

Why was notes null inside report() despite having an initialiser? The superclass constructor runs before any subclass field initialiser. Calling an overridable method from a constructor therefore reaches a half-built object.

How do you avoid that? Do not call overridable methods from constructors — make them private, final or static, or move the work out of construction.

What is the difference between overriding a method and hiding a field? Methods dispatch on the object's actual type; fields resolve on the reference type, so the same object can report two different values.

When should you prefer composition? Whenever the relationship is not a genuine "is-a", and whenever you only want a few of the other type's capabilities. Composition exposes what you choose and leaves extends free.

Practice 3, the discount that never applied. pricePaise(int) is an overload, not an override, so Plan.pricePaise() runs and the price is the undiscounted 214110. Adding @Override turns it into a compile error. The fix is to drop the parameter and take the discount from a field set in the constructor:

class StudentPlan extends Plan {
    @Override
    int pricePaise() {
        return super.pricePaise() * 90 / 100;
    }
}

Practice 5, Notifier by composition.

class SmsNotifier {
    private final MessageGateway gateway;

    SmsNotifier(MessageGateway gateway) {
        this.gateway = gateway;
    }

    void remind(Subscriber s) {
        gateway.send(s.phone(), "Your tiffin is on the way");
    }
}

The test passes a fake MessageGateway that records calls instead of sending anything. With extends there is nothing to substitute — which is the practical reason composition wins far more often than the "is-a" argument does.

Practice

  1. Build the plan hierarchy. Plan with name() and pricePaise(), then MonthlyPlan, TrialPlan (free) and StudentPlan (10% off). Put them in an array typed Plan[] and print a price list. Then add a fourth plan without touching the printing loop.

  2. Call super. Make StudentPlan.pricePaise() use super.pricePaise() rather than recalculating. Then change the base price in Plan and confirm the student price follows.

  3. Break an override on purpose. Add a parameter to an overriding method and run it — the base version still runs, silently. Then add @Override and read the compile error.

  4. Reproduce the constructor bug. A parent whose constructor calls an overridable method, a child with a field initialiser, and a print inside the override. Confirm you see 0 and null. Then fix it by making the method final and observe that the child can no longer override it at all — which is the trade.

  5. Replace inheritance with composition. Write SmsNotifier extends HttpClient, then rewrite it to hold one. Then write a fake gateway that records what it was asked to send, and use it to test remind() without any network. Note which version you could test.

  6. Harder — break the substitution. Write Rectangle with setWidth/setHeight and area(), then Square extends Rectangle that keeps both sides equal. Now write a method taking a Rectangle, setting width to 5 and height to 4, and asserting the area is 20. Pass it a Square and watch it fail. Nothing you can do inside Square fixes it. This is the Liskov substitution principle arriving as a concrete failure, and the lesson is that "a square is a rectangle" being true in mathematics does not make it true for mutable objects.

Next: interfaces — how to share behaviour without spending your one extends.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship