mattone
dopo mattoneTHE BOOK SERIES
IT/EN
← Java guide

Java 25 · 9/39

9. Inheritance, composition and sealed hierarchies

Java 25 · Complete guide · Draft under review

This guide retains the book’s draft status. Editorial review and comprehension checks with independent readers remain to be completed.

Search the whole guide →

When “is a” means something

In chapter 2 we tried reading a relationship between classes as a sentence: “a car is a vehicle” sounds sensible; “a car is an engine” does not, because the engine is part of the car. This distinction guides the choice between inheritance and composition. The first allows a class to specialize another and be used where the general type is expected. The second builds an object by connecting other objects with their own responsibilities. Neither is a guaranteed shortcut to writing less code.

The base class, also called the superclass, establishes a contract that subclasses must respect. If a function accepts a Vehicle, it must be able to work with a Car or a Bicycle without continually checking which has arrived. When a subclass changes an operation's expected meaning, the hierarchy becomes fragile even if it compiles. Reuse and local maintenance are possible benefits, but must be demonstrated case by case.

In the complete source, Vehicle declares the description() method. Car and Bicycle implement it according to their own meaning; Car has an Engine, represented as a record. Figure 9.1 visually separates the two relationships before we look at keywords.

Inheritance and composition among vehicles
Figure 9.1 – Car and Bicycle are specializations of Vehicle; Car contains an Engine. Subclass arrows point to the base class. The engine connection indicates a part, not another kind of vehicle.

Declaring the relationship

The declaration's core is this. abstract says that Vehicle defines an incomplete model: we cannot construct new Vehicle(...) because the abstract method does not yet have a body. extends, used by subclasses in the complete program, indicates the base class. Each concrete subclass provides its own method implementation.

sealed abstract class Vehicle permits Car, Bicycle {
    private final String name;
    protected Vehicle(String name) {
        this.name = name;
    }
    public String name() {
        return name;
    }
    public abstract String description();
}

Car does not inherit from Engine: it keeps it in a field. If we changed how we describe the power source, the engine would remain a separate responsibility. In the complete source the Car constructor calls super(name) to let the base class initialize its own part of the state, then assigns the engine field. Calling an overridable method from the base constructor can be dangerous, because the subclass might not have completed initialization; here the base constructor only stores the name.

The constructor's protected modifier allows subclasses to invoke it. For members generally, protected also includes access from the same package and imposes particular rules outside the package: the chapter 8 table is guidance, while the access specification gives the full rule. A subpackage alone does not obtain the privileges of the package preceding it in the name.

The short constructor-order program prints base first and then child: 7. Construction of the inherited part precedes completion of the subclass constructor. Field initializations and initialization blocks add steps to this order: do not confuse the order in which source shows fields with a safe call to overridable methods from the base constructor. The instance-creation specification describes the complete sequence.

The wheel and the radius: saving the lesson, correcting the model

The example with Circumference and Wheel shows that the subclass constructor cannot directly assign a private field of the base. The access lesson is valid; the model “a wheel is a circumference” is less convincing, because a wheel is a physical object and a circumference is a measurement or geometric figure. The small experiment is a syntax test, with this modeling limitation. In an application about real vehicles it would often be more natural for Wheel to have a radius value and use a geometric function, without inheriting from Circle.

In the DemoWheel program, the base class keeps radius as a private field and validates it in its own constructor. The subclass calls super(radius) rather than trying to assign another class's field. The code is complete and lets us follow construction without leaving a deliberately uncompilable line in the positive example.

class Circle {
    private final double radius;

    protected Circle(double radius) {
        if (!Double.isFinite(radius) || radius <= 0) {
            throw new IllegalArgumentException("invalid radius");
        }
        this.radius = radius;
    }

    public final double radius() {
        return radius;
    }

    public double circumference() {
        return 2 * Math.PI * radius;
    }
}

class Wheel extends Circle {
    Wheel(double radius) {
        super(radius);
    }

    public double diameter() {
        return 2 * radius();
    }
}

public class DemoWheel {
    public static void main(String[] args) {
        Wheel wheel = new Wheel(5);
        System.out.println("radius: " + wheel.radius());
        System.out.println("diameter: " + wheel.diameter());
        System.out.println("circumference > diameter: "
                + (wheel.circumference() > wheel.diameter()));
    }
}

The output is radius: 5.0, diameter: 10.0, circumference > diameter: true. The diameter is twice the radius; with radius 5 we obtain 10. The circumference length is 2 × π × radius, where π is the constant ratio between circumference length and diameter; this is why it exceeds the positive diameter. We use Math.PI rather than rounding π to 3.14 in each object's state. The program prints a boolean comparison, not a long sequence of decimal digits distracting from the relationship between base and subclass.

The check Double.isFinite(radius) also rejects NaN and infinities: checking only radius <= 0 would not reject NaN, because ordered comparisons with that value are false. We do not need every floating-point detail to read the example; it is enough to observe that the base class protects its own data before delivering a completed object to the subclass. The call radius() is legal because the method is public, while Wheel cannot write this.radius = ... because the field belongs to Circle's private implementation.

