Oops Blog Java

Polymorphism in Action: 4 Real-World Examples for OOP Developers

Object shapes interlocking, representing inheritance and polymorphism concepts in OOP.

What is Polymorphism in Java?

Polymorphism is a core concept in object-oriented programming (OOP) that allows objects of different classes to be treated as objects of a common superclass. It’s one of the four fundamental OOP concepts, the others being inheritance, encapsulation, and abstraction.

Polymorphism enables a single interface to represent different underlying forms (data types).

In more detail, polymorphism means “many forms”. It’s a feature that allows you to treat objects of derived classes as objects of their base class.

This enables you to write more generic, reusable code because you can design methods to accept parameters of the base class type, but pass in objects of any derived class and have them behave appropriately.

Let’s consider an example of a payment processing system that needs to handle different payment methods like debit cards, credit cards, UPI (Unified Payments Interface), and mobile wallets like PhonePe and Google Pay. We can use polymorphism to design a flexible and extensible solution.

Here’s how you can implement it in Java:

Java
abstract class PaymentMethod {
    protected String name;

    public PaymentMethod(String name) {
        this.name = name;
    }

    public abstract void processPayment(double amount);
}

class DebitCard extends PaymentMethod {
    private String cardNumber;

    public DebitCard(String name, String cardNumber) {
        super(name);
        this.cardNumber = cardNumber;
    }

    @Override
    public void processPayment(double amount) {
        // Code to process debit card payment...
        System.out.println("Processing " + name + " payment of $" + amount + " using card number " + cardNumber);
    }
}

class CreditCard extends PaymentMethod {
    private String cardNumber;

    public CreditCard(String name, String cardNumber) {
        super(name);
        this.cardNumber = cardNumber;
    }

    @Override
    public void processPayment(double amount) {
        // Code to process credit card payment...
        System.out.println("Processing " + name + " payment of $" + amount + " using card number " + cardNumber);
    }
}

class UPI extends PaymentMethod {
    private String upiId;

    public UPI(String name, String upiId) {
        super(name);
        this.upiId = upiId;
    }

    @Override
    public void processPayment(double amount) {
        // Code to process UPI payment...
        System.out.println("Processing " + name + " payment of $" + amount + " using UPI ID " + upiId);
    }
}

class MobileWallet extends PaymentMethod {
    private String walletId;

    public MobileWallet(String name, String walletId) {
        super(name);
        this.walletId = walletId;
    }

    @Override
    public void processPayment(double amount) {
        // Code to process mobile wallet payment...
        System.out.println("Processing " + name + " payment of $" + amount + " using wallet ID " + walletId);
    }
}

public class Main {
    public static void main(String[] args) {
        PaymentMethod[] payments = new PaymentMethod[]{
                new DebitCard("Visa Debit", "1234-5678-9012-3456"),
                new CreditCard("Mastercard Credit", "9876-5432-1098-7654"),
                new UPI("PhonePe", "johndoe@upi"),
                new MobileWallet("Google Pay", "johndoe@gmail.com")
        };

        double amount = 100.0;
        for (PaymentMethod payment : payments) {
            payment.processPayment(amount);
        }
    }
}

In this example:

  1. We define an abstract base class PaymentMethod with a constructor that takes the name of the payment method and an abstract processPayment() method.
  2. We create derived classes DebitCard, CreditCard, UPI, and MobileWallet that extend PaymentMethod. Each of these classes provides its own implementation of the processPayment() method.
  3. In the Main class, we create an array of PaymentMethod objects that includes instances of each specific payment method.
  4. We then iterate over this array and call the processPayment() method on each object with a payment amount.

When you run this code, you’ll see output like:

Java
Processing Visa Debit payment of $100.0 using card number 1234-5678-9012-3456
Processing Mastercard Credit payment of $100.0 using card number 9876-5432-1098-7654
Processing PhonePe payment of $100.0 using UPI ID johndoe@upi
Processing Google Pay payment of $100.0 using wallet ID johndoe@gmail.com

The key point here is that the main code is working with the PaymentMethod base class, but thanks to polymorphism, the correct processPayment() method is called for each object based on its actual type.

This design makes it easy to add new payment methods in the future. For example, if you needed to add support for a new mobile wallet, you could simply create a new class that extends PaymentMethod and implements processPayment(), without needing to change any of the existing code that processes payments.

This is a powerful way to create flexible, maintainable software systems that can easily adapt to new requirements.

