Java Polymorphism, instanceof, and Object Equality Detection

Java's polymorphism is a feature of object-oriented programming that allows different objects to be accessed and manipulated in a uniform way. It enables an instance of a class to exhibit multiple forms at runtime. Polymorphism in Java relies on two basic concepts: inheritance and method overriding.

In Java, a subclass can inherit methods from its parent class and override them to implement its own specific behavior. When we create an object, it can reference its own class type or any parent class or implemented interface type.

This approach allows us to write more generic code, reduce repetitive logic, and improve code extensibility and maintainability. By managing different subclass objects through a unified parent interface, we can handle various objects more flexibly without depending on specific subclass types.

This is the core idea of Java polymorphism: an object has multiple appearances and behaviors depending on the context in which it is used.

The benefits of Java polymorphism include improved code reusability and maintainability, making code more flexible and extensible. For example, a method can accept a base class (like Object) as a parameter, and at runtime, an instance of a subclass can be passed in, allowing the method to handle various object types without needing to be rewritten.

When you need to accept instances of different subclass types in a method without rewriting the method for each subclass, you can use polymorphism and method overloading in Java. Polymorphism allows you to reference subclass objects through base class type references and call the correct method at runtime.

Suppose we have a base class Shape and its two subclasses Circle and Rectangle:

abstract class Shape {
    abstract void draw();
}

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

class Rectangle extends Shape {
    @Override
    void draw() {
        System.out.println("Drawing a rectangle");
    }
}

Now, you can create a method that accepts a base class parameter and calls its draw method:

public class Main {
    static void drawShape(Shape shape) {
        shape.draw();
    }
    
    public static void main(String[] args) {
        Shape circle = new Circle();
        Shape rectangle = new Rectangle();
        
        drawShape(circle);     // Calls Circle's draw method
        drawShape(rectangle);  // Calls Rectangle's draw method
    }
}

In this example, the drawShape method accepts a Shape type parameter. You can pass Circle or Rectangle objects as arguments because they are subclasses of Shape. At runtime, Java calls the correct draw method based on the actual object type, demonstrating polymorphism.

When a parent reference points to an object with subclass identity, a polymorphic relationship is created.

Two Ways to Achieve Polymorphism in Java

  1. Compile-time polymorphism (static polymorphism): Achieved through method overloading. Method overloading defines multiple methods with the same name but different parameter lists within a class. The compiler selects which method to call based on the argument types.

  2. Runtime polymorphism (dynamic polymorphism): Achieved through method overriding. Method overriding occurs when a subclass redefines a method with the same name and parameter list as in the parent class. At runtime, the method called depends on the actual object type.

Brief Explanation: ParentType ref = new SubclassObject();

Conditions for runtime polymorphism:

  • Inheritance relationship: The subclass inherits from the parent class.
  • Method overriding: The subclass overrides a parent method.
  • Parent reference pointing to subclass object: A parent reference is used to refer to a subclass object; at runtime, the appropriate method is called based on the actual object type.

A "reference" is a variable or expression that identifies the memory location of an object. References can be used to access object properties and methods. When a parent reference refers to a subclass object, you can manipulate the subclass instance using the parent reference without directly using the subclass type name.

Important Notes

It prevents errors when accessing subclass-specific properties and methods later in the code. Additionally, it adheres to object-oriented design principles: a subclass should extend the parent class, not replace it. This restriction helps maintain code clarity and maintainability. Another reason is that the actual type of an object determines which methods can be called. If a parent object were assigned to a subclass reference, the compiler would treat the object as the parent type, allowing only parent methods to be called, thus violating the polymorphism principle. Therefore, Java does not allow assigning a parent object to a subclass reference.

Polymorphism diagram

Detailed Steps

  1. Establishing parent-child relationship:
class Parent {
    void show() {
        System.out.println("Parent's show method");
    }
}

class Child extends Parent {
    void show() {
        System.out.println("Child's show method");
    }
}
  1. Using polymorphism:
Parent obj1 = new Parent();
Parent obj2 = new Child();

obj1.show(); // Calls parent's method
obj2.show(); // Calls child's method
  1. Method overriding:
@Override
void show() {
    System.out.println("Child's overridden show method");
}
  1. Parent reference calling child method:
Parent obj = new Child();
obj.show(); // Calls child's method

Appropriate Use Cases for Polymorphism