Important note – A syntax example is not always a good domain. DemoWheel teaches super(...) and member access; it does not invite us to derive every wheel from a geometric figure. Separating the two evaluations avoids turning a teaching device into a design rule. When the “is a” relationship does not hold, keep the calculation but change the relationship between classes.

Important note – Only one base class. A Java class can directly extend only one class. This does not mean it can play only one role: in chapter 13 we will encounter interfaces. Composing objects remains another possibility, often simpler when the natural relationship is “has a”. Two diagrams of single and multiple inheritance show the shape of relationships, but are not enough to choose a domain-appropriate model.

The complete initialization order, observed twice

The DemoOrder program showed only the two constructors. Let us add static and instance blocks in the base and subclass. Follow the test in the DemoCompleteOrder source, creating two objects: the second lets us separate what happens once for classes from what happens for each instance.

class CompleteOrderBase {
    static {
        System.out.println("static base");
    }

    {
        System.out.println("instance base");
    }

    CompleteOrderBase() {
        System.out.println("base constructor");
    }
}

class CompleteOrderChild extends CompleteOrderBase {
    static {
        System.out.println("static child");
    }

    {
        System.out.println("instance child");
    }

    CompleteOrderChild() {
        System.out.println("child constructor");
    }
}

public class DemoCompleteOrder {
    public static void main(String[] args) {
        new CompleteOrderChild();
        new CompleteOrderChild();
    }
}

At the beginning of the first construction, static base and static child appear. The base part's group follows: instance base, base constructor. Then comes the subclass's group, here consisting of instance child and child constructor. With the second new the two static lines do not return: the two instance lines and two constructors repeat in the same relative order. The base's instance block precedes its constructor body; the child's precedes the child's constructor body. Looking only at the order in which classes appear in the file would not let us correctly deduce this result.

The test shows class initialization in the chosen startup path without equating it with mere .class file loading by the JVM. It also shows that the base part is constructed before the child-specific part. We must not use a static block to initialize an instance field, or expect a base constructor to already know the fields the child constructor will assign afterward. For a real program with many classes and dependencies, keeping initialization simple reduces surprises; here we deliberately made it verbose to see every step.

Designing the base class first

Before building a Vehicle class and specializing it, consider the problem it must solve. What must code be able to ask any vehicle? A name and description can belong to the common contract. The door count cannot: a bicycle has none, and adding a fictitious value just to satisfy the base class would produce a misleading model. If an operation has no coherent meaning for every subclass, it probably does not belong in the base.

A base class can offer already-complete behavior or declare an abstract method that each concrete subclass must implement. Vehicle.name() returns common data; description() is abstract because the concrete type decides what to say. But not every difference requires an abstract method. If the program only needs to store name and category, a simple class or record may suffice. Inheritance is justified when client code really needs to work with the common type and substitutable variants.

Compare multiple and single inheritance. Java lets a class directly extend only one superclass, avoiding, among other things, ambiguity in choosing between two implementations of the same member inherited from two classes. This does not prevent combining capabilities: a Car can have an Engine and later can implement several interfaces. The design question remains which relationship best describes the problem.

From the diagram arrow to the code contract

The inheritance arrow is a visual aid, but using it well requires turning it into a verifiable sentence. If Car extends Vehicle, every point in the program requiring a Vehicle can receive a Car. This sentence has a concrete consequence: code working on the base type should not need to know the wheel count, fuel or other subclass details to perform an operation promised by Vehicle. If we force it to cast before every call, we have drawn an arrow without building real common behavior.

Conversely, “has a” does not make the types interchangeable. A Car owns an Engine, but we cannot pass the car to a method requiring an Engine. We can instead ask the car for an operation using the engine, or pass it an Engine in its constructor. This separation protects responsibilities: if the way energy is produced changes tomorrow, Vehicle's general contract need not necessarily change along with the engine representation. Composition is not always superior; here it is the relationship correctly describing the object.

To understand – A substitutability test. Imagine a method printDetails(Vehicle v) calling v.name() and v.description(). Mentally test it with a car and a bicycle. If making it work requires asking whether v is a car before printing the description, reconsider description()'s responsibility. If instead you need to show engine displacement, does that data really belong to all vehicles? A domain question avoids adding methods to the base that make no sense for some subclasses.

Overloading and overriding: a test with calls

Suppose Vehicle declares description() and description(boolean detailed). These are two overloaded methods: they have the same name but different parameters. If Car declares description() with a compatible signature, it overrides the first. It can continue inheriting the second, or override that too. A call vehicle.description(true) is selected by the compiler based on argument types and methods available in the declared type; only afterward, if the selected instance method is overridden, does the runtime choose the body associated with the actual object.

This separation avoids a common misunderstanding: the actual object does not magically make every method Car has added appear in Vehicle. If Car declares openTrunk(), Vehicle vehicle = new Car(...) does not allow vehicle.openTrunk() while the compiler sees only Vehicle. We can change the reference type, verify that the object is a Car or, if the operation must apply to all, rethink the common contract. A cast does not change the object: it changes the type through which we ask the compiler to treat that reference, and can be checked at runtime.

An example can start a vehicle at 1 km/h and then add another version of move receiving speed. We follow that progression in a complete program, with names needed to distinguish it from the chapter's other example:

