Skip to content

VOL 01 / CH 08 / LESSON 02

8.2 Polymorphism and Dynamic Dispatch ​

Prerequisites: Inheritance, method signatures, and compile-time types Estimated time: 30 minutes You are ready to continue when: You can predict instance-method dispatch, identify members outside it, and keep downcasts at explicit boundaries

The previous page's EmailSender replaced the inherited send() implementation. That is method overriding; @Override asks the compiler to check that a method can actually be overridden. The next page covers signature and access rules. Here, we first examine which implementation a call selects.

The first four sections reuse the previous page's MessageSender and two-argument EmailSender, adding a demonstration SMS channel and a dispatch center. All channels only print messages:

java
class SmsSender extends MessageSender {
    SmsSender(String message) { super(message); }
    @Override public void send() {
        logMessage();
        System.out.println("demo SMS channel");
    }
}

final class DispatchCenter {
    void dispatch(MessageSender sender) {
        java.util.Objects.requireNonNull(sender, "sender");
        sender.send();
    }
}

1. Compile-Time Type Limits Calls; Runtime Type Selects Overrides ​

java
MessageSender email = new EmailSender("Capacity alert", "ops@example.com");
MessageSender sms = new SmsSender("Service recovered");

DispatchCenter center = new DispatchCenter();
center.dispatch(email);
center.dispatch(sms);

The compile-time type of a variable is MessageSender, so the source code can only directly invoke members visible at that type. When executing sender.send(), the JVM selects between EmailSender.send and SmsSender.send based on the actual class of the receiver. This is known as dynamic dispatch of instance methods.

It is not the compiler generating a sequence of if (sender instanceof ...) at runtime. The JVM may employ strategies such as virtual method tables, inline caching, or devirtualization, but these do not alter the dispatch contract at the language level.

If the receiver is null, there is no actual class available for dispatch, and the call will throw NullPointerException.

2. The Value of Polymorphism Comes from Stable Contracts ​

When adding BeaconSender, the scheduler remains unmodified as long as the new type adheres to all the specifications of MessageSender.send. If a subclass arbitrarily throws exceptions on valid messages, alters the meaning of success, or disrupts observable ordering, "compilation success" does not imply safe replacement.

Therefore, extensibility is not an absolute guarantee that "adding any subclass with zero code changes" is possible. The caller remains closed only when it depends on a stable abstraction and the new implementation satisfies the contract. Configuration, dependency injection, and error handling may still require updates.

3. Static methods and fields do not participate in this dynamic dispatch ​

java
class Parent {
    String field = "parent";
    static String kind() { return "parent"; }
    String instanceKind() { return "parent"; }
}

class Child extends Parent {
    String field = "child";
    static String kind() { return "child"; }
    @Override String instanceKind() { return "child"; }
}

Parent value = new Child();
System.out.println(value.field);          // parent
System.out.println(Parent.kind());        // parent
System.out.println(value.instanceKind()); // child

Only the last ordinary instance method undergoes override dispatch. Fields are resolved at compile time based on the expression's type, while static methods are bound to the class name.

4. Upcasting Safety, Downcasting Requires Proof ​

Assigning a subclass object to a parent-class variable is upcasting and does not require explicit casting:

java
MessageSender sender = new EmailSender("Message", "ops@example.com");

The reverse, however, cannot be safely assumed based solely on variable type:

java
// EmailSender email = (EmailSender) sender;

If the actual object is not of type EmailSender, a cast will throw ClassCastException. Java 17 supports pattern matching, which allows both validation and binding in a single expression:

java
if (sender instanceof EmailSender email) {
    System.out.println(email.recipient());
}

null instanceof EmailSender returns false. Pattern variables are only accessible within the scope where the compiler can prove the match has succeeded.

instanceof is suitable for parsing external boundaries, maintaining compatibility with legacy APIs, or when actual subclass-specific data is required. If a dispatch center routes based on concrete types and every new sender requires modifying the branching logic, it indicates a lack of appropriate abstraction or design patterns such as visitors.

5. Interfaces are usually better than concrete parent classes for callers ​

Using MessageSender makes class inheritance visible. A dispatch center that only needs a sending capability can depend on a smaller contract:

java
interface Sendable {
    void send();
}

final class DispatchCenter {
    void dispatch(Sendable sender) {
        java.util.Objects.requireNonNull(sender, "sender").send();
    }
}

EmailChannel, SmsChannel, and ConsoleChannel can share an abstract base class or achieve common formatters through composition. As long as they implement Sendable, the scheduler doesn't need to care about their inheritance hierarchy. After the lesson on overriding, we will compare interfaces and abstract classes.

6. A Runnable Dispatch Example ​

Save this independent version as DispatchDemo.java, then compile and run it. Here, send() returns demonstration text. Keep this interface separate from the earlier version that returns void. It still performs no real delivery.

java
interface Sendable {
    String send();
}

final class EmailChannel implements Sendable {
    @Override
    public String send() {
        return "demo email channel";
    }
}

final class SmsChannel implements Sendable {
    @Override
    public String send() {
        return "demo SMS channel";
    }
}

public class DispatchDemo {
    static void dispatch(Sendable sender) {
        System.out.println(sender.send());
    }

    public static void main(String[] args) {
        dispatch(new EmailChannel());
        dispatch(new SmsChannel());
    }
}

Output:

text
demo email channel
demo SMS channel

Professional Quick Reference ​

ScenarioChoice
Only requires shared capabilitiesDependency on small interfaces
Needs shared stable state or skeletonControlled abstract base class
Only reuses implementationPrefer composition or delegation
Must read subclass-specific informationPattern matching at boundaries
Large number of concrete type branchesRe-examine the abstract contract

Before Proceeding ​

Pass EmailChannel, SmsChannel, and ConsoleChannel to the same DispatchCenter. Mark which members are callable at compile time and which override is selected at runtime. Fields and static methods do not participate in this dispatch; an invalid downcast should produce ClassCastException, and null instanceof EmailSender should be false. If the core flow keeps branching on concrete types, revisit the abstraction contract.

→ Next: Method Overriding, @Override, and super

Specification reference: JLS §15.12.4.4: Runtime Selection of Target Method.

Built with VitePress | Software Systems Atlas