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).labelgives 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
labelquestion, and writing one does not change the other — the hidden field does not vanish. @Overrideadds 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:
matchgives two separate answers from two references whilename()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.