Java Inheritance: Syntax and Design Tradeoffs
java inheritance: Understand Java inheritance through practical code examples: extends syntax, method overriding, constructor chains, super, polymorphism, and when com...
java inheritance requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
In Java, inheritance is the mechanism that lets one class acquire the fields and methods of another class. The extends keyword declares the relationship, and the subclass can then reuse, extend, or replace behavior defined in the superclass. This is a core feature of object-oriented programming, but its practical value depends on how carefully you design the class hierarchy.
What Inheritance Does in Java
When a class inherits from another, the subclass automatically has access to all non-private fields and methods of the superclass. It can also add its own members or override existing ones. The primary benefit is code reuse: common state and behavior live in one place, and subclasses only implement differences.
Consider a simple example:
public class Animal { protected String name; public Animal(String name) { this.name = name; } public void eat() { System.out.println(name + " is eating."); } } public class Dog extends Animal { public Dog(String name) { super(name); } public void bark() { System.out.println(name + " says woof."); } }
Here Dog inherits eat() and the name field from Animal. The Dog class adds a bark() method. Without inheritance, you would need to duplicate eat() in every animal class or use a helper class, which quickly becomes unmaintainable.
Declaring a Subclass with extends
The extends keyword is the only way to declare a direct superclass in Java. A class can extend exactly one superclass, because Java does not support multiple inheritance of classes. The syntax is straightforward:
public class Subclass extends Superclass { // additional fields and methods }
The superclass must be accessible, and it cannot be final if you intend to extend it. If the superclass has no default constructor, the subclass constructor must explicitly call a superclass constructor using super(...). The compiler enforces this rule to guarantee that the superclass is fully initialized before the subclass constructor body runs.
Method Overriding and the @Override Annotation
A subclass can redefine a method from its superclass. This is called method overriding. The overriding method must have the same signature and return type (or a covariant return type). Access level cannot be more restrictive than the overridden method. The @Override annotation is optional but strongly recommended because it makes the compiler check that you are actually overriding a method, not accidentally declaring a new one.
public class Cat extends Animal { public Cat(String name) { super(name); } @Override public void eat() { System.out.println(name + " is eating fish."); } }
Without @Override, a typo in the method name would silently create a separate method, and the subclass would still inherit the original eat() from Animal. The annotation catches this mistake at compile time.
How Constructors Behave in an Inheritance Chain
Constructors are not inherited in Java. Each class must define its own constructors, and every constructor must call either another constructor in the same class (this(...)) or a constructor of the superclass (super(...)). If you do not write an explicit super(...) call, the compiler inserts a call to the no-argument constructor of the superclass. If that constructor does not exist, you get a compilation error.
public class Vehicle { protected String model; public Vehicle(String model) { this.model = model; } } public class Car extends Vehicle { private int doors; public Car(String model, int doors) { super(model); // must be the first statement this.doors = doors; } }
This ordering ensures that the superclass state is initialized before the subclass adds its own. Attempting to access superclass fields before super(...) runs will fail because those fields are not yet set.
Using super to Access Superclass Members
The super keyword serves two purposes: calling a superclass constructor and accessing superclass members that are hidden or overridden. Inside a subclass, super.method() invokes the superclass version of a method, which is useful when the subclass wants to extend the original behavior rather than replace it.
public class Husky extends Dog { public Husky(String name) { super(name); } @Override public void eat() { super.eat(); // call Animal.eat() System.out.println(name + " also likes treats."); } }
Here super.eat() runs the Animal implementation, and the subclass adds extra output. This pattern is common when a subclass needs to augment the superclass logic without duplicating it.
Polymorphism and Upcasting
Inheritance enables polymorphism: a reference of the superclass type can point to an object of any subclass. This allows you to write code that works with the superclass type and automatically supports all current and future subclasses.
Animal pet = new Dog("Rex"); pet.eat(); // calls Animal.eat() unless overridden
If Dog does not override eat(), the inherited version runs. If it does override, the subclass version runs. This dynamic dispatch happens at runtime based on the actual object type, not the reference type. The tradeoff is that you can only call methods declared in the superclass through such a reference. To call bark(), you must cast back to Dog or use a more specific reference type.
Common Pitfalls with Inheritance
Inheritance is easy to misuse. One frequent mistake is overriding a method but forgetting to call super when the superclass method performs essential setup. Another is designing a deep hierarchy where a change in a superclass unexpectedly affects many subclasses. This is known as the fragile base class problem.
A more subtle issue is overriding equals or hashCode in a subclass without respecting the superclass contract. If two subclasses of the same superclass are compared, the equals method must be symmetric and transitive, which is hard to guarantee when inheritance is involved. Many developers prefer to avoid inheritance for value objects and use composition instead.
Composition as an Alternative
Composition means building a class by holding references to other objects, rather than inheriting state and behavior. It gives you more control over which methods are exposed and avoids the coupling that inheritance introduces.
public class Dog { private Animal animal; public Dog(String name) { this.animal = new Animal(name); } public void eat() { animal.eat(); } public void bark() { System.out.println(animal.name + " says woof."); } }
This approach makes the dependency explicit and allows you to change the internal implementation without affecting callers. The general rule is to prefer composition when the relationship is not a true "is-a" relationship, or when you want to limit what subclasses can override. Inheritance is still valuable when you need polymorphism and a stable superclass contract, but it should be used deliberately.
Maintainability and Runtime Considerations
Inheritance affects maintainability in ways that are not always obvious at compile time. Because method dispatch is dynamic, a change in a superclass method can alter the behavior of all subclasses, even those that did not override it. This can introduce subtle bugs in production if the superclass is modified without considering all subclasses. The runtime cost of virtual method dispatch is negligible in modern JVMs, but the maintenance cost can be significant.
When you design a hierarchy, keep it shallow. Prefer interfaces for defining contracts and use inheritance only for code reuse that is genuinely tied to the type hierarchy. If you control the superclass, document which methods are intended for overriding and which are not. Mark methods final if they must not be overridden, and mark the class final if it should not be extended at all. These constraints make the behavior of the hierarchy predictable and reduce the risk of unintended interactions.