It is a way to perform a single action in different ways. Polymorphism can be divided into two types: compile-time (or static) polymorphism and runtime (or dynamic) polymorphism.

Compile-time Polymorphism

First understand what method signature

Compile-time polymorphism, also known as method overloading or static polymorphism, occurs when a class has multiple methods with the same name but different method signatures, i.e., they differ in the type, number, or order of parameters.

The method to be executed is determined at compile time based on the method signature. Since this determination is made at compile time based on the method signature, it’s called compile-time polymorphism.

In other words, when you call an overloaded method, the compiler looks at the arguments you’ve passed and their types, and decides which version of the overloaded method to call based on this information. This decision happens at compile time, hence the name compile-time polymorphism.

Here’s a simple example in Java:

Example:

Java
class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
    
    public double add(double a, double b) {
        return a + b;
    }
    
    public int add(int a, int b, int c) {
        return a + b + c;
    }
    
    public static void main(String args[]) {
        System.out.println(add(5, 10)); // Calls the first method
        System.out.println(add(5.0, 10.0)); // Calls the second method
    }
    
    
}

In this example, the add method is overloaded with different parameter types: one accepts integers and the other accepts doubles. The correct version is chosen at compile time based on the argument types.

Runtime Polymorphism

Runtime polymorphism, also known as method overriding or dynamic polymorphism, is a process in which a call to an overridden method is resolved at runtime rather than compile-time. In this case, the overridden method is called through the reference variable of a superclass.

Example:

Java
class Shape {
    public void draw() {
        System.out.println("Drawing a shape");
    }
}

class Circle extends Shape {
    @Override
    public void draw() {
        System.out.println("Drawing a circle");
    }
}

class Square extends Shape {
    @Override
    public void draw() {
        System.out.println("Drawing a square");
    }
}

public class Main {
    public static void main(String[] args) {
        Shape shape1 = new Circle();
        Shape shape2 = new Square();

        shape1.draw();  // Output: Drawing a circle
        shape2.draw();  // Output: Drawing a square
    }
}

In this example, we have a base class Shape with a method draw(). The Circle and Square classes are derived from Shape and override the draw() method with their own implementations.

In the main method, we create two variables of type Shape, but we assign them instances of Circle and Square respectively. This is possible because a Circle is-a Shape and a Square is-a Shape due to inheritance.

When we call draw() on shape1 and shape2, the Java Virtual Machine (JVM) determines at runtime which version of draw() to call based on the actual object type.

  • When shape1.draw() is called, the JVM sees that shape1 is actually a Circle object and calls Circle‘s draw() method.
  • Similarly, when shape2.draw() is called, the JVM calls Square‘s draw() method because shape2 is actually a Square object.

This is runtime polymorphism. The decision of which draw() method to call is made at runtime based on the actual type of the object, not the type of the variable.

The key points of runtime polymorphism are:

  1. The method to be called is determined at runtime based on the actual object type.
  2. The variable type determines what methods can be called (i.e., the methods defined in the base class or interface), but the actual object type determines which implementation of the method is used.
  3. Runtime polymorphism is achieved through method overriding, where a subclass provides a specific implementation of a method that is already provided by its superclass.

Runtime polymorphism is a powerful feature of object-oriented programming that allows for more flexible and extensible code. It’s a key aspect of the “open-closed principle” of software design, which suggests that classes should be open for extension but closed for modification.

Operator Overloading in Java

There is no concept of operator overloading in Java. Unlike C++, Java does not support operator overloading. In Java, the operators are predefined and cannot be overloaded or redefined for user-defined types.

In Java, the “+” operator is predefined to work differently based on the types of the operands:

  1. When “+” is used with string operands (e.g., “BA” + “JJ”), it performs string concatenation. The “+” operator is predefined to concatenate strings when at least one of the operands is a string.
  2. When “+” is used with numeric operands (e.g., 2 + 3), it performs arithmetic addition. The “+” operator is predefined to add numeric values when both operands are numeric types.

This behavior is built into the Java language and is not considered operator overloading because you cannot change or redefine the behavior of the “+” operator for user-defined types.

The decision not to include operator overloading in Java was made by the language designers to keep the language simpler, more readable, and less prone to ambiguity or misuse. They believed that operator overloading could lead to confusion and make the code harder to understand, especially if the overloaded operators are not used in a conventional or expected manner.

