CSE143X Notes for Wednesday, 11/22/23

I turned to a new topic. I said that I wanted to explore how to design a set of classes for storing information about various shapes like rectangles, circles and squares. I started with these three classes:

        public class Circle {
            private double radius;
        
            public Circle(double radius) {
                this.radius = radius;
            }
        
            public double area() {
                return Math.PI * radius * radius;
            }
        
            public String toString() {
                return "circle of area " + area();
            }
        }

        public class Rectangle {
            private double length;
            private double width;
                
            public Rectangle(double length, double  width) {
                this.length = length;
                this.width = width;
            }
                
            public double area() {
                return length * width;
            }
                
            public String toString() {
                return "rectangle of area " + area();
            }
        }
        
        public class Square {
            private double length;
        
            public Square(double length) {
                this.length = length;
            }
        
            public double area() {
                return length * length;
            }
        
            public String toString() {
                return "square of area " + area();
            }
        }
If we were designing a complete system, we would probably have more than just an area and toString method for these, but this set of operations will be enough to explore the design issues involved. I showed the following client program that sets up an array of shapes and that attempts to sort them by calling the built-in Arrays.sort method:

        import java.util.*;

        public class ShapeTest {
            public static void main(String[] args) {
                Object[] data = {new Square(12), new Rectangle(15, 3.2),
                                 new Circle(8.4), new Circle(1.5), new Square(8.7),
                                 new Rectangle(7.2, 3.2), new Square(2.4),
                                 new Circle(3.7), new Circle(7.9)};
                for (Object s : data) {
                    System.out.println(s);
                }
                System.out.println();
                Arrays.sort(data);
                for (Object s : data) {
                    System.out.println(s);
                }
            }
        }
The array stores a combination of Circle, Square and Rectangle objects, which is why we had to declare it to be of type Object[]. The client code uses a foreach loop to print the shapes before and after the call on Arrays.sort:

       for (Object s : data) {
            System.out.println(s);
       }
When we executed this program, it properly printed the various shapes but then it reached an execution error when we called Arrays.sort. The error message reported was ClassCastException for the Square class. The problem is that we never had our shape classes implement the Comparable interface. So we opened up the Square class and tried to figure out how to make it implement Comparable.

First we had to change its header:

        public class Square implements Comparable<Square> {
            ...
        }
Then we had to think about how to write an appropriate compareTo method. It's a bit tricky because it involves the difference of two doubles. So we can't just say:

        public int compareTo(Square other) {
            return area() - other.area();
        }
We could cast to an int, but even that doesn't work:

        public int compareTo(Square other) {
            return (int) (area() - other.area());
        }
This returns an int, but it returns the wrong value when the difference is something like 0.5 or -0.3, both of which would be turned to 0 when you cast to int. Instead we need a 3-way comparison:

        public int compareTo(Square other) {
            double difference = area() - other.area();
            if (difference < 0) {
                return -1;
            } else if (difference == 0) {
                return 0;
            } else { // difference > 0
                return 1;
            }
        }
We tried compiling with this definition and we ran the client program again. It failed again, but this time it failed on a ClassCastException on Rectangle. That's because we only modified the Square class, not the Rectangle class. We discussed the possibility of copying the compareTo method to those other classes, but someone pointed out a problem with our definition. We have defined a compareTo method that allows a Square to compare itself to another Square, but we need one that allows a Square to compare itself to any shape, including a Circle or Rectangle.

Someone suggested that we could have a Shape interface that they all implement:

        public interface Shape {

        }
We then modified our Square class header and the compareTo method:

        public class Square implements Comparable<Shape> {
            ...

            public int compareTo(Shape other) {
                double difference = area() - other.area();
                if (difference < 0) {
                    return -1;
                } else if (difference == 0) {
                    return 0;
                } else { // difference > 0
                    return 1;
                }
            }
        }
But this code didn't compile. We got a complaint that it couldn't find the method area(). This seemed odd, because each of the three classes has an area method. But remember from our discussion about inheritance that Java has a notion of a "role". The Shape role does not include an area method unless we specifically say that it does.
        public interface Shape {
            public double area();
        }