Polymorphism is suitable in the following scenarios:

  • Inheritance relationships: When multiple classes share an inheritance hierarchy, polymorphism handles their objects flexibly and simplifies code logic.
  • Method parameters: When a method needs to accept different object types and perform similar operations, polymorphism makes the code more generic and extensible.
  • Method return types: When a method's return type is a base class or interface, but the actual returned instance is a subclass, polymorphism can be used.
  • Collections: When using collections (like List, Set, Map), polymorphism allows storing different object types and operating through a unified interface.

In summary, polymorphism makes code more flexible, maintainable, reusable, and extensible.

Key Points for Achieving Polymorphism

0. Additional Concepts

  • Upcasting: Assigning a subclass object to a parent reference. This enables polymorphism but restricts access to methods defined in the parent class only.
  • Downcasting: Converting a parent reference back to a subclass type using explicit casting. However, downcasting may throw a ClassCastException, so type checking with instanceof is recommended before casting.

1. Inheritance Relationship

First, design a parent class (base class) with common properties and methods. Then, create one or more subclasses that inherit these and add their own specific features.

abstract class Shape {
    abstract void draw();
}

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

class Rectangle extends Shape {
    @Override
    void draw() {
        System.out.println("Drawing a rectangle");
    }
}

2. Method Overriding

Use the @Override annotation to override methods in the subclass. Ensure the method signature (name, parameter list, and return type) matches the parent method for polymorphism to work.

3. Parent Reference and Subclass Object

Using a parent reference variable to refer to a subclass object is key to polymorphism. This provides flexibility at compile time and allows runtime decision on which method to call.

Shape myShape1 = new Circle(); // Parent reference, subclass object
Shape myShape2 = new Rectangle();

myShape1.draw(); // Output: Drawing a circle
myShape2.draw(); // Output: Drawing a rectangle

Polymorphism allows us to use a parent reference to point to a subclass object because a subclass object can be assigned to a parent reference. This promotes flexible and extensible code.

In Shape myShape1 = new Circle();, we create a Circle object and assign it to a Shape reference variable myShape1. Since Circle is a subclass of Shape, this assignment is valid. Although myShape1 is of type Shape, it actually points to a Circle object.

By using a generic Shape reference to refer to different concrete shape objects, we achieve polymorphism and inheritance. This design pattern allows us to operate on objects more abstractly while retaining their specific functionality.

4. Runtime Binding

When a method is called through a parent reference, Java dynamically determines which method to invoke at runtime. This is called runtime binding. You can use the same method call in different contexts, but the actual executed method belongs to the actual subclass.

5. Using Abstract Classes or Interfaces (Optional)

If you want to define a set of shared method signatures, you can use abstract classes or interfaces. Abstract classes can provide partial implementations, while interfaces enforce implementation of all methods. This makes polymorphism more flexible and allows sharing more behaviors among different classes.

interface Sound {
    void makeSound();
}

class Dog implements Sound {
    @Override
    public void makeSound() {
        System.out.println("Dog barks");
    }
}

class Cat implements Sound {
    @Override
    public void makeSound() {
        System.out.println("Cat meows");
    }
}

In summary, achieving polymorphism requires inheritance, method overriding, parant reference with subclass object, runtime binding, and optionally abstract classes or interfaces. These concepts make polymorphism a powerful feature in OOP, improving code flexibility and maintainability.

Characteristics of Polymorphic Member Access

  • Variable access: Compilation looks at the left side (declared type), and runtime also looks at the left side.
  • Method access: Compilation looks at the left side, but runtime looks at the right side (actual object type).
  1. For variable access, the compiler checks the declared type (left) for accessible variables, and at runtime, the variable's value comes from the left type, ignoring the actual object type.
  2. For method access, the compiler determines availability based on the left type, but at runtime, the actual method executed is based on the right type.
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");
    }
}

class Cat extends Animal {
    @Override
    public void makeSound() {
        System.out.println("Cat meows");
    }
}

public class Main {
    public static void main(String[] args) {
        Animal myAnimal1 = new Dog();
        Animal myAnimal2 = new Cat();

        myAnimal1.makeSound(); // Compile: left is Animal; runtime: right is Dog -> "Dog barks"
        myAnimal2.makeSound(); // Compile: left is Animal; runtime: right is Cat -> "Cat meows"
    }
}

Method dispatch diagram

Using instanceof for Type Checking and Casting

When using instanceof for type checking and casting, ensure type safety: the object's actual type must match the target type. Otherwise, a ClassCastException will be thrown at runtime.

Method 1: Check and cast separately

if (object instanceof MyClass) {
    MyClass myObject = (MyClass) object;
    // Use myObject to access MyClass-specific methods and properties
}

