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 radius
  • super.toString() to refer to the parent's toString() method
  • super(..) 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 Circle and FilledCircle objects. The following duplicate() method is already defined in the Circle class that returns a copy of itself:

Circle duplicate() {
    return new Circle(this);
}

Is the following duplicate() method in FilledCircle class overriding? Note the different return type.

FilledCircle duplicate() {
    return new FilledCircle(this, this.color);
}

Now, change the name of the method from duplicate to clone. Observe what happens. How do you properly define the clone() methods in Circle and FilledCircle?

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 of shape because any implementation class of Shape will define the getArea() method;
  • we can call the toString method of shapebecause any implementation class of Shape (that is also a sub-class of Object) will have the toString() method defined;
  • we cannot call fillColor method of shape because any implementation class of Shape need not define the fillColor 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]