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
Same signature: The method name and parameter list must match the parent’s method exactly.
Return type: Must be the same or a subclass of the parent’s return type (covariant return type).
Access modifier: The overriding method cannot be more restrictive than the parent (e.g., you cannot override a
publicmethod and make itprivate).Exceptions: An overriding method cannot throw broader checked exceptions than the method in the superclass.
Lines 3–5: We define a
send()method in the parent classNotification.Line 9: We derive
EmailNotificationfromNotification.Line 11: The
@Overrideannotation 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 theEmailNotificationobject, 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:
Reference type: The type of the variable (e.g.,
Notification). This determines what methods we can call at compile time.Object type: The actual object in memory (e.g.,
SMSNotification). This determines how the method behaves at runtime.
Line 5: We declare a variable
nof typeNotification, but we assign it a new instance ofSMSNotification. This is valid because an SMS is a Notification.Line 7: We call
n.send(). The compiler checksNotificationto ensuresend()exists, but the actualSMSNotificationobject 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.
Line 3:
n1is aNotificationreference pointing to anEmailNotification.Line 4:
n2is aNotificationreference pointing to anSMSNotification.Line 6: Even though
n1is typed asNotification, the JVM executesEmailNotification.send().Line 7: Similarly,
n2triggersSMSNotification.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).
Line 4: The
alertUsermethod takes aNotificationparameter. 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. ThealertUsermethod treats it as aNotificationbut 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.
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 anArrayList(implementation/subclass), you can later swap in aLinkedListor a custom list without changing a single line of your method code.Always use
@Override: While optional, omitting@Overrideis 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@Overrideannotation turns this silent bug into a loud compile-time error.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 subclasssaveData()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).
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.