JAVA / INHERITANCE AND POLYMORPHISM
Polymorphism and programming to a supertype
Write methods, fields and collections in terms of a supertype so one piece of code handles every subclass and new subclasses need no changes to callers.
What you will learn
- Declare parameters and return types as the supertype; only `new` names a subclass.
- Loop over a List<LineItem> so one body serves every current and future subclass.
- Add a subclass without touching consumers that use only the supertype's methods.
- Read an instanceof chain in a loop as a missing method on the supertype.
Understanding Polymorphism and programming to a supertype
Every reference in Java carries two types: the declared type, which decides what the compiler lets you call, and the runtime class of the object, which decides which body actually executes. Programming to a supertype means deliberately choosing the declared type to be the general one, so a variable is a LineItem rather than a UnitItem, a parameter is List<LineItem> rather than List<UnitItem>, and a factory returns Cipher rather than Rot13. Consumer code is then written against a promise instead of against an implementation, and the concrete class name only has to appear where the object is created.
The payoff is measured by where concrete class names cluster. If parameter, field and collection element types name concrete classes, each new kind of item forces edits in every consumer: another overload, another instanceof branch, another case in a switch on a kind field. When those same types name the supertype, a method like printReceipt is already complete for kinds nobody has written yet, and the only thing that keeps it correct is that subclasses honour the supertype's contract. That is why the abstract method list should be derived from what consumers need to do, not from whatever methods the first subclass happened to have.
The cost is visibility: through a supertype reference you can only reach the members the supertype declares, so a subclass-only method is a compile error even though the object really has it. That restriction is the mechanism, not an accident, because it is what stops the consumer from silently depending on one subclass. It does make the supertype API a real design decision: too thin and consumers start casting to get their work done, too wide and some subclass is forced into an empty or exception-throwing override that callers can no longer trust.
import java.util.List;
abstract class LineItem {
private final String label;
protected LineItem(String label) {
this.label = label;
}
public String label() {
return label;
}
public abstract int totalCents();
}
class UnitItem extends LineItem {
private final int quantity;
private final int unitPriceCents;
public UnitItem(String label, int quantity, int unitPriceCents) {
super(label);
this.quantity = quantity;
this.unitPriceCents = unitPriceCents;
}
@Override
public int totalCents() {
return quantity * unitPriceCents;
}
}
class WeighedItem extends LineItem {
private final int grams;
private final int centsPerKilo;
public WeighedItem(String label, int grams, int centsPerKilo) {
super(label);
this.grams = grams;
this.centsPerKilo = centsPerKilo;
}
@Override
public int totalCents() {
return grams * centsPerKilo / 1000;
}
}
public class Main {
// Nothing in this method names UnitItem or WeighedItem: it needs only
// the two operations LineItem promises, so it is already finished for
// subclasses that do not exist yet.
static int printReceipt(List<LineItem> items) {
int total = 0;
for (LineItem item : items) {
int cents = item.totalCents();
System.out.printf("%-14s%s%n", item.label(), money(cents));
total += cents;
}
return total;
}
static String money(int cents) {
return String.format("%d.%02d", cents / 100, cents % 100);
}
public static void main(String[] args) {
List<LineItem> cart = List.of(
new UnitItem("Coffee mug", 3, 799),
new WeighedItem("Green beans", 450, 320),
new UnitItem("Notebook", 1, 1250));
System.out.printf("%-14s%s%n", "ITEM", "AMOUNT");
int total = printReceipt(cart);
System.out.printf("%-14s%s%n", "TOTAL", money(total));
}
}
The declared type of a parameter, field or return value should name the abstraction the code depends on, leaving `new` as the only place a concrete subclass appears.
Worked examples
An interface as the supertype
One validation loop works over any mix of rule classes because the loop is written against the Rule interface.
import java.util.ArrayList;
import java.util.List;
interface Rule {
boolean rejects(String line);
String reason();
}
class TooLong implements Rule {
private final int max;
TooLong(int max) {
this.max = max;
}
public boolean rejects(String line) {
return line.length() > max;
}
public String reason() {
return "longer than " + max;
}
}
class Blank implements Rule {
public boolean rejects(String line) {
return line.trim().isEmpty();
}
public String reason() {
return "blank";
}
}
public class Main {
static List<String> problems(List<String> lines, List<Rule> rules) {
List<String> found = new ArrayList<>();
for (String line : lines) {
for (Rule rule : rules) {
if (rule.rejects(line)) {
found.add("[" + line + "] " + rule.reason());
}
}
}
return found;
}
public static void main(String[] args) {
List<Rule> rules = List.of(new TooLong(8), new Blank());
List<String> lines = List.of("ok", " ", "this line is far too long");
for (String p : problems(lines, rules)) {
System.out.println(p);
}
}
}
Example explained
Line 1The element type List<Rule> is what lets one list hold a TooLong and a Blank side by side.
Line 2rule.rejects(line) compiles because Rule declares it; which body runs comes from the object in the list.
Line 3problems never mentions TooLong or Blank, so a third rule class needs no edit here, only a longer rules list.
Line 4TooLong keeps its max field private and reports it through reason(), so the loop needs no knowledge of rule-specific state.
Returning the supertype from a factory method
Declaring the return type as the abstraction keeps callers from depending on which subclass was constructed.
abstract class Cipher {
abstract String apply(String text);
}
class Rot13 extends Cipher {
@Override
String apply(String text) {
StringBuilder out = new StringBuilder();
for (char ch : text.toCharArray()) {
if (ch >= 'a' && ch <= 'z') {
out.append((char) ('a' + (ch - 'a' + 13) % 26));
} else if (ch >= 'A' && ch <= 'Z') {
out.append((char) ('A' + (ch - 'A' + 13) % 26));
} else {
out.append(ch);
}
}
return out.toString();
}
}
class Reverse extends Cipher {
@Override
String apply(String text) {
return new StringBuilder(text).reverse().toString();
}
String describe() {
return "reverses the characters";
}
}
public class Main {
static Cipher named(String name) {
if (name.equals("reverse")) {
return new Reverse();
}
return new Rot13();
}
public static void main(String[] args) {
Cipher c = named("reverse");
System.out.println(c.apply("stream"));
// c.describe(); would not compile: Cipher declares no describe()
System.out.println(named("rot13").apply("Java"));
}
}
Example explained
Line 1named is declared to return Cipher, so main cannot become coupled to Rot13 or Reverse even though it triggers their construction.
Line 2c.apply("stream") runs Reverse.apply because the object is a Reverse; the declared type only guaranteed that apply exists.
Line 3The commented call is a compile-time error, not a runtime one: describe() lives on the object but not in the Cipher contract.
Line 4Because the subclass choice happens in exactly one method, adding a third cipher means editing named and nothing else.
Important notes
Only instance methods are selected from the object's class; fields and static members are resolved from the declared type, so keep state private and publish it through methods like label().
The supertype may be an interface or an abstract class; prefer an interface when there is no shared state to inherit, since a class can implement several interfaces but extend only one class.
Common mistakes
Typing the parameter as the subclass, such as printReceipt(List<UnitItem>), because that is what the first caller has; a List<WeighedItem> will not even compile against it since generics are not covariant, and the method ends up duplicated per subclass.
Calling a subclass-only method through a supertype reference and silencing the compile error with a cast; the consumer is coupled to that one subclass again and fails as soon as a different subclass is passed in.
Adding an abstract method to the supertype for one subclass's benefit, which forces the other subclasses into empty or exception-throwing overrides so callers can no longer rely on the contract.
Try it yourself
Change, predict, then run
Add a DiscountItem subclass whose totalCents() returns a negative amount, such as -500 for a coupon, and put one in the cart list. Run the program without editing printReceipt or money, and check that the TOTAL line dropped by 5.00.
Open the Java workspaceCheck your understanding
printReceipt is declared as static int printReceipt(List<LineItem> items) and inside the loop it calls only label() and totalCents(). A new GiftCard extends LineItem is written and added to the cart. What must change in printReceipt?
- An instanceof GiftCard branch, so the correct subtotal calculation is chosen
- An overload printReceipt(List<GiftCard>), because a List<LineItem> cannot hold a GiftCard
- Nothing: a GiftCard is a LineItem, and its own totalCents() runs when the loop calls it
- A cast to GiftCard inside the loop so that the subclass version of totalCents() is used
Show answer
The method names only the supertype, so the new object is accepted by the parameter type and the body that runs for totalCents() comes from the object's class. The cast in the last option is the tempting answer, but a cast only changes the compile-time type of an expression; it cannot redirect which override executes, and it would put a concrete class name back into code that had been independent of it.