Production incident

The notifier that stopped notifying

45 minjunior28 yrs

A real incident: symptom first, cause hidden, tradeoff at the end.

The incident

Two defects, both from the same misunderstanding, and neither throws. 1. Urgent alerts are going out over email instead of SMS. The UrgentNotifier subclass defines the SMS channel. It is never used. 2. A customer object held in a variable typed Recipient is formatted by the generic renderer rather than the customer one, so alerts go out without the account number support asks every caller for. Both look like dispatch bugs, and neither is a bug in dispatch. Java does exactly what it is specified to do in each case — the code asked for the wrong thing. Fix both without changing main(), and then say which was fixed by a keyword and which by a signature, and why they could not be fixed the same way.

What this teaches

  • A static method is hidden, not overridden, and hiding follows the reference
  • Overload resolution happens at compile time from the DECLARED argument type
  • If you want the object's type to decide, the behaviour must be a method on the object
  • Overloading on a subtype relationship is a design smell — the caller cannot see which they hit
  • Neither defect throws, warns, or shows up in a test written against the subclass

Starter

Starter.javaOpen in playground
import java.util.*;

/**
 * Incident reproduction: the notifier that stopped notifying.
 *
 * Two defects, both from the same misunderstanding, and neither throws.
 *
 *   1. Urgent alerts are going out over email instead of SMS. The
 *      UrgentNotifier subclass defines the SMS channel. It is never used.
 *
 *   2. A customer object typed as Recipient is being formatted by the
 *      generic renderer rather than the customer one, so alerts go out
 *      without the account number that support asks every caller for.
 *
 * Both look like dispatch bugs and neither is a bug in dispatch. Java is
 * doing exactly what it is specified to do in each case; the code asked for
 * the wrong thing.
 *
 * TASKS
 *   1. Run it and confirm both symptoms.
 *   2. For each, decide: did the COMPILER choose, from the declared type, or
 *      did the JVM choose, from the runtime type? They are not the same
 *      defect.
 *   3. Fix both, without changing main().
 *   4. In a comment: one of these two is fixed by a keyword and the other by
 *      a signature. Say which and why.
 */
public class Starter {

    /* ── defect 1: a static method, hidden rather than overridden ── */

    static class Notifier {
        /** DEFECT: static. A subclass cannot override this. */
        static String channel() {
            return "email";
        }

        String send(String message) {
            return channel() + ": " + message;
        }
    }

    static final class UrgentNotifier extends Notifier {
        /** This HIDES Notifier.channel(). It does not override it. */
        static String channel() {
            return "sms";
        }
    }

    /* ── defect 2: an overload chosen from the declared type ── */

    static class Recipient {
        final String email;

        Recipient(String email) {
            this.email = email;
        }
    }

    static final class Customer extends Recipient {
        final String accountNumber;

        Customer(String email, String accountNumber) {
            super(email);
            this.accountNumber = accountNumber;
        }
    }

    /** DEFECT: two overloads. The compiler picks from the DECLARED type. */
    static String render(Recipient r) {
        return "to " + r.email;
    }

    static String render(Customer c) {
        return "to " + c.email + " (account " + c.accountNumber + ")";
    }

    public static void main(String[] args) {
        System.out.println("── outgoing ──");

        Notifier urgent = new UrgentNotifier();
        String line = urgent.send("card blocked");
        System.out.println("  " + line);

        Recipient recipient = new Customer("ravi@example.com", "AC-4417");
        String rendered = render(recipient);
        System.out.println("  " + rendered);

        System.out.println();
        boolean urgentUsesSms = line.startsWith("sms:");
        boolean customerRendered = rendered.contains("AC-4417");

        System.out.println("urgent alert went over SMS      : " + urgentUsesSms);
        System.out.println("customer rendered with account  : " + customerRendered);
        System.out.println(urgentUsesSms && customerRendered ? "PASS" : "FAIL");
    }
}

Run it locally:

cd exercises/java/oop/overloading-vs-overriding/03-production
javac Starter.java -d /tmp/out && java -cp /tmp/out Starter

Hints

  1. Hint 1

    For defect 1: inside Notifier.send(), what is the reference type of the thing channel() is called on? Would calling send() on an UrgentNotifier change that?

  2. Hint 2

    For defect 2: at the moment javac compiles `render(recipient)`, what is the only thing it knows about the argument?

  3. Hint 3

    One of these can be fixed by deleting a single keyword.

  4. Hint 4

    The other cannot be fixed by adding a cast at the call site, because main() is off limits — and that constraint is the lesson. Where does the behaviour have to live instead?

