Skip to content
academia.sh

Lesson 03 / 13

Inheritance and Overriding

Eight decisions are read on a single reference and the answer splits right down the middle: four come from the runtime type, four from the declared type. Redeclaring fields under the same name is not overriding, it is hiding; once the signature changes, overriding never arises at all and the decision switches sides.

Contents

For two lessons, one side of the decision was measured at a time. The runtime type picked the overridden method called from a constructor; the declared type decided whether a member was visible at all. The two were never set side by side, so the rule between them was never asked either.

This lesson is the course’s core measurement. A single reference is set up — declared type Base, runtime type Sub — and eight separate language decisions are read through that reference. For each decision, one question is asked: which type gave the result? The measurement’s oracle is the rig itself; since we deliberately separated the two types, every answer’s source is known from the start.

The Programming Paradigms course measured inheritance’s two promises and how the subtype contract gets violated; what was counted there was contract violation. What is counted here is which side the decision comes from, and the measurement comes from an actual run.

The Measurement Core

The core is made of three types: an interface (Carrier), a base class (Base), and a subclass (Sub) extending it. Sub overrides Base‘s method, and redeclares its field under the same name — that the two are separate things is this lesson’s second measurement.

  • CI11 — Every member returns its own origin as text (base field, sub method); the returned value itself says which declaration it came from, no inference is needed.
  • CI12 — All eight decisions are read through the same reference; the only thing that changes is the kind of member being read.
  • CI13 — The measurement reads no environment-dependent data: identity hash, duration, and path are not written; identity, where needed, is compared with ==.
// Base.java — the course's core: an interface, a base class, a subclass extending it
interface Carrier {
    default String carry() { return "interface default"; }
}

class Base implements Carrier {
    String label = "base field";
    String name() { return "base method"; }
    String match(Base u) { return "Base.match(Base)"; }
    static String status() { return "base static"; }
    @Override public String toString() { return "Base object"; }
}

class Sub extends Base {
    String label = "sub field";
    @Override String name() { return "sub method"; }
    String match(Sub a) { return "Sub.match(Sub)"; }
    static String status() { return "sub static"; }
    @Override public String carry() { return "sub override"; }
    @Override public String toString() { return "Sub object"; }
}

Two measurements in this lesson read not a run but a compile: in one, whether a source is accepted; in the other, the fields counted in the class file produced. Both come from the same helper.

// Compile.java — compiles a source; gives either the compile result or a class's fields
import java.io.PrintWriter;
import java.io.Writer;
import java.lang.classfile.*;
import java.nio.file.*;
import java.util.*;
import java.util.spi.ToolProvider;

class Compile {
    static Path compile(String source) throws Exception {
        Path d = Files.createTempDirectory("inherit");
        Path f = d.resolve("Trial.java");
        Files.writeString(f, source);
        PrintWriter sink = new PrintWriter(Writer.nullWriter());
        return ToolProvider.findFirst("javac").orElseThrow()
                .run(sink, sink, "-d", d.toString(), f.toString()) == 0 ? d : null;
    }

    static String outcome(String source) throws Exception {
        return compile(source) == null ? "not compiled" : "compiled";
    }

    static List<String> fields(String source, String className) throws Exception {
        Path d = compile(source);
        List<String> names = new ArrayList<>();
        for (FieldModel f : ClassFile.of().parse(d.resolve(className + ".class")).fields())
            names.add(f.fieldName().stringValue());
        return names;
    }
}

Eight Decisions, One Reference

// Decision.java — eight decisions on a single reference
public class Decision {
    static String pick(Base u) { return "Base signature"; }
    static String pick(Sub a) { return "Sub signature"; }

    static void report(String decision, String result, String decider) {
        System.out.printf("%-24s %-22s %s%n", decision, result, decider);
    }

    public static void main(String[] args) {
        Base d = new Sub();
        System.out.println("declared type Base, runtime type "
                + d.getClass().getSimpleName());
        System.out.println();
        System.out.printf("%-24s %-22s %s%n", "decision", "result", "decided by");
        report("overridden method", d.name(), "runtime type");
        report("interface default", d.carry(), "runtime type");
        report("toString conversion", d.toString(), "runtime type");
        report("field read", d.label, "declared type");
        report("overload pick", pick(d), "declared type");
        report("static method", Base.status(), "declared type");
        report("type test", String.valueOf(d instanceof Sub), "runtime type");
        report("downcast", ((Sub) d).label, "declared type (after cast)");
    }
}
declared type Base, runtime type Sub

