Photo by Shubham Dhage on Unsplash
Understanding Polymorphism and Abstract Classes: Code Reuse and Design Contracts
A previous post discussed the Object-Oriented Programming (OOP) concept of generalisation (or "is-a-kind-of")—a relationship between a general category (superclass or parent) and a more specific type (subclass or child). That article focussed on the inheritance concept that allows code reuse of superclass methods in the associated subclasses.
Building on this foundation, this article further considers a generalisation scenario from the polymorphism perspective. Unlike inheritance, polymorphism emphasises reusing code by allowing objects of different subclasses to be treated as instances of a common superclass. This enables the same method to be called on different objects, triggering behaviour specific to the object's subclass. In this way, polymorphism provides a flexible mechanism for code reuse, enhancing the adaptability and maintainability of software.
In OOP, together with polymorphism, concepts like abstract classes, design contracts, and method overriding play pivotal roles in creating flexible, reusable, and maintainable code. This blog post delves into these concepts, explaining their significance and how they interrelate to form the backbone of robust software design.
Introduction to Polymorphism
Polymorphism, derived from the Greek words "poly" (many) and "morph" (form), refers to the ability of different objects to respond to the same function call—the same method—in different ways. This concept is fundamental in OOP as it allows for dynamic method dispatch, where the method to be invoked is determined at runtime based on the object's actual type.
Polymorphism is manifested when objects of different subclasses are treated as objects of a common superclass. This allows a single interface to represent different underlying forms (data types). The following class diagram shows a generalisation scenario where the Circle
and Rectangle
classes are specialised Shape
types:
In this system, the display()
method of the Shape
superclass does not define a behaviour that all the subclasses can reuse, as each shape is displayed differently. Instead, the method defines a public interface that all the subclasses inherit. In other words, Circle
and Rectangle
inherit the signature definition of display()
—not the body. Each subclass will implement display()
(technically, override it) in a different way.
From a pure programming viewpoint, polymorphism happens when a parent class reference-type variable refers to a child class object. Considering the system described in the previous class diagram, the existence of a generalisation relationship allows writing the following code:
Shape circle1 = new Circle ( ... );
Shape rectangle1 = new Rectangle ( ... );
Where the reference variables of the Shape
superclass circle1
and rectangle1
have been used to store references to objects of the Circle
and Rectangle
subclasses respectively. This is expected to work as we know that “a circle is a kind of shape” and “a rectangle is a kind of shape”. Therefore, the Java compiler will let us write those instructions without generating errors.
Moreover, the compiler will not complain if we attempt to call display()
using the declared variables, as this method is included in the Shape
class (public) interface:
circle1.display();
rectangle1.display();
The remainder of the article will discuss how the above call to the display
method will produce a different behaviour depending on the type of shape.
Abstract Classes
Let's expand the system shown in the previous diagram by adding more attributes and behaviours to the Shape
superclass. We'll include the colour
and filled
attributes, along with their getter methods. Besides the display()
method, let's also consider other common behaviours that geometric shapes might have, such as getArea()
and getPerimeter()
.
If asked to display a shape and calculate its area and perimeter, we would naturally ask, "Sure, but what shape is it?" Shape is an abstract idea, and we can only visualise, draw, and calculate the features of real shapes like circles, rectangles, squares, etc.
Therefore, the following code defines the Shape
superclass and its methods display
, getArea
and getPerimeter
as abstract, using the corresponding abstract
Java keyword:
public abstract class Shape {
private String colour;
private boolean filled;
public Shape(String colour, boolean filled) {
this.colour = colour;
this.filled = filled;
}
public String getColour() {
return colour;
}
public boolean isFilled() {
return filled;
}
public abstract void display();
public abstract double getArea();
public abstract double getPerimeter();
}
It can be noticed that abstract methods are declared without an implementation. They only have the signature definition followed by ;
instead of the curly braces that specify the beginning {
and the end }
of the method’s body.
Therefore, since these methods are “incomplete”, abstract
classes cannot be directly instantiated and are meant to be subclassed. The subclasses will (must) provide an implementation for those abstract
methods to become instantiable.
In essence, abstract classes serve as blueprints for other classes. As described later in this post, they define contracts that subclasses must fulfil, ensuring that certain methods are implemented to promote consistency and reliability across different subclasses.
Here, Shape
defines the abstract
methods display()
, getArea()
, and getPerimeter()
. Any subclass of Shape
must implement these methods, ensuring that all shapes can be displayed and their area and perimeter can be calculated by calling the same method names.
Method Overriding
Method overriding occurs when a subclass provides a specific implementation for an abstract method or a method already defined in its superclass. This is a key aspect of polymorphism, allowing subclasses to tailor inherited method interfaces to their specific needs.
Overriding Abstract Methods
When a subclass inherits from an abstract class, it must override and provide concrete implementations for all the abstract methods of the superclass to become instantiable.
For example, the following code shows a possible implementation of a Circle
subclass that extends the abstract
Shape
class:
public class Circle extends Shape {
private Point centre; // class to represent 2D points
private double radius;
public Circle(Point c, double r) {
super("red", true); // Example values
this.centre = c;
this.radius = r;
}
@Override
public void display() {
System.out.println("Centre: " + centre.toString());
System.out.println("Radius: " + radius);
}
@Override
public double getArea() {
return Math.PI * radius * radius;
}
@Override
public double getPerimeter() {
return 2 * Math.PI * radius;
}
}
In this example, Circle
overrides (note the @Override
annotation) the abstract
methods display()
, getArea()
, and getPerimeter()
from the Shape
class, providing specific implementations for a circle.
The code of other potential geometric shapes would have a structure consistent with the one above, determined using the abstract
Shape
class as a template. However, their implementation of display()
, getArea()
, and getPerimeter()
would be specific for the type of shape they represent.
Before demonstrating how this allows writing elegant, reusable code, let’s consider the case when override is done on (concrete) methods of a superclass that already have an implementation (i.e., they are not abstract
).
Overriding Concrete Methods
A subclass can override concrete methods in a superclass to provide a more specific behaviour. This is useful when the superclass's default implementation is unsuitable for the subclass.
To show this, let’s temporarily move away from the previous Shape system and consider the following Person
class:
public class Person {
private String name;
private String surname;
private int yearOfBirth;
private Address address; // class that deals with addresses
public Person(String name, String surname, int yearOfBirth, Address address) {
this.name = name;
this.surname = surname;
this.yearOfBirth = yearOfBirth;
this.address = address;
}
public void display() {
System.out.println("Name: " + name);
System.out.println("Surname: " + surname);
System.out.println("Year of Birth: " + yearOfBirth);
System.out.println("Address: " + address.toString());
}
}
The above code demonstrates the usage of a display()
method to print the attributes of the Person
class, namely name
, surname
, yearOfBirth
and address
. However, the code of the following Teacher
subclass (of Person
) shows that teachers have the additional attributes related to their salary and subject taught:
public class Teacher extends Person {
private double salary;
private String subject;
public Teacher(String name, String surname, int yearOfBirth, Address address, double salary, String subject) {
super(name, surname, yearOfBirth, address);
this.salary = salary;
this.subject = subject;
}
@Override
public void display() {
super.display();
System.out.println("Salary: " + salary);
System.out.println("Subject: " + subject);
}
}
The version of display()
inherited from Person
would not be suitable for a Teacher
since it would not print its specific features. Therefore, the above definition of the Teacher
subclass overrides the display()
method of Person
to print the specific attributes salary
and subject
. The overridden method version also demonstrates the super
keyword to call the display()
method of the superclass, and reuse the code already defined there (whereby the attributes of Teacher
inherited from Person
can be accessed and printed).
Polymorphism in Action
Polymorphism allows the same methods to be called on different subclasses and trigger different behaviours based on the actual object type at runtime. Let's see how this works in practice using the Shape
class and its subclasses in the following code:
public class ShapeTest {
public static void main(String[] args) {
Shape[] shapes = new Shape[3];
shapes[0] = new Circle(new Point(0, 0), 5);
shapes[1] = new Rectangle(new Point(0, 0), 4, 6);
shapes[2] = new Circle(new Point(1, 1), 3);
for (Shape shape : shapes) {
shape.display();
System.out.println("Area: " + shape.getArea());
System.out.println("Perimeter: " + shape.getPerimeter());
System.out.println();
}
}
}
In the above example, we define an array named shapes
to hold references to objects of the Shape
class. However, the beauty of polymorphism is that this array can hold any object that is a subclass of Shape
, such as Circle
or Rectangle
.
In our example, the shapes
array has three slots. We then fill these slots with different shapes: the first slot holds a Circle
object, the second a Rectangle
object, and the third another Circle
object. Even though the array is of type Shape
, it can seamlessly accommodate these different types of shapes because Circle
and Rectangle
are subclasses of Shape
.
Next, we use a for
loop to iterate over each element in the shapes
array. During each iteration, the variable shape
holds a reference to the current Shape
object in the array. Here’s where polymorphism truly shines. When we call the display()
, getArea()
, and getPerimeter()
methods on the shape
variable, the actual method that gets executed depends on the runtime type of the object that shape
references.
In the first iteration, shape
references a Circle
object. Therefore, the display()
, getArea()
, and getPerimeter()
methods of the Circle
class are executed. The loop then moves to the second iteration, where shape
now references a Rectangle
object. This time, the display()
, getArea()
, and getPerimeter()
methods of the Rectangle
class are called. Finally, in the third iteration, shape
references another Circle
object, and once again, the methods specific to the Circle
class are executed.
This dynamic method dispatch, where the method executed is determined at runtime based on the actual object type, is a hallmark of polymorphism. The same method call can result in different behaviours depending on the object type. This makes the code more flexible and reusable and simplifies the process of extending the system with new types of shapes that adhere to the Shape
design contract.
This idea of a design contract is explored further in the next section.
Abstract Classes and Design Contracts
Design contracts in OOP are agreements that define how classes should interact with each other. Abstract classes often serve as design contracts and blueprints for other classes specifying methods that subclasses must implement. This ensures all subclasses adhere to a consistent interface, facilitating polymorphism and code reuse.
Specifically, a design contract can be defined as a set of abstract methods in an abstract class that all subclasses must fulfil. This contract guarantees that certain methods will be consistently available among various subclasses, allowing other system parts to interact with these subclasses predictably. For example, a ShapeDrawer
class might rely on the previous Shape
contract to draw any shape:
public class ShapeDrawer {
public static void drawShape(Shape shape) {
shape.display();
System.out.println("Area: " + shape.getArea());
System.out.println("Perimeter: " + shape.getPerimeter());
}
}
Here, ShapeDrawer
can draw any shape that adheres to the Shape
contract, regardless of the specific type of shape. As also highlighted earlier, the same ShapeDrawer
system would work seamlessly without needing modification even if new geometric shapes—that follow the Shape
contract—are added to the system.
Conclusion
Polymorphism, abstract classes, design contracts, and method overriding are fundamental concepts in OOP that enable the creation of flexible, reusable, and maintainable code. By understanding and applying these concepts, developers can design robust and adaptable systems capable of evolving with changing requirements.
Polymorphism allows for dynamic method invocation, enabling objects of different subclasses to be treated as instances of a common superclass. This flexibility is crucial for building systems that can handle new requirements and changes with minimal impact on existing code. By treating different subclass objects uniformly through a common interface, polymorphism enhances the overall maintainability and scalability of the software.
Abstract classes and design contracts ensure that subclasses adhere to a consistent interface, promoting code reuse and reducing duplication. Method overriding allows subclasses to provide specific implementations for inherited methods, tailoring behaviour to meet specific needs.
Embracing these principles helps create functional, elegant, and sustainable software in the long term.