Lecture03 - nus-cs2030/2324-s2 GitHub Wiki
OOP Principle of Inheritance and Substitutability
We conclude the discussion of the four principles of OOP by focusing on inheritance and the is-a relationship established between the parent (super) and child (sub) classes. We also look at the concept of substitutability and how valid Java programs can be constructed with an awareness of compile-time type versus run-time type.
Defining a Sub-class
Suppose we have a Shape
interface with an implementation class Circle
that defines the getArea
method specified in Shape
.
class Circle implements Shape {
private final double radius;
Circle(double radius) {
this.radius = radius;
}
double getArea() {
return Math.PI * this.radius * this.radius;
}
public String toString() {
return "Circle with radius " + this.radius;
}
}
Now we implement the FilledCircle
class
with the getArea
, as well as toString
methods.
We can define the FilledCircle
class this way.
import java.awt.Color;
class FilledCircle implements Shape {
private final double radius;
private final Color color;
FilledCircle(double radius, Color color) {
this.radius = radius;
this.color = color;
}
double getArea() {
return Math.PI * this.radius * this.radius;
}
public String toString() {
return "FilledCircle with radius " + this.radius +
" and color " + this.color;
}
}
However, you will notice that FilledCircle
will be very similar to
Circle
in terms of their implementations because both are circles,
and as such have the radius properties and exactly the same
getArea
method implementations. Can we avoid duplicating code?
DRY Principle: Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
Adherence to the DRY principle implies that there is only one place
for which to define the radius
property. Likewise, the getArea
method should only be defined in one place.
As such, we make use of inheritance.
class FilledCircle extends Circle {
private final Color color;
FilledCircle(double radius, Color color) {
super(radius);
this.color = color;
}
public String toString() {
return "Filled" + super.toString() + " and color " + this.color;
}
}
Notice in the FilledCircle
class that there is no longer the radius
property and getArea
method, as these are inherited from the parent
Circle
class. To refer to the parent's properties, methods and
constructor, we can use the super
keyword.
super.radius
to refer to the parent's radiussuper.toString()
to refer to the parent's toString() methodsuper(..)
to refer to the parent's constructor
Note that unlike this
, super
cannot be assigned to a variable,
passed to a method or returned from a method.
Inheritance and the Is-A Relationship
In the last lecture, we established the relationships
Circle
is-a Shape
, as well as Rectangle
is-a Shape
.
Now, we can establish the relationship FilledCircle
is-a Circle
.
But unlike the previous examples, both FilledCircle
and Circle
are classes.
More importantly, this allows us to call a method defined in the
parent (say Circle
) class from its child (say FilledCircle
)
class using new FilledCircle(1.0, Color.BLUE).getArea()
A caveat is that a child class can only extend from one parent. In Java, multiple inheritance is not allowed. Why is that so?
The main reason why Java does not allow multiple inheritance is to avoid the diamond inheritance problem which is a common issue in languages that support multiple inheritance. This happens when a class inherits from two classes with a common ancestor. If both parent classes have methods with the same name, it is difficult to determine which method the derived class should inherit.
As an example:
class A {
void printClass() {
System.out.println("Class A");
}
}
class B extends A {
void printClass() {
System.out.println("Class B");
}
}
class C extends A {
void printClass() {
System.out.println("Class C");
}
}
class D extends B, C {
}
If class D
was allowed to inherit from both class B
and class C
, and both class B
and class C
overrides the printClass
method from class A
, it would cause ambiguity to arise for class D
.
In order to determine which printClass
method to use, some
languages employs a resolution strategy like method resolution order
(MRO) in Python where the method to be executed is chosen based on the order specified while inheriting the classes.
Java just avoids this problem entirely be only allowing inheritance
from a single parent class ("Java loves to avoid its problems"
[@EnzioKam]).
[credit: @HoSCD, @ZHD1987E, @Shanicey98, @mfchloe, @myathetchai]
Method Overriding
A child class can redefine the method that has been inherited from its
parent, effectively overriding the parent's implementation.
An example is the toString()
method.
In the absence of an overriding method, the FilledCircle
class
class FilledCircle extends Circle {
private final Color color;
FilledCircle(Color color) {
super(radius);
this.color = color;
}
}
simply makes use of the toString
method that is inherited from
Circle
.
jshell> new FilledCircle(1.0, Color.BLUE).toString()
$.. ==> circle with radius 1.0
However, by including its own definition of toString()
,
@Override
public String toString() {
return "Filled " + super.toString() + " and color " + this.color;
}
FilledCircle
class can now return its own string representation
jshell> new FilledCircle(1.0, Color.BLUE).toString()
$.. ==> Filled circle with radius 1.0 with color java.awt.colo[r=0, g=0, b=255]
Also notice the use of the Override
annotation?
This is useful for the compiler to check that you have indeed written
a method in a class with an intention to override a method in the
parent.
As an example, if you mis-spell toString
as toStringg
, then the
compiler checks that there is no toStringg
method in the parent,
and flag this as an error.
More interestingly, you will notice that without defining toString
in both the Circle
and FilledCircle
classes, you will still be
able to call the toString
method.
This is indicative that there is a "default" implementation that
comes from some parent class.
Indeed every class is a sub-class of Java's Object
class!
protected
Modifier
The In order for a parent class to give access to it's properties or
methods (including constructors) to its child classes, they have to
be defined with the protected
modifier.
As an example, if we were to create a fillColor
method in the
FilledCircle
class as follows:
FilledCircle fillColor(Color color) {
return new FilledCircle(super.radius, color);
}
then it necessitates that the radius
property in Circle
class be
declared as:
protected final double radius;
Alternatively, to keep radius encapsulated, even from the sub-class,
we can delegate the setting of the radius to the Circle
class in the following way.
class Circle {
private final double radius;
Circle(double radius) {
this.radius = radius;
}
protected Circle(Circle circle) {
this.radius = circle.radius; // or this(circle.radius);
}
...
}
class FilledCircle {
private final Color color;
private FilledCircle(Circle circle, Color color) {
super(circle);
this.color = color;
}
FilledCircle fillColor(Color color) {
return new FilledCircle(this, color);
}
...
}
Notice here that fillColor
calls a private constructor of
FilledCircle
, passes this
to the constructor (valid since
FilledCircle
is a Circle
), which in turn calls the protected
Circle
constructor to assign the radius.
Although we could have dispensed from having to create additional
constructors by having Circle
make the radius
value available to
its sub-classes by defining a getRadius()
method, it is always good
to begin with a mindset of avoiding getter methods as they reveal implementation details, e.g. how Circle is being implemented via radius.
The implementor of Circle may wish to change from radius to diameter as its new implementation of Circle.
With abstraction in place via private modifier, such a change can only be conducted entirely within the Circle class since the constructor that takes in the Circle
is still within the Circle
class itself.
Hence, FilledCircle
as client of Circle
is completely unaffected since it is only making use of the service from this second constructor method, and does not have direct access to radius (or diameter) as its implementation detail.
Keep in mind that implementation details of classes are often changed by the developer during maintenance and improvement phases. However, functionality of classes are quite stable, though they may still change from time-to-time depending also on the requirements from clients.
The Tell-Don't-Ask principle is thus to help enforce the abstraction wall to isolate client from unnecessary implementation details that may be freely changed by developers from time-to-time without having to inform clients.
[credit: @chinwn]
Q2: Suppose that we want to duplicate
Circle
andFilledCircle
objects. The followingduplicate()
method is already defined in theCircle
class that returns a copy of itself:Circle duplicate() { return new Circle(this); }
Is the following
duplicate()
method inFilledCircle
class overriding? Note the different return type.FilledCircle duplicate() { return new FilledCircle(this, this.color); }
Now, change the name of the method from
duplicate
toclone
. Observe what happens. How do you properly define theclone()
methods inCircle
andFilledCircle
?
Substitutability
Consider the following mixed-typed assignment
$$ var_T = expression_S $$
The above is only valid if $S$ is-a $T$, and we say that $S$ is substitutable for $T$. We have seen two instances where this can happen:
Shape shape = new Circle(1.0);
Circle circle = new FilledCircle(1.0, Color.BLUE)
In particular, for the second case, since circle
can be assigned to
either a Circle
object or a FilledCircle
object, the expression
circle.getArea()
will always be valid, but not circle.fillColor(..)
.
Compile-Time verses Run-Time Type
Notice in the above section that our focus have been on whether expressions or statements are valid, and not about the result that they produce. Indeed, for a strongly-type language like Java, it is important that we consider whether a program is compilable first, before we look at the behaviour by running the compiled program.
Consider the following assignment:
Circle circle = new FilledCircle(1.0, Color.BLUE)
The variable circle
has a compile-time type of Circle
(the
declared type of the variable that the compiler checks during
compilation), but a
runtime type of FilledCircle
(the type of the actual object when
the program runs).
Using the compile-time type restricts the statements or expressions
that we can write. For example, circle.getArea()
is valid,
circle.toString()
is valid, but not circle.fillColor(Color.BLUE)
.
Having compiled the program, the behaviour of the program is then
decided by the runtime type. For example, circle.toString()
will
behave differently whether circle
is assigned to a Circle
or a
FilledCircle
object.
jshell> Circle circle = new Circle(1.0)
circle ==> circle with area 3.14
jshell> circle.toString()
$.. ==> "circle with area 3.14"
jshell> circle = new FilledCircle(1.0, Color.BLUE)
circle ==> Filled circle with area 3.14 and color java.awt.Color[r=0,g=0,b=255]
jshell> circle.toString()
$.. ==> "Filled circle with area 3.14 and color java.awt.Color[r=0,g=0,b=255]"
Notice that the difference in behaviour is a choice between which of the overridden methods is actually being executed when the program runs.
Another way to look at compile-time versus run-time type is in the
context of parameter passing.
Suppose we have a method foo
with a parameter of type
Shape
that returns a String
.
String foo(Shape shape) {
double area = shape.getArea();
return shape.toString();
}
Within the method,
- we can call the
getArea()
method ofshape
because any implementation class ofShape
will define thegetArea()
method; - we can call the
toString
method ofshape
because any implementation class ofShape
(that is also a sub-class ofObject
) will have the toString() method defined; - we cannot call
fillColor
method ofshape
because any implementation class ofShape
need not define thefillColor
method.
This is particularly apparent now because, during compilation we do not know what is the actual object that will be passed as an argument when the foo
method is called.
Moreover, the actual String
that is returned depends on the runtime
type of shape
(say Circle
or FilledCircle
) when the foo
method is being activated during program run.
jshell> foo(new Circle(1.0))
$.. ==> "circle with area 3.14"
jshell> foo(new FilledCircle(1.0, Color.BLUE))
$.. ==> "Filled circle with area 3.14 and color java.awt.Color[r=0,g=0,b=255]"
Method Overloading
In contrast to method overriding where methods of the same name with the same method signature (number, type and order of parameters) can be defined across parent/child classes, method overloading allows method of the same name, but different method signatures to be defined.
Consider the methods toString()
and toString(String prompt)
.
These two methods can co-exist since calling circle.toString()
or
circle.toString("abc")
can distinguish between them.
Contrast this to toString(String s1)
and toString(String s2)
;
if the method call is circle.toString("abc")
, which method will be
invoked?
Overloading is also very common in constructors.
For example, in our Circle
class
class Circle {
private final double radius;
Circle(double radius) {
this.radius = radius;
}
protected Circle(Circle circle) {
this(circle.radius);
}
...
}
There are two overloaded constructors; calling new Circle(1.0)
and
Circle(new Circle(1.0))
can distinguish between them.
More interestingly, one constructor uses the other one by
calling this(circle.radius)
.
And finally to put everything into perspective. During compilation, Java employs static binding to resolve the methods to be called. This also includes overloaded methods. Once a specific method call (with specific method signature) is decided based on the compile-time type, during runtime Java makes use of dynamic binding to resolve among the overriding methods with that particular method signature.
An Exercise on Object Equality
Let us create a class A
with a property x
.
jshell> class A {
...> private final int x;
...> A(int x) {
...> this.x = x;
...> }
...> }
| created class A
We can test the equality of two objects using ==
which returns false
.
jshell> new A(1) == new A(1)
$5 ==> false
The reason for it to be false
is because they are two different objects created, despite that they have the same content.
We can also make use of the equals
method
jshell> new A(1).equals(new A(1))
$6 ==> false
which also returns false
.
Despite that equals
is not defined in A
, since A
is a sub-class of Object
, it makes use of the equals
method defined in the Object
class where the behaviour is similar to ==
.
Now how do we test for object equality by comparing its contents?
We first try to define an overloaded equals
method.
jshell> class A {
...> private final int x;
...> A(int x) {
...> this.x = x;
...> }
...> boolean equals(A a) {
...> return this.x == a.x;
...> }
...> }
| replaced class A
By overloading, it means that we can invoke two different equals
methods: one that takes in a A
and one that takes in an Object
.
jshell> new A(1).equals(new A(1)) // calls equals(A)
$.. ==> true
jshell> new A(1).equals(1) // calls equals(Object)
$.. ==> false
By passing an A
object into equals
, we call the equals
method defined in class A
.
Note that although equals(Object)
defined in Object
class can also take in A
, Java chooses the most specific method.
By passing an integer into equals
, the equals
method in Object
class will be called.
Now consider the following:
jshell> Object obj = new A(1)
obj ==> A@b1bc7ed
jshell> obj.equals(new A(1))
$.. ==> false
During compilation, obj
is of type Object
. There is only one equals
method defined in class Object
, and it is the method that will be called.
When the program runs, since the method has already been chosen during compile time, it is the one that is executed. Hence it returns false
.
In order to return true
, we need to make use of the runtime type of obj
which is A
.
Now having committed to calling equals(Object)
, during program run, the same method must be defined in class A
so as to override it.
Hence we need to define equals
method as an overriding method instead.
jshell> class A {
...> private final int x;
...> A(int x) {
...> this.x = x;
...> }
...> public boolean equals(Object obj) {
...> if (this == obj) {
...> return true;
...> }
...> if (obj instanceof A a) {
...> return this.x == a.x;
...> }
...> return false;
...> }
...> }
| replaced class A
jshell> Object obj = new A(1)
obj ==> A@70177ecd
jshell> obj.equals(new A(1))
$.. ==> true
Notice that the outcome is now true
since the runtime type of obj
is A
and the overriding method in class A
is invoked when the program runs.
Before we end, a short note on the instanceof
method.
This operator allows us to check the type of an object.
It is very useful in the overriding equals
method because it allows us to check if the argument is of the same type, before we proceed to check the internals (in our case the x
property).
However, do not indiscriminately use instanceof
to check types to decide if say, a shape
variable of type Shape
is actually a Circle
or a Rectangle
. We should let polymorphism take effect, and have shape
behave like a circle if it is assigned to a Circle
, or behave like a rectangle if it is assigned to a Rectangle
.
In general, restrict yourselves to using instanceof A
only in class A
.
Abstract Class
Till now, we have relied on two design constructs when modeling the OOP solution for a given problem, namely class (or to be more specific concrete class) and interface. While concrete classes have properties declared and methods defined, interfaces have no properties and only method specifications. There will come a time where we need to strike a middle ground. An example is given below:
import java.awt.Color;
interface Shape {
public double getArea();
}
abstract class FillableShape {
private final Color color;
FillableShape(Color color) {
this.color = color;
}
public String toString() {
return "color=" + this.color;
}
}
class FilledCircle extends FillableShape {
private final double radius;
FilledCircle(double radius, Color color) {
super(color);
this.radius = radius;
}
public double getArea() {
return Math.PI * radius * radius;
}
public String toString() {
return "filled circle: radius=" + this.radius + ";" +
super.toString();
}
}
In this example, FillableShape
implements the Shape
interface,
which implies that the method getArea
could be defined in the
FillableShape
. However, since we do not know the definitive shape, the
getArea
method specification is inherited from the Shape
interface. This is fine because, just like Shape
, we cannot
instantiate a FillableShape
.
Despite this, FillableShape
has the property color
declared, and
indeed also contains a constructor to create a FillableShape
.
However, this constructor cannot be called directly.
As an aside, one can also define a method specification in the
abstract class. For example, if FillableShape
does not implement
Shape
, then it needs to specify getArea
by including the
following:
abstract double getArea();
Now to make use of FillableShape
, we require a sub-class to complete
the creation of the object. This is done via the FilledCircle
class that inherits from FillableShape
.
Specifically, FilledCircle
defines the getArea
method using it's
radius
property, and also completes the construction of a
FilledCircle
object, by first calling the FillableShape
constructor to
construct the "core" of the object, and then complete the
construction of the object with the rest of the instructions in the FillableShape
constructor.
jshell> new FilledCircle(1.0, Color.BLUE)
$.. ==> filled circle: radius=1.0;color=java.awt.Color[r=0,g=0,b=255]