However, Java does provide other mechanisms to achieve similar functionality without operator overloading. For example:

  1. Method overloading: Java supports method overloading, which allows you to define multiple methods with the same name but different parameters. This can be used to provide similar functionality to operator overloading in some cases.
  2. Arithmetic operations on primitive types: Java’s built-in arithmetic operators (+, -, *, /, etc.) work with primitive types such as int, float, double, etc., providing a way to perform arithmetic operations without the need for operator overloading.
  3. Methods for common operations: Java provides methods for common operations on objects, such as equals(), compareTo(), and toString(), which can be used to define the behavior of equality comparison, ordering, and string representation for user-defined types.

While operator overloading can be a powerful feature in languages like C++, Java’s design philosophy prioritizes simplicity, readability, and maintainability. The absence of operator overloading in Java is a deliberate choice to keep the language more straightforward and avoid potential misuse or confusion.

If you need to perform operations on user-defined types in Java, you can define methods with appropriate names to encapsulate the desired behavior, rather than relying on operator overloading.

Use of Polymorphism in Java

Java uses polymorphism extensively in its standard libraries. A common example is the Java Collections Framework, where polymorphism allows you to interact with different types of collections (like ArrayList, LinkedList, HashSet, etc.) through a common interface (like List or Set).

This makes it possible to write more general and flexible code. For instance, you can write a method that sorts a List without caring whether it’s an ArrayList or a LinkedList:

Java
import java.util.Collections;
import java.util.List;

public class Test {
    public static void sortList(List<?> list) {
        Collections.sort(list); // This method can sort any type of List
    }
}

Here, polymorphism allows the Collections.sort() method to operate on any object that implements the List interface, showcasing runtime polymorphism through the use of interface implementations.

More Examples

Java
List<String> list = new ArrayList<>();
list.add("Hello");
list.add("World");

List<String> linkedList = new LinkedList<>();
linkedList.add("Hello");
linkedList.add("World");

// The same method can process any List implementation
public static void printList(List<String> list) {
    for (String s : list) {
        System.out.println(s);
    }
}

Some real-world applications of polymorphism

Polymorphism is a powerful concept in object-oriented programming that mirrors the flexibility found in real-world scenarios. It enables software developers to write more generic, reusable, and maintainable code.

Here are some real-world applications of polymorphism, along with code examples to illustrate how it can be effectively used in method callbacks, event handling, and interface-driven designs.

1. Method Callbacks

Method callbacks are a programming pattern where a method (callback) is passed as an argument to another method. Using polymorphism, you can define a common interface for all callbacks, allowing different implementations to be passed as needed.

Example:

Java
interface Callback {
    void call();
}

class SuccessCallback implements Callback {
    public void call() {
        System.out.println("Success");
    }
}

class ErrorCallback implements Callback {
    public void call() {
        System.out.println("Error");
    }
}

class EventProcessor {
    public void processEvent(Callback callback) {
        // Event processing logic...
        callback.call(); // Calling back
    }
}

public class Test {
    public static void main(String[] args) {
        EventProcessor processor = new EventProcessor();
        processor.processEvent(new SuccessCallback()); // Process event with success callback
        processor.processEvent(new ErrorCallback()); // Process event with error callback
    }
}

2. Event Handling

In GUI applications or event-driven programming, polymorphism is used extensively for event handling. Different events (like clicks, keyboard input) can trigger different actions using the same event handling interface.

Example:

Java
interface EventHandler {
    void handle();
}

class ClickHandler implements EventHandler {
    public void handle() {
        System.out.println("Button clicked");
    }
}

class KeyPressHandler implements EventHandler {
    public void handle() {
        System.out.println("Key pressed");
    }
}

class EventListener {
    public void listen(EventHandler handler) {
        // Event listening logic...
        handler.handle(); // Handle the specific event
    }
}

public class UI {
    public static void main(String[] args) {
        EventListener listener = new EventListener();
        listener.listen(new ClickHandler()); // Listen for clicks
        listener.listen(new KeyPressHandler()); // Listen for key presses
    }
}

3. Interface-driven Designs

Interface-driven design leverages polymorphism to define common interfaces for implementing various functionalities. This approach allows for swapping out implementations without changing the code that uses them.

Example:

Java
interface PaymentProcessor {
    void processPayment(double amount);
}

class CreditCardProcessor implements PaymentProcessor {
    public void processPayment(double amount) {
        System.out.println("Processing credit card payment for " + amount);
    }
}

