Lecture02 - nus-cs2030/2324-s2 GitHub Wiki

Interface and the OOP Principle of Polymorphism

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.

Interface as a Contract

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.

Is-A Relationship

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.

Polymorphism

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!

Multiple Inheritance

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]

Interfaces in the Java API

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.

Iterable and Iterator Interfaces

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

Comparator Interface

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]

⚠️ **GitHub.com Fallback** ⚠️