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.radiusto 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!
The protected Modifier
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
CircleandFilledCircleobjects. The followingduplicate()method is already defined in theCircleclass that returns a copy of itself:Circle duplicate() { return new Circle(this); }Is the following
duplicate()method inFilledCircleclass overriding? Note the different return type.FilledCircle duplicate() { return new FilledCircle(this, this.color); }Now, change the name of the method from
duplicatetoclone. Observe what happens. How do you properly define theclone()methods inCircleandFilledCircle?
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 ofshapebecause any implementation class ofShapewill define thegetArea()method; - we can call the
toStringmethod ofshapebecause any implementation class ofShape(that is also a sub-class ofObject) will have the toString() method defined; - we cannot call
fillColormethod ofshapebecause any implementation class ofShapeneed not define thefillColormethod.
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]