Lec 03 - Polymorphism

Slides:

Overloading

Method overloading is when we have two or more methods:

  1. in the same class

  2. with the same method name but a different method signature.

In other words, we create an overloaded method by changing the type, order, and number of parameters of the method but keeping the method name identical.

Overloading vs. Overriding

Name
Difference

Override

must have same method descriptor (the return type can be subtype also!)

Overload

must have same method name, in the same class and different method signature.

Note that in overloading methods, the return type doesn't matter. Sometimes, it will be useful to write overloading methods with different return types. See more in this thread.

Method Invocation

"Thanks to" polymorphism and the overriding, we may find it hard to determine which method we invocate will be executed. To make this problem clear, let's introduce the mechanism of method invocation in Java.

Basically, the process can be divided into two parts:

During Compile Time

Goal: To get the method descriptor of the function we are going to invocate. Otherwise generate a compilation-error.

1

Find the compile-time type target

"Target" is an object. More specifically, with the invocation curr.equals(obj), the "target" should be curr. Then the compile-time type of curr, let's denote it as CC, is what we are interested in.

This compile-time type is actually a class, in which we need to search for the methods that have the same name with method name we want to invocate. In our case, we should find the method with name equals() in the class CC.

When doing the search, all the inherited methods from CC's supertypes/superclass are included in the search.

2

Find the most specific method

We may have several matching methods, and how do we decide which one's descriptor to get? Here, we can use the principle of "the most specific one".

Basically, we should first pass in the arguments from our invocated method to the method we found during step 1. If the arguments cannot be passed into the method we found, we move to the next available option. (If no more option is left, we will generate a compilation error).

Type casting is done during the compilte-time. So it means for example, if we have an argument (Circle) o2, where o2 's CTT is Object . It will be considered as Circle during the compile-time.

After trying all the options, if there is only one matching method, then we have found and we are done for the compile time!

Otherwise, if there are more than one methods that satisfy our requirements, we can use the principle of "the most specific one" to return the method descriptor of the most specific one.

The principle of "the most specific one": a method MM is more specific than method NN if the arguments to MM can be passed to NN without compilation error.

Basically, this means that the type of the parameter in MM is the subtype of the type of the parameter in NN. For example, equals(Circle) is more specific than equals(Object).

Note that after this step, if there are still more than one method that satisfy our requirements, than the compiler will generate a compilation error.

3

Pass the method descriptor we found to run-time step

As the name suggests, we will pass the all the information in the method descriptor to the next step.

Note that in this step, we don't need to return any information about the class since it is not included in method descriptor.

During Run Time

Goal: Find the only method to execute.

1

Retrieve the method descriptor

As the name suggests, this step will retrieve the information of the method descriptor that is passed from the compile process above.

The information from the method descriptor we get during compile-time includes:

  1. The compile-time type of the method parameters.

  2. The return type of the method.

2

Find the run-time type target

Similar to the compile process, now we should find the run-time type of the target, let's say it's DD (If using the same example, DD should be the subtype of CC. Otherwise, a compilation error will be generated because this is considered as a narrowing type conversion without explicit casting)

This run-time type information tells us the class that we should start to search from. Basically, it is to search from this class all the way up to the root class Object.

3

Find a matching method descriptor

Using the retrieved method descriptor, now we should find a method from the run-time class all the way up to the root class Object which has the same descriptor with our retrieved method descriptor.

The first matching method will be executed.


Till now, we have seen that a variable obj with type Object can have many "similar" methods. In Java, 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.

Other programming language, like C, may use a different mechanism called early binding, which basically means that which function to run will be decided during the compile-time.

Class methods

The description above only applies to instance methods. Class methods, on the other hand, do not support dynamic binding. The method to invoke is resolved statically during compile time. The same process in compile-time is taken, but the corresponding method implementation in class will always be executed during run-time, without considering the run-time type of the target.

Polymorphism

Methods overriding enables polymorphism, which is the last pillar of OOP, and arguably the most powerful one. It basically states that we should use the base-type class as much as possible.

Since polymorphism will dynamically decide which method implementation to execute during run-time, so that to change how our existing code behaves, we don't have to change a single line of our existing code. Instead, we can just create a new derived/sub "type" and then use polymorphism to achieve what we want to achieve.

Let's use an example to have a glimpse of the power of polymorphism.

At 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.

Liskov Substitution Principle

Goal: To provide a way to decide when we misuse overriding and inheritance (a.k.a polymorphism).

Soul/Main content: A subclass should not break the expectations / specifications set by the superclass. In other words, the test cases that are passed in superclass should also be passed in the subclass.

The LSP (Liskov Substitution Principle) is a formal way of speaking subtype. It is a very important technique to tell you whether you should inherit a class from another.

Pure substitution vs. Impure substitution