With this change, the Square class compiled properly. Then I said that we want to copy whatever pattern we use for the Square class in each of the other classes. But before we went to do that, I wanted to make sure that we had the right pattern. So again looked closely at the Square class:

        public class Square implements Comparable<Shape> {
            private double length;
        
            public Square(double length) {
                this.length = length;
            }
        
            public double area() {
                return length * length;
            }
        
            public String toString() {
                return "square of area " + area();
            }
        
            public int compareTo(Shape other) {
                double difference = area() - other.area();
                if (difference < 0) {
                    return -1;
                } else if (difference == 0) {
                    return 0;
                } else { // difference > 0
                    return 1;
                }
            }
        }
I said that we're missing a key ingredient. Someone mentioned that we never established the "is a" relationship to say that a Square is a Shape. So we had to modify the header to indicate that:

        public class Square implements Shape, Comparable<Shape> {
            ...
        }
We could have repeated this pattern in each of the other classes, but it seems somewhat redundant to always have to say that a class implements both Shape and Comparable<Shape>. And we don't want to have some Shape classes that fail to implement the Comparable interface. Java provides us a nice alternative. We can elevate this restriction to the Shape interface itself:
        public interface Shape extends Comparable<Shape> {
            public double area();
        }
It may seem odd that use the keyword "extends" rather than the keyword "implements." That's because in this case we have an interface extending another interface and in Java you specify that with the keyword "extends" rather than the keyword "implements."

This allowed us to simplify the Square class header:

        public class Square implements Shape {
            ...
        }
We then changed each of the different shape classes to say that they implement this interface:

        public class Circle implements Shape {
            ...
        }

        public class Rectangle implements Shape {
            ...
        }

        public class Square implements Shape {
            ...
        }
With this change, we were able to compile the Square class. We then copied the compareTo method to the other two classes and compiled them. This allowed the client program to run properly, producing this output:

        square of area 144.0
        rectangle of area 48.0
        circle of area 221.6707776372958
        circle of area 7.0685834705770345
        square of area 75.68999999999998
        rectangle of area 23.040000000000003
        square of area 5.76
        circle of area 43.00840342764427
        circle of area 196.066797510539
        
        square of area 5.76
        circle of area 7.0685834705770345
        rectangle of area 23.040000000000003
        circle of area 43.00840342764427
        rectangle of area 48.0
        square of area 75.68999999999998
        square of area 144.0
        circle of area 196.066797510539
        circle of area 221.6707776372958
The program correctly sorts the shapes into increasing area. In fact, given the new Shape interface we were able to rewrite the main program to replace "Object" with "Shape":

        Shape[] data = {new Square(12), new Rectangle(15, 3.2),
                        new Circle(8.4), new Circle(1.5), new Square(8.7),
                        new Rectangle(7.2, 3.2), new Square(2.4),
                        new Circle(3.7), new Circle(7.9)};
        for (Shape s : data) {
            System.out.println(s);
        }
        System.out.println();
        Arrays.sort(data);
        for (Shape s : data) {
            System.out.println(s);
        }
But this still wasn't very satisfying because we have three copies of the compareTo method, one in each of the different shape classes. This kind of redundancy is a bad idea. When we first talked about inheritance, we talked about the idea of having a 20-page employee manual that is shared across all types of employees. We want something like that here.

Someone suggested that we change the Shape interface to a class and move the method there:

        public class Shape implements Comparable<Shape> {
            public double area();

            public int compareTo(Shape other) {
                double difference = area() - other.area();
                if (difference < 0) {
                    return -1;
                } else if (difference == 0) {
                    return 0;
                } else { // difference > 0
                    return 1;
                }
            }
        }
Unfortunately, this didn't compile. It produced the following error message:

        Error: missing method body, or declare abstract