class MovingVehicle {
    private int speed;

    public void move() {
        move(1);
    }

    public void move(int newSpeed) {
        if (newSpeed < 0) {
            throw new IllegalArgumentException("negative speed");
        }
        speed = newSpeed;
        System.out.println(description() + ": " + speed);
    }

    public void stop() {
        move(0);
    }

    public String description() {
        return "Vehicle";
    }
}

class MovingCar extends MovingVehicle {
    @Override
    public String description() {
        return "Car";
    }
}

public class DemoMovement {
    public static void main(String[] args) {
        MovingVehicle vehicle = new MovingCar();
        vehicle.move();
        vehicle.move(10);
        vehicle.stop();
    }
}

The method without arguments delegates to the one with an int, so the speed-update rule is in one place. stop() uses the same rule by passing zero. The negative-speed check protects the meaning of the data: a private field without checking is not enough to give the data meaning. main keeps a MovingCar in a base-type variable and first calls move(), then move(10) and finally stop(). The output is Car: 1, Car: 10, Car: 0. Selection between the two move signatures depends on the arguments written; the word Car in the three lines instead depends on overriding description() in the actual object. In the same program we therefore see the two mechanisms without confusing them.

Try replacing new MovingCar() with new MovingVehicle(). The calls remain legal, but the three lines begin with Vehicle: the variable's declared type has not changed, the created object has. This is the concrete test of the difference between selecting a method and choosing the executed body. The file uses System.out to observe state; in a real application we could also return the speed and leave presentation to another component.

Read the three calls with two separate questions. In vehicle.move(), the compiler finds the no-parameter signature in MovingVehicle; that body delegates to move(1). In vehicle.move(10), it finds the signature with an int. In vehicle.stop(), it finds stop() and the body calls move(0). All three eventually invoke description(). This is where overriding intervenes: the instance method executed for description() depends on the actual MovingCar object, so the printed word is Car. The result does not come from the compiler changing the move signature during execution.

Overloading requires distinguishable parameters; changing only the return type is not enough. Two declarations int measure() and double measure() in the same class do not offer a reliable caller choice based on return context and are not valid overloading. A no-argument call and one with an int, however, are distinguishable. Constructor overloading follows the same parameter-list principle: GradeRegister(String, int) and GradeRegister(String, int[]) from chapter 8 choose two entries for constructing the same type, without inheriting constructors as if they were methods.

Overriding, in contrast, requires a signature compatible with an inherited method and concerns the body selected for the instance. @Override makes this intention checkable. If we accidentally wrote description(String detail) in MovingCar, we would add a new overload; the call description() would continue using the base body. With @Override above the wrong signature, the compiler stops work and forces us to correct the program. It is a small check avoiding a defect hard to recognize by looking only at output different from what we expected.

Further reading – static methods and fields. The dynamic dispatch described here applies to overridable instance methods. A static method declared with the same name in base and subclass is hidden, not overridden in the same sense; a field with the same name follows hiding rules too. This is why we do not infer every member's behavior from the test with description(). The distinction will become more important when we read APIs with static factories and extensive hierarchies.

The written type and the real object

In the program's main we find:

Vehicle vehicle = new Car("A", new Engine("electric"));
System.out.println(vehicle.description());

The declared type of the variable vehicle is Vehicle. The object created is a Car, so its actual class is Car. The compiler allows calling description() because the method is part of Vehicle's contract. During execution the body overridden in Car is chosen, and the printed line is Car A with electric engine. This choice of instance method based on the actual object is dynamic dispatch. It does not imply that the variable changes declared type or that all methods behave alike: static methods, for example, are not overridden by this mechanism.

Important note – The primitive case. The distinction between a reference's declared type and an object's actual class is useful when a variable can indicate instances of different subclasses. An int speed variable does not contain a reference to an object with a more specific hidden class: it contains a primitive int value. A numeric conversion can produce a value of another type, and boxing can create or obtain a wrapper, but there is no int that becomes the subclass of another int at runtime. This is why the Vehicle and Car diagrams must not be mechanically applied to chapter 4's primitives.

Figure 9.2 keeps together the two pieces of information historical diagrams spread across different pages: the type available to the compiler and the actual object's class. The arrow indicates a reference, not a cast that changed the object.

Declared type and actual object
Figure 9.2 – vehicle is declared Vehicle and indicates a Car. description() is in the common contract; powerSource() requires a reference known as Car, for example after an instanceof check.

@Override asks the compiler to check that description() really overrides a base-class method. If we get the name or parameters wrong, the check reports the problem. Overriding concerns an inherited method and behavior selected for the object. Overloading instead declares several methods with the same name and different parameters, like the two GradeRegister constructors in chapter 8. Confusing them makes it hard to predict which body will execute.

What is inherited and what stays protected

A subclass inherits members according to language rules, but this does not mean every base field becomes accessible by name in its code. Vehicle's name field is private: Car cannot read it directly. It uses name(), the method the base exposes, thus depending on the promise “provide the name” rather than the field's position or name. The base can later change how it stores the name without forcing the subclass to know the internal representation.

