🚀 Method Overriding in Java: A Comprehensive Guide

🌟 Introduction to Method Overriding in Java

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 parent class. This powerful feature is one of the cornerstones of polymorphism in Java, enabling objects to behave differently based on their actual class, even when they're referenced by a common superclass type.

When a subclass overrides a method from its superclass, it essentially says, "I know my parent has a way of doing this, but I need to do it differently." This capability allows for more specialized behavior in derived classes while maintaining a common interface defined by the parent class.

In this comprehensive tutorial, we'll explore:

  • What method overriding is and how it differs from method overloading
  • The rules and conditions for properly overriding methods
  • How access modifiers, return types, and exceptions affect overriding
  • Common pitfalls and best practices
  • Real-world applications and use cases

Whether you're just starting with Java or looking to deepen your understanding of object-oriented principles, this guide will provide you with a solid foundation in method overriding.


🧩 Understanding Java Method Overriding

What is Method Overriding in Java?

Method overriding occurs when a subclass provides a specific implementation for a method that is already defined in its parent class. The overridden method in the subclass must have:

  • The same name as the method in the parent class
  • The same parameter list (number, type, and order of parameters)
  • A compatible return type (more on this later)

Here's a simple example:

// Parent class
class Animal {
    public void makeSound() {
        System.out.println("The animal makes a sound");
    }
}

// Child class
class Dog extends Animal {
    @Override
    public void makeSound() {
        System.out.println("The dog barks");
    }
}

In this example, the Dog class overrides the makeSound() method from the Animal class to provide a more specific implementation.

Java Method Overriding vs. Method Overloading

It's important to distinguish between method overriding and method overloading, as they are often confused:

Feature Method Overriding Method Overloading
Definition Providing a new implementation for an inherited method Creating multiple methods with the same name but different parameters
Inheritance Requires inheritance relationship Can occur within the same class
Method signature Must have the same name and parameters Must have the same name but different parameters
Return type Must be compatible (covariant) Can be different
Runtime/Compile time Runtime polymorphism Compile-time polymorphism

The @Override Annotation

While not strictly required, it's a best practice to use the @Override annotation when overriding a method:

class Dog extends Animal {
    @Override
    public void makeSound() {
        System.out.println("The dog barks");
    }
}

This annotation serves two important purposes:

  1. It clearly communicates your intention to override a method
  2. It helps the compiler catch errors if you're not actually overriding a method (e.g., if you misspelled the method name or used different parameters)

📋 Rules and Conditions for Method Overriding in Java

1. Access Modifiers

When overriding a method, the access modifier in the subclass cannot be more restrictive than the access modifier in the superclass. The hierarchy of access modifiers from least restrictive to most restrictive is:

  1. public
  2. protected
  3. Default (no modifier)
  4. private

Here's what's allowed and what's not:

Superclass Method Allowed Subclass Method Modifiers
public public only
protected public, protected
Default public, protected, default
private Cannot be overridden

Example:

class Parent {
    public void methodA() { }
    protected void methodB() { }
    void methodC() { }
    private void methodD() { }
}

class Child extends Parent {
    // Valid: same access level
    @Override
    public void methodA() { }
    
    // Valid: less restrictive access
    @Override
    public void methodB() { }
    
    // Valid: less restrictive access
    @Override
    protected void methodC() { }
    
    // Invalid: methodD is private in Parent and cannot be overridden
    // This would be a new method, not an override
    private void methodD() { }
}

2. Return Types

In Java 5 and later, an overriding method can return a subtype of the return type declared in the superclass method. This is known as covariant return types.

class Animal {
    public Animal getAnimal() {
        return new Animal();
    }
}

class Dog extends Animal {
    @Override
    public Dog getAnimal() {  // Covariant return type
        return new Dog();
    }
}

In this example, Dog.getAnimal() returns a Dog object, which is a subtype of Animal (the return type of Animal.getAnimal()).

However, this only works for reference types. For primitive return types, the overriding method must have exactly the same return type.

3. Exception Handling

When it comes to exceptions, an overriding method:

  • Can throw any unchecked exceptions (RuntimeException and its subclasses, Error and its subclasses), regardless of whether the superclass method declares them
  • Cannot throw checked exceptions that are broader or new compared to those declared by the superclass method
  • Can throw narrower checked exceptions or fewer exceptions than the superclass method

Here's a diagram showing the exception hierarchy:

Throwable
├── Error (unchecked)
└── Exception
    ├── RuntimeException (unchecked)
    └── Other Exceptions (checked)

Examples:

import java.io.IOException;
import java.io.FileNotFoundException;

class Parent {
    // Declares a checked exception
    public void methodA() throws IOException {
        // Implementation
    }
    
    // Declares no exceptions
    public void methodB() {
        // Implementation
    }
    
    // Declares multiple checked exceptions
    public void methodC() throws IOException, ClassNotFoundException {
        // Implementation
    }
}

class Child extends Parent {
    // Valid: throws the same exception
    @Override
    public void methodA() throws IOException {
        // Implementation
    }
    
    // Valid: throws a narrower exception
    @Override
    public void methodA() throws FileNotFoundException {  // FileNotFoundException is a subclass of IOException
        // Implementation
    }
    