The problem is with the area method. Currently we just have a semicolon rather than a body defining what it should do. But the definitions of the area methods are in the various shape classes (Circle, Square, Rectangle). Someone suggested deleting it from the class, but then it didn't compile because the compareTo method calls area. So we have to define it here in some way.

We considered the possibility of giving it a "dummy" definition just to make it compile:

        public double area() {
            return 42.42;
        }
We just made up a number for it to return. This allowed us to compile the Shape class, but it's not a good idea. It means that someone might extend the Shape class and inherit this strange version of the area method. It's better to leave the shape method unspecified, the way it was with the interface.

Someone pointed out that the original error message seemed to be giving us a suggestion. It said that we could declare it to be "abstract". The keyword "abstract" is a modifier that can be applied to methods and classes like the keywords "public" and "static", so we just added this extra modifier to the method header:

        public class Shape implements Comparable<Shape> {
            public abstract double area();

            public int compareTo(Shape other) {
                ...
            }
        }
That also didn't work. The compiler pointed at the class header and said, "Shape is not abstract and does not override abstract method area() in Shape." In other words, the compiler is saying that if you are going to have an abstract method called area in the Shape class, then the class itself has to be declared abstract:

        public abstract class Shape implements Comparable<Shape> {
            public abstract double area();

            public int compareTo(Shape other) {
                ...
            }
        }
After this change the class finally compiled. We then had to change the individual shape classes to say that they extend this class rather than implementing an interface called Shape.

At last we had arrived at the name of the topic for the lecture: abstract classes. We've seen two extremes in Java. A "normal" class (often called a concrete class) is one where every method has a definition. An interface is like a purely abstract class where every method is abstract. In other words, in an interface we list only method headers (methods that need to be filled in). An abstract class sits somewhere in the middle, typically having some methods that are filled in and some methods that are abstract. You can think of this as a spectrum from concrete to purely abstract, with abstract classes in the middle:

concrete <---+-----------------------+------------------------+----> abstract
             |                       |                        |
      concrete class          abstract class              interface
Because the Shape class is an abstract class, it can't be directly instantiated, as in:

        Shape s = new Shape();  // illegal
You aren't allowed to create instances of an abstract class. But that doesn't mean you can't have variables of type Shape, as in:

        Shape s = new Rectangle(20, 30);  // legal
I mentioned that this is similar to the way that we use the word "Employee". We know that everyone who works for a company is in some sense an employee, but you wouldn't find someone who is just an employee. If you ask someone, "What do you do?" they won't answer, "I'm an employee." If they did, you'd say, "I know, but what do you do?" The word "employee" is like an abstract class. It is a useful concept in creating our hierarchies and it's useful to talk about people as employees, but there aren't any people who are actually just plain employees.

In the last few minutes of class I pointed out that there was still some redundancy between these three classes. They each have a toString method that is very similar, as in the Circle class version:

       public String toString() {
           return "circle of area " + area();
       }
The only difference between the three versions is the name of the shape. We talked about how to move this up into the Shape class. To do so, we need a new field to keep track of the name of the shape:

        public String toString() {
            return name + " of area " + area();
        }
We modified the Shape class to have this as a field that is set by a call on a Shape class constructor:

        public abstract class Shape implements Comparable<Shape> {
            private String name;
        
            public Shape(String name) {
                this.name = name;
            }

            ...        
        }
This required us to include calls on the "super" constructor in each of our Shape classes, as in this version for Circle:

        public Circle(double radius) {
            super("circle");
            this.radius = radius;
        }
I briefly mentioned one last detail. You can include the access modifier "final" to a method or class to prevent it from being overridden through inheritance. The final version of the Shape class makes both the compareTo and toString methods final, which means that subclasses can't override this shared behavior of shape objects. This can be useful to prevent malicious or careless errors. For example, you wouldn't want to have most of your shape objects comparing themselves in one way while some others are comparing in some other way. You also wouldn't want to let a shape subclass modify the toString method because then it could masquerade as something other than what it is.


Stuart Reges
Last modified: Wed Nov 22 15:24:27 PST 2023