Here, object is the object to check, and MyClass is the class to test against. If object is an instance of MyClass or its subclass, the block executes.

Code Example 1: Check if an object is an instance of a class

class ParentClass { /* ... */ }
class ChildClass1 extends ParentClass { /* ... */ }
class ChildClass2 extends ParentClass { /* ... */ }

public class Main {
    public static void main(String[] args) {
        ParentClass obj = new ChildClass1();
        
        if (obj instanceof ChildClass1) {
            System.out.println("obj is an instance of ChildClass1");
        } else if (obj instanceof ChildClass2) {
            System.out.println("obj is an instance of ChildClass2");
        } else {
            System.out.println("obj is not an instance of ChildClass1 or ChildClass2");
        }
    }
}

Code Example 2: Check and cast

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

class Dog extends Animal {
    public void makeSound() {
        System.out.println("Dog barks");
    }
    
    public void fetch() {
        System.out.println("Dog fetches the ball");
    }
}

public class Main {
    public static void main(String[] args) {
        Animal myAnimal = new Dog();

        if (myAnimal instanceof Dog) {
            Dog myDog = (Dog) myAnimal;
            myDog.fetch(); // Can call Dog-specific method
        }

        myAnimal.makeSound(); // Output: Dog barks
    }
}

Method 2: Pattern matching for instanceof (Java 14+)

if (object instanceof MyClass myObject) {
    // Use myObject directly
}

Example:

class MyClass {
    public void myMethod() {
        System.out.println("MyClass method");
    }
}

public class Main {
    public static void main(String[] args) {
        Object object = new MyClass();

        if (object instanceof MyClass myObject) {
            myObject.myMethod(); // Directly use myObject
        }
    }
}

Object Equality Detection Methods

Case 1: Subclass Defines Its Own Equality Concept – Use getClass()

When subclasses have their own notion of equality, different from the superclass, use getClass() to ensure both objects are of the same class before comparing fields.

abstract class Shape {
    // base class
}

class Circle extends Shape {
    private double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj == this) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Circle other = (Circle) obj;
        return Double.compare(radius, other.radius) == 0;
    }
}

class Square extends Shape {
    public double side;

    public Square(double side) {
        this.side = side;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj == this) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Square other = (Square) obj;
        return Double.compare(side, other.side) == 0;
    }
}

public class Main {
    public static void main(String[] args) {
        Circle circle1 = new Circle(5.0);
        Circle circle2 = new Circle(5.0);

        System.out.println(circle1.equals(circle2)); // true
    }
}

Analysis

The equals method checks:

  • If the same reference, return true.
  • If null or different class, return false.
  • Otherwise, cast and compare fields.

Using getClass() ensures that only objects of the exact same class are considered equal. This is appropriate when subclasses have their own equality semantics.

Case 2: Superclass Defines Equality Concept – Use instanceof

When the superclass defines equality and subclasses inherit that concept, different subclass objects may be considered equal if they share the same superclass attributes. Use instanceof to check.

class Animal {
    protected String species;

    public Animal(String species) {
        this.species = species;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj == this) return true;
        if (obj == null || !(obj instanceof Animal)) return false;
        Animal other = (Animal) obj;
        return species.equals(other.species);
    }
}

class Dog extends Animal {
    public Dog() {
        super("Dog");
    }
}

class Cat extends Animal {
    public Cat() {
        super("Cat");
    }
}

public class Main {
    public static void main(String[] args) {
        Animal dog = new Dog();
        Animal cat = new Cat();

        System.out.println(dog.equals(cat)); // false, different species
    }
}

In this case, equals uses instanceof to allow any subclass of Animal to be compared, but the equality logic is based on the superclass field species. This is appropriate when equality is defined solely by superclass properties.

Equality diagram

Explanation of equals

The Object class provides a default equals implementation based on reference equality. Overriding equals allows custom logic. The contract includes reflexivity, symmetry, transitivity, consistency, and non-nullity. Overriding equals often requires overriding hashCode as well.

Summary of When to Use Each Case

  • Case 1 (getClass): Use when subclasses have distinct equality concepts. For example, comparing two Circle objects should only consider radius; comparing a Circle to a Square should always be false even if they have the same area.
  • Case 2 (instanceof): Use when equality is defined by superclass attributes and any subclass instance can be compared based on those attributes. For example, all Animal objects are equal if they have the same species, regardless of whether they are Dog or Cat.

Choose the approach that aligns with your design requirements.

Tags: java Polymorphism instanceof Object Equality Inheritance

Posted on Mon, 06 Jul 2026 17:09:53 +0000 by Who