Search⌘ K
AI Features

Method Overriding and Polymorphism

Explore how method overriding customizes inherited behavior and how polymorphism enables treating subclass objects as their superclass type. Learn dynamic method dispatch and best practices, including @Override usage and adhering to substitution principles, to write robust Java code that is flexible and extensible.

Inheritance allows classes to acquire fields and methods from a parent. However, inheritance isn’t just about copying code; it’s about specialization. Often, a subclass needs to inherit a general behavior but implement it differently. For example, a “Notification” system might know how to “send” a message, but an “Email” sends differently than an “SMS.”

In this lesson, we will learn how to customize inherited behavior and how to treat different objects as a single, unified type, a concept that makes Java applications flexible and scalable.

Redefining behavior with overriding

When a subclass inherits a method, it can provide its own specific implementation. This is called method overriding. Overriding allows a subclass to replace the behavior defined in its superclass while keeping the same method name and parameters.

To override a method, we define it in the subclass with the exact same signature (name and parameter list) as the parent. We also add the @Override annotation. While this annotation is optional, we always use it because it forces the compiler to verify that we are actually overriding a method. If we make a typo in the name or get the parameters wrong, the compiler will catch the error immediately. @Override also prevents accidental overloading, where a method with a similar name but different parameters is created instead of overriding the intended method.

Rules for overriding

  1. Same signature: The method name and parameter list must match the parent’s method exactly.

  2. Return type: Must be the same or a subclass of the parent’s return type (covariant return type).

  3. Access modifier: The overriding method cannot be more restrictive than the parent (e.g., you cannot override a public method and make it private).

  4. Exceptions: An overriding method cannot throw broader checked exceptions than the method in the superclass.

Java 25
// Base class representing a generic notification
class Notification {
public void send() {
System.out.println("Sending a generic notification...");
}
}
// Subclass representing an Email
class EmailNotification extends Notification {
@Override // Confirms we are correctly overriding the parent method
public void send() {
System.out.println("Sending an email via SMTP...");
}
}
public class Main {
public static void main(String[] args) {
EmailNotification email = new EmailNotification();
email.send();
}
}
  • Lines 3–5: We define a send() method in the parent class Notification.

  • Line 9: We derive EmailNotification from Notification.

  • Line 11: The @Override annotation ensures that the method below matches a method in the parent class.

  • Lines 12–14: We provide a specialized implementation for send().

  • Line 20: When we call send() on the EmailNotification object, the new implementation executes.

Polymorphic references

The word polymorphism comes from the Greek, meaning many forms. In Java, its most powerful application is the ability for a reference variable of a superclass type to hold an object of a subclass type. This is often called upcasting.

We must distinguish between two things:

  1. Reference type: The type of the variable (e.g., Notification). This determines what methods we can call at compile time.

  2. Object type: The actual object in memory (e.g., SMSNotification). This determines how the method behaves at runtime.

Java 25
public class PolymorphismExample {
public static void main(String[] args) {
// Reference Type: Notification
// Object Type: SMSNotification
Notification n = new SMSNotification();
n.send();
}
}
class Notification {
public void send() {
System.out.println("Sending a generic notification...");
}
}
class EmailNotification extends Notification {
@Override
public void send() {
System.out.println("Sending an email via SMTP...");
}
}
class SMSNotification extends Notification {
@Override
public void send() {
System.out.println("Sending SMS via carrier network...");
}
}
  • Line 5: We declare a variable n of type Notification, but we assign it a new instance of SMSNotification. This is valid because an SMS is a Notification.

  • Line 7: We call n.send(). The compiler checks Notification to ensure send() exists, but the actual SMSNotification object executes the logic.

Dynamic method dispatch

How does Java know which method to run? When we write n.send(), the compiler only sees the Notification type. However, at runtime, the Java Virtual Machine (JVM) looks at the actual object stored in the heap (the SMSNotification).

This process is called dynamic method dispatch (or runtime polymorphism). The JVM dispatches the call to the implementation found in the actual object, not the type of the variable holding it. This is late binding: the decision of which code to run is deferred until the program is actually running.

