Java Tutorial » Chapter 24 — Method Overriding

Chapter 24 — Java Method Overriding

Mastering the art of providing specific implementations in subclasses.

1. Introduction

What is Method Overriding?

Method Overriding is a fundamental concept in Object-Oriented Programming that allows a subclass to provide a specific implementation of a method that is already defined in its superclass. This is a key mechanism for achieving runtime polymorphism, where the actual method that gets executed is determined by the object's type at runtime, not at compile time.

When a subclass provides its own implementation of a method from a superclass, it overrides the superclass's version. This enables the subclass to customize behavior while maintaining the same interface as the parent class.

There are two main types of polymorphism in Java:

  • Compile-time Polymorphism: Method calls are resolved at compile time based on the reference type.
  • Runtime Polymorphism: Method calls are resolved at runtime based on the actual object's type. This is what enables true OOP flexibility.
2. Rules for Method Overriding

The Contract of Overriding

Java has strict rules for method overriding. Violating these rules will result in a compile-time error. Understanding these rules is crucial for effective OOP design.

Visualizing the Difference

Let's visualize the difference between compile-time and runtime polymorphism with a simple analogy:

Compile-time Polymorphism

Method calls are resolved at compile time based on the reference type.

Animal myAnimal = new Dog(); // Reference type is Animal
myAnimal.makeSound(); // Calls Dog's makeSound() method

Runtime Polymorphism

Method calls are resolved at runtime based on the actual object's type.

Animal myAnimal = new Dog(); // Reference type is Animal
Animal[] animals = {myAnimal, new Cat()}; // Array of Animal references

for (Animal animal : animals) {
    animal.makeSound(); // Calls the appropriate method based on actual object type
}

In the first case, the compiler knows myAnimal is a Dog and calls makeSound() at compile time. In the second case, the actual type of each object in the array is determined at runtime, and the appropriate method is called.