A protected member requires different reasoning. In the same package it is accessible according to package rules; outside the package, the subclass can access it in the context permitted by inheritance rules. This is why Vehicle's protected constructor can be called by Car with super(name), but is not a constructor available to any external caller. Choosing protected must be justified by a contract for class extenders: once a member is exposed to subclasses, changing that contract becomes more costly.

The word super has two uses we will see often. super(name) invokes a base-class constructor during subclass construction. super.description() can invoke an inherited method implementation when the subclass wants to extend it, provided that implementation exists and is accessible. An abstract method has no body to call with super. In both cases super does not create a second base object: the base part and the specific part belong to the same Car instance.

In the DemoSuper program we see both forms in the same object. The subclass constructor entrusts the name to super(name) and stores the power source in its own field. The description() override starts from the base text with super.description() and adds the car detail. This differs from copying the base body: if the base changes the description format, the subclass will keep reusing its result. This dependency is useful only if the base promises a result suitable for extension; otherwise a separate composition might be clearer.

class BaseVehicle {
    private final String name;

    BaseVehicle(String name) {
        this.name = name;
    }

    public String description() {
        return "vehicle " + name;
    }
}

class DescribedCar extends BaseVehicle {
    private final String powerSource;

    DescribedCar(String name, String powerSource) {
        super(name);
        this.powerSource = powerSource;
    }

    @Override
    public String description() {
        return super.description() + " with " + powerSource + " engine";
    }
}

public class DemoSuper {
    public static void main(String[] args) {
        BaseVehicle vehicle = new DescribedCar("A", "electric");
        System.out.println(vehicle.description());
    }
}

The output is vehicle A with electric engine. vehicle is declared as BaseVehicle, so the compiler allows calling description() because it is present in the base. The object is a DescribedCar, so the body selected during execution is the override; inside that body, the super-qualified call selects the base body. The final text is composed from both. If we remove super. and write only description() inside the override, we call the same car method again and obtain recursion with no useful termination, not implicit access to the base.

To understand – Constructor and method. super(name) does not use a dot because it invokes a constructor in a position governed by construction rules. super.description() uses the dot because it invokes a base method on the current instance. Similar spelling must not make us call a constructor an “inherited method”: constructors are not inherited like ordinary methods.

Constructing the subclass without calling it too soon

Follow new Car("A", engine) from outside. A new Car instance is created and its constructor must ensure initialization of the inherited part. The call super(name) delegates the choice of how to safeguard the name to the base class. Only after base construction completes can the subclass constructor finish its own part by assigning the engine. These are not two objects placed together: a reference to the Car reaches one instance that also owns the state defined by the base. Composition with Engine, however, involves another object reached through a field.

If Vehicle called description() from its constructor, dynamic dispatch could execute the version overridden in Car before the engine field was ready. The result might be incomplete text or an error. The robust choice is to initialize in the base only what the base knows, without delegating to overridable behavior while the object is still under construction. This example explains why constructor order is a design rule and not merely a curiosity to guess from printed lines.

The base's constructor is not inherited as though it were an ordinary public method. The subclass declares its own constructors and can invoke an accessible base constructor with super(...); if it omits the explicit call, the rules for implicit no-argument constructor invocation apply. If the base has no accessible one, the subclass code must explicitly select another signature. In other words, adding a constructor to the base can force subclasses to change: this too is a dependency to assess when designing a hierarchy.

Important note – Class visibility and member visibility. Even a method declared public does not become usable by any code if its declaring class, package or module is not accessible to the caller. Reading one modifier without context can lead to a wrong conclusion.

protected between packages and subclasses: the complete test

The experiment with Producer and Consumer lets us observe a case often explained too quickly. A protected member is accessible to classes in the same package even when they are not subclasses. A subclass in another package has access according to subclass rules, but cannot use an arbitrary base reference as a shortcut to read any instance. The experiment's four sources distinguish the two situations. We keep them in different files because the package name is part of the test, not a decorative label.

In the first file, Producer declares a protected field and method. In the same package, Consumer does not extend Producer, but can invoke producer.readData() on a received reference. The reason is the shared package. In access.external, Derived extends Producer and can call this.readData() to read its own inherited data. The main program prints protected data twice: once through package privilege and once through the subclass relationship.

package access.base;

public class Producer {
    protected String data = "protected data";

    protected String readData() {
        return data;
    }
}
package access.base;

public class Consumer {
    public String read(Producer producer) {
        return producer.readData();
    }
}
package access.external;

import access.base.Producer;

public class Derived extends Producer {
    public String readOwnData() {
        return this.readData();
    }
}

The negative variant consists of adding to Derived a method String readOther(Producer other) { return other.readData(); }. Because Derived is outside access.base, that access through a reference declared Producer is rejected by the compiler. Saying “the method is visible to the subclass” is not enough: we must look at through which expression the subclass attempts to use it. If it were a public method, access would still depend on class and package or module visibility, but not on this specific protected rule.

To reproduce the test, compile the four files together with javac --release 25 -d build access/base/Producer.java access/base/Consumer.java access/external/Derived.java access/DemoAccess.java from their containing folder and execute java -cp build access.DemoAccess. The command names the startup class's fully qualified name, because this time a package is declared. The experiment connects chapters 6, 8 and 9: package, access and subclass act together. If all the code were in the same package, we could not observe the limit we want to teach.