Done when

  • The urgent alert goes over SMS
  • The customer is rendered with the account number
  • main() is unchanged, and the declared types there are still the supertypes
  • render is a single method rather than two overloads
  • A comment says which fix was a keyword, which was a signature, and why

Solution

Show the solution — try it yourself first
Solution.javaOpen in playground
import java.util.*;

/**
 * Solution: the notifier that stopped notifying.
 *
 * Two defects, one misunderstanding, and they need different fixes because
 * they are decided by different things at different times.
 *
 * DEFECT 1 — the channel, decided by the COMPILER from the reference type.
 *
 *   channel() was static, so UrgentNotifier.channel() HID it rather than
 *   overriding it. Hiding is resolved from the reference type, and inside
 *   Notifier.send() the reference is Notifier, so Notifier.channel() is what
 *   the bytecode calls. It would have behaved this way even if send() had
 *   been called on a UrgentNotifier a hundred times.
 *
 *   Fix: drop `static`. An instance method IS overridden, and overriding is
 *   dispatched on the object. One keyword.
 *
 * DEFECT 2 — the renderer, also decided by the COMPILER, from the declared
 * type of the ARGUMENT.
 *
 *   render(Recipient) and render(Customer) are overloads, and overload
 *   resolution happens at compile time. `recipient` is declared Recipient,
 *   so render(Recipient) was selected and burned into the call site. The
 *   object being a Customer at runtime is not consulted and never was.
 *
 *   Fix: stop overloading on a subtype relationship, and let dispatch do the
 *   work instead. render becomes ONE method, and the varying part becomes an
 *   overridable method on the type itself. One signature.
 *
 * The general rule both come from: overloading looks at the reference,
 * overriding looks at the object. If you want the object's type to decide,
 * you need a method on the object.
 */
public class Solution {

    /* ── fix 1: an instance method, so it can actually be overridden ── */

    static class Notifier {
        /** FIX: no longer static, so a subclass overrides rather than hides. */
        String channel() {
            return "email";
        }

        String send(String message) {
            return channel() + ": " + message;
        }
    }

    static final class UrgentNotifier extends Notifier {
        @Override
        String channel() {
            return "sms";
        }
    }

    /* ── fix 2: one method, and the type decides its own rendering ── */

    static class Recipient {
        final String email;

        Recipient(String email) {
            this.email = email;
        }

        /** The varying part, moved onto the object so dispatch reaches it. */
        String describe() {
            return "to " + email;
        }
    }

    static final class Customer extends Recipient {
        final String accountNumber;

        Customer(String email, String accountNumber) {
            super(email);
            this.accountNumber = accountNumber;
        }

        @Override
        String describe() {
            return "to " + email + " (account " + accountNumber + ")";
        }
    }

    /**
     * One method, no overload. There is nothing left for the compiler to
     * choose between, so the object's type is the only thing that can decide.
     */
    static String render(Recipient r) {
        return r.describe();
    }

    public static void main(String[] args) {
        System.out.println("── outgoing ──");

        Notifier urgent = new UrgentNotifier();
        String line = urgent.send("card blocked");
        System.out.println("  " + line);

        Recipient recipient = new Customer("ravi@example.com", "AC-4417");
        String rendered = render(recipient);
        System.out.println("  " + rendered);

        // Proof that the fixes are about dispatch and not about these two
        // call sites: the declared types here are the supertypes, exactly as
        // before, and both now reach the subclass behaviour.
        System.out.println();
        System.out.println("  declared type of `urgent`    : Notifier");
        System.out.println("  declared type of `recipient` : Recipient");

        System.out.println();
        boolean urgentUsesSms = line.startsWith("sms:");
        boolean customerRendered = rendered.contains("AC-4417");

        System.out.println("urgent alert went over SMS      : " + urgentUsesSms);
        System.out.println("customer rendered with account  : " + customerRendered);
        System.out.println(urgentUsesSms && customerRendered ? "PASS" : "FAIL");
    }
}

Stretch

Someone will propose fixing defect 2 with `instanceof` and a cast inside render(). It works. Write down what it costs: every new Recipient subtype needs an edit to a method in a different file, and forgetting is silent. Then compare it with a sealed Recipient hierarchy plus an exhaustive switch, where forgetting is a compile error, and say when each is the better answer.

← Back to What is the difference between overloading and overriding?