Lecture #5: Advanced Object-Oriented Concepts
Introduction
Once you know how to write a class and make one class inherit from another, the natural question becomes: why would you design a hierarchy in the first place, and what does the language actually guarantee you once you have one? This chapter covers the three ideas that make class hierarchies powerful rather than just a way to avoid retyping fields: abstraction (factoring out what several classes have in common into a shared, non-instantiable ancestor), subtyping (the rule that lets an object of a subclass be used wherever an object of a superclass is expected), and polymorphism (the fact that the same line of code can trigger different behavior depending on the actual, run-time type of the object it operates on). These three ideas are tightly linked: abstraction gives you the vocabulary, subtyping gives you the legal permission, and polymorphism gives you the payoff.
1. Two Ways to Read a Class Hierarchy
When you look at a class hierarchy — a tree of classes rooted at some common ancestor — there are actually two different questions you can ask about a “sub”-class relationship, and it is important not to confuse them.
The extensional reading treats a class as a set of possible objects (or, equivalently, a type). Under this reading, “B is a subclass of A” should mean “every object that qualifies as a B also qualifies as an A” — in other words, B’s set of instances is included in A’s set of instances. This is the reading that matters for correctness: it is what lets you safely substitute a B wherever an A is expected.
The intensional reading treats a class as a chunk of reusable code — fields and method bodies. Under this reading, “B is a subclass of A” just means “B reuses A’s implementation and adds or overrides some of it.” This is a purely technical, code-factoring relationship, similar in spirit to #include in C: it says nothing about whether B’s instances are conceptually a kind of A.
The trap is that a compiler only ever checks the intensional relationship — it is perfectly happy to compile class Square extends Rectangle even if that is a conceptually wrong model (a Square cannot legally override setWidth and setHeight independently the way a Rectangle can). Whether a subclass relationship is also a valid subtype relationship is a design judgment the programmer must make; nothing in the compiler can verify it for you. Good class hierarchy design always starts by asking the extensional question first — “is every B really an A, in every way callers of A are allowed to rely on?” — and lets inheritance follow from a positive answer, not the other way around.
2. Abstraction: Factoring Out a Common Superclass
Abstraction, in this context, means looking at several classes that are structurally or behaviorally similar and pulling their shared parts up into a new ancestor class. The classic motivation is code factoring: if EmailNotification and SmsNotification both have a recipient field and both need to be “sent” and “logged,” repeating that structure in each class is wasteful and error-prone. Instead you introduce a common superclass, say Notification, that owns the shared state and the shared behavior, and let each concrete kind override only the part that is genuinely different.
Original slide illustration: two structurally similar classes (logic gates AND and OR) sharing enough behavior to justify factoring out a common ancestor.
It is tempting, once you have two similar classes, to just make one inherit from the other — for instance making SmsNotification extends EmailNotification because they happen to share code. This is a design mistake: an SMS notification is not conceptually a kind of email notification, so code that expects an EmailNotification (and, say, calls a method to set a “subject line”) would break if handed an SmsNotification. Whenever two classes are similar but neither is legitimately a specialization of the other, the correct move is to introduce a new, more general ancestor that captures only what is truly common to both, rather than forcing one existing class to play that role.
Original slide illustration: the design error of making one sibling class extend the other instead of introducing a shared ancestor.
Original slide illustration: the fix — factor the shared contract into a new abstract superclass that both siblings extend.
3. Abstract Classes and Abstract Methods
Once you have identified what is common, you express it as an abstract class. An abstract class can hold real fields and real (fully implemented) methods, but it can also declare abstract methods: methods with a signature but no body, which every concrete subclass is obliged to implement. An abstract class cannot be instantiated directly — new Notification() would be a compile error — because it is intentionally incomplete; it exists only to be extended.
abstract class Notification {
protected String recipient;
protected String message;
protected boolean delivered;
Notification(String recipient, String message) {
this.recipient = recipient;
this.message = message;
this.delivered = false;
}
// Abstract methods: every concrete subclass MUST provide these.
abstract void deliver();
abstract String describeChannel();
// A concrete method, shared as-is by every subclass.
void markDelivered() {
delivered = true;
}
}
The subclasses only need to “fill in the blanks” — they inherit everything else for free:
class EmailNotification extends Notification {
private String subject;
EmailNotification(String recipient, String subject, String message) {
super(recipient, message);
this.subject = subject;
}
void deliver() {
System.out.println("Emailing " + recipient + ": [" + subject + "] " + message);
markDelivered();
}
String describeChannel() {
return "email";
}
}
class SmsNotification extends Notification {
SmsNotification(String recipient, String message) {
super(recipient, message);
}
void deliver() {
System.out.println("Texting " + recipient + ": " + message);
markDelivered();
}
String describeChannel() {
return "sms";
}
}
Note what the abstract keyword buys you beyond documentation: the compiler statically guarantees that no concrete subclass can “forget” to implement deliver() or describeChannel(). That guarantee is exactly what lets other code call deliver() on any Notification, without knowing which subclass it actually is — which is the subject of the next section.
4. The Template Method Pattern: One Body, Many Behaviors
Inside an abstract class, a method that is fully implemented (not abstract) but internally calls one of the class’s own abstract methods through this follows what’s called the Template Method pattern. It looks like ordinary code, but because it invokes an abstract method, its actual behavior is only fixed once you know which concrete subclass this refers to. The same method body therefore produces different observable behavior for different subclasses — without ever being rewritten.
(A terminology note, since Java has a completely unrelated feature with an almost identical name: this pattern is sometimes informally called a “generic method” in older OOP texts, meaning “one generic body, many behaviors.” That is not the same thing as a Java generic method — a method with its own type parameter, like <T> T firstOf(List<T> list), which you’ll meet properly in the Collections chapter. This course uses “Template Method” throughout specifically to avoid that clash.)
abstract class Notification {
// ... fields and constructor as before ...
abstract void deliver();
abstract String describeChannel();
// Template Method: same code, different effect depending on the
// actual (dynamic) type of "this".
void send() {
System.out.println("Dispatching via " + describeChannel() + "...");
this.deliver();
System.out.println("Done.");
}
}
Notification n1 = new EmailNotification("alice@example.com", "Hi", "Welcome!");
Notification n2 = new SmsNotification("+33612345678", "Your code is 4821");
n1.send(); // "Dispatching via email..." then the email deliver() body runs
n2.send(); // "Dispatching via sms..." then the SMS deliver() body runs
send() was written once, in Notification, and never appears in EmailNotification or SmsNotification at all — yet calling it produces two different sequences of actions. This is the essence of the Template Method idea: the superclass fixes the shape of an algorithm and delegates the variable steps to abstract methods that subclasses supply.
5. Two Kinds of Polymorphism
The word “polymorphism” (“many forms”) covers more than one mechanism, and it helps to keep them apart.
Overload polymorphism happens when a class (or even unrelated classes) offers several methods with the same name but different parameter types; the compiler picks which one applies purely from the static types of the arguments, at compile time. There is no run-time decision involved.
class Logger {
void log(String s) { System.out.println("STR: " + s); }
void log(int n) { System.out.println("INT: " + n); }
void log(Exception e){ System.out.println("ERR: " + e.getMessage()); }
}
Override (inclusion) polymorphism is what we saw with send()/deliver(): a single method name, declared once (possibly abstractly) in a superclass, is re-implemented differently in each subclass, and the version that actually executes is chosen at run time based on the real type of the object, not the type of the variable. This is also called dynamic binding, and it is the mechanism that makes the Template Method pattern work.
n1.deliver(); // always runs EmailNotification's version, because n1's
// *actual* object is an EmailNotification
6. Class Hierarchies Are Type Hierarchies
A class hierarchy is not just an implementation-sharing tree — it simultaneously defines a type hierarchy. Every instance of a subclass automatically qualifies as an instance of every one of its superclasses too. Put differently: anywhere your code expects a value of type Notification, it is perfectly legal to hand it an EmailNotification or an SmsNotification instead. This is often called the Liskov Substitution Principle: subtypes must be usable wherever their supertype is expected, without breaking the caller’s expectations.
This gives rise to polymorphic variables: a variable declared with type C is not restricted to referencing only exact instances of C — it may reference an instance of C or of any subclass of C.
Notification note; // declared type: Notification
note = new EmailNotification("bob@example.com", "Reminder", "Meeting at 3pm");
note = new SmsNotification("+33698765432", "Package arrived");
// Both assignments are legal: both objects "are" Notifications.
7. Static Type vs. Dynamic Type, and Casting
Every variable has a static type — the type written in its declaration, fixed forever at compile time. This is what the compiler uses to decide which method calls are even legal to write. A variable’s dynamic type, by contrast, is the actual class of the object it currently references at run time, and it can change from one assignment to the next (as in the example above). Dynamic binding always resolves an overridden instance method call using the dynamic type, never the static type.
This last point comes with an important exception, worth stating precisely: dynamic binding applies only to overridden instance methods. Fields and static methods are resolved using the static type of the variable, not the dynamic type of the object — they are never overridden the way instance methods are, only hidden, which is a different (and much less useful) mechanism. A subclass that declares a field with the same name as one in its superclass doesn’t override it; both fields exist, and which one you see depends entirely on the compile-time type of the reference you use to access it. In practice: never rely on polymorphism for fields or static methods — only instance methods get the dynamic-dispatch behavior this section describes.
Assigning a subclass object to a supertype variable (“upcasting”) is always safe and implicit, because a subtype object satisfies every guarantee the supertype makes. Going the other way (“downcasting” — treating a general reference as a more specific type) is not always safe, and Java forces you to make it explicit and checks it at run time:
Notification note = new EmailNotification("carol@example.com", "Hi", "Hello!");
EmailNotification e1 = (EmailNotification) note; // OK: note really is one
SmsNotification s1 = (SmsNotification) note; // compiles, but throws
// ClassCastException at run time
if (note instanceof EmailNotification) {
EmailNotification safe = (EmailNotification) note; // guarded, always safe
System.out.println("Subject-bearing channel confirmed.");
}
Note that casting never changes the object itself — it only changes the type of the reference through which you’re allowed to see it. The underlying object stays whatever it was created as.
Dynamic binding is not limited to calls through a simple local variable; it applies equally to this, to instance fields, to method parameters, and to elements of arrays or collections. A method that receives a Notification parameter, for instance, will dispatch deliver() dynamically no matter which concrete subclass was actually passed in — which is precisely what lets a single piece of application code work uniformly over a whole family of related objects, including one holding a mix of different subclasses at once:
Notification[] outbox = new Notification[3];
outbox[0] = new EmailNotification("a@x.com", "Hi", "msg1");
outbox[1] = new SmsNotification("+33600000000", "msg2");
outbox[2] = new EmailNotification("b@x.com", "Yo", "msg3");
for (Notification n : outbox) {
n.send(); // each call dispatches to the right deliver() at run time
}
8. Case Study: A Small UI Widget Toolkit
Consider designing a tiny toolkit for on-screen widgets: buttons, sliders, and so on. Every widget can be drawn, erased, repositioned, and recolored, but how each of those things happens is specific to the widget’s shape. This is a textbook case for the abstraction/polymorphism combination above. (Assume Point and Color here are simple data classes you’ve already written — an x/y pair and an RGB triple respectively — not java.awt’s classes of the same name.)
abstract class Widget {
Point position;
Color color;
abstract void render();
abstract void erase();
// Template Method: written once, works for every future widget kind.
void recolor(Color newColor) {
this.color = newColor;
this.render();
}
void moveTo(Point newPosition) {
this.erase();
this.position = newPosition;
this.render();
}
}
class Button extends Widget {
String label;
void render() { System.out.println("Drawing button [" + label + "] at " + position); }
void erase() { System.out.println("Erasing button [" + label + "]"); }
}
class Slider extends Widget {
int value;
void render() { System.out.println("Drawing slider at value " + value); }
void erase() { System.out.println("Erasing slider"); }
}
A Panel that manages a whole screen full of widgets can then be written entirely against the abstract Widget type, without knowing anything about Button or Slider specifically:
import java.util.List;
import java.util.ArrayList;
class Panel {
private List<Widget> widgets = new ArrayList<>();
void add(Widget w) { widgets.add(w); }
void renderAll() { for (Widget w : widgets) w.render(); }
// Shifts every widget by (dx, dy) relative to its OWN current position —
// not to a single shared absolute point.
void moveAll(int dx, int dy) {
for (Widget w : widgets) {
w.moveTo(new Point(w.position.x + dx, w.position.y + dy));
}
}
}
Original slide illustration for this style of case study: a family of on-screen shapes sharing a draw/erase/move/recolor contract.
Whatever new widget class you add later — a Checkbox, a Dropdown — the Panel code above never needs to change. This combination of properties is worth naming explicitly, because it is the practical payoff of the whole chapter:
- Extensibility: adding a new subclass requires no edits to existing, already-tested code.
- Reuse:
recolor,moveTo, thePanelloop logic — none of it is duplicated per widget kind. - Generic programming: code written against the abstract supertype automatically keeps working for every subtype written afterward, including ones that did not exist yet when that code was written.
9. What You Would Lose Without Inheritance
It is worth briefly imagining the alternative, if the language offered no inheritance or dynamic dispatch at all. One option is a flat set of unrelated types with a separate operation for each (type, action) pair — renderButton, renderSlider, eraseButton, eraseSlider, and so on. Without a common supertype, you cannot even declare a single array or list holding “any widget,” because there is no shared type to declare it with.
A second, more elaborate workaround is a tagged (variant) record: one struct-like type carrying an explicit “kind” tag plus a union of the fields each kind needs, and functions that dispatch manually with a switch on the tag:
// A hand-rolled simulation of polymorphism using a tag field.
class WidgetRecord {
enum Kind { BUTTON, SLIDER }
Kind kind;
Point position;
Color color;
String label; // only meaningful if kind == BUTTON
int value; // only meaningful if kind == SLIDER
}
void render(WidgetRecord w) {
switch (w.kind) {
case BUTTON: System.out.println("Drawing button [" + w.label + "]"); break;
case SLIDER: System.out.println("Drawing slider at " + w.value); break;
}
}
This can simulate the same effect, but at a real cost: every operation needs its own switch over every kind, adding a new kind means hunting down and editing every such switch (the opposite of the “no changes to existing code” property above), unused fields waste memory on every instance, and nothing stops you from writing w.value on a record whose kind is BUTTON — the compiler has no way to catch that mistake. Abstract classes with dynamic dispatch exist precisely to avoid all four problems at once.
Recap
- A subclass relationship can be read extensionally (is-a-subtype, checked by the designer’s judgment) or intensionally (code-reuse, checked by the compiler); good design starts from the first and lets the second follow.
- Abstraction means factoring what several similar classes have in common into a new, deliberately non-instantiable abstract class, which may declare abstract methods that every concrete subclass must implement.
- The Template Method pattern: ordinary code in the superclass that calls an abstract method through
this; because the abstract method is resolved dynamically, the same method body behaves differently per subclass. (Not to be confused with Java’s own generic methods — methods with a type parameter — covered in the Collections chapter.) - Overload polymorphism is resolved statically from argument types; override (inclusion) polymorphism is resolved dynamically from the object’s actual run-time type — this is dynamic binding.
- A class hierarchy doubles as a type hierarchy: any subtype instance can substitute for its supertype (Liskov substitution), which is what makes polymorphic variables, parameters, and heterogeneous collections possible.
- Every variable has a static type (compile-time, fixed) and a dynamic type (run-time, the actual object); upcasting is always safe, downcasting must be explicit and can fail at run time (
ClassCastException), andinstanceoflets you guard a downcast safely. - The payoff of designing this way is extensibility, reuse, and generic programming — new subclasses slot in without touching existing, already-tested code, which is exactly what hand-simulated alternatives (flat types, tagged records) cannot offer.