Good practice – Expose an operation, not a field to monitor. Producer makes data protected only to isolate the rule in a test. In a class intended to evolve, a private field and accessible methods with a stable contract reduce coupling between base and subclass. protected is a tool for consciously designed extension; using it on every field does not replace an interface for extenders.

When we need an operation specific to Car, the declared type Vehicle alone is not enough to call it. Our program uses an instanceof pattern:

if (vehicle instanceof Car car) {
    System.out.println(car.powerSource());
}

The check establishes that the object is a Car and, in the same branch, introduces the variable car with the more specific type. The output is electric. A direct cast without a check could fail during execution if the vehicle were a Bicycle. Nor should the pattern become the usual way to bypass a poorly designed base class: if every caller must recognize the subclass before operating, ask whether the common behavior really belongs to the base contract.

Assigning, converting and recognizing a reference

A Car reference can be assigned to a Vehicle variable because every Car is a Vehicle: this is conversion toward a more general type. The reverse path is not guaranteed. If a Vehicle variable actually indicates a Bicycle, writing (Car) vehicle compiles in a context where the cast is allowed, but fails when the program attempts conversion during execution. The pattern instanceof Car car combines checking and declaring the specific variable, avoiding repeating the cast.

Perform a mental test with two lines: Vehicle first = new Car(...); Vehicle second = new Bicycle(...);. The variables' declared types are the same. first.description() and second.description() are both allowed by the compiler because Vehicle promises that method. The body chosen at runtime differs because the two actual objects differ. first.powerSource() is not allowed, although the object is a Car, because that capability does not appear in the declared type. After an instanceof check we can use the pattern-introduced name to request the power source.

In the DemoTypes program the same variable vehicle indicates first a car and then a bike. Its declaration does not change: it remains TypedVehicle. The reference value we assign changes and therefore the reached object's actual class. We use names slightly different from those in the sealed program to isolate this test: here we want to understand assignments and casts, not a switch's exhaustiveness.

class TypedVehicle {
    public String description() {
        return "vehicle";
    }
}

class TypedCar extends TypedVehicle {
    @Override
    public String description() {
        return "car";
    }

    public int doors() {
        return 4;
    }
}

class TypedBike extends TypedVehicle {
    @Override
    public String description() {
        return "bike";
    }
}

public class DemoTypes {
    public static void main(String[] args) {
        TypedVehicle vehicle = new TypedCar();
        System.out.println(vehicle.description());
        if (vehicle instanceof TypedCar car) {
            System.out.println("doors: " + car.doors());
        }

        vehicle = new TypedBike();
        System.out.println(vehicle.description());
        System.out.println(vehicle instanceof TypedCar);

        TypedVehicle absent = null;
        System.out.println(absent instanceof TypedCar);
        try {
            TypedCar wrong = (TypedCar) vehicle;
            System.out.println(wrong.doors());
        } catch (ClassCastException error) {
            System.out.println("invalid cast");
        }
    }
}

The output is car, doors: 4, bike, false, false, invalid cast. The first description() call executes the car version; after the new assignment it executes the bike version. instanceof TypedCar car introduces car only in the branch where the check is true. After vehicle indicates a bike, the check returns false. It also returns false for null: the null value is not a subclass instance. Finally the direct cast compiles because the declared type could contain a car, but fails at runtime because at that moment it contains a bike.

The final catch is present to make the failure observable without interrupting the demonstration; it is not a recommended strategy for normally calling doors(). If we know the value can indicate different types, an instanceof pattern is clearer when the specific operation is really necessary. If an algorithm must ask every vehicle for doors(), we can consider a method in the common contract only if the question makes sense for all. A bike has no four hidden doors: adding doors() to the base and returning zero for every bike could signal forced modeling.

The scope of a pattern-introduced name

Traditional instanceof returned a boolean; the programmer then had to write a cast to obtain a name of the more specific type. With instanceof TypedCar car, the check also introduces the variable car, but only on paths where the compiler knows the check succeeded. In the true branch of if (vehicle instanceof TypedCar car), the name is available. In the else branch of that same if we cannot use it, because that branch means precisely that vehicle was not recognized as a car.

Negation reverses the paths. In if (!(vehicle instanceof TypedCar car)) { ... } else { System.out.println(car.doors()); }, the name car is available in the else, where the original check must be true. It is unavailable in the first branch, where we know only that the value is not a car. The rule is tied to control flow, not the visual proximity of parentheses. This also explains why, with complicated boolean expressions, a pattern variable's scope can require more attention than a well-contained explicit cast. If the reader must mentally traverse too many &&, || and ! to know whether the name exists, splitting the condition into clearer steps improves the code.

Important note – null does not enter the true branch. If the reference is null, vehicle instanceof TypedCar car is false and no car value is introduced in the true branch. The check therefore also avoids a cast on null, but does not alone decide whether null is legal input. That meaning belongs to the contract of the method receiving vehicle.