decision                 result                 decided by
overridden method        sub method             runtime type
interface default        sub override           runtime type
toString conversion      Sub object             runtime type
field read               base field             declared type
overload pick            Base signature         declared type
static method            base static            declared type
type test                true                   runtime type
downcast                 sub field              declared type (after cast)

Four of eight decisions come from the runtime type, four from the declared type. The sentence “Java is object-oriented, a call goes to the real object” explains only half of the decisions.

The split is not arbitrary. The top three rows plus the seventh concern behavior: which body runs, what the object actually is. These look at the runtime type, because the answer can only be known once the object exists. The remaining four are name resolution: which field gets read, which signature gets picked, which class’s static method gets called. These end at the compiler’s desk, and the compiler has only the declared type in hand.

The rule fits in one sentence: the behavior called looks at the runtime type, the name picked looks at the declared type. The second row is this rule’s least visible instance — a default method coming from an interface can be overridden too, and its decision also comes from the runtime type.

The last row adds a fourth reading. ((Sub) d).label gives sub field, even though the object has been the same object all along. A downcast does not change the object, it changes the declared type; it is a promise given to the compiler, and the promise’s truth is tested at runtime.

Separating Overriding from Hiding

The fourth row is this lesson’s quietest result. d.name() gives sub method while d.label gives base field; both are written through the same d, with a two-character difference. Redeclaring fields under the same name is not overriding; it is hiding. To see what the hidden field is, two separate references are bound to a single object.

// Hiding.java — the hidden field does not vanish
public class Hiding {
    public static void main(String[] args) {
        Base u = new Sub();
        Sub a = (Sub) u;
        System.out.println("are the two references the same object: " + (u == a));
        System.out.println("label via Base reference   : " + u.label);
        System.out.println("label via Sub reference    : " + a.label);
        System.out.println("name() via Base reference  : " + u.name());
        System.out.println("name() via Sub reference   : " + a.name());
        a.label = "written";
        System.out.println("u.label after writing a.label: " + u.label);
    }
}
are the two references the same object: true
label via Base reference   : base field
label via Sub reference    : sub field
name() via Base reference  : sub method
name() via Sub reference   : sub method
u.label after writing a.label: base field

The first line is the measurement’s foundation: there is exactly one object. Yet that single object gives two separate answers to the label question, and one answer to the name() question. The overridden method replaces the one above it; the redeclared field does not replace the one above it, it sits on top of it.

The last line is this in memory terms. After a.label is written to, u.label still says base field. There are two separate places inside the same object, and writing one does not change the other. The hidden field has not vanished; it has only become invisible from a reference whose declared type is Sub.

That the two places are truly two places can also be read from the class file. The same pair is compiled and the fields each class declares are listed.

// Location.java — where the hidden field sits in the class file
public class Location {
    static final String SOURCE = """
        class Base { String label = "base field"; String name() { return "base method"; } }
        class Sub extends Base { String label = "sub field";
                                @Override String name() { return "sub method"; } }
        """;

    public static void main(String[] args) throws Exception {
        System.out.println("fields in Base's class file: " + Compile.fields(SOURCE, "Base"));
        System.out.println("fields in Sub's class file: " + Compile.fields(SOURCE, "Sub"));
    }
}
fields in Base's class file: [label]
fields in Sub's class file: [label]

Two separate class files, one label declaration in each. The overridden name() method also has two bodies, but a call only ever goes to one of them; a field has no such choice — both stay readable. Inheritance replaces one with the other for a method, and leaves the two standing side by side for a field.

This is where the cost is read. A programmer writing a field under the same name as the one above it in a subclass usually believes they have changed the field above. They have not — both fields go on living, and which one is read is decided by looking at the declared type of the reference on that line. The defect is invisible where it is written; it shows where it is read, silently returning the wrong value.

What the Compiler Catches and Misses

Overriding is not a declaration; it arises on its own whenever two signatures line up exactly. For this reason, its not arising is automatic too, and silent. The @Override mark exists precisely to break that silence: it makes the compiler answer, for the subclass, the question “is this really an override?”

  • CI14 — In all five trials, the base class is byte-for-byte identical; the only thing that changes is the subclass’s single-line declaration. What is measured is only the compile result.
// Mark.java — what @Override catches, how far a signature can drift
public class Mark {
    static final String ROOT = """
        class A { String name() { return "base"; } A make() { return this; }
                  String match(A a) { return "A"; } }
        """;

    static final String[][] TRIALS = {
        { "unmarked, name misspelled", "class B extends A { String naem() { return \"sub\"; } }" },
        { "marked, name misspelled", "class B extends A { @Override String naem() { return \"sub\"; } }" },
        { "marked, return type narrowed", "class B extends A { @Override B make() { return this; } }" },
        { "marked, return type widened", "class B extends A { @Override Object make() { return this; } }" },
        { "marked, parameter type narrowed", "class B extends A { @Override String match(B b) { return \"B\"; } }" },
    };