    // Valid: throws no exceptions
    @Override
    public void methodA() {
        // Implementation
    }
    
    // Invalid: throws a broader exception
    @Override
    public void methodA() throws Exception {  // Exception is broader than IOException
        // Implementation
    }
    
    // Invalid: throws a new checked exception
    @Override
    public void methodB() throws IOException {  // Parent's methodB doesn't declare IOException
        // Implementation
    }
    
    // Valid: throws unchecked exceptions
    @Override
    public void methodB() throws RuntimeException {
        // Implementation
    }
    
    // Valid: throws fewer exceptions
    @Override
    public void methodC() throws IOException {
        // Implementation
    }
}

4. Method Visibility

A method cannot be overridden if it is:

  • final: Final methods cannot be overridden
  • static: Static methods cannot be overridden (they can be hidden, which is different)
  • private: Private methods are not visible to subclasses, so they cannot be overridden

Example:

class Parent {
    public final void finalMethod() {
        System.out.println("This is a final method");
    }
    
    public static void staticMethod() {
        System.out.println("This is a static method");
    }
    
    private void privateMethod() {
        System.out.println("This is a private method");
    }
}

class Child extends Parent {
    // Invalid: cannot override a final method
    @Override
    public void finalMethod() {
        // This will cause a compilation error
    }
    
    // This is method hiding, not overriding
    public static void staticMethod() {
        System.out.println("This is a static method in Child");
    }
    
    // This is a new method, not an override
    private void privateMethod() {
        System.out.println("This is a private method in Child");
    }
}

5. Abstract Methods

Abstract methods in an abstract class must be overridden in concrete subclasses. They provide a contract that subclasses must fulfill.

abstract class Shape {
    // Abstract method - must be overridden by concrete subclasses
    public abstract double calculateArea();
}

class Circle extends Shape {
    private double radius;
    
    public Circle(double radius) {
        this.radius = radius;
    }
    
    @Override
    public double calculateArea() {
        return Math.PI * radius * radius;
    }
}

6. Interface Methods

Methods declared in an interface are implicitly public and abstract (unless they are default or static methods in Java 8+). When a class implements an interface, it must override all the abstract methods.

interface Drawable {
    void draw();  // Implicitly public and abstract
}

class Rectangle implements Drawable {
    @Override
    public void draw() {
        System.out.println("Drawing a rectangle");
    }
}

🔄 Complete Example: Method Overriding in Action

Let's explore a comprehensive example that demonstrates various aspects of method overriding:

Too see full example, Click to expand
import java.io.IOException;
import java.io.FileNotFoundException;
import java.util.ArrayList;
import java.util.List;

// Base class for all vehicles
abstract class Vehicle {
    private String make;
    private String model;
    private int year;
    
    public Vehicle(String make, String model, int year) {
        this.make = make;
        this.model = model;
        this.year = year;
    }
    
    // Abstract method that must be implemented by all subclasses
    public abstract void start() throws IOException;
    
    // Regular method that can be overridden
    public void stop() {
        System.out.println("Vehicle stopped");
    }
    
    // Final method that cannot be overridden
    public final String getInfo() {
        return year + " " + make + " " + model;
    }
    
    // Method with a return type
    public Vehicle getVehicle() {
        return this;
    }
    
    // Method that throws exceptions
    public void checkMaintenance() throws IOException, ClassNotFoundException {
        System.out.println("Checking vehicle maintenance...");
    }
    
    // Protected method
    protected void performMaintenance() {
        System.out.println("Performing standard maintenance");
    }
    
    // Getters
    public String getMake() { return make; }
    public String getModel() { return model; }
    public int getYear() { return year; }
}

// Car class extending Vehicle
class Car extends Vehicle {
    private int numDoors;
    
    public Car(String make, String model, int year, int numDoors) {
        super(make, model, year);
        this.numDoors = numDoors;
    }
    
    // Implementing the abstract method
    @Override
    public void start() {
        System.out.println("Car started: Turn key in ignition");
    }
    
    // Overriding a regular method
    @Override
    public void stop() {
        System.out.println("Car stopped: Apply brakes and shift to park");
    }
    
    // Overriding with covariant return type
    @Override
    public Car getVehicle() {
        return this;
    }
    
    // Overriding with narrower exception
    @Override
    public void checkMaintenance() throws FileNotFoundException {
        System.out.println("Checking car maintenance...");
    }
    
    // Overriding with less restrictive access modifier
    @Override
    public void performMaintenance() {
        System.out.println("Performing car-specific maintenance");
    }
    
    // Car-specific method
    public void honk() {
        System.out.println("Honk! Honk!");
    }
    
    public int getNumDoors() { return numDoors; }
}

// ElectricCar class extending Car
class ElectricCar extends Car {
    private int batteryCapacity;
    
    public ElectricCar(String make, String model, int year, int numDoors, int batteryCapacity) {
        super(make, model, year, numDoors);
        this.batteryCapacity = batteryCapacity;
    }
    
    // Overriding the start method with no exceptions
    @Override
    public void start() {
        System.out.println("Electric car started: Press start button");
    }
    
    // Overriding with no exceptions
    @Override
    public void checkMaintenance() {
        System.out.println("Checking electric car maintenance...");
    }
    
