Unit 14: Polymorphism
Learning Objectives
After reading this unit, students should
- understand polymorphism
- be aware of dynamic binding
- be aware of the
equals
method and the need to override it to customize the equality test - understand when narrowing type conversion and type casting are allowed
Taking on Many Forms
Method overriding enables polymorphism, the fourth and the last pillar of OOP, and arguably the most powerful one. It allows us to change how existing code behaves, without changing a single line of the existing code (or even having access to the code).
Consider the function say(Object)
below:
1 2 3 |
|
Note that this method receives an Object
instance. Since both Point
<: Object
and Circle
<: Object
, we can do the following:
1 2 3 4 |
|
When executed, say
will first print Hi, I am (0.0, 0.0)
, followed by Hi, I am { center: (0.0, 0.0), radius: 4.0 }
. We are invoking the overriding Point::toString()
in the first call, and Circle::toString()
in the second call. The same method invocation obj.toString()
causes two different methods to be called in two separate invocations!
In biology, polymorphism means that an organism can have many different forms. Here, the variable obj
can have many forms as well. Which method is invoked is decided during run-time, depending on the run-time type of the obj
. This is called dynamic binding or late binding or dynamic dispatch.
Before we get into this in more detail, let's consider overriding Object::equals(Object)
.
The equals
method
Object::equals(Object)
compares if two object references refer to the same object. Suppose we have:
1 2 3 |
|
c2.equals(c1)
returns true
, but c0.equals(c1)
returns false
. Even though c0
and c1
are semantically the same, they refer to two different objects.
To compare if two circles are semantically the same, we need to override this method1. After all, Java does not know what it means for two circles to be equal, so we have to define it.
Circle v0.7a with Overriding equals | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
|
This is more complicated than toString
. There are a few new concepts involved here:
equals
takes in a parameter of compile-time typeObject
. It only makes sense if we compare (during run-time) a circle with another circle. So, we first check if the run-time type ofobj
is a subtype ofCircle
. This is done using theinstanceof
operator. The operator returnstrue
ifobj
has a run-time type that is a subtype ofCircle
.- To compare
this
circle with the given circle, we have to access the centerc
and radiusr
. But if we accessobj.c
orobj.r
, the compiler will complain. As far as the compiler is concerned,obj
has the compile-time typeObject
, and there is no such fieldsc
andr
in the classObject
! This is why, after assuring that the run-time type ofobj
is a subtype ofCircle
, we assignobj
to another variablecircle
that has the compile-time typeCircle
. We finally check if the two centers are equal (again,Point::equals
is left as an exercise) and if the two radii are equal2. - The statement that assigns
obj
tocircle
involves type casting. We mentioned before that Java is strongly typed, so it is very strict about type conversion. Here, Java allows type casting from type \(T\) to \(S\) if \(S <: T\). 3: This is called narrowing type conversion. Unlike widening type conversion, which is always allowed and always correct, a narrowing type conversion requires explicit typecasting and validation during run-time. If we do not ensure thatobj
has the correct run-time type, casting can lead to a run-time error (which if you recall, is bad).
All these complications would go away, however, if we define Circle::equals
to take in a Circle
as a parameter, like this:
Circle v0.7b with Overriding equals | |
---|---|
1 2 3 4 5 6 7 8 9 10 |
|
This version of equals
, however, does not override Object::equals(Object)
. Since we hinted to the compiler that we meant this to be an overriding method, using @Override
, the compiler will give us an error. This method is not treated as method overriding, since the method signature for Circle::equals(Circle)
is different from Object::equals(Object)
.
Why then is overriding important? Why not just leave out the line @Override
and live with the non-overriding, one-line, equals
method above?
The Power of Polymorphism
Let's consider the following example. Suppose we have a general contains
method that takes in an array of objects. The array can store any type of object: Circle
, Square
, Rectangle
, Point
, String
, etc. The method contains
also takes in a target obj
to search for, and returns true if there is an object in array
that equals to obj
.
contains v0.1 with Polymorphism | |
---|---|
1 2 3 4 5 6 7 8 |
|
With overriding and polymorphism, the magic happens in Line 3 — depending on the run-time type of curr
, the corresponding, customized version of equals
is called to compare against obj
. So if the run-time type of curr
is Circle
, then we will invoke Circle::equals(Object)
and if the run-time type of curr
is Point
, then we will invoke Point::equals(Object)
. This, of course, assumes that Object::equals(Object)
is overridden in both classes.
However, if Circle::equals(Object)
takes in a Circle
as the parameter, the call to equals
inside the method contains
would not invoke Circle::equals(Circle)
. It would invoke Object::equals(Object)
instead due to the matching method signature, and we cannot search for Circle
based on semantic equality.
Why is this the case? Look closely at how the method is invoked: curr.equals(obj)
. Here, we can see that the parameter we are passing is obj
. The compile-time type of obj
is Object
as seen from the parameter declaration at Line 2. So at compile-time, we only know that its type is Object
.
To have a generic contains
method without polymorphism and overriding, we will have to do something like this:
contains v0.2 without Polymorphism | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
which is not scalable since every time we add a new class, we have to come back to this method and add a new branch to the if-else
statement!
As this example has shown, polymorphism allows us to write succinct code that is future-proof. By dynamically deciding which method implementation to execute during run-time, the implementer can write short yet very general code that works for existing classes as well as new classes that might be added in the future by the client, without even the need to re-compile.
-
If we override
equals()
, we should generally overridehashCode()
as well, but let's leave that for another lesson on another day. ↩ -
The right way to compare two floating-point numbers is to take their absolute difference and check if the difference is small enough. We are sloppy here to keep the already complicated code a bit simpler. You shouldn't do this in your code. ↩
-
This is not the only condition where type casting is allowed. We will look at other conditions in later units. ↩