Polymorphism in Java
Java is essentially a language for object-oriented programming, or OOP. Significant organisational gains are made possible by OOP, which structures programs around data (objects) and clearly defined interfaces to that data. One of the three fundamental tenets of OOP, along with inheritance and encapsulation, is polymorphism. The Greek terms “polymorphism” and “many forms” are the foundation of the name. It enables a generic class of activities to be performed through a single interface, with the particular action being determined by the particular circumstance. The expression “one interface, multiple methods” is frequently used in Java to describe this.
Method Overloading (Compile-time / Static Polymorphism)
If the argument declarations of the methods are different, method overloading enables you to define more than one method with the same name within the same class. The compiler differentiates between overloaded methods based on the number, type, and order of the parameters. Importantly, overloading a method requires a change in the argument list; simply changing the return type is insufficient.
In Java’s Math
class, for example, the abs()
method is overloaded to accommodate many numeric types, improving program readability by enabling related operations to be accessed by a common name.
Here’s an example of method overloading:
class Calculator {
// Overload add for two integer parameters
public int add(int a, int b) {
System.out.println("Adding two integers: " + a + " + " + b);
return a + b;
}
// Overload add for three integer parameters
public int add(int a, int b, int c) {
System.out.println("Adding three integers: " + a + " + " + b + " + " + c);
return a + b + c;
}
// Overload add for two double parameters
public double add(double a, double b) {
System.out.println("Adding two doubles: " + a + " + " + b);
return a + b;
}
}
public class OverloadingDemo {
public static void main(String[] args) {
Calculator calc = new Calculator();
System.out.println("Result 1: " + calc.add(10, 20));
System.out.println("Result 2: " + calc.add(10, 20, 30));
System.out.println("Result 3: " + calc.add(1.5, 2.5));
}
}
Output:
Adding two integers: 10 + 20
Result 1: 30
Adding three integers: 10 + 20 + 30
Result 2: 60
Adding two doubles: 1.5 + 2.5
Result 3: 4.0
This is referred to as static binding or compile-time polymorphism since the compiler uses the argument list to decide which version of the method to call at compile time. Java also handles automatic type conversions for parameters; for example, a byte
or short
argument might be converted to int
if an f(int)
method is available and no f(byte)
or f(short)
exists.
Method Overriding (Runtime Polymorphism)
A method in a subclass is said to be overriding if it has the same signature (name and parameter list) and return type as a method in its superclass. The behaviour of the superclass method is then redefined or overridden by the subclass method. When a method is meant to override a superclass method, the @Override
annotation is usually used to help identify possible compilation issues in the event that the method signatures don’t match.
A fundamental aspect of inheritance is overriding, in which a subclass (child class) extends a superclass (parent class), creating a “is-a” relationship. The subclass can then modify the inherited behaviour to suit its own requirements.
Consider this example:
class Animal {
public void makeSound() {
System.out.println("Animal makes a sound");
}
}
class Dog extends Animal {
@Override
public void makeSound() {
System.out.println("Dog barks: Woof Woof!");
}
}
public class OverridingDemo {
public static void main(String[] args) {
Animal myAnimal = new Animal();
Dog myDog = new Dog();
myAnimal.makeSound(); // Calls Animal's makeSound
myDog.makeSound(); // Calls Dog's makeSound
}
}
Output:
Animal makes a sound
Dog barks: Woof Woof!
Dynamic Method Dispatch (Runtime Polymorphism)
Java uses dynamic method dispatch to support runtime polymorphism, which enables the full capability of method overriding. The call to an overridden method is resolved by this approach at runtime instead of compile time. Java chooses the version of the overridden method to run depending on the type of the object being referenced, not the type of the reference variable, when the method is called through a superclass reference variable that points to a subclass object.
Here’s an example demonstrating dynamic method dispatch:
class Shape {
public void draw() {
System.out.println("Drawing a generic shape.");
}
}
class Circle extends Shape {
@Override
public void draw() {
System.out.println("Drawing a Circle.");
}
}
class Rectangle extends Shape {
@Override
public void draw() {
System.out.println("Drawing a Rectangle.");
}
}
public class DynamicDispatchDemo {
public static void main(String[] args) {
Shape genericShape = new Shape();
Shape circleShape = new Circle(); // Superclass reference, subclass object
Shape rectangleShape = new Rectangle(); // Superclass reference, subclass object
genericShape.draw(); // Calls Shape's draw()
circleShape.draw(); // Calls Circle's draw() at runtime
rectangleShape.draw(); // Calls Rectangle's draw() at runtime
}
}
Output:
Drawing a generic shape.
Drawing a Circle.
Drawing a Rectangle.
As you can see, even though circleShape
and rectangleShape
are declared as Shape
references, the Java Virtual Machine (JVM) correctly invokes the draw()
method specific to Circle
and Rectangle
objects at runtime. Code that utilises the superclass interface can be made more adaptable and extensible by introducing new subclasses without changing old code.
Object Casting
As long as there is an inheritance relationship between the types, object casting entails changing an object’s type.
- Widening (Upcasting): Java’s implicit casting of a subclass object to a superclass type is always safe. An example of a widening conversion is
Shape s = new Circle();
. - Narrowing (Downcasting): Casting a superclass object to a subclass type necessitates an explicit type-cast and may result in a
ClassCastException
at runtime if the object isn’t an instance of the target subclass. This is known as narrowing (downcasting).
Using the instanceof
operator allows downcasting to be done properly. The instanceof
operator returns true
if an object is an instance of the class, a subclass, or implements a specific interface after comparing it to a given type.
Here’s an example demonstrating instanceof
and object casting:
class Vehicle {}
class Car extends Vehicle {}
class Bike extends Vehicle {}
public class ObjectCastingDemo {
public static void main(String[] args) {
Vehicle myCar = new Car();
Vehicle myBike = new Bike();
Vehicle genericVehicle = new Vehicle();
// Check types using instanceof
System.out.println("myCar instanceof Car: " + (myCar instanceof Car)); // true
System.out.println("myCar instanceof Vehicle: " + (myCar instanceof Vehicle)); // true
System.out.println("myBike instanceof Car: " + (myBike instanceof Car)); // false
// Safe downcasting with instanceof
if (myCar instanceof Car) {
Car carObject = (Car) myCar; // Explicit cast is safe
System.out.println("Successfully cast myCar to Car type.");
}
// Unsafe downcasting (will throw ClassCastException at runtime)
try {
Car anotherCar = (Car) myBike; // myBike is a Bike, not a Car
} catch (ClassCastException e) {
System.out.println("Caught exception: " + e.getMessage());
}
// Another safe check
if (genericVehicle instanceof Car) {
Car carObject = (Car) genericVehicle; // This block will not execute
} else {
System.out.println("genericVehicle is not a Car type.");
}
}
}
Output:
myCar instanceof Car: true
myCar instanceof Vehicle: true
myBike instanceof Car: false
Successfully cast myCar to Car type.
Caught exception: class Bike cannot be cast to class Car (Bike and Car are in unnamed module of loader 'app')
genericVehicle is not a Car type.
When dealing with intricate class hierarchies, the instanceof
operator is extremely helpful since it enables programs to prevent ClassCastException
failures and retrieve runtime type information about an object.
In Conclusion
Java’s strong object-oriented features depend on Method Overloading, Method Overriding, Dynamic Method Dispatch, and Object Casting all working together. Because of them, developers may design code that is resilient, reusable, and adaptable enough to adjust to changing needs and intricate systems.