    // Electric car specific method
    public void charge() {
        System.out.println("Charging electric car...");
    }
    
    public int getBatteryCapacity() { return batteryCapacity; }
}

// Motorcycle class extending Vehicle
class Motorcycle extends Vehicle {
    private boolean hasSidecar;
    
    public Motorcycle(String make, String model, int year, boolean hasSidecar) {
        super(make, model, year);
        this.hasSidecar = hasSidecar;
    }
    
    // Implementing the abstract method with the same exception
    @Override
    public void start() throws IOException {
        System.out.println("Motorcycle started: Kick start or press button");
        if (!hasSidecar) {
            System.out.println("Balance required!");
        }
    }
    
    // Motorcycle-specific method
    public void wheelie() {
        System.out.println("Performing a wheelie!");
    }
    
    public boolean hasSidecar() { return hasSidecar; }
}

// Main class to demonstrate method overriding
public class VehicleDemo {
    public static void main(String[] args) {
        // Create a list of vehicles
        List<Vehicle> vehicles = new ArrayList<>();
        vehicles.add(new Car("Toyota", "Camry", 2020, 4));
        vehicles.add(new ElectricCar("Tesla", "Model S", 2021, 4, 100));
        vehicles.add(new Motorcycle("Harley-Davidson", "Street 750", 2019, false));
        
        // Demonstrate polymorphism through method overriding
        for (Vehicle vehicle : vehicles) {
            System.out.println("\nVehicle Info: " + vehicle.getInfo());
            
            try {
                // This will call the appropriate overridden method based on the actual object type
                vehicle.start();
                
                // This will also call the appropriate overridden method
                vehicle.stop();
                
                // Demonstrate exception handling with overridden methods
                try {
                    vehicle.checkMaintenance();
                } catch (FileNotFoundException e) {
                    System.out.println("File not found: " + e.getMessage());
                } catch (IOException e) {
                    System.out.println("IO error: " + e.getMessage());
                } catch (ClassNotFoundException e) {
                    System.out.println("Class not found: " + e.getMessage());
                }
                
                // Demonstrate covariant return types
                Vehicle returnedVehicle = vehicle.getVehicle();
                System.out.println("Returned vehicle type: " + returnedVehicle.getClass().getSimpleName());
                
                // Use instanceof to access subclass-specific methods
                if (vehicle instanceof Car) {
                    Car car = (Car) vehicle;
                    System.out.println("Number of doors: " + car.getNumDoors());
                    car.honk();
                    
                    if (car instanceof ElectricCar) {
                        ElectricCar electricCar = (ElectricCar) car;
                        System.out.println("Battery capacity: " + electricCar.getBatteryCapacity() + " kWh");
                        electricCar.charge();
                    }
                } else if (vehicle instanceof Motorcycle) {
                    Motorcycle motorcycle = (Motorcycle) vehicle;
                    System.out.println("Has sidecar: " + motorcycle.hasSidecar());
                    motorcycle.wheelie();
                }
                
            } catch (IOException e) {
                System.out.println("Error starting vehicle: " + e.getMessage());
            }
            
            System.out.println("-----------------------------------");
        }
    }
}

Code Explanation

This example demonstrates various aspects of method overriding:

  1. Abstract Method Overriding:

    • Vehicle.start() is an abstract method that must be implemented by all subclasses
    • Car, ElectricCar, and Motorcycle all provide their own implementations
  2. Regular Method Overriding:

    • Vehicle.stop() is a regular method that is overridden in Car
    • ElectricCar inherits the overridden version from Car
  3. Final Method:

    • Vehicle.getInfo() is a final method that cannot be overridden
  4. Covariant Return Types:

    • Vehicle.getVehicle() returns a Vehicle
    • Car.getVehicle() overrides it to return a Car (a subtype of Vehicle)
  5. Exception Handling:

    • Vehicle.checkMaintenance() throws IOException and ClassNotFoundException
    • Car.checkMaintenance() overrides it to throw only FileNotFoundException (a subtype of IOException)
    • ElectricCar.checkMaintenance() overrides it to throw no exceptions
  6. Access Modifiers:

    • Vehicle.performMaintenance() is protected
    • Car.performMaintenance() overrides it as public (less restrictive)
  7. Polymorphism:

    • The main method demonstrates polymorphism by calling methods on Vehicle references
    • The actual method executed depends on the runtime type of the object

⚠️ Common Pitfalls in Java Method Overriding

1. Confusing Overriding with Overloading

One of the most common mistakes is confusing method overriding with method overloading:

class Parent {
    public void display(String message) {
        System.out.println("Parent: " + message);
    }
}

class Child extends Parent {
    // This is overloading, not overriding!
    public void display(String message, String prefix) {
        System.out.println(prefix + " Child: " + message);
    }
    
    // This is overriding
    @Override
    public void display(String message) {
        System.out.println("Child: " + message);
    }
}

2. Forgetting the @Override Annotation

While not required, omitting the @Override annotation can lead to subtle bugs:

class Parent {
    public void displayMessage() {
        System.out.println("Parent message");
    }
}

class Child extends Parent {
    // Typo in method name - this is a new method, not an override
    public void displayMesssage() {  // Notice the extra 's'
        System.out.println("Child message");
    }
}