Key concept – Two checks at two times. At compile time Java checks that a call is legal for the type available in the source. At runtime, for overridable instance methods, it chooses the implementation associated with the actual object. A checked cast belongs to yet another question: is the actual object compatible with the requested more specific type?

Compatibility can be read as a path through the hierarchy. A Car object can be treated through a Car reference, through a Vehicle reference and, more generally, through an Object reference, because every class participates in the chain reaching Object. Each step toward a more general type leaves the object unchanged but reduces what the compiler can promise about calls written through that reference. Object offers common methods such as toString(), but knows nothing of Vehicle's description(): if we declare Object element = new Car(...), the call element.description() does not compile. We can recover a more specific capability only by checking or knowing the actual type with certainty, not imagining that the compiler looks at the distant new in every situation.

This also explains why conversion toward the base class is ordinary and requires no written cast, while conversion toward a subclass may require a runtime check. If we use a Car reference as a Vehicle, the declaration already tells us that the car satisfies the vehicle contract. In the other direction, a reference declared as Vehicle might refer to a bicycle: writing a cast to Car does not turn it into a car. The compiler allows some casts that could succeed; if the object is incompatible at runtime, ClassCastException results. We therefore track two separate things: the type through which the code uses the reference, and the object reached through that reference. The cast can change which operations the compiler permits through the reference, but does not change the object’s identity or class.

Further reading – Bridge methods. When we encounter generics, we will see cases where the compiler produces a small additional method, called a bridge method, to preserve overriding after erasure of some type information in bytecode. It is not a method the author must write by hand in ordinary Car source. For now the observable rule suffices: a legal call through the base type must continue to reach the correct subclass implementation. Chapter 15 will show why implementing that promise sometimes requires a bridge.

The table follows the same reference through four statements. In every row vehicle's value indicates a Car object, but the type available for writing the call changes according to the expression used.

Expression What the compiler knows Outcome
Vehicle vehicle = new Car(...); Car is compatible with Vehicle legal assignment
vehicle.description() Vehicle declares the method executes the Car body
vehicle.powerSource() Vehicle does not declare that method compilation error
if (vehicle instanceof Car car) car.powerSource(); in the true branch car has type Car legal call

The cast (Car) vehicle would be a programmer assertion subject to runtime checking. It does not modify the object and does not make wrong assumptions true: if vehicle indicates a Bicycle, the cast fails. This is why the instanceof pattern is useful when the specific case is truly necessary. If the same check appears in many program points, the repetition suggests the common contract or distribution of responsibilities deserves revision.

Truly substituting, without breaking the promise

The most useful test for a hierarchy is not “does it compile?” but “can a caller knowing only Vehicle keep reasoning in the same way?”. If the base promises that description() always returns nonempty text, a subclass returning null breaks the promise. If the base allows stopping any vehicle, a subclass throwing an exception for an ordinary stop makes substitution questionable. These are behavioral contract problems, which the word extends alone does not check.

Another trap is using inheritance only to recover two already-written lines. If Car and Bicycle share a formatting function, we can move it into a support class or compose an object responsible for it. Creating a superclass with an artificial name to avoid a small duplication can introduce a permanent relationship more costly than duplication itself. Reuse is a possible effect of a well-designed relationship, not proof that the relationship is right.

Rereading the first Vehicle class as code reviewers

Let us return to Vehicle to examine code quality. Imagine a first form that publicly exposes type, speed and direction, so the Car subclass can assign them directly. This is convenient for quickly making the example move, but distributes responsibility for rules across every subclass and caller. What prevents assigning a negative speed? What does an integer direction other than the three expected constants mean? If a new subclass forgets to initialize type, the base may find itself in a state its methods cannot describe.

Making fields private and adding getters and setters solves part of the problem. This is a step, but a setSpeed(int) accepting any integer reopens the same hole through a public door. In our DemoMovement, move(int newSpeed) checks the value before modifying the field and stop() uses the same operation passing zero. The method name declares a domain request; validation stays in one place. If the model needed to distinguish “stopped”, “moving” and “broken”, we could introduce an appropriate type rather than multiplying special integers scattered throughout the program.

Further reading – Side effect. When a method modifies observable state beyond the value it returns, that modification is a side effect. move(30) changes vehicle speed; a later call to description() may therefore produce different text. A side effect is not necessarily a defect: it models a real change. It becomes hard to control when a call with an apparently harmless name modifies a shared object, or when base and subclass update the same state through different routes. Naming the effect in the contract helps predict behavior after a call and write a test observing it.

File order also helps the person who will maintain the class. One useful convention puts constants at the beginning and getters and setters at the end: this is one possible arrangement, because it makes shared declarations easy to find. It is not, however, a language rule or the best way for every class. If a read method is the only public operation tied to a domain rule, keeping it close to that rule may be clearer than sending it to the bottom of a long accessor series. In our Vehicle, we group what describes the common contract and keep methods changing the same state close together; then we maintain that choice consistently in the project.

The three directions STRAIGHT, LEFT and RIGHT in the historical example were static final int constants. Writing Vehicle.LEFT is more readable than an isolated -1, but an int direction variable can still receive 27. A Direction enum would make the domain more explicit, as we saw in chapter 10. The choice must be gradual: a short overloading demonstration can keep simple numbers; a type intended for maintenance and use by many components deserves a stronger contract. The revision does not add keywords as decoration: it uses new tools when they solve a real defect in the example.