Java 25
public class DispatchExample {
public static void main(String[] args) {
Notification n1 = new EmailNotification();
Notification n2 = new SMSNotification();
n1.send(); // JVM finds an EmailNotification object -> runs Email logic
n2.send(); // JVM finds an SMSNotification object -> runs SMS logic
}
}
class Notification {
public void send() {
System.out.println("Sending a generic notification...");
}
}
class EmailNotification extends Notification {
@Override
public void send() {
System.out.println("Sending an email via SMTP...");
}
}
class SMSNotification extends Notification {
@Override
public void send() {
System.out.println("Sending SMS via carrier network...");
}
}
  • Line 3: n1 is a Notification reference pointing to an EmailNotification.

  • Line 4: n2 is a Notification reference pointing to an SMSNotification.

  • Line 6: Even though n1 is typed as Notification, the JVM executes EmailNotification.send().

  • Line 7: Similarly, n2 triggers SMSNotification.send().

Building flexible code with polymorphism

The real power of polymorphism is that it allows us to write flexible, reusable code. We can write methods that accept the superclass type as a parameter. These methods will then work automatically with any current or future subclass we create, without needing modification.

This adheres to the open/closed principle: our code is open for extension (we can add new notification types) but closed for modification (we don’t need to rewrite the alert logic).

Java 25
public class NotificationService {
// This method accepts ANY Notification type
public static void alertUser(Notification n) {
System.out.print("Alerting: ");
n.send();
}
public static void main(String[] args) {
EmailNotification email = new EmailNotification();
SMSNotification sms = new SMSNotification();
// The same method handles different types seamlessly
alertUser(email);
alertUser(sms);
}
}
class Notification {
public void send() {
System.out.println("Sending a generic notification...");
}
}
class EmailNotification extends Notification {
@Override
public void send() {
System.out.println("Sending an email via SMTP...");
}
}
class SMSNotification extends Notification {
@Override
public void send() {
System.out.println("Sending SMS via carrier network...");
}
}
  • Line 4: The alertUser method takes a Notification parameter. It does not know (or care) if it receives an email or an SMS.

  • Line 6: It calls n.send(). Dynamic dispatch ensures the correct version runs.

  • Line 13: We pass an EmailNotification. The alertUser method treats it as a Notification but executes email logic.

  • Line 14: We pass an SMSNotification. The same method now executes SMS logic.

Practical patterns and best practices

Polymorphism is not just a clever trick; it is a fundamental tool for writing clean, maintainable systems. Here are the best ways we apply these concepts in real-world Java projects.

  1. Code against the superclass type: Whenever possible, define your variables and method parameters using the most general type that supports the behavior you need. This makes your code “future-proof.” If you write a method that accepts a List (interface/superclass) rather than an ArrayList (implementation/subclass), you can later swap in a LinkedList or a custom list without changing a single line of your method code.

  2. Always use @Override: While optional, omitting @Override is dangerous. If you misspell the method name or mismatch parameters (e.g., process(int i) vs. process(Integer i)), Java will silently create a Line 13: overloaded method instead of overriding the existing one. The @Override annotation turns this silent bug into a loud compile-time error.

  3. Respect the Liskov substitution principle: When you override a method, you are promising to fulfill the contract set by the superclass. Your subclass should be swappable with the parent without breaking the application.

    • Don’t break expectations: If the parent method saveData() saves to a file, your subclass saveData() shouldn’t delete the file.

    • Don’t throw new checked exceptions: You cannot throw a checked exception that the parent method didn’t declare (we will cover exceptions later).

  1. Avoid god classes: Don’t force every subclass to override a method if it doesn’t make sense. If you find yourself overriding a method just to make it do nothing (an empty method body), your inheritance hierarchy might be flawed. It often means the method belongs in a specific subclass or an interface, not the common parent.

We separate the action (sending a notification) from the implementation (email or SMS). By overriding methods and referencing objects through a common interface or superclass, we treat different implementations uniformly. The JVM dispatches the correct method at runtime based on the actual object type. This runtime polymorphism underpins many common design patterns.