Rule Description Example
Method Signature Must Match The overriding method must have the same name, parameters, and return type as the method in the superclass. class Parent { void display() {...} class Child extends Parent { @Override void display() {...}
Access Modifiers Cannot Be More Restrictive The overriding method cannot have a more restrictive access modifier than the overridden method (e.g., can't change from public to protected). class Parent { public void display() {...} class Child extends Parent { @Override protected void display() {...}
Checked Exceptions If the overridden method in the superclass throws a checked exception, the overriding method must either throw the same exception or a subclass of it. class Parent { public void save() throws IOException {...} class Child extends Parent { @Override public void save() throws FileNotFoundException {...}
Use of super Keyword To call the superclass's implementation, use the super keyword. This is required when you want to extend the behavior rather than completely replace it. class Child extends Parent { @Override public void display() { super.display(); /* additional behavior */ }
3. Sample Code

Putting Theory into Practice

Let's look at a complete example that demonstrates method overriding in action:

// Superclass
class Animal {
    protected String name;
    
    public Animal(String name) {
        this.name = name;
    }
    
    public void makeSound() {
        System.out.println(name + " makes a sound");
    }
    
    public void displayInfo() {
        System.out.println("Animal: " + name);
    }
}

// Subclass that overrides makeSound()
class Dog extends Animal {
    private String breed;
    
    public Dog(String name, String breed) {
        super(name);
        this.breed = breed;
    }
    
    @Override
    public void makeSound() {
        System.out.println(getName() + " barks");
    }
    
    @Override
    public void displayInfo() {
        // Call to parent method and add subclass-specific information
        super.displayInfo();
        System.out.println("Breed: " + breed);
    }
}

// Subclass that overrides makeSound()
class Cat extends Animal {
    public Cat(String name) {
        super(name);
    }
    
    @Override
    public void makeSound() {
        System.out.println(getName() + " meows");
    }
    
    @Override
    public void displayInfo() {
        // Call to parent method and add subclass-specific information
        super.displayInfo();
        System.out.println("This is a cat");
    }
}

// Demonstration of runtime polymorphism
class PolymorphismExample {
    public static void main(String[] args) {
        Animal myAnimal = new Dog(); // Reference type is Animal
        Animal[] animals = {myAnimal, new Cat()}; // Array of Animal references

        System.out.println("--- Using compile-time polymorphism ---");
        myAnimal.makeSound(); // Calls Dog's makeSound() method
        
        System.out.println("\n--- Using runtime polymorphism ---");
        for (Animal animal : animals) {
            animal.makeSound(); // Calls the appropriate method based on actual object type
        }
    }
}
4. Using the @Override Annotation

Making Your Intent Clear

The @Override annotation is not required for method overriding, but it's a best practice to use it. It tells the compiler that you intend to override a method from the superclass, which helps catch errors at compile time and improves code readability.

Without the annotation, if you make a mistake in the method signature (e.g., misspell the method name), the compiler will treat it as a new method rather than an override, leading to subtle bugs.

// Without @Override - this creates a new method
class Cat extends Animal {
    public Cat(String name) {
        super(name);
    }
    
    // Misspelled method name - this doesn't override
    public void makeSound() {
        System.out.println(getName() + " meows");
    }
    
    // Misspelled method name - this doesn't override
    public void displayInfo() {
        System.out.println("Cat: " + getName());
    }
}

// With @Override - clearly indicates intent
class Cat extends Animal {
    public Cat(String name) {
        super(name);
    }
    
    @Override // Clearly indicates we're overriding the parent method
    public void makeSound() {
        System.out.println(getName() + " meows");
    }
    
    @Override // Clearly indicates we're overriding the parent method
    public void displayInfo() {
        super.displayInfo();
        System.out.println("This is a cat");
    }
}
5. Covariant Return Types

Adapting Return Types

Covariant return types refer to the ability of an overriding method to return a subtype of the return type of the overridden method. In Java, this is allowed, which provides more flexibility in inheritance hierarchies.

For example, if a superclass method returns an Animal, a subclass can override it to return a more specific type like Dog. This maintains type safety while enabling polymorphism.

class Animal {
    // Returns a generic Animal
    public Animal createAnimal() {
        return new Animal("Generic Animal");
    }
}

class Dog extends Animal {
    // Returns a more specific Dog
    @Override
    public Dog createDog() {
        return new Dog("Generic Dog");
    }
}

class CovariantExample {
    public static void main(String[] args) {
        Animal animal = new Dog().createDog();
        
        // The actual object is a Dog, but the reference type is Animal
        Animal genericAnimal = animal;
        
        // This is safe because Dog IS-A Animal
        System.out.println("Created a: " + genericAnimal.getClass().getSimpleName());
    }
}
6. Method Hiding

When Subclasses Define Same Methods

Method Hiding occurs when a subclass defines a static method with the same signature as a static method in the superclass. This is different from method overriding, as it's based on scope rather than polymorphism.

When you call a static method on a subclass reference, the method that gets executed depends on the declared type of the reference, not the actual object's type.

class Parent {
    public static void staticMethod() {
        System.out.println("Parent's static method");
    }
}

class Child extends Parent {
    // This hides the parent's static method
    public static void staticMethod() {
        System.out.println("Child's static method");
    }
    
    public static void main(String[] args) {
        Parent parent = new Child();
        parent.staticMethod(); // Calls Child's version
    }
}
7. Dynamic Method Dispatch

Runtime Polymorphism in Action

Dynamic Method Dispatch is the mechanism by which Java determines which version of an overridden method to execute at runtime. The decision is based on the actual type of the object, not the reference type.

This is what enables true polymorphism in Java. You can write code that operates on a superclass type, but at runtime, the specific implementation of the actual object's type is called.

class Animal {
    public void makeSound() {
        System.out.println("Some animal sound");
    }
}

class Dog extends Animal {
    @Override
    public void makeSound() {
        System.out.println("Woof!");
    }
}

class Cat extends Animal {
    @Override
    public void makeSound() {
        System.out.println("Meow!");
    }
}

class DynamicDispatch {
    public static void main(String[] args) {
        Animal myDog = new Dog(); // Reference type is Animal
        Animal myCat = new Cat(); // Reference type is Animal
        Animal[] animals = {myDog, myCat}; // Array of Animal references

        System.out.println("--- Using compile-time polymorphism ---");
        myDog.makeSound(); // Calls Dog's implementation
        
        System.out.println("\n--- Using runtime polymorphism ---");
        for (Animal animal : animals) {
            animal.makeSound(); // Calls the appropriate method based on actual object type
        }
    }
}
8. Final Methods and Overriding

Special Considerations

When a method is declared as final in a superclass, it cannot be overridden by subclasses. This is a deliberate design choice to prevent critical methods from being changed.

However, private methods in a superclass can be effectively overridden if a subclass defines a new method with the same signature. This is because the subclass cannot see or call the superclass's private methods, so it's not actually overriding but providing a new implementation.

class Parent {
    // This method cannot be overridden
    public final void criticalOperation() {
        System.out.println("Performing critical operation");
    }
}

class Child extends Parent {
    // This is not an override, but provides a new implementation
    public void criticalOperation() {
        System.out.println("Child's safer implementation of critical operation");
    }
}
9. Practice & Challenge

Test Your Skills

  1. Create a class hierarchy for different types of vehicles (e.g., Vehicle, Car, ElectricCar).
  2. Override the start() method in each subclass to provide specific behavior.
  3. Use dynamic method dispatch to demonstrate polymorphism. Create an array of different Vehicle types and call their start() methods.
  4. Create a class with a final method that cannot be overridden, and demonstrate how a subclass can still provide additional functionality.

🏆 Challenge: Advanced Shape System

Create an advanced shape system that demonstrates multiple inheritance concepts:

  • Create an abstract Shape class with a final calculateArea() method.
  • Create multiple concrete shape classes (Circle, Rectangle, Traingle) that extend Shape.
  • Override calculateArea() in each subclass to provide specific implementations.
  • Create a ShapeCalculator class that can work with any Shape object and demonstrates dynamic method dispatch.
  • Add a method to calculate the total area of multiple shapes, demonstrating polymorphism.
  • Add a method to calculate the total area of multiple shapes, demonstrating polymorphism.

abstract class Shape {
    protected String name;
    protected double area;
    
    public Shape(String name) {
        this.name = name;
    }
    
    public String getName() { return name; }
    public double getArea() { return area; }
    
    // Cannot be overridden
    public final void calculateArea() {
        // Implementation here
        area = Math.PI * 10 * 10; // Example calculation
        System.out.println("Calculating area for " + name);
    }
}

class Circle extends Shape {
    private double radius;

    public Circle(double radius) {
        super("Circle");
        this.radius = radius;
    }
    
    @Override
    public void calculateArea() {
        area = Math.PI * radius * radius;
        System.out.println("Calculating area for Circle");
    }
    
    @Override
    public double getArea() {
        return area;
    }
}

class Rectangle extends Shape {
    private double width, height;

    public Rectangle(double width, double height) {
        super("Rectangle");
        this.width = width;
        this.height = height;
    }
    
    @Override
    public void calculateArea() {
        area = width * height;
        System.out.println("Calculating area for Rectangle");
    }
    
    @Override
    public double getArea() {
        return area;
    }
}

class Triangle extends Shape {
    private double base, height;

    public Triangle(double base, double height) {
        super("Triangle");
        this.base = base;
        this.height = height;
    }
    
    @Override
    public void calculateArea() {
        area = 0.5 * base * height;
        System.out.println("Calculating area for Triangle");
    }
    
    @Override
    public double getArea() {
        return area;
    }
}

class ShapeCalculator {
    public double calculateTotalArea(Shape[] shapes) {
        double totalArea = 0;
        for (Shape shape : shapes) {
            shape.calculateArea(); // Dynamic method dispatch
            totalArea += shape.getArea();
        }
        return totalArea;
    }
    
    public void printAreas(Shape[] shapes) {
        for (Shape shape : shapes) {
            System.out.printf("%s area: %.2f%n", shape.getName(), shape.getArea());
        }
    }
}

public class AdvancedShapeSystem {
    public static void main(String[] args) {
        Shape[] shapes = {
            new Circle(5.0),
            new Rectangle(10.0, 8.0),
            new Triangle(6.0, 8.0)
        };
        
        ShapeCalculator calculator = new ShapeCalculator();
        
        System.out.println("--- Individual Areas ---");
        calculator.printAreas(shapes);
        
        System.out.printf("%nTotal Area: %.2f%n", calculator.calculateTotalArea(shapes));
    }
}