Good practice – A rule owned by one class. The base must protect its own data; the subclass must add behavior specializing it without duplicating the base's internal checks. If every Car must remember to put speed = 0 in its own constructor, a future subclass may forget. A base constructor establishing common state makes the invariant easier to maintain throughout the hierarchy.

When a subclass adds and when it changes

A subclass can add a method the base does not have. Car might offer openTrunk(): someone with a reference declared Car can call it, while someone with only Vehicle cannot. This is coherent if the trunk is not part of every vehicle's contract. If instead the subclass overrides description(), it changes the body of an operation already known to the base. A call through Vehicle continues to compile, but at runtime reaches the subclass body. These are two different ways to specialize a class and have different effects on its user.

A method declared final in the base cannot be overridden by the subclass. This may be sensible if the base must maintain an invariant step, for example a check preceding a more specific operation. Declaring every method final for fear of subclasses, however, makes promising an extension point pointless. Conversely, leaving a method called by the constructor overridable exposes the base to the risk of executing code on an incomplete subclass. The extension choice must be documented in the contract: which methods can be customized, which invariants they must maintain and when they can be called.

Fields do not follow the dynamic dispatch of overridden methods. If a subclass declares a field with the same name as one in the base, the new field hides the inherited one in the appropriate context; it does not become an alternative version of the same data selected according to the actual object. This makes code difficult to read: two apparently similar expressions can refer to different fields depending on the type through which they are written. To represent common state, leaving control of its fields to the base and offering methods with precise meaning is clearer. super.name() can call an accessible base method; duplicating a name field in the subclass would instead create two sources of truth.

Important note – @Override protects an intention. Write it when you intend to override a method: if you get the signature wrong, the compiler warns you. Without the annotation, a method with a different parameter could become an overload, and the program would continue compiling while executing the base body in calls you wanted to change. The annotation does not make the new implementation correct at the contract level, but catches a very common form error.

A case where composition makes change local

Imagine that a vehicle description must include a pricing policy. A car and bicycle can both be vehicles, but the rate also depends on location, day and promotions. If we put all those rules in Vehicle and then override them in every subclass, each new policy risks forcing us to change the whole hierarchy. We can instead make the vehicle collaborate with a Rate object receiving the necessary data and calculating the price. The vehicle keeps its own identity and vehicle behavior; the policy has a separate responsibility that can be tested alone.

This does not mean introducing a support object for every line of code. It means recognizing two different axes of variation: the vehicle type and the pricing rule. Inheritance models an “is a” relationship well when the common contract is stable. Composition helps when behavior can change independently of that classification. The criterion also applies to the engine example: Car is a Vehicle and has an Engine; a future car with a different power source does not require a new superclass just to describe the component.

Closing a hierarchy when the domain requires it

The word sealed in Vehicle limits permitted direct subclasses to those listed by permits: here Car and Bicycle. Both are final, so they cannot be extended further. A permitted subclass could also be sealed, continuing to fix a list, or non-sealed, reopening extension. This choice must be made on the model: if we know the domain has a closed set of cases, the compiler can help check that all are handled. If instead a library must allow unknown extensions, closing it can be an obstacle.

Really designing a closed hierarchy

A sealed type is not a final class with longer spelling. final prevents every further subclass; sealed permits precisely the direct subclasses expected by the declaration. If Vehicle permits Car and Bicycle, a new Scooter extends Vehicle class cannot be added in another file without changing the authorized hierarchy. The rule is intentional: when the domain has a known set of alternatives, adding an alternative must become a visible change for those maintaining algorithms that examine them.

Every permitted direct subclass must declare how its own story continues: final closes it, sealed in turn lists permitted descendants, non-sealed reopens extension. If we declare non-sealed Car, another class can extend Car according to normal access rules; the base still knows the direct variant Car, but can no longer list all its possible concrete subclasses. The choice does not concern only the compiler: it tells model readers which boundaries are established and which points are offered for new specializations.

For our three types in the same file, permits Car, Bicycle makes the relationship explicit. Under some conditions the compiler can infer permitted subclasses declared in the same file, but for a reader learning the hierarchy, the written list has teaching value. In a modular project the permitted direct subclasses belong to the same named module as the base; outside a named module they must belong to the same package. We therefore cannot use permits as an open registry of plugins distributed in arbitrary modules. An API designed for external extensions has a different need from a closed hierarchy.

Further reading – Closure and maintenance. Limiting the hierarchy does not prevent errors in subclass method bodies. It does help us know where to look for variants and identify some algorithms to update when one is added. If a new Scooter type enters the domain, the exhaustive switch that currently handles car and bicycle must be reconsidered. This compiler-imposed work is useful when the classification is really closed; it is an unnecessary cost when the platform must receive external implementations without changing the base module.

The final switch uses precisely that known set:

String category = switch (vehicle) {
    case Car car -> "car";
    case Bicycle bicycle -> "bicycle";
};