With the @Override annotation, the compiler would catch this error:

class Child extends Parent {
    @Override
    public void displayMesssage() {  // Compiler error: method does not override a method from its superclass
        System.out.println("Child message");
    }
}

3. Violating the Rules of Exception Handling

Throwing broader or new checked exceptions in an overriding method is a common mistake:

class Parent {
    public void process() throws IOException {
        // Implementation
    }
}

class Child extends Parent {
    @Override
    public void process() throws Exception {  // Compilation error: broader exception
        // Implementation
    }
}

4. Misunderstanding Method Hiding vs. Overriding

Static methods cannot be overridden; they can only be hidden:

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

class Child extends Parent {
    // This is method hiding, not overriding
    public static void staticMethod() {
        System.out.println("Child's static method");
    }
}

The key difference is that method overriding is resolved at runtime based on the object's type, while method hiding is resolved at compile time based on the reference type:

Parent p = new Child();
p.staticMethod();  // Calls Parent's staticMethod
Child c = new Child();
c.staticMethod();  // Calls Child's staticMethod

5. Attempting to Override Private Methods

Private methods are not visible to subclasses and cannot be overridden:

class Parent {
    private void privateMethod() {
        System.out.println("Parent's private method");
    }
    
    public void callPrivate() {
        privateMethod();
    }
}

class Child extends Parent {
    // This is a new method, not an override
    private void privateMethod() {
        System.out.println("Child's private method");
    }
}

When you call callPrivate() on a Child object, it will still call Parent's privateMethod().

6. Changing the Return Type Incorrectly

The return type in the overriding method must be compatible with the return type in the superclass method:

class Parent {
    public Number getValue() {
        return 10;
    }
}

class Child extends Parent {
    @Override
    public Integer getValue() {  // Valid: Integer is a subclass of Number
        return 20;
    }
}

class AnotherChild extends Parent {
    @Override
    public String getValue() {  // Compilation error: String is not a subclass of Number
        return "20";
    }
}

7. Reducing Visibility in the Overriding Method

You cannot reduce the visibility of an overridden method:

class Parent {
    public void display() {
        System.out.println("Parent display");
    }
}

class Child extends Parent {
    @Override
    protected void display() {  // Compilation error: cannot reduce visibility
        System.out.println("Child display");
    }
}

🏆 Best Practices for Java Method Overriding

1. Always Use the @Override Annotation

Always use the @Override annotation when overriding a method:

class Child extends Parent {
    @Override
    public void display() {
        System.out.println("Child display");
    }
}

This helps catch errors at compile time and makes your code more readable.

2. Follow the Liskov Substitution Principle

The Liskov Substitution Principle (LSP) states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. When overriding methods, ensure that:

  • Preconditions cannot be strengthened in the subclass
  • Postconditions cannot be weakened in the subclass
  • Invariants must be preserved
class Rectangle {
    protected int width;
    protected int height;
    
    public void setWidth(int width) {
        this.width = width;
    }
    
    public void setHeight(int height) {
        this.height = height;
    }
    
    public int getArea() {
        return width * height;
    }
}

// This violates LSP
class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width);  // A square must have equal sides
    }
    
    @Override
    public void setHeight(int height) {
        super.setHeight(height);
        super.setWidth(height);  // A square must have equal sides
    }
}

This violates LSP because code that works with Rectangle might not work correctly with Square.

3. Don't Change the Expected Behavior

When overriding a method, don't change its expected behavior drastically:

class Parent {
    public boolean isPositive(int number) {
        return number > 0;
    }
}

// Bad practice
class Child extends Parent {
    @Override
    public boolean isPositive(int number) {
        return number >= 0;  // Changed behavior: zero is now considered positive
    }
}

4. Consider Using super to Extend Behavior

Instead of completely replacing the parent's implementation, consider extending it:

class Parent {
    public void process() {
        System.out.println("Parent processing");
    }
}

class Child extends Parent {
    @Override
    public void process() {
        super.process();  // Call parent's implementation
        System.out.println("Child additional processing");
    }
}

5. Be Careful with Constructors

Constructors are not inherited and cannot be overridden. Always ensure proper constructor chaining:

class Parent {
    private String name;
    
    public Parent(String name) {
        this.name = name;
    }
}

class Child extends Parent {
    private int age;
    
    public Child(String name, int age) {
        super(name);  // Call parent constructor
        this.age = age;
    }
}

6. Document Overridden Methods

Clearly document any changes in behavior when overriding methods:

class Child extends Parent {
    /**
     * {@inheritDoc}
     * <p>
     * This implementation adds additional validation for negative numbers.
     * </p>
     */
    @Override
    public void process(int number) {
        if (number < 0) {
            throw new IllegalArgumentException("Number cannot be negative");
        }
        super.process(number);
    }
}

7. Be Consistent with Exception Handling

When overriding methods that throw exceptions, be consistent and follow the rules:

class Parent {
    public void process() throws IOException {
        // Implementation
    }
}

// Good practice
class Child extends Parent {
    @Override
    public void process() throws FileNotFoundException {  // Narrower exception
        // Implementation
    }
}