class PayPalProcessor implements PaymentProcessor {
    public void processPayment(double amount) {
        System.out.println("Processing PayPal payment for " + amount);
    }
}

class Checkout {
    private PaymentProcessor paymentProcessor;

    public Checkout(PaymentProcessor processor) {
        this.paymentProcessor = processor;
    }

    public void completePayment(double amount) {
        paymentProcessor.processPayment(amount);
    }
}

public class ShoppingCart {
    public static void main(String[] args) {
        Checkout checkout = new Checkout(new CreditCardProcessor());
        checkout.completePayment(100.0);

        checkout = new Checkout(new PayPalProcessor());
        checkout.completePayment(200.0);
    }
}

The key advantage of this interface-driven design is that it allows for flexibility and extensibility. If we need to add a new payment processor in the future, we can simply create a new class that implements the PaymentProcessor interface and use it with the existing Checkout class without modifying its code.

Polymorphism is achieved through the use of the PaymentProcessor interface. The Checkout class works with the interface type, not the concrete implementations. This allows different payment processors to be used interchangeably, and the specific behavior is determined at runtime based on the actual object type.

By utilizing interface-driven design and polymorphism, the code becomes more modular, maintainable, and open to extension. It promotes loose coupling between classes and enables easy swapping of implementations without affecting the rest of the codebase.

4. Real-world Example: Company Employees

Imagine a company where every employee has a method called performDuties(). The way a Manager performs their duties is different from how an Engineer or an Accountant would.

In object-oriented programming, this is akin to having a base class or interface named Employee with a method performDuties(), and several subclasses (Manager, Engineer, Accountant) each with its own implementation of performDuties().

Java
interface Employee {
    void performDuties();
}

class Manager implements Employee {
    public void performDuties() {
        System.out.println("Managing team and resources.");
    }
}

class Engineer implements Employee {
    public void performDuties() {
        System.out.println("Designing and developing software.");
    }
}

class Accountant implements Employee {
    public void performDuties() {
        System.out.println("Managing financial records.");
    }
}

class Company {
    public static void manageEmployee(Employee employee) {
        employee.performDuties();
    }

    public static void main(String[] args) {
        Employee manager = new Manager();
        Employee engineer = new Engineer();
        Employee accountant = new Accountant();

        manageEmployee(manager);    // Output: Managing team and resources.
        manageEmployee(engineer);   // Output: Designing and developing software.
        manageEmployee(accountant); // Output: Managing financial records.
    }
}

In this example, Employee is an interface that declares a method performDuties(). Each class (Manager, Engineer, Accountant) implements the Employee interface and provides its own implementation of performDuties().

The Company the class has a method manageEmployee() that accepts an Employee object and calls its performDuties() method. When you run the main method, each class’s performDuties() method is called, demonstrating polymorphism—the ability to treat objects of different classes uniformly through a common interface.

This real-world analogy and code example highlight how polymorphism enables flexibility and extensibility in software design, allowing for code that is more abstract and less coupled to specific implementations.

Conclusion

In each of these examples, polymorphism allows for the creation of flexible, loosely coupled systems where the specific implementation details are abstracted away behind a common interface. This makes the code more modular, easier to extend, and adapt to future changes.

If you want to learn more about SOLID principles and see how they are applied in real-world industry scenarios, I recommend checking out the following resource:

This article provides a comprehensive explanation of each SOLID principle along with practical examples from industry codebases. It covers the Single Responsibility Principle (SRP), Open-Closed Principle (OCP), Liskov Substitution Principle (LSP), Interface Segregation Principle (ISP), and Dependency Inversion Principle (DIP). The examples demonstrate how these principles can be applied to design robust, maintainable, and extensible software systems in various domains.

By exploring this resource, you can gain a deeper understanding of SOLID principles and learn best practices for applying them in your own projects.

Avatar

Neelabh

About Author

As Neelabh Singh, I am a Senior Software Engineer with 6.6 years of experience, specializing in Java technologies, Microservices, AWS, Algorithms, and Data Structures. I am also a technology blogger and an active participant in several online coding communities.

You may also like

Blog Design Pattern

Understanding the Builder Design Pattern in Java | Creational Design Patterns | CodeTechSummit

Overview The Builder design pattern is a creational pattern used to construct a complex object step by step. It separates
Blog Tech Toolkit

Base64 Decode

Base64 encoding is a technique used to encode binary data into ASCII characters, making it easier to transmit data over