Lecture02 - nus-cs2030/2324-s2 GitHub Wiki
This lecture focuses on one other important principle of OOP, namely polymorphism. We show how an interface can be used to support polymorphism by establishing an is-a relation between classes. We then explore various interfaces from the Java API and how they can be used.
From the last lecture, we know that an abstraction barrier is established between an implementer class and a client. This client could be any programming construct (JShell, a method or another class) that uses the implementer class. This abstraction barrier is established not only to allow the implementer to provide abstractions in the form of services to the client, but more importantly to encapsulate implementation details and hide them from the client.
As you can see, the client has a direct dependency on the implementer and relies heavily on the services exposed by the latter. Due to this dependency, if the implementer changes it's services, the client breaks! To protect the interests of the client, both implementer and client must obey a contract of services, and this contract is realized in the form of an interface.
With this interface established, the direct dependency between the implementer and client is replaced by two dependencies:
- a dependency from the implementer to the interface
- a dependency from the client to the interface
We call the above programming to an interface, not an implementer.
But should we establish an interface for every direct dependency from client to implementer? Some methods are only specific to certain implementer classes, so it is unnecessary to establish an interface for every single method. However, in the case where there are likely to be multiple implementations of a particular functionality, the contract specification of an interface is especially useful in ensuring consistencies through the establishment of the is-a relationship. It also ensures that the various client classes do not break by replacing the single direct dependency with having both client and implementer depend on the interface instead (by 'programming to the interface'). [credit: @AvaZard]
The following example shows the dependency from the implementer
Circle
to the interface Shape
.
interface Shape {
public double getArea(); // methods MUST be specified public
}
class Circle implements Shape {
private final double radius;
Circle(double radius) {
this.radius = radius;
}
public double getArea() { // methods MUST be defined public following the Shape interface
return Math.PI * this.radius * this.radius;
}
public String toString() {
return "Circle with radius " + this.radius;
}
}
Here is how the client findVolume
programs to the interface
Shape
.
double findVolume(Shape s, double height) {
return s.getArea() * height;
}
Notice that findVolume
takes in a Shape
, but cannot take in a
Shape
object since Shape
is abstract.
However a Circle
, being a shape, can be passed to findVolume
so
that it's volume can be correspondingly returned.
The interface and implementation class establishes an is-a
relationship between them.
In our example, Circle
is-a Shape
.
This relationship is important as it allows a variable declared with
an interface type to be assigned with an object of its implementation type.
Shape shape = new Circle(1.0)
Similarly, a Circle
object can be passed as an argument to a method
that takes in a Shape
.
findVolume(new Circle(1.0), 10.0)
Indeed both scenarios above involves an assignment. Specifically, calling a method with an argument is analogous to assigning the argument to the formal parameter of the method, i.e. the assignment occurs across methods.
Other than the OOP principles of abstraction and encapsulation,
polymorphism (meaning "many-forms") is one other important principle that facilitates extensibility to an object-oriented design.
In our example above, a Shape
variable can take the form of a Circle
via
the assignment shape = new Circle(1.0)
.
Shape
can also take other forms, e.g. Rectangle
, Square
,
Triangle
, etc. provided that these classes are defined as
implementations of the Shape
interface with corresponding
getArea()
methods defined.
Using the example of calling the findVolume
method, we see that it is
now possible to call findVolume(new Rectangle(2.0, 3.0), 10.0)
,
findVolume(new Square(5.0), 1.0))
, etc.
By passing in different concrete instances of shapes, the corresponding getArea
methods will be called, so that the desired volume is returned.
Without polymorphism supported by an interface, the individual Circle
, Rectangle
, Square
classes will have to be defined independently of each other.
Moreover, in order to pass different shapes to the findVolume
method, we will need to define separate findVolume
methods that take in the different classes!
A class can implement methods specified by multiple interfaces.
The example that we used in class was to have Circle
implement both
the Shape
interface, as well as the Movable
interface given below:
interface Movable {
public Movable moveBy(double x, double y);
}
This allows us to not only retain the findVolume
method as a client that
takes in a Shape
, but also include another method move
that
takes in a Movable
.
Hence, a Circle
object can be passed to both the findVolume
method (as a Shape
), as well as the move
method (as a Movable).
class Circle {
private final Point centre;
private final double radius;
...
public double getArea() {
return Math.PI * radius * radius;
}
public Circle moveBy(double dx, double dy) {
return new Circle(this.centre.moveBy(dx, dy), this.radius);
}
...
}
Note that moveBy
in the Circle
class returns Circle
and not Movable
(as specified in the Movable
interface). Since Circle
class is compilable, we are assured that the moveBy
method has been defined according to
the contract.
Consider the following client method foo
:
void foo(Movable movable) {
Movable newMovable = movable.moveBy(1.0, 1.0);
}
The above would work when we call the method foo(new Circle(new Point(0.0, 0.0), 1.0)
. This is because moveable.moveBy
will activate the moveBy
method in Circle
which returns another Circle
.
And since Circle
is a Movable
, the assignment to newMovable
can proceed without error.
Then, why don't we define the moveBy
method in Circle
to return Movable
instead? Although this is possible, consider what happens to the following?
Circle newCircle = new Circle(new Point(0.0, 0.0), 1.0).moveBy(1.0, 1.0)
The above assignment cannot compile, since the LHS expression will evaluate to a Movable
type, and Movable
is not `Circle!
We desire that the above statement works, because a circle should be able to be moved around and still be a circle!
Although it seems overkill to have two different method specifications defined in two separate interfaces, and suggests that one larger interface suffices such as the one defined below,
interface MovableShape {
public double getArea();
MovableShape moveBy(double x, double y);
}
we need to consider that other class implementations may need to
have only one of the methods defined, but not both.
An example will be the Point
class.
A point can be moved to another location, but does not have an area.
Having Point
define the getArea
method would unnecessary force
the class to provide a method definition that none of its other
clients will ever need.
There is an OOP design principle called Interface Segregation Principle (one of the five SOLID principles).
This principle states that "no code should be forced to depend on methods it does not use", thus a class should not be forced to implement methods it doesn't need.
In our example, having separate interfaces (Shape
and Movable
) allows for flexibility in implementation. This adheres to the ISP because classes like Point can choose to implement only the methods that they need without being burdened by unnecessary ones.
For the example of the MovableShape
interface that combines both getArea
and moveBy
methods, it violates ISP when there are classes (eg. Point) that only need one of the methods.
By keeping interfaces separate, we allow classes to adhere to the principle, implementing only what they need and avoiding unnecessary dependencies on methods that are irrelevant to their function.
[credit: @HoSCD]
Armed with an understanding of how interfaces and implementation classes are defined, the next part of the lecture attempts to have the students apply their conceptual understanding to common interfaces in the Java API.
We started off by looking at the List
interface.
As an interface, it specifies methods use for the operations of a list.
These include add
, get
, set
, sizeof
, etc.
We then look for implementations of List
, and find that ArrayList
is one of them. As such, it is very common to see the following
assignment:
List<String> list = new ArrayList<String>()
which is valid because ArrayList
is a List
.
It follows that we can add an element, get an element, set a element,
or find the size of an ArrayList
.
Another example of a List
is the AbstractImmutableList
.
This list is immutable in the sense that no modifications can be made
on the list. The add
method is defined, but throws an exception
when it is called.
Unlike our own version of the ImList
which returns a new ImList
when add
is called, the AbstractImmutableList
, being an
implementation of List
, has to define the add
method that
returns a boolean
value instead.
This also suggests that our ImList
cannot implement the List
interface since the add
method is defined with a different return type.
We also notice that the List
interface has to abide by methods specified in a parent Collection
interface.
Specifically, the Collection
interface specifies the add
and sizeof
methods, but not the get
and set
methods.
The reason is that a collection does not require an ordering among
elements within the collection, but a list does.
For example, a Set
is a collection of unordered elements.
Furthermore, it seems that List
is some kind of object with a
definition of the of
method.
However, notice that we call the of
method
via List.of(..)
rather than new List<String>().of(..)
.
Here, the of
method is a static
method which is called through
the class, rather than an object of the class.
In due time, we will look at other static
methods in the functional
programming part of the course.
We also took the opportunity to study two other interfaces.
The first one is Iterable
, which is a parent interface of
Collection
(and therefore a (grand-)parent interface of List
), as well as an interface implemented by ImList
.
Iterable
specifies one method iterator()
with a return value of
Iterator
, with Iterator
being another interface.
This means that any iterator
method will return some implementation
of an Iterator
.
Focusing on the Iterator
interface, we see that there are two
methods specified: hasNext()
and next()
.
Using these two methods, we can now iterate over all elements of a
List
or ImList
.
Here is an example of how we iterate over elements of an ImList
jshell> Iterator<String> iter = new
ImList<String>().add("one").add("two").iterator()
iter ==> java.util.ArrayList$Itr@312b1dae
jshell> while (iter.hasNext()) {
...> System.out.println(iter.next());
...> }
one
two
Admittedly, the above looks tedious, especially when an enhanced-for
loop can be used instead.
jshell> ImList<String> strings = new ImList<String>().add("one").add("two"))
$.. ==> [one, two]
jshell> for (String s : strings) {
...> System.out.println(s);
...> }
one
two
Lastly, it has to be noted that iter.next()
has side-effects.
This is apparent since each call to iter.next()
will change
the state of iter
to "refer" to the subsequent element.
Hence, the enhanced-for
loop has side-effects too as the state of
the looping variable (e.g. s
in the above) changes as looping
progresses.
That being said, one can always turn to iterating the list via by looping the index instead.
jshell> for (int i = 0; i < strings.size(); i = i + 1) {
...> System.out.println(strings.get(i));
...> }
one
two
The last interface that we are introduced to sets the tone for more of such interfaces to come.
This is the Comparator
interface with the method specification compare
.
Specifically, a Comparator
interface for, say String
objects declared with the Comparator<String>
type, requires that an implementation of this interface implement the compare
method that takes in two String
arguments: compare(String s1, String s2)
.
We use an example of the isSorted
method to demonstrate how implementations of Comparator<String>
can be passed to allow for different sorting order of strings.
boolean isSorted(List<String> strings, Comparator<String> cmp) {
for (int i = 1; i < strings.size(); i++) {
if (cmp.compare(strings.get(i-1), strings.get(i)) > 0) {
return false;
}
}
return true;
}
Now we can define an implementation of a Comparator<String>
that compares two strings in lexicographical order:
class LexiComp implements Comparator<String> {
public int compare(String s1, String s2) {
return s1.compareTo(s2); // using String's compareTo method
}
}
or another implementation of a Comparator<String>
that compares two strings by non-decreasing order of their lengths:
class LenComp implements Comparator<String> {
public int compare(String s1, String s2) {
return s1.length() - s2.length();
}
}
Here is how they are used.
jshell> isSorted(List.of("one","two","three"), new LexiComp())
$.. ==> false
jshell> isSorted(List.of("one","two","three"), new LenComp())
$.. ==> true
Once again, without the ability to define separate implementations of the
Comparator<String>
interface and have them passed as arguments to the
isSorted
method, it necessitates that we define separate variants
of the isSorted
method where much of the for
loop construct remains the same,
apart from the if
condition that performs the actual comparison
between two strings.
Although you might think that it's no big deal since isSorted
is a
relatively small
method, there are other methods like sort
with
more complex logic that would need different Comparator
implementations to allow a list to be sorted based on different orderings.
Indeed, there is such a sort
method specified in List
, and also defined
in ImList
.
Finally, there is another comparison mechanism provided in Java
API.
An example of this is shown in the body of the compare
method of the LexiComp
class.
The compareTo
method of the String class compares two strings lexicographically as documented in the Java API.
Note under the "Specified by" section, that compareTo
is specified
in the Comparable<String>
interface.
Clicking on Comparable
will bring you to its documentation, and
under "Abstract Methods" you see the specification:
int compareTo(T o)
Comparable
is a generic interface, i.e. we can define an
implementation of a Comparable
over any type. This allows String
class to implement Comparable<String>
, Integer
class to implement Comparable<Integer>
, etc.
In the case of String
, the compareTo
method is defined to take in
one other String
and return the integer result of the comparison
between the String
object from which compareTo
is called from, and the
argument String
.
Unlike Comparator
where we can define different Comparator<String>
implementations to compare two strings in different ways, we cannot define multiple compareTo
methods in the String
class.
The compareTo
method must define the natural ordering of String
objects. Likewise, there is a natural ordering over Integers
defined by its compareTo
method.
jshell> "abc".compareTo("XYZ") // lexicographical order over strings; "XYZ" before "abc"
$.. ==> 9
jshell> new Integer(1).compareTo(2) // ascending order over integers; 1 before 2
$.. ==> -1
It is worth noting that there is also a compareToIgnoreCase
method in the String
class, which compares two strings lexicographically, ignoring case differences. This method is not specified by any interface.
[credit: @jopwm, @ZHD1987E, @samuelneo]