    public static void main(String[] args) throws Exception {
        System.out.printf("%-38s %s%n", "subclass", "result");
        for (String[] d : TRIALS)
            System.out.printf("%-38s %s%n", d[0], Compile.outcome(ROOT + d[1] + "\n"));
    }
}
subclass                               result
unmarked, name misspelled              compiled
marked, name misspelled                not compiled
marked, return type narrowed           compiled
marked, return type widened            not compiled
marked, parameter type narrowed        not compiled

The first two rows carry the mark’s whole value. A method with a misspelled name compiles when no mark is placed: as far as the compiler is concerned there is no defect, a new method has only been added to the subclass. The program runs, no warning is raised, and the base class’s version keeps getting called. The same source does not compile with @Override. The mark adds no behavior; it only states an assumption to the compiler, and rejects the source when that assumption does not hold.

The third and fourth rows measure how far a return type is allowed to drift. An overriding method can narrow its return type — if the one above returns A, the one below can return B — but cannot widen it. The reason is the subtype contract: when a caller expecting A gets a B, the contract is not broken; when it gets an Object, it is. Narrowing strengthens the promise given to the caller, widening weakens it.

The fifth row opens the door to the next section. When the parameter type is narrowed, the marked source does not compile: there is no override in play at all. The narrowing that is free in a return type is forbidden in a parameter — and the consequence of this is not only a compile error.

A Bounding Measurement: When the Signature Changes, the Decision Switches Sides

When @Override is not written, the same declaration compiles. What happens then? The match pair added to the core is exactly this case: Base takes a Base, Sub takes a Sub, the signatures do not line up, and so what exists is not overriding but overloading.

  • CI15 — In both calls the object is the same object and the argument is the same argument; the only thing that changes is the declared type of the reference the call is made through.
// Signature.java — when the signature changes, overload replaces overriding
public class Signature {
    public static void main(String[] args) {
        Base d = new Sub();
        Sub real = new Sub();
        System.out.println("match via Base reference : " + d.match(real));
        System.out.println("match via Sub reference  : " + ((Sub) d).match(real));
        System.out.println("name() via Base reference: " + d.name());
        System.out.println("name() via Sub reference : " + ((Sub) d).name());
    }
}
match via Base reference : Base.match(Base)
match via Sub reference  : Sub.match(Sub)
name() via Base reference: sub method
name() via Sub reference : sub method

The four lines are read in pairs. The bottom two are overriding’s signature: two separate references, one answer. Changing the reference’s declared type does not change the result, because it is not the side giving the decision.

The top two, in turn, are overloading’s signature: same object, same argument, two separate answers. Two calls that could be written without changing a single character go to separate bodies for no reason but the reference’s declared type. The decision has switched sides.

This is what the boundary means. A programmer writing a method in a subclass usually believes they are overriding; if a letter or a parameter type slips in the signature, overriding never arises at all, and in its place comes a choice that never listens to the runtime type. The program compiles, runs, and calls the base class’s version. This is why @Override is not a comment added to the source — it is the only way to freeze, at compile time, which side a decision will come from.

Summary

  • Eight decisions are read on a single reference and the answer splits down the middle: four come from the runtime type, four from the declared type.
  • The rule is single: the behavior called looks at the runtime type, the name picked looks at the declared type. A default method from an interface can be overridden too and falls into the first group.
  • A downcast does not change the object, it changes the declared type: ((Sub) d).label gives sub field, while the object has been the same object all along.
  • Redeclaring fields under the same name is hiding: a single object gives two answers to the label question, and writing one does not change the other — the hidden field does not vanish.
  • @Override adds no behavior, it states an assumption: a method with a misspelled name compiles unmarked, does not compile marked. The return type can be narrowed, not widened.
  • When the signature does not line up, overriding never arises, it becomes overloading, and the decision switches sides: match gives two separate answers from two references while name() gives one.

Next Step

In this lesson, Base carried a body: every method had an implementation, and the subclass overrode them if it chose to. The default method from the interface entered the table as one row too, but what the interface itself is for was never asked. The next lesson sets two tools of abstraction side by side: a capability can be established with an abstract class as well as an interface, one is bound to a single supertype, the other to many. The measurement looks at the case where two interfaces give the same default method — in how many cases does the compiler refuse to decide, in how many does one side silently win — and shows why a capability carrying state cannot be established with an interface.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close