Pure substitution can be thought of as inheritance should override only parent-class methods (and not add new methods that aren't in the parent class). In this case, the relationship between the derived-class and the base-class (a.k.a parent-class) can be viewed as a "is-a" relationship.

Even if we only override the parent-class methods, that doesn't mean we are 100% sure that the overriden method conforms to the LSP. An example is in the lecture notes about Restaurant and LunchRestaurant.

Pure substitution is the ideal way to treat inheritance. However, there are times we may need to add new method elements to the derived-class. In this case, the relationship becomes "is-like-a" relationship and it is known as impure substitution.

The final keyword

The final keyword can help prevent a class to be inherited from and a method to be overriden. So, till now, the use of final keyword is as follows:

  1. In a field declaration to prevent re-assignment.

  2. In a class declaration to prevent inheritance.

  3. In a method declartion to prevent overriding.

Abstract class

Goal: To fully take the advantage of polymorphism, we want to make our method (a.k.a a kind of abstraction) as general as possible.

One way to do so is to keep defining the object from the root class Object. For example, suppose we want to generalize equals() to check if two objects are equal or not (and extend it to all the other objects, like Circle, Bicycle etc), we can write the code as follows:

However, this style of code has a prerequisite: the Object class must have the method called equals(). So, what if we want to generalize other functions, like a function getArea() to get the area of a circle, or rectangle? Now the all-time Object method doesn't work because there is no such method called getArea() inside the root class Object!

So, seems that now we want to create something more specific than Object that supports the function we want, yet more general than Circle or Rectangle. Here it comes the other way — The use of abstract class and abstract methods.


An abstract class in Java is a class that has been made into something so general that it cannot and should not be instantiated! Otherwise, a compile error will be generated!

In our example, we may want to create an abstract class called Shape. To do so, we can use the keyword abstract.

The abstract keyword

Goal: Basically, the abstract keyword is used to creat an abstract class and an abstract method.

For example, we can implement our abstract class Shape and the abstract method getArea() as follows:

An abstract method cannot be implemented and therefore should not have any method body.


A class with at least one abstract method must be declared as abstract. Otherwise, a compile error will be generated. On the other hand, an abstract class may have no abstract method.

Concrete class

We call a class that is not abstract as a concrete class. A concrete class cannot have any abstract method. Thus, any concrete subclass of Shape must override getArea() to supply its own implementation.

Interface

Goal: To make a method even more generalisable!

The abstraction that models what an entity can do is called interface.

An interface is also a type and is declared with the keyword interface. Since an interface models what an entity can do, the name usually ends with the "-able" suffix.

For example, let's make the getArea() even more generalisable so that it can not only get the area of a shape, but also the area of a real estate property, etc

All methods declared in an interface are public abstract by default, so we can omit these two keywords. Similary, all fields declared in an interface are public static final (constant) by default, so we can omit these three keywords also.

Now, for every class that we wish to be able call getArea() on, we tell Java that the class implements that particular interface.

1

For an abstract class

For example, we want our abstract class Shape to be able to call getArea, we can do as follows:

2

For a concrete class

For a concrete class, it can also implement the interface. For example,

In a concrete class, for it to implement an interface, it has to override all abstract methods from the interface and provide implementation to each. Otherwise, the class becomes abstract.

Interface as Supertype

If a class CC implements an interface II, then C<:IC<:I. This definition implies that a type can have multiple supertypes because a type can implement multiple interfaces.

So far, we have seen two ways to establish the subtype relationship between classes and interfaces:

  1. base on inheritance and use the keyword extends

  2. base on interface and use the keyword implements

Type Casting using an interface

As we have seen in the previous lecture, in Java, two types without a subtype relationship cannot be casted. However, let's consider the code as follows,

Will the Line 14 compile? The answer is yes. The compiling principle for the Java compiler is that:

it does not let us cast when it is provable that it will not work. i.e. casting between two classes that have no subtype relationship. However, for interface, there is the possibility that a subclass could implement the interface. Therefore, the Java compiler trusts that the programmer knows what they are doing, and allows it to compile.

So, in our example, one such possibility is as follows,

Here, the subtype relationship we can get is AI<:IAI<:I and AI<:AAI<:A. This cannot tell us the AA is the subtype of II. And based solely on this code, it can compile but it will generate a run-time error.

So, given that we are pretty sure that AA is not the subtype of II, why this code still compiles? That is because the compiler doesn't know whether there is a possibility that AA has a subclass that implements II. If so, the code will run perfectly without any compile-time error and run-time error.

For example, let's assume class CC extends from AA and implements II. Now, we run the following code

Type casting can work in this case because even though compile-time type of a is A , its actual run-time type is C , which implements I . Since compiler can't foresee what is the actual run-time type of a , it would just be lenient and allow your code to compile.


So, coming back to our problem about this line of code I i2 = (I) new A();. Since the compiler has no idea about what kinds of subclasses A has, so even if class C does not exist in the code. The line I i2 = (I) new A(); would still compile.

However, if you actually declare class A with final keyword which prevents it from being extended with any subclasses. Compiler can be 100% sure that class A cannot hold any instance whose type is a subtype of I in any situation and simply give you compile-time error.

Tips

  1. The essence of the original equals method in Object is that it will compare whether two objects are referenced to the same memory address or not.

  2. The use of instanceOf operator: for example, obj instanceOf Circle will check if the run-time type of obj is a subtype of Circle. (Note that it is the run-time of obj)

  3. In polymorphism, remember to do explicit type casting since sometimes the object from the root class Object does not have the fields we want.

  4. Override the functions that should not be overriden will generate a compilation error.

  5. Include the behavior of the compiler regarding the typecasting containing Interface into cheatsheet!

Last updated