Java, from nothing · chapter 4 of 33
Inheritance and polymorphism: extends, super, overriding, and dynamic dispatch
Chapter 1.4 · Part 1, Java from nothing · about 45 minutes
What you need before this chapter: chapter 1.3, References, null, and what equals really compares. You should be comfortable with references versus the objects they point at, == versus .equals(...), and reading a stack trace. Chapter 1.3 also mentioned, twice, that extends was coming. This is that chapter.
When you finish this chapter you will be able to:
- Write a subclass with
extends, and say exactly which fields and methods it inherits from its parent and which it does not - Chain a subclass constructor to its parent with
super(...), and read the compiler error you get the moment you forget it - Override a method correctly, use
@Overrideto catch a broken override at compile time, and tell overriding apart from overloading on sight - Explain dynamic dispatch and predict, for a
Listholding several different subclasses, exactly which version of a method runs for each one - Use
instanceofpattern matching and casting without crashing, and say whatfinalforbids on a class and on a method
1. The problem: two classes, one shape repeated
A payment can arrive by card or by UPI. Here is the code you would write for each, knowing only what chapters 1.1 to 1.3 taught you: fields, a constructor that validates its arguments, and a method.
public class Duplication {
public static void main(String[] args) {
CardPayment card = new CardPayment(1500.00, "TXN-9001", "4412");
UpiPayment upi = new UpiPayment(600.00, "TXN-9002", "pratyush@okhdfc");
card.pay();
upi.pay();
}
}
class CardPayment {
private final double amount;
private final String reference;
private final String last4;
CardPayment(double amount, String reference, String last4) {
if (amount <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
this.amount = amount;
this.reference = reference;
this.last4 = last4;
}
void pay() {
System.out.println("[" + reference + "] charging Rs " + amount + " to card ending " + last4);
}
}
class UpiPayment {
private final double amount;
private final String reference;
private final String upiId;
UpiPayment(double amount, String reference, String upiId) {
if (amount <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
this.amount = amount;
this.reference = reference;
this.upiId = upiId;
}
void pay() {
System.out.println("[" + reference + "] charging Rs " + amount + " via UPI id " + upiId);
}
}
Compile and run it:
javac Duplication.java
java Duplication
[TXN-9001] charging Rs 1500.0 to card ending 4412
[TXN-9002] charging Rs 600.0 via UPI id pratyush@okhdfc
Nothing here is wrong. It compiles, it runs, and the output is correct. Look at what is repeated anyway: both classes carry amount and reference, both constructors run the identical check on amount, and both assign the same two fields the same way. Add NetBankingPayment next month and you type all of that a third time. Change the validation rule, say a maximum transaction amount, and you have to remember to change it in every class that copied it.
The code is not broken. One idea is written out twice: a payment has an amount and a reference, and the amount must be positive. It will drift the moment one copy is edited and the other is not.
2. extends: naming the shape once
The fix is to say the shared part once, in one class, and have CardPayment and UpiPayment each say "I am one of those, plus this." Java's keyword for that relationship is extends.
class Payment {
protected final double amount;
protected final String reference;
Payment(double amount, String reference) {
if (amount <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
this.amount = amount;
this.reference = reference;
}
void pay() {
System.out.println("[" + reference + "] charging Rs " + amount);
}
}
class CardPayment extends Payment {
private final String last4;
CardPayment(double amount, String reference, String last4) {
super(amount, reference);
this.last4 = last4;
}
String getLast4() {
return last4;
}
}
class CardPayment extends Payment makes Payment the superclass (also called the parent or base class) and CardPayment the subclass (also called the child). CardPayment now has, for free, everything Payment declared: the amount and reference fields, and the pay() method. It gets all of that without retyping any of it. CardPayment adds exactly one new thing, the last4 field, plus a getter for it.
Two changes to Payment's fields are worth noticing. They moved from private to protected. A private field is invisible even to a subclass, so CardPayment could not read amount at all if it stayed private. protected means "visible to this class, to subclasses, and to other code in the same package." That is the minimum widening that lets CardPayment use its parent's fields directly. It is a real trade-off, not a free upgrade. It exposes more than private did, and chapter 2.1 teaches you to be more careful about it than this chapter is being. For now, protected is what makes a useful base class possible.
Try this new CardPayment, matching chapter 1.1's habits of typing, compiling and running it yourself:
public class Extends1 {
public static void main(String[] args) {
CardPayment card = new CardPayment(1500.00, "TXN-9001", "4412");
card.pay();
System.out.println("last 4: " + card.getLast4());
}
}
[TXN-9001] charging Rs 1500.0
last 4: 4412
card.pay() ran Payment's pay() method, because CardPayment has not written its own version yet. It prints the generic line, with no mention of the card. That is expected, and the next section fixes it. What is worth sitting with first is what CardPayment got for free: one line, extends Payment, and it inherited two fields and a working method, with none of that logic appearing inside CardPayment itself.
What a subclass does not inherit. Constructors are not inherited. CardPayment needed its own constructor even though Payment already had one that does almost the same work, and section 3 explains why. private members are also not inherited in any usable sense: a subclass has no way to name them directly, which is the whole point of private.
3. Constructors and super(...)
An object's construction always starts at the top of the family tree and works down. Before a single line of CardPayment's own constructor body runs, Payment's constructor has to run first and finish. A CardPayment cannot count as a valid Payment until the part it inherited is set up. Java enforces this literally: the first statement in a subclass constructor must be a call to super(...), which runs the parent's constructor.
You did not write that call by hand in chapter 1.1 or 1.2. Every class you wrote so far extended nothing you named, so Java quietly inserted a call to a no-argument parent constructor for you. Payment here has no no-argument constructor. It demands an amount and a reference, so the compiler cannot insert anything and forces you to write the call yourself. Delete it and see what happens:
class CardPayment extends Payment {
private final String last4;
CardPayment(double amount, String reference, String last4) {
this.last4 = last4;
}
}
NoSuper.java:28: error: constructor Payment in class Payment cannot be applied to given types;
CardPayment(double amount, String reference, String last4) {
^
required: double,String
found: no arguments
reason: actual and formal argument lists differ in length
1 error
Read this the way chapter 1.1 taught you to read a compiler message: it tells you exactly what it tried and exactly why it failed. It tried to insert a call to Payment() with no arguments, because you gave it no other instruction, and no such constructor exists. Payment only has the two-argument one. The fix is the line you saw in section 2:
CardPayment(double amount, String reference, String last4) {
super(amount, reference);
this.last4 = last4;
}
super(amount, reference) calls Payment's constructor with those two values. It runs the validation check and sets the two inherited fields, exactly as if you had typed that logic inside CardPayment yourself. Only after that call returns does this.last4 = last4; run. The check now lives in exactly one place. Every subclass constructor is required to route through it, so there is no way to build a CardPayment with a non-positive amount that skips the check.
4. Overriding a method, and @Override
card.pay() currently prints the generic message, because CardPayment inherited pay() unchanged. To make it print something specific to a card, CardPayment declares its own pay() method with the exact same name and the exact same parameter list as Payment's. That is called overriding: replacing an inherited method's behaviour with a new one, under the same name.
class CardPayment extends Payment {
private final String last4;
CardPayment(double amount, String reference, String last4) {
super(amount, reference);
this.last4 = last4;
}
@Override
void pay() {
System.out.println("[" + reference + "] charging Rs " + amount + " to card ending " + last4);
}
}
[TXN-1] charging Rs 200.0
[TXN-9001] charging Rs 1500.0 to card ending 4412
The first line is a plain Payment, constructed directly, still using Payment's own pay(). The second is the CardPayment above, now using its own.
@Override sits on the line directly above the method. Be precise about what it does: it tells the compiler "I claim this method replaces one from a supertype, so check that, and fail loudly if I am wrong." It changes nothing about how the program runs. Delete it and pay() still overrides exactly the same as before. What it buys you is a check, and the easiest way to see its value is to break it on purpose. Misspell the method name, without @Override:
class CardPayment extends Payment {
// ...
void pey() {
System.out.println("[" + reference + "] charging Rs " + amount + " to card ending " + last4);
}
}
[TXN-9001] charging Rs 1500.0
This compiles cleanly and runs without error, and it is wrong. pey() is not an override of anything. It is a brand new method that happens to live in CardPayment and is never called by anyone. pay() still resolves to Payment's generic version, because as far as the compiler is concerned CardPayment never touched pay() at all. The output above proves it: no card number, no sign that CardPayment customised anything. This is a real bug. It produces no error and no warning, and it can sit in a codebase for months before anyone happens to look closely at the output.
Now add @Override on top of the same typo:
class CardPayment extends Payment {
// ...
@Override
void pey() {
System.out.println("[" + reference + "] charging Rs " + amount + " to card ending " + last4);
}
}
Typo2.java:33: error: method does not override or implement a method from a supertype
@Override
^
1 error
That is the entire value of the annotation, stated as a compiler error instead of a silent bug. It costs nothing to write, and it turns "compiles fine, behaves wrong" into "does not compile." Write @Override on every method you intend to be an override, every time, for exactly this reason.
5. Overriding versus overloading
Overloading looks similar and is a different mechanism, doing a different job. It means two methods in the same class share a name but differ in their parameter list. Give CardPayment a second pay that takes an extra note:
class CardPayment extends Payment {
// ... fields and constructor as before, plus the override from section 4 ...
void pay(String note) {
System.out.println("[" + reference + "] charging Rs " + amount + " to card ending " + last4
+ " (" + note + ")");
}
}
pay() and pay(String note) are two entirely separate methods that happen to share a name. CardPayment now has both. One question separates overriding from overloading: which version runs is decided when, and based on what? Call the new one through a variable declared as the parent type:
Payment p = new CardPayment(1500.00, "TXN-9001", "4412");
p.pay();
p.pay("via app");
Overload1.java:5: error: method pay in class Payment cannot be applied to given types;
p.pay("via app");
^
required: no arguments
found: String
reason: actual and formal argument lists differ in length
1 error
The object p refers to is, at runtime, a CardPayment, and a CardPayment genuinely has a pay(String) method. The compiler refuses anyway. Overload resolution is decided at compile time, purely from the declared type of the reference, here Payment, and Payment has no pay(String). The compiler never looks at what object p will actually hold when the program runs. It cannot, because that is a runtime fact and this is a compile-time decision. Declare the same object as its real type and the call works:
CardPayment c = new CardPayment(600.00, "TXN-9002", "7788");
c.pay("via app");
[TXN-9002] charging Rs 600.0 to card ending 7788 (via app)
Hold that difference next to overriding from section 4. card.pay(), called on a variable declared as Payment but holding a CardPayment, ran CardPayment's version, not Payment's: the opposite resolution rule. Overriding is decided at runtime, from the object's actual type. Overloading is decided at compile time, from the reference's declared type. Interviewers ask this exact question often, because the two mechanisms use the same syntax and the same keyword-free method declaration. The resolution rules are opposite. Mixing them up is easy until you have watched both rules break in front of you, which you now have.
6. Object: the ancestor of every class
Chapter 1.3 mentioned, in passing, that Ticket.equals(...) falls back to a default "because every class in Java extends Object whether it says so or not." This is the chapter where that claim gets proven. Every class you have ever written, including every Payment in this chapter, silently extends a class called Object unless you extend something else. Even then, whatever you extend eventually traces back to Object. Object is the one class every other class shares.
CardPayment card = new CardPayment(1500.00, "TXN-9001", "4412");
System.out.println(card);
System.out.println("CardPayment's parent: " + card.getClass().getSuperclass());
System.out.println("Payment's parent: " + card.getClass().getSuperclass().getSuperclass());
CardPayment@372f7a8d
CardPayment's parent: class Payment
Payment's parent: class java.lang.Object
The hexadecimal digits after @ on your machine will most likely differ. That value comes from the object's default hash code, and it exists only to make the two lines below it demonstrable. The two lines that matter are the ancestry. CardPayment's parent is Payment, exactly as you wrote. Payment's parent, never written anywhere in this file, is java.lang.Object.
println(card) works with no override anywhere because Object supplies a default toString() method, inherited the same way CardPayment inherited pay() from Payment in section 2. The default is ClassName@hexHashCode, which is why the first line above looks the way it does. Object also supplies the default equals() chapter 1.3 already showed you, "equal only if it is the exact same object," and a matching default hashCode(). All three are ordinary inherited methods, overridable exactly like pay():
@Override
public String toString() {
return "CardPayment{reference=" + reference + ", amount=" + amount + ", last4=" + last4 + "}";
}
CardPayment{reference=TXN-9001, amount=1500.0, last4=4412}
Nothing about overriding toString() differs from overriding pay() in section 4. It is one more method inherited from a class further up the tree, replaced the same way, checked by @Override the same way. One difference is worth naming. Object's toString(), equals() and hashCode() are all public, so an override of any of them must also be declared public. A subclass is never allowed to narrow the access of a method it overrides, only keep it the same or widen it.
7. Polymorphism: one reference, many behaviours
Here is the payoff for everything so far. Add two more subclasses the same way CardPayment was built, and put several different kinds of Payment in one collection.
class UpiPayment extends Payment {
private final String upiId;
UpiPayment(double amount, String reference, String upiId) {
super(amount, reference);
this.upiId = upiId;
}
@Override
void pay() {
System.out.println("[" + reference + "] charging Rs " + amount + " via UPI id " + upiId);
}
}
class CashPayment extends Payment {
CashPayment(double amount, String reference) {
super(amount, reference);
}
@Override
void pay() {
System.out.println("[" + reference + "] recording Rs " + amount + " received in cash");
}
}
List<Payment> and ArrayList are borrowed a little early. Collections proper are chapter 1.7, and the angle brackets are chapter 1.8's generics. But this much is enough to read what follows. A List<Payment> is an ordered collection that holds only Payment objects. new ArrayList<>() is one way to create such a list, and .add(...) appends to it. for (Payment p : batch) is a for-each loop: it visits every element of batch in order, calling it p each time, without you managing an index yourself.
List<Payment> batch = new ArrayList<>();
batch.add(new CardPayment(1500.00, "TXN-9001", "4412"));
batch.add(new UpiPayment(600.00, "TXN-9002", "pratyush@okhdfc"));
batch.add(new CashPayment(200.00, "TXN-9003"));
for (Payment p : batch) {
p.pay();
}
[TXN-9001] charging Rs 1500.0 to card ending 4412
[TXN-9002] charging Rs 600.0 via UPI id pratyush@okhdfc
[TXN-9003] recording Rs 200.0 received in cash
Look at what the loop body actually says: p.pay();. One line, one method name, no if statement asking what kind of payment p is. And yet three different messages come out, each specific to the real class of the object sitting in that slot of the list. p's declared type never changes. It is Payment for the whole loop, on every iteration. What changes each time through the loop is p's runtime type: the actual class of the object it refers to on that iteration, CardPayment, then UpiPayment, then CashPayment.
This is polymorphism: one reference type, Payment, standing in for many different concrete types, each free to behave differently when the same method is called on it. The mechanism that makes it work has a name, and it is the single most important idea in this chapter: dynamic dispatch. When p.pay() runs, Java does not look at what p was declared as. It looks at the actual object p points to right now, finds that object's class, and calls the version of pay() defined there. If that class did not override the method, Java walks up to the nearest ancestor that did. The decision happens at runtime, per call, based on the object, never based on the variable's declared type. Section 5 showed you the opposite rule for overloading. That is why the distinction matters as much as it does.
8. Upcasting, downcasting, instanceof, and casting gone wrong
Storing a CardPayment in a variable declared as Payment is called upcasting: moving up the family tree, from a specific type to a more general one. It happens silently and safely every time you write Payment p = new CardPayment(...). A CardPayment genuinely is a Payment in every sense that matters, so nothing about it is lost, and Java never asks you to say so explicitly.
Downcasting is the reverse. You tell Java that a reference you are holding as a general type is actually, right now, some more specific type, so you can reach members that only the specific type has. Unlike upcasting, this is a claim you make, and Java holds you to it:
Payment p = new UpiPayment(600.00, "TXN-9002", "pratyush@okhdfc");
CardPayment bad = (CardPayment) p;
bad.pay();
Exception in thread "main" java.lang.ClassCastException: class UpiPayment cannot be cast to class CardPayment (UpiPayment and CardPayment are in unnamed module of loader 'app')
at Cast1.main(Cast1.java:4)
The cast (CardPayment) p compiles, because the compiler only checks that CardPayment and Payment are related at all, which they are. Whether the cast is actually valid is a runtime question. Java checks it the moment the line executes, against the object's real class, and the real object here is a UpiPayment, not a CardPayment. Java refuses and throws ClassCastException rather than letting bad hold a reference typed as something it is not.
instanceof asks the same question, "is this object really that type," but as a check you make before the cast rather than a claim you make and hope survives. Since Java 16, the pattern-matching form asks the question and hands you a correctly typed variable in one step, only inside the branch where the check passed:
for (Payment p : batch) {
if (p instanceof CardPayment c) {
System.out.println(c.reference + " is a card ending " + c.getLast4());
} else if (p instanceof UpiPayment u) {
System.out.println(u.reference + " is UPI id " + u.getUpiId());
} else {
System.out.println(p.reference + " is cash, nothing further to check");
}
}
TXN-9001 is a card ending 4412
TXN-9002 is UPI id pratyush@okhdfc
TXN-9003 is cash, nothing further to check
p instanceof CardPayment c is true only when p's real object is a CardPayment. Exactly when it is true, c comes into existence as a variable of type CardPayment, already correctly cast, valid for the rest of that branch. There is no separate cast line to get wrong, and no ClassCastException to risk, because the variable only exists where the check already passed. This is the safe way to downcast. Treat every plain, unchecked (SomeType) reference in code you review as a place where a ClassCastException is one bad assumption away, because it is.
One honest note on style: reaching for instanceof chains like this one is often a sign that pay() should have stayed the single virtual call from section 7. The chain above only exists to show you both tools side by side. A real payment batch processor almost never needs to ask "what kind of payment is this" once polymorphism is already doing that work through pay() itself.
9. final: forbidding further change
Everything so far assumed a class or a method is open to being extended or overridden. final closes that door on purpose, in two places.
A final class cannot be extended by anything, ever:
final class CashPayment {
protected final double amount;
CashPayment(double amount) {
this.amount = amount;
}
}
class RefundableCash extends CashPayment {
RefundableCash(double amount) {
super(amount);
}
}
FinalClass.java:15: error: cannot inherit from final CashPayment
class RefundableCash extends CashPayment {
^
1 error
A final method can still be inherited, but a subclass cannot override it:
class Payment {
// ...
final void pay() {
System.out.println("[" + reference + "] charging Rs " + amount);
}
}
class CardPayment extends Payment {
// ...
@Override
void pay() {
System.out.println("[" + reference + "] charging Rs " + amount + " to card ending " + last4);
}
}
FinalMethod.java:30: error: pay() in CardPayment cannot override pay() in Payment
void pay() {
^
overridden method is final
1 error
final on a field, which you have already seen since chapter 1.2, forbids reassignment after construction. final on a class or a method makes a different promise: nothing built on top of this is allowed to change how it behaves. You reach for it when a class's correctness depends on a method meaning exactly one thing everywhere it is called. A common example is a class representing an immutable value. Letting a subclass override its equals() could let two objects that look identical compare as unequal, just because a different concrete class happened to build them.
Interviewers have a name for this chapter's two ideas, and for two others still to come: the four pillars of object-oriented programming. This chapter taught inheritance, in sections 2 and 3. It taught polymorphism, in sections 7 and 8. Chapter 2.1 teaches encapsulation, who is allowed to change what. Chapter 1.5, next, teaches abstraction, naming a contract without committing to how it is met. Interviewers often open with this question. Knowing all four names cold, plus one sentence on what each one buys you, is worth more than the question seems to deserve.
10. Your turn
Add a fourth payment type, NetBankingPayment, with one extra field, bankName. Give it a constructor that calls super(...) correctly, and override pay() to print a line naming the bank. Add one to batch from section 7 and run the loop again.
Do this before reading the answer. The shape is identical to UpiPayment from section 7, and that is deliberate. Noticing the repetition is most of the exercise.
The answer.
class NetBankingPayment extends Payment {
private final String bankName;
NetBankingPayment(double amount, String reference, String bankName) {
super(amount, reference);
this.bankName = bankName;
}
@Override
void pay() {
System.out.println("[" + reference + "] charging Rs " + amount + " via net banking " + bankName);
}
}
batch.add(new NetBankingPayment(999.00, "TXN-9004", "HDFC"));
Running the loop again prints:
[TXN-9001] charging Rs 1500.0 to card ending 4412
[TXN-9002] charging Rs 600.0 via UPI id pratyush@okhdfc
[TXN-9003] recording Rs 200.0 received in cash
[TXN-9004] charging Rs 999.0 via net banking HDFC
Nothing in the loop from section 7 changed. for (Payment p : batch) { p.pay(); } did not need to know NetBankingPayment existed, and it still produced the right line for it. That is the actual argument for polymorphism, stated in code rather than in the abstract. New behaviour arrives by adding a class, not by editing a loop that already worked.
11. Going deeper
Calling an overridable method from a constructor
Section 3 established that a parent's constructor runs to completion before a subclass's own constructor body starts. Here is the consequence nobody warns you about until it costs them a debugging session. If the parent's constructor calls a method that the subclass overrides, that overridden version runs during construction, before the subclass has had any chance to set its own fields.
class Payment {
protected final double amount;
protected final String reference;
Payment(double amount, String reference) {
this.amount = amount;
this.reference = reference;
System.out.println("inside Payment constructor: " + describe());
}
String describe() {
return "generic payment of Rs " + amount;
}
}
class CardPayment extends Payment {
private final String last4;
CardPayment(double amount, String reference, String last4) {
super(amount, reference);
this.last4 = last4;
System.out.println("inside CardPayment constructor, after last4 is set: " + describe());
}
@Override
String describe() {
return "card ending " + last4;
}
}
CardPayment card = new CardPayment(1500.00, "TXN-9001", "4412");
System.out.println("after construction: " + card.describe());
inside Payment constructor: card ending null
inside CardPayment constructor, after last4 is set: card ending 4412
after construction: card ending 4412
Read the first line again: card ending null. Payment's constructor called describe(), and dynamic dispatch, the exact mechanism from section 7, looked at the object's real runtime type, CardPayment, and ran CardPayment's describe(), not Payment's. Dynamic dispatch does not pause or make an exception for construction still being in progress. It always runs the overriding class's version. The problem is timing, not dispatch. At that moment, super(amount, reference) has not yet returned, this.last4 = last4; has not yet run, and last4 sits at Java's default value for an uninitialised reference field, null. CardPayment's describe() reads a field that, from the object's own perspective, has not been born yet.
This is not a contrived example. It is a real, well-documented bug class. Any time a base class constructor calls a non-final, non-private method, every subclass that overrides it risks seeing its own fields at their default value: null for objects, 0 for numbers, false for booleans. No compiler warning fires anywhere. It explains the standing advice, which you have probably heard stated as a rule to memorise: never call an overridable method from a constructor. You now know why the rule exists rather than only that it does. The fix, if a constructor genuinely needs describe()-like behaviour, is one of two moves. Make the method final, so there is no overriding version for dynamic dispatch to reach for. Or avoid calling it from the constructor at all.
static methods are hidden, not overridden
One more case is worth seeing once, because it is a favourite trick question and it inverts everything section 7 taught you. Give both classes a static method with the same name:
class Payment {
static String kind() {
return "generic payment";
}
}
class CardPayment extends Payment {
static String kind() {
return "card payment";
}
}
Payment p = new CardPayment();
System.out.println("p.kind(): " + p.kind());
System.out.println("CardPayment.kind(): " + CardPayment.kind());
p.kind(): generic payment
CardPayment.kind(): card payment
p's runtime object is a CardPayment, exactly like every dynamic dispatch example in section 7. And yet p.kind() printed Payment's version, not CardPayment's. static methods belong to the class itself, not to any instance, which chapter 1.1's "Going deeper" section already showed you when it introduced the class-object. Calling one through a reference is legal, but it never triggers dynamic dispatch. The compiler resolves p.kind() at compile time using p's declared type, Payment, because no object lookup is involved at all. CardPayment's kind() is not an override of Payment's. It is a completely separate method that happens to hide the parent's: reachable through CardPayment.kind(), invisible through a Payment-typed reference. Overriding is resolved on the object, at runtime. Hiding a static method is resolved on the reference, at compile time. They look identical on the page and behave oppositely, which is exactly why interviewers ask about it.
12. Why this matters in an interview
Every one of the ten problems this course is built around eventually asks you to model several kinds of one thing: several vehicle types, several notification channels, several pricing tiers. Each one grades you on whether the code that uses them needs to change every time a new kind shows up. Section 10's exercise is the entire test in miniature. A correct design lets NetBankingPayment join batch without touching the loop that processes it. A design where pay() was replaced by an if/else if chain checking each concrete type would need that chain edited for every new payment method, forever. An interviewer who watches you reach for that chain instead of overriding a method is watching you fail the exact thing this chapter teaches.
The other habit worth carrying out of this chapter is smaller, and it shows up in code review as often as in interviews. Write @Override on every method you mean to override, without exception. Section 4 showed you the cost of skipping it: a method that silently does nothing, with no error and no warning. It sits in a codebase until someone happens to compare the output against what they expected. That single annotation is the cheapest correctness check available in the entire language.
Next: chapter 1.5, Interfaces: naming a contract. extends lets a subclass share one parent's implementation. An interface goes further: unrelated classes can promise the same method without sharing any code at all. That is abstraction, the pillar this chapter named but did not teach.
← 1.3 References, null, and what equals really compares · All chapters · 1.5 Interfaces: naming a contract →