8. Use Covariant Return Types Appropriately

Use covariant return types when it makes sense semantically:

class Parent {
    public Number getValue() {
        return 10;
    }
}

// Good practice
class Child extends Parent {
    @Override
    public Integer getValue() {  // More specific return type
        return 20;
    }
}

🌐 Why Method Overriding Matters in Java

1. Polymorphism

Method overriding is essential for polymorphism, one of the four pillars of object-oriented programming. It allows objects of different classes to be treated as objects of a common superclass, while each providing its own implementation of methods:

// Polymorphism in action
Shape shape1 = new Circle(5);
Shape shape2 = new Rectangle(4, 6);

// The correct implementation is called based on the actual object type
System.out.println(shape1.calculateArea());  // Uses Circle's implementation
System.out.println(shape2.calculateArea());  // Uses Rectangle's implementation

2. Code Reusability

Method overriding promotes code reusability by allowing subclasses to inherit and customize behavior:

class UIComponent {
    public void render() {
        // Common rendering logic
        System.out.println("Preparing to render component");
        doRender();  // This will be overridden by subclasses
        System.out.println("Component rendered");
    }
    
    protected void doRender() {
        // Default implementation
    }
}

class Button extends UIComponent {
    @Override
    protected void doRender() {
        System.out.println("Rendering a button");
    }
}

class TextField extends UIComponent {
    @Override
    protected void doRender() {
        System.out.println("Rendering a text field");
    }
}

3. Extensibility

Method overriding makes your code more extensible by allowing new subclasses to provide specialized behavior without modifying existing code:

// Original code
abstract class PaymentProcessor {
    public abstract void processPayment(double amount);
}

class CreditCardProcessor extends PaymentProcessor {
    @Override
    public void processPayment(double amount) {
        System.out.println("Processing credit card payment of $" + amount);
    }
}

// Later, you can add new payment methods without changing existing code
class PayPalProcessor extends PaymentProcessor {
    @Override
    public void processPayment(double amount) {
        System.out.println("Processing PayPal payment of $" + amount);
    }
}

4. Framework and Library Design

Method overriding is crucial for designing frameworks and libraries that can be extended by users:

// Framework code
public abstract class HttpServlet {
    public final void service(Request request, Response response) {
        // Common processing
        
        // Dispatch to appropriate method based on HTTP method
        if (request.getMethod().equals("GET")) {
            doGet(request, response);
        } else if (request.getMethod().equals("POST")) {
            doPost(request, response);
        }
        // ...
    }
    
    // Default implementations that can be overridden
    protected void doGet(Request request, Response response) {
        response.sendError(405, "Method Not Allowed");
    }
    
    protected void doPost(Request request, Response response) {
        response.sendError(405, "Method Not Allowed");
    }
}

// User code
public class MyServlet extends HttpServlet {
    @Override
    protected void doGet(Request request, Response response) {
        response.setContentType("text/html");
        response.getWriter().println("<h1>Hello, World!</h1>");
    }
}

5. Template Method Pattern

Method overriding is the foundation of the Template Method design pattern, which defines the skeleton of an algorithm in a method, deferring some steps to subclasses:

abstract class DataProcessor {
    // Template method
    public final void processData() {
        readData();
        validateData();
        transformData();
        writeData();
    }
    
    // Steps that can be overridden by subclasses
    protected abstract void readData();
    protected abstract void validateData();
    protected void transformData() {
        // Default implementation
    }
    protected abstract void writeData();
}

class FileDataProcessor extends DataProcessor {
    @Override
    protected void readData() {
        System.out.println("Reading data from file");
    }
    
    @Override
    protected void validateData() {
        System.out.println("Validating file data");
    }
    
    @Override
    protected void writeData() {
        System.out.println("Writing processed data to file");
    }
}

📝 Exercises and Mini-Projects

Let's put your knowledge of method overriding into practice with some exercises and mini-projects.

Exercise 1: Shape Hierarchy

Task: Create a shape hierarchy with a base Shape class and various subclasses (Circle, Rectangle, Triangle). Implement method overriding for calculating area and perimeter.

Requirements:

  • Create an abstract Shape class with abstract methods for calculating area and perimeter
  • Implement concrete subclasses for different shapes
  • Override the methods appropriately in each subclass
  • Create a test program that demonstrates polymorphism
Too see full example, Click to expand!

Solution:

// Abstract base class
abstract class Shape {
    // Abstract methods to be overridden by subclasses
    public abstract double calculateArea();
    public abstract double calculatePerimeter();
    
    // Common method for all shapes
    public void display() {
        System.out.println("This is a " + getClass().getSimpleName());
        System.out.println("Area: " + calculateArea());
        System.out.println("Perimeter: " + calculatePerimeter());
    }
}

// Circle implementation
class Circle extends Shape {
    private double radius;
    
    public Circle(double radius) {
        if (radius <= 0) {
            throw new IllegalArgumentException("Radius must be positive");
        }
        this.radius = radius;
    }
    
    @Override
    public double calculateArea() {
        return Math.PI * radius * radius
    }
    
    @Override
    public double calculatePerimeter() {
        return 2 * Math.PI * radius;
    }
    
    public double getRadius() {
        return radius;
    }
}