There is no default: for a sealed hierarchy whose direct and concrete subtypes the compiler knows, the two cases cover the permitted non-null values. If vehicle were null, this switch would still throw an exception at runtime; when null is expected by the contract, it must be handled explicitly as we did in chapter 5. Removing a case prevents compilation because the expression is no longer exhaustive. The official summary distinguishes sealed classes, stable since Java 17, from pattern matching for switch, stable since Java 21; both are ordinary in Java 25.

The switch answers a different question from the virtual method description(). When we ask every vehicle to describe itself, behavior belongs to the variant and dynamic dispatch chooses the body. When an external algorithm must produce an administrative category for each known variant, an exhaustive switch can make it visible that all cases have been considered. We do not turn every polymorphic method into a switch, or hide every external decision inside subclasses on principle. Behavior's location depends on who owns the rule and how often types and operations change.

Key concept – Substitutability before syntax. extends is easy to write; the real work is ensuring every subclass respects what a caller can reasonably expect from the base class. A closed hierarchy makes explicit who can extend it, but does not automatically make its contract good.

The word final takes related but distinct meanings according to the declaration: on a class it prevents subclasses; on a method it prevents overriding; on a field it prevents a new assignment after permitted initialization. It does not alone guarantee that objects reachable from that field are immutable. Keeping these three cases separate helps us read final class Car, final String name and final String description() without attributing the same promise to all.

Closed hierarchies, immutable data and records

Vehicle is sealed because in this example we want to know its direct variants. A final class instead closes extension completely. A record is implicitly final and can be a good way to represent a transparent value such as Engine(String powerSource). None of these words makes everything the object can reach deeply immutable. If a record contains an array, the component reference cannot be changed through an ordinary setter, but the array elements can be modified by whoever possesses that reference. Chapter 8's defensive copies remain relevant.

Two NaturalPerson objects with the same data show that records compare components. It is a good example if components are values whose equals represents the desired comparison. With an array component, however, generated equality uses that array's equality, which is normally identity-based, not an element-by-element comparison. The word “record” does not alone resolve every decision about equality's meaning.

The person in the historical example and the meaning of “equal”

In the record NaturalPerson(firstName, lastName, age, height, weight), two instances separately created with the same components are equal according to equals. The result lets us see that the compiler generates a comparison consistent with the component declaration. But it does not demonstrate that a person in a real personal-record domain is identified by those five pieces of data. Two people can have equal names and measurements; one person can change age or weight. The record represents a data snapshot well when component comparison is the question we want to ask. For identity persisting while data change, an explicit decision is needed, for example a stable identifier safeguarded by a class with an appropriate contract.

Immutability must be read the same way, starting from observation. A record offers no component setters, and corresponding fields cannot be reassigned after construction. If a component is a String, the text value is immutable; if a component is an array, the reference is not reassigned but elements can change. This is the difference between shallow immutability and protecting the entire reachable object graph. Chapter 8's GradeRegister used entry and exit copies precisely to prevent indirect changes. We do not promise thread safety or security in general merely because a class is final or a record: we must examine all mutable objects it exposes and how they are published between threads.

In chapter 7 we presented Edition(String title, int year) as a transparent value. Here the record Engine(String powerSource) is a car component. Both help reduce repetitive code when component declarations match the value's meaning. Vehicle, in contrast, defines a behavioral contract for substitutable variants. Records, final and sealed answer different questions: how I represent a value, whether I permit further subclasses and which direct subclasses I authorize. They can be used together in a project, but must not be treated as three versions of the same solution.

Initialization order also becomes more interesting with a hierarchy. Before executing the Car constructor body, its Vehicle part must be constructed; initializers and constructors participate in a defined sequence. This is why Vehicle should not call a method that Car can override from its own constructor: the Car body may not yet have assigned engine, and the override would read incomplete state. The small DemoOrder program shows the case without blocks; the instance-creation specification describes the full sequence. When order matters, we verify it with a reduced case instead of trusting the visual impression of lines.

To verify

Compile the complete program with javac --release 25 -Xlint:all -d build DemoVehicles.java, then execute java -cp build DemoVehicles from the file's folder. Before launching it, predict the three printed lines. Then replace construction of the Car with new Bicycle("B"): which switch branch will be selected? What must you do with the instanceof check? Activity C09 separates these two observations.

For a second check, keep the declaration Vehicle vehicle fixed and change only the assigned object: first Car, then Bicycle. Separately predict three things: which methods the compiler permits naming, which description() body executes and which switch case recognizes the object. If you change only the reference's declared type while keeping the same instance, the first answer may change while the object does not transform. This separation is the criterion for checking longer examples too.

Finally try explaining without code why Car can have an Engine without extending it, why Wheel can call super(radius) without directly assigning the base's private field, and why catching ClassCastException does not turn a wrong conversion into a good design choice. If an answer depends only on the keyword, return to the relationship between contracts and objects: syntax serves to express that relationship, not replace it.

Try the examples

Running the programs requires JDK 25. Download the individual Java files linked in the chapter or the complete example project, which includes instructions and a launcher script. The explanations also compare expected output: predict it before running the program.

Massimiliano Tarquini · CC BY-NC 4.0

Back to the top ↑