// Rectangle implementation
class Rectangle extends Shape {
    private double width;
    private double height;
    
    public Rectangle(double width, double height) {
        if (width <= 0 || height <= 0) {
            throw new IllegalArgumentException("Width and height must be positive");
        }
        this.width = width;
        this.height = height;
    }
    
    @Override
    public double calculateArea() {
        return width * height;
    }
    
    @Override
    public double calculatePerimeter() {
        return 2 * (width + height);
    }
    
    public double getWidth() {
        return width;
    }
    
    public double getHeight() {
        return height;
    }
}

// Triangle implementation
class Triangle extends Shape {
    private double sideA;
    private double sideB;
    private double sideC;
    
    public Triangle(double sideA, double sideB, double sideC) {
        // Check if the sides can form a valid triangle
        if (sideA <= 0 || sideB <= 0 || sideC <= 0) {
            throw new IllegalArgumentException("All sides must be positive");
        }
        if (sideA + sideB <= sideC || sideA + sideC <= sideB || sideB + sideC <= sideA) {
            throw new IllegalArgumentException("The sides do not form a valid triangle");
        }
        
        this.sideA = sideA;
        this.sideB = sideB;
        this.sideC = sideC;
    }
    
    @Override
    public double calculateArea() {
        // Using Heron's formula
        double s = (sideA + sideB + sideC) / 2;
        return Math.sqrt(s * (s - sideA) * (s - sideB) * (s - sideC));
    }
    
    @Override
    public double calculatePerimeter() {
        return sideA + sideB + sideC;
    }
    
    public double getSideA() {
        return sideA;
    }
    
    public double getSideB() {
        return sideB;
    }
    
    public double getSideC() {
        return sideC;
    }
}

// Test program
class ShapeTest {
    public static void main(String[] args) {
        // Create an array of shapes
        Shape[] shapes = new Shape[3];
        shapes[0] = new Circle(5);
        shapes[1] = new Rectangle(4, 6);
        shapes[2] = new Triangle(3, 4, 5);
        
        // Demonstrate polymorphism
        for (Shape shape : shapes) {
            System.out.println("\n----- Shape Information -----");
            shape.display();
            
            // Demonstrate instanceof and casting
            if (shape instanceof Circle) {
                Circle circle = (Circle) shape;
                System.out.println("This is a circle with radius: " + circle.getRadius());
            } else if (shape instanceof Rectangle) {
                Rectangle rectangle = (Rectangle) shape;
                System.out.println("This is a rectangle with width: " + rectangle.getWidth() + 
                                  " and height: " + rectangle.getHeight());
            } else if (shape instanceof Triangle) {
                Triangle triangle = (Triangle) shape;
                System.out.println("This is a triangle with sides: " + 
                                  triangle.getSideA() + ", " + 
                                  triangle.getSideB() + ", " + 
                                  triangle.getSideC());
            }
        }
    }
}

Your turn: Try implementing a similar hierarchy for different types of vehicles (e.g., Car, Motorcycle, Truck) with methods like calculateFuelEfficiency() and getMaxSpeed().

Exercise 2: Banking System

Task: Create a banking system with different types of accounts that override methods for interest calculation and transaction processing.

Requirements:

  • Create a base Account class with methods for deposit, withdraw, and calculate interest
  • Implement subclasses for different account types (e.g., SavingsAccount, CheckingAccount, FixedDepositAccount)
  • Override the methods appropriately in each subclass
  • Create a test program that demonstrates the different behaviors
Too see full example, Click to expand!

Solution:

// Base Account class
abstract class Account {
    private String accountNumber;
    private String accountHolder;
    protected double balance;  // Protected to allow direct access in subclasses
    
    public Account(String accountNumber, String accountHolder, double initialBalance) {
        if (initialBalance < 0) {
            throw new IllegalArgumentException("Initial balance cannot be negative");
        }
        this.accountNumber = accountNumber;
        this.accountHolder = accountHolder;
        this.balance = initialBalance;
    }
    
    // Method to deposit money
    public void deposit(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Deposit amount must be positive");
        }
        balance += amount;
        System.out.println("Deposited: $" + amount);
        System.out.println("New balance: $" + balance);
    }
    
    // Method to withdraw money - can be overridden
    public void withdraw(double amount) throws InsufficientFundsException {
        if (amount <= 0) {
            throw new IllegalArgumentException("Withdrawal amount must be positive");
        }
        if (amount > balance) {
            throw new InsufficientFundsException("Insufficient funds. Current balance: $" + balance);
        }
        balance -= amount;
        System.out.println("Withdrawn: $" + amount);
        System.out.println("New balance: $" + balance);
    }
    
    // Abstract method to calculate interest - must be implemented by subclasses
    public abstract double calculateInterest();
    
    // Method to apply interest to the account
    public void applyInterest() {
        double interest = calculateInterest();
        balance += interest;
        System.out.println("Interest applied: $" + interest);
        System.out.println("New balance: $" + balance);
    }
    
    // Method to display account information
    public void displayInfo() {
        System.out.println("Account Number: " + accountNumber);
        System.out.println("Account Holder: " + accountHolder);
        System.out.println("Balance: $" + balance);
        System.out.println("Account Type: " + getClass().getSimpleName());
    }
    
    // Getters
    public String getAccountNumber() {
        return accountNumber;
    }
    
    public String getAccountHolder() {
        return accountHolder;
    }
    
    public double getBalance() {
        return balance;
    }
}

// Custom exception for insufficient funds
class InsufficientFundsException extends Exception {
    public InsufficientFundsException(String message) {
        super(message);
    }
}

// Savings Account implementation
class SavingsAccount extends Account {
    private double interestRate;
    private int withdrawalsThisMonth;
    private final int FREE_WITHDRAWALS_LIMIT = 3;
    private final double WITHDRAWAL_FEE = 2.0;
    
    public SavingsAccount(String accountNumber, String accountHolder, double initialBalance, double interestRate) {
        super(accountNumber, accountHolder, initialBalance);
        if (interestRate < 0) {
            throw new IllegalArgumentException("Interest rate cannot be negative");
        }
        this.interestRate = interestRate;
        this.withdrawalsThisMonth = 0;
    }
    
    @Override
    public void withdraw(double amount) throws InsufficientFundsException {
        withdrawalsThisMonth++;
        
        // Apply withdrawal fee if exceeded free withdrawals limit
        if (withdrawalsThisMonth > FREE_WITHDRAWALS_LIMIT) {
            double totalAmount = amount + WITHDRAWAL_FEE;
            if (totalAmount > balance) {
                throw new InsufficientFundsException("Insufficient funds including fee. Current balance: $" + balance);
            }
            super.withdraw(amount);
            balance -= WITHDRAWAL_FEE;
            System.out.println("Withdrawal fee applied: $" + WITHDRAWAL_FEE);
            System.out.println("New balance after fee: $" + balance);
        } else {
            super.withdraw(amount);
        }
    }
    
    @Override
    public double calculateInterest() {
        return balance * interestRate / 12;  // Monthly interest
    }
    
    public void resetWithdrawals() {
        withdrawalsThisMonth = 0;
        System.out.println("Monthly withdrawal count reset for account: " + getAccountNumber());
    }
    
    public double getInterestRate() {
        return interestRate;
    }
    
    public int getWithdrawalsThisMonth() {
        return withdrawalsThisMonth;
    }
}

// Checking Account implementation
class CheckingAccount extends Account {
    private double overdraftLimit;
    private final double MONTHLY_FEE = 5.0;
    private final double INTEREST_RATE = 0.001;  // 0.1% annual interest
    
    public CheckingAccount(String accountNumber, String accountHolder, double initialBalance, double overdraftLimit) {
        super(accountNumber, accountHolder, initialBalance);
        if (overdraftLimit < 0) {
            throw new IllegalArgumentException("Overdraft limit cannot be negative");
        }
        this.overdraftLimit = overdraftLimit;
    }
    
    @Override
    public void withdraw(double amount) throws InsufficientFundsException {
        if (amount <= 0) {
            throw new IllegalArgumentException("Withdrawal amount must be positive");
        }
        
        // Allow withdrawal up to balance + overdraft limit
        if (amount > balance + overdraftLimit) {
            throw new InsufficientFundsException("Exceeds overdraft limit. Available funds: $" + 
                                               (balance + overdraftLimit));
        }
        
        balance -= amount;
        System.out.println("Withdrawn: $" + amount);
        
        if (balance < 0) {
            System.out.println("Account is in overdraft. Current balance: $" + balance);
        } else {
            System.out.println("New balance: $" + balance);
        }
    }
    
    @Override
    public double calculateInterest() {
        // Only apply interest if balance is positive
        return balance > 0 ? balance * INTEREST_RATE / 12 : 0;
    }
    
    public void applyMonthlyFee() {
        balance -= MONTHLY_FEE;
        System.out.println("Monthly fee applied: $" + MONTHLY_FEE);
        System.out.println("New balance: $" + balance);
    }
    
    public double getOverdraftLimit() {
        return overdraftLimit;
    }
}

// Fixed Deposit Account implementation
class FixedDepositAccount extends Account {
    private double interestRate;
    private int termMonths;
    private boolean matured;
    private final double EARLY_WITHDRAWAL_PENALTY = 0.05;  // 5% penalty
    
    public FixedDepositAccount(String accountNumber, String accountHolder, double initialBalance, 
                              double interestRate, int termMonths) {
        super(accountNumber, accountHolder, initialBalance);
        if (interestRate < 0) {
            throw new IllegalArgumentException("Interest rate cannot be negative");
        }
        if (termMonths <= 0) {
            throw new IllegalArgumentException("Term must be positive");
        }
        this.interestRate = interestRate;
        this.termMonths = termMonths;
        this.matured = false;
    }
    
    @Override
    public void withdraw(double amount) throws InsufficientFundsException {
        if (!matured) {
            System.out.println("Warning: Early withdrawal before maturity will incur a penalty.");
            double penalty = amount * EARLY_WITHDRAWAL_PENALTY;
            double totalAmount = amount + penalty;
            
            if (totalAmount > balance) {
                throw new InsufficientFundsException("Insufficient funds including penalty. Current balance: $" + balance);
            }
            
            super.withdraw(amount);
            balance -= penalty;
            System.out.println("Early withdrawal penalty applied: $" + penalty);
            System.out.println("New balance after penalty: $" + balance);
        } else {
            super.withdraw(amount);
        }
    }
    
    @Override
    public double calculateInterest() {
        return balance * interestRate / 12;  // Monthly interest
    }
    
    public void mature() {
        matured = true;
        System.out.println("Account has matured. No early withdrawal penalties will apply.");
    }
    
    public boolean isMatured() {
        return matured;
    }
    
    public double getInterestRate() {
        return interestRate;
    }
    
    public int getTermMonths() {
        return termMonths;
    }
}

// Test program
class BankingSystemTest {
    public static void main(String[] args) {
        // Create different types of accounts
        try {
            SavingsAccount savings = new SavingsAccount("SA001", "John Doe", 1000, 0.05);
            CheckingAccount checking = new CheckingAccount("CA001", "Jane Smith", 2000, 500);
            FixedDepositAccount fixedDeposit = new FixedDepositAccount("FD001", "Bob Johnson", 5000, 0.08, 12);
            
            System.out.println("===== Account Information =====");
            savings.displayInfo();
            System.out.println("\n");
            checking.displayInfo();
            System.out.println("\n");
            fixedDeposit.displayInfo();
            
            System.out.println("\n===== Deposit Operations =====");
            savings.deposit(500);
            checking.deposit(1000);
            
            System.out.println("\n===== Withdrawal Operations =====");
            try {
                savings.withdraw(200);
                savings.withdraw(100);
                savings.withdraw(50);
                savings.withdraw(50);  // This should apply a fee
            } catch (InsufficientFundsException e) {
                System.out.println("Error: " + e.getMessage());
            }
            
            try {
                checking.withdraw(2500);  // This should use the overdraft
            } catch (InsufficientFundsException e) {
                System.out.println("Error: " + e.getMessage());
            }
            
            try {
                fixedDeposit.withdraw(1000);  // This should apply a penalty
            } catch (InsufficientFundsException e) {
                System.out.println("Error: " + e.getMessage());
            }
            
            System.out.println("\n===== Interest Calculations =====");
            System.out.println("Savings Interest: $" + savings.calculateInterest());
            System.out.println("Checking Interest: $" + checking.calculateInterest());
            System.out.println("Fixed Deposit Interest: $" + fixedDeposit.calculateInterest());
            
            System.out.println("\n===== Applying Interest =====");
            savings.applyInterest();
            checking.applyInterest();
            fixedDeposit.applyInterest();
            
            System.out.println("\n===== Monthly Maintenance =====");
            savings.resetWithdrawals();
            checking.applyMonthlyFee();
            
            System.out.println("\n===== Fixed Deposit Maturity =====");
            fixedDeposit.mature();
            try {
                fixedDeposit.withdraw(1000);  // No penalty should apply now
            } catch (InsufficientFundsException e) {
                System.out.println("Error: " + e.getMessage());
            }
            
        } catch (IllegalArgumentException e) {
            System.out.println("Error creating accounts: " + e.getMessage());
        }
    }
}

Your turn: Try extending this banking system by adding more account types (e.g., StudentAccount, BusinessAccount) with their own specific behaviors and overridden methods.

Exercise 3: Employee Management System

Task: Create an employee management system with different types of employees that override methods for calculating salary and benefits.

Requirements:

  • Create a base Employee class with methods for calculating salary, benefits, and displaying information
  • Implement subclasses for different employee types (e.g., FullTimeEmployee, PartTimeEmployee, Contractor)
  • Override the methods appropriately in each subclass
  • Create a test program that demonstrates the different behaviors

Your turn: Try implementing this employee management system on your own before looking at a solution.


🎯 Key Takeaways: Mastering Method Overriding in Java

Let's summarize the key points about method overriding in Java:

  1. Definition: Method overriding allows a subclass to provide a specific implementation of a method that is already defined in its parent class.

  2. Rules:

    • The method must have the same name and parameter list
    • The return type must be the same or a subtype (covariant return)
    • The access modifier cannot be more restrictive
    • The method cannot throw broader or new checked exceptions
    • Final, static, and private methods cannot be overridden
  3. Benefits:

    • Enables runtime polymorphism
    • Promotes code reusability
    • Enhances extensibility
    • Supports the "is-a" relationship in inheritance
  4. Best Practices:

    • Always use the @Override annotation
    • Follow the Liskov Substitution Principle
    • Don't change the expected behavior drastically
    • Consider using super to extend behavior
    • Be consistent with exception handling
    • Document overridden methods
  5. Common Pitfalls:

    • Confusing overriding with overloading
    • Forgetting the @Override annotation
    • Violating exception handling rules
    • Misunderstanding method hiding vs. overriding
    • Attempting to override private methods
    • Changing the return type incorrectly
    • Reducing visibility in the overriding method

Method overriding is a powerful feature that, when used correctly, can make your code more flexible, maintainable, and extensible. By understanding the rules and best practices, you can leverage method overriding to create well-designed object-oriented systems.

Remember, the best way to master method overriding is through practice. Try to implement the exercises and mini-projects provided in this tutorial, and then create your own projects that leverage method overriding to solve real-world problems.

Happy coding! 🚀