Module 4: Inheritance and Polymorphism II

Module Overview

Learn Overriding and Extending Behavior for Java applications.

Overriding and Extending Behavior

In previous sections, we took a look at how different Container subclasses calculate their volume differently, and, as such, each subclass overrode Container's base volume implementation.

However, it's also possible to extend a superclass's method's behavior in a subclass. Here, we'll take a look at super in more detail.

Reference and Instance Types

Before we can dive deeper into super, let's recall from polymorphism that we can refer to an object by its superclass. For example, you can refer to a Cylinder which is-a Container as a Container:

Container cylinder = new Cylinder("beer", 8, 10);

In this way, the object cylinder can have two types: an implementing type and a reference type.

The type on the left of the assignment operator, the reference type, defines what can be called on an object. Only the reference type's, i.e. Container's, methods can be called.

The type on the right side of the assignment operator, the implementing type, defines how those methods work.

Repeat this mantra - Left, what. Right, how.

Container Recap

Let's take another look at our Container class, this time with some added functionality.

/**
* Container class to serve as a super class for different containers.
*/
public class Container {
    private String contents;

    /**
    * Creates a Container with the given contents.
    *
    * @param contents the contents of this container.
    * @throws IllegalArgumentException if contents is null.
    */
    public Container(String contents) {
        if (contents != null) {
            this.contents = contents;
        } else {
            throw new IllegalArgumentException(
                "Containers must have some contents.");
        }
    }

    /**
    * Calculates and returns the volume of this container;
    * defaults to zero.
    *
    * @return the volume of this container.
    */
    public double volume() {
        return 0.0;
    }

    /**
    * Returns how to open this container. 
    * Defaults to "Puncture"
    *
    * @return directions for opening this container.
    */
    public String openingInstructions() {
        return "Puncture";
    }

    /**
    * Returns a description of how the containers are grouped for sale.
    * Defaults to "Individually"
    *
    * @return a description of how the containers are grouped for sale.
    */
    public String grouping() {
        // most containers can be sold individually
        return "Individually";
    }

    /**
    * Builds and returns a string representation of this container.
    *
    * @return a string representation of this container.
    */
    public String toString() {
        return String.format("%s with %.2f units of %s. /nOpening Instructions: %s. /nSold: %s.",
        this.getClass().getSimpleName(), this.volume(), this.contents, this.openingInstructions(), this.grouping());
    }
}

All Containers have a contents field that must be non-empty (enforced by Container's constructor.) We also already know that all Containers can calculate their volume calculated via the volume method.

We've added the following behaviors: all Containers can be opened, and all Containers can be grouped for sale. Finally, all Containers describe themselves with the toString method.

Below is a code segment demonstrating the Container class:

Container container = new Container("Milk");
System.out.println(container);

The output of the above code shows how a Container describes itself:

"Container with 0.00 units of Milk.
Opening Instructions: Puncture.
Sold: Individually."

The following classes build upon the Container class by extending it and adding their unique behaviors.

Overriding

We've already covered Overriding in previous readings, but let's run through another example of overriding methods here.

A Cylinder is a Container that can only be sold in six-packs and is opened via a pop-tab:

/**
* Cylinder shaped Container subclass.
*/
public class Cylinder extends Container {
    private double radius;
    private double height;

    /**
    * Creates a Cylinder with the given contents and dimensions.
    *
    * @param contents the contents of this cylinder.
    * @param radius the radius of this cylinder.
    * @param height the height of this cylinder.
    */
    public Cylinder(String contents, double radius, double height) {
        super(contents);
        this.radius = radius;
        this.height = height;
    }

    /**
    * Calculates and returns the volume of this cylinder.
    *
    * @return the volume of this cylinder.
    */
    @Override
    public double volume() {
        return Math.PI * Math.pow(this.radius, 2) * this.height;
    }

    /**
    * Returns how to open this container. 
    *
    * @return directions for opening this container.
    */
    @Override
    public String openingInstructions() {
        return "Pull on the pop-tab";
    }

    /**
    * Returns a description of how the containers are grouped for sale.
    *
    * @return a description of how the containers are grouped for sale.
    */
    @Override
    public String grouping() {
        return "In six-packs";
    }
}

Let's create a new Cylinder and call its toString method.

Container cylinder = new Cylinder("beer", 8, 10);
System.out.println(cylinder.toString());

The above code would output:

Cylinder with 2010.62 units of beer
Opening Instructions: Pull on the pop-tab.
Sold: In six-packs.

Let's trace what methods are called against cylinder when we call cylinder.toString():

  • The first method called is toString. Because Cylinder does not override the toString method, the JVM uses the implementation from Container.
  • Within toString, the next method called is this.getClass(), which returns the instance type of our Object. In this case, the instance type is Cylinder. We then call getSimpleName() on the Cylinder class, which returns the string "Cylinder".
  • The next method called is this.volume(). Because Cylinder overrides the volume method, the JVM uses the implementation of volume from Cylinder.
  • The next method called is this.openingInstructions(). Because Cylinder overrides the openingInstructions method, the JVM uses Cylinder's implementation of openingInstructions.
  • The next method called is this.grouping(). Because Cylinder overrides the grouping method, the JVM uses Cylinder's implementation of grouping.

Note that every time we call a method, an implementation of that method is first looked for on the instance type. The instance type is the actual type of the object on the heap. It's defined on the right-hand side of the assignment operator (=). If that class doesn't implement the requested method, like the example of toString(), we move up the inheritance chain for that method's implementation.

Extending using super()

A Box is-a Container. It is opened by using a provided spout. It can be sold individually or in pairs.

/**
* Box shaped Container subclass.
*/
public class Box extends Container {
    private double width;
    private double height;
    private double depth;

    /**
    * Creates a Box with the given contents and dimensions.
    *
    * @param contents the contents of this box.
    * @param width the width of this box.
    * @param height the height of this box.
    * @param depth the depth of this box.
    */
    public Box(String contents, double width, double height, double depth) {
        super(contents);
        this.width = width;
        this.height = height;
        this.depth = depth;
    }

    /**
    * Calculates and returns the volume of this box.
    *
    * @return the volume of this box.
    */
    @Override
    public double volume() {
        return this.width * this.height * this.depth;
    }

    /**
    * Returns how to open this container. 
    *
    * @return directions for opening this container.
    */
    @Override
    public String openingInstructions() {
        return "Attach the provided spout";
    }

    /**
    * Returns a description of how the containers are grouped for sale.
    *
    * @return a description of how the containers are grouped for sale.
    */
    @Override
    public String grouping() {
        // most containers can be sold individually
        return super.grouping() + " and in pairs.";
    }
}

Let's create a new Box and call it's toString method:

Container box = new Box("wine", 13, 14, 15);
System.out.println(box.toString());

The output from this code block would be:

Box with 2730.00 units of wine.
Opening Instructions: Attach the provided spout.
Sold: Individually and in pairs.

Let's trace what methods are called against box when we call box.toString():

  • The first method called is toString. Because Box does not override the toString method, the JVM uses the implementation from Container.
  • Within toString, the next method called is this.getClass(), which returns the instance type of our Object. In this case, the instance type is Box. We then call getSimpleName() on the Box class, which returns the string "Box".
  • The next method called is this.volume(). Because Box overrides the volume method, the JVM uses the implementation of volume from Box.
  • The next method called is this.openingInstructions(). Because Box overrides the openingInstructions method, the JVM uses Box's implementation of openingInstructions.
  • The next method called is this.grouping(). Because Box overrides the grouping method, the JVM uses Box's implementation of grouping.
  • Within Box's implementation of grouping, super.grouping() is called. The JVM calls the grouping method implemented by Box's superclass, Container.

Note that the grouping method in the Box class calls super.grouping() so it can add additional ways to be grouped for sale, rather than completely replacing the superclass grouping. This is a convenient thing to be able to do as the alternative would be to repeat code already written in the Container class and repeating identical code is generally best avoided.

Subclass trees

Note that a class can be a subclass of a class that is, in turn, a subclass of a different superclass.

In this example, a RecyclableBox is-a Box, which is-a Container. It acts as a box, but it is recyclable after use:

/**
* Recyclable Box subclass.
*/
public class RecyclableBox extends Box {
    /**
    * Creates a RecyclableBox with the given contents and dimensions.
    *
    * @param contents the contents of this box.
    * @param width the width of this box.
    * @param height the height of this box.
    * @param depth the depth of this box.
    */
    public RecyclableBox(String contents, double width, double height, double depth) {
        super(contents, width, height, depth);
    }

    /**
    * Returns how to open this container. 
    *
    * @return directions for opening this container.
    */
    @Override
    public String openingInstructions() {
        return super.openingInstructions() + ". Recycle after use";
    }
}

Let's create a new RecyclableBox and call it's toString method:

Container recyclableBox = new RecyclableBox("wine", 13, 14, 15);
System.out.println(recyclableBox.toString());

The output from this code block would be:

RecyclableBox with 2730.00 units of wine.
Opening Instructions: Attach the provided spout. Recycle after use.
Sold: Individually and in pairs.

Since a RecyclableBox is-a Box and a Box is-a Container, it is also safe to say a RecyclableBox is-a Container. Also, again notice the use of super.openingInstructions(), which allows the RecyclableBox to add to the superclass (Box) behavior. Note that we don't need to re-write grouping or volume methods for RecyclableBox. Because Box is the superclass of RecyclableBox, Box's implementations of those methods will be used by default.

Let's trace what methods are called against recyclableBox when we call recyclableBox.toString():

  • The first method called is toString. Because both RecyclableBox and Box do not override the toString method, the JVM uses the implementation from Container.
  • Within toString, the next method called is this.getClass(), which returns the instance type of our Object. In this case, the instance type is RecyclableBox. We then call getSimpleName() on the RecyclableBox class, which returns the string "RecyclableBox".
  • The next method called is this.volume(). RecyclableBox does not override the volume method, but Box does. As such, the JVM uses the implementation of volume from Box.
  • The next method called is this.openingInstructions(). Because RecyclableBox overrides the openingInstructions method, the JVM uses RecyclableBox's implementation of openingInstructions.
  • Within RecyclableBox's implementation of openingInstructions, super.openingInstructions() is called. The JVM calls the openingInstructions method implemented by RecyclableBox's superclass, Box.
  • The next method called is this.grouping(). RecyclableBox does not override the grouping method, but Box does. Because of this, the JVM uses Box's implementation of grouping.
  • Within Box's implementation of grouping, super.grouping() is called. The JVM calls the grouping method implemented by Box's superclass, Container.

Note that, when we call the method, if that class doesn't implement the requested method, we move up the inheritance chain for that method's implementation and do so one class at a time. Take, for example, toString. Since RecyclableBox doesn't implement that method, the JVM next checks against its parent class, Box. Since Box doesn't implement toString, the JVM checks its parent class Container for an implementation of toString, which is found and used.

Similarly, in the case of volume, RecyclableBox doesn't implement it, so the JVM checks its parent, Box. In this case, Box does implement volume, so the JVM will use Box's implementation of volume. The JVM does not need to travel any higher up the inheritance chain to find an implementation for volume.

More super traversals

Similarly, when super is called, the JVM will travel up the inheritance chain, one class at a time, to find an implementation of the method by a superclass. In our previous cases, super always called the method of the immediate superclass, but that doesn't need to be the case. Let's take a look at a couple more implementations of Container to explore this concept.

Sphere is a Container, and in turn, has a subclass, CappedSphere. CappedSphere is-a Sphere, so it also is-a Container:

/**
* Sphere shaped Container subclass.
*/
public class Sphere extends Container {
    private double radius;

    /**
    * Creates a Sphere with the given contents and dimensions.
    *
    * @param contents the contents of this sphere.
    * @param radius the radius of this sphere.
    */
    public Sphere(String contents, double radius) {
        super(contents);
        this.radius = radius;
    }

    /**
    * Calculates and returns the volume of this sphere.
    *
    * @return the volume of this sphere.
    */
    @Override
    public double volume() {
        return 4.0 / 3.0 * Math.PI * Math.pow(this.radius, 3);
    }
}

CappedSphere is a Sphere with a twist-off cap:

/**
* Capped Sphere subclass.
*/
public class CappedSphere extends Sphere {
    /**
    * Creates a CappedSphere with the given contents and dimensions.
    *
    * @param contents the contents of this sphere.
    * @param radius the radius of this sphere.
    */
    public CappedSphere(String contents, double radius) {
        super(contents, radius);
    }

    /**
    * Returns how to open this container. 
    *
    * @return directions for opening this container.
    */
    @Override
    public String openingInstructions() {
        return "Twist off the cap";
    }
}

Let's see what happens if we create one Container, one Sphere, and one CappedSphere, and take a look at printing toString for each:

List<Container> containers = new ArrayList<>();
containers.add(new Container("water"));
containers.add(new Sphere("milk", 8));
containers.add(new CappedSphere("juice", 8));
for (Container container : containers) {
    System.out.println(container);
}

The above code will print the following:

Container with 0.00 units of water.
Opening Instructions: Puncture.
Sold: Individually.
Sphere with 2144.66 units of milk.
Opening Instructions: Puncture.
Sold: Individually. 
CappedSphere with 2144.66 units of juice.
Opening Instructions: Twist off the cap.
Sold: Individually.

Read through this output carefully, trace each bit of output to the methods that produced it, ensuring you understand the trail of method calls. One of the more interesting tidbits is Sphere extends Container but does not override the openingInstructions method, yet CappedSphere extends Sphere and does override the openingInstructions method. In a multi-layer inheritance hierarchy, a method inherited from several levels above can still be overridden in a subclass.

Summary

We have now seen how the design principles of encapsulation, inheritance, and polymorphism work together to create safe and adaptable software. There are other helpful design strategies used in object-oriented programming. In particular, implementing an interface in Java is closely related to the is-a relationship of inheritance. We will discuss this in more detail in the next reading in this lesson. Also, a design technique called composition establishes a has-a relationship, which is sometimes more appropriate than the is-a relationship. This will be the topic of a future lesson.

Implementing equals() and hashCode() with Inheritance

We've covered how to implement equals() and hashCode(), and we've covered how to use inheritance, so now let's walk through how to implement equals() and hashCode() with inheritance!

Our example for today: we want to create spray-on packaging for items of different shapes and sizes. We've developed a SprayableShape base class that contains information on what type of spray to use and defines a method for getting the sprayable area. We also have two subclasses developed, Circle and Rectangle.

enum Spray {
   BLUE_GLOSS,
   RED_MATTE
}

public class SprayableShape {
   private Spray spray;
   
   public SprayableShape(Spray spray) {
        this.spray = spray;
   }
   
   // The base `SprayableShape` has a surface area of 0
   public double getSprayableArea() {
        return 0;
   }
}

public class Circle extends SprayableShape {
   private double radius;
   
   public Circle(Spray spray, double radius) {
        super(spray);
        this.radius = radius;
   }
   
   @Override
   public double getSprayableArea() {
        // We previously wrote some implementation here
   }
}

public class Rectangle extends SprayableShape {
   private double length;
   private double width;
   
   public Rectangle(Spray spray, double length, double width) {
        super(spray);
        this.length = length;
        this.width = width;
   }
   
   @Override
   public double getSprayableArea() {
        // We previously wrote some implementation here
   }
}

We were able to write code to get the sprayable area of each different shape, but now we want to optimize our system and group the shapes by the Spray type and the dimension so that we can create separate spray-on packaging assembly lines to avoid reconfiguring the machines for each item.

Example diagram showing each spray-on packaging assembly line, grouped by the spray type and the shape’s dimensions, so that the machines don’t need to be reconfigured for each item that comes on the line.

To do this, we need to check for equality and override Object::equals(), as well as hashCode() as we learned that these two methods are linked.

Recall from inheritance that the code using our SprayableShape objects can refer to any SprayableShape instance, and Java will call the instance's equals() method. If the instance doesn't override equals(), Java will check its superclass, then its superclass, and continue up the chain until it finds a superclass that implements equals(). Since Object is a superclass of every class, we're guaranteed to find equals() there even if it's not implemented anywhere else.

Unfortunately, Object::equals() isn't smart enough to check classes or attributes; it only returns true when the references (locations in memory) being compared refer to the same instance.

Every time we override equals, we want to make sure we are writing code to check for the following:

  • Is the passed-in object null? Since we're in an instance of the class, we know we aren't null, and can't possibly be equal to null. Return false.
  • Is the passed-in object the exact same object? If our reference is pointing to the same object in memory, then the attributes must be equal. Return true.
  • Is the passed-in object a different type? Then we don't have the same set of attributes to compare. Return false.
  • Finally, implement the checks for equality between attributes that make the two objects equal.

For the SprayableShape base class, the only property we have is the Spray type, so that's all we will check in the equals() method and the hashCode() method.

public class SprayableShape {
   ...
   
   @Override
   public boolean equals(Object o) {
        // An object can't be equal to null.
        if (o == null) {
            return false;
        }

        // If two objects have the same reference, they should be equal.
        if (this == o) {
            return true;
        }
        
        // If the objects are of different types, they shouldn't be equal. 
        if (getClass() != o.getClass()) {
            return false;
        }
            
        SprayableShape that = (SprayableShape) o;
        
        // Finally, implement the checks for equality between attributes that make the two objects equal.
        return spray == that.spray;
   }

   @Override
   public int hashCode() {
        // Use the same properties that make the objects equal to compute a hashCode.
        return Objects.hash(spray);
   }
}

Let's break down this example.

This example uses getClass() to check if the object we are comparing to has precisely the same class as our object. getClass() is another method that every object provides. It returns the instance type of the object, the actual type of the object on the heap. Because we compare classes, a subclass can never be equivalent to one of its superclasses. In our example, no Circle can ever be equal to a SprayableShape.

Instead of using getClass(), you may see people implementing equals using a keyword instanceof:

if (!(obj instanceof SprayableShape)) {
    return false;
}

We do not recommend using it. instanceof checks if the object is that particular type or a sub-type. So in this snippet, instanceof will return true if obj is an instance of SprayableShape or an instance of any of SprayableShape's subclasses. This means that an object that is a SprayableShape, Circle, or a Rectangle would all be considered an instanceof SprayableShape, as we established that Circle is-a SprayableShape and Rectangle is-a SprayableShape through inheritance.

This can cause some weird problems. Check out what happens if we call equals() on the same two objects in a different order:

SprayableShape baseShape = new SprayableShape(Spray.RED_MATTE);
SprayableShape circle = new Circle(Spray.RED_MATTE, 1.25);

// This returns true -- Circle is an instanceof SprayableShape, and they both use RED_MATTE spray.
assertTrue(baseShape.equals(circle));
// This returns false -- SprayableShape is NOT an instanceof Circle, and it doesn't even have a radius.
assertFalse(circle.equals(baseShape));

By allowing the subclasses to use the superclasses equals() method, we now get inconsistent results. To prevent this, use the getClass() check to make sure we only ever compare two objects of the same type.

Now for Circle and Rectangle, we have the dimensional properties available to us, which we want to check to avoid reconfiguring our machines frequently. However, we also want to check the Spray type that's stored in the superclass. Since our superclass SprayableShape implements equals() and hashCode() now, we need to reuse that code when implementing equals() and hashCode() in the subclasses, to make sure that those superclass properties are also equal. IntelliJ's auto-generate feature will take care of that for you automatically.

public class Circle {
   ...
   
   @Override
   public boolean equals(Object o) {
        // An object can't be equal to null.
        if (o == null) {
            return false;
        }

        // If two objects have the same reference, they should be equal.
        if (this == o) {
            return true;
        }
        
        // If the objects are of different types, they shouldn't be equal. 
        if (getClass() != o.getClass()) {
            return false;
        }
        
        // Something new! Check that our superclass properties are also equal.
        if (!super.equals(o)) { 
            return false;
        }
        
        // Check that the properties of this class are equal.
        Circle circle = (Circle) o;
        return Double.compare(circle.radius, radius) == 0;
    }

   @Override
   public int hashCode() {
        // Take into account superclass properties in addition to our own class properties.
        return Objects.hash(super.hashCode(), radius);
   }
}

Our superclass SprayableShape implements equals() and hashCode(), which means we have to add those calls to super.equals() and super.hashCode() in our subclass implementation.

Check out what would happen if we didn't call super.equals() and super.hashCode() from the subclass implementation:

Circle circle1 = new Circle(Spray.BLUE_GLOSS, 4.5);
Circle circle2 = new Circle(Spray.RED_MATTE, 4.5);

// The radiuses are equal, and that's all the subclass knows about, so it returns true
assertTrue(circle1.equals(circle2));

assertTrue(circle1.hashCode() == circle2.hashCode());

Now we might decide that we don't care about the superclass properties in's equality check. However, that might bring up the question of whether or not Circle really is-a SprayableShape, and indicate a problem with our inheritance hierarchy.

Changing Superclasses

Risky Inheritance

So far, we've covered the benefits of inheritance. It is often tempting to use inheritance to solve all your problems, but inheritance isn't always an appropriate approach to problems, as discussed in the last reading. Furthermore, using inheritance carries some risks, the most notable of which is the risk of change to your superclasses.

Let's take a look at an example of this:

Oversized containers, oh my!

Let's revisit our Container class and its subclass, Box.


/**
* Container class to serve as a super class for different containers.
*/
public class Container {
    private String contents;

    /**
    * Creates a Container with the given contents.
    *
    * @param contents the contents of this container.
    * @throws IllegalArgumentException if contents is null.
    */
    public Container(String contents) {
        if (contents != null) {
            this.contents = contents;
        } else {
            throw new IllegalArgumentException(
                "Containers must have some contents.");
        }
    }

    /**
    * Calculates and returns the volume of this container;
    * defaults to zero.
    *
    * @return the volume of this container.
    */
    public double volume() {
        return 0.0;
    }
}

/**
* Box shaped Container subclass.
*/
public class Box extends Container {
    private double width;
    private double height;
    private double depth;

    /**
    * Creates a Box with the given contents and dimensions.
    *
    * @param contents the contents of this box.
    * @param width the width of this box.
    * @param height the height of this box.
    * @param depth the depth of this box.
    */
    public Box(String contents, double width, double height, double depth) {
        super(contents);
        this.width = width;
        this.height = height;
        this.depth = depth;
    }

    /**
    * Calculates and returns the volume of this box.
    *
    * @return the volume of this box.
    */
    @Override
    public double volume() {
        return this.width * this.height * this.depth;
    }

}

In most cases, the way we've implemented these classes is acceptable, but every once in a while, we see a case where we get a negative volume! Diving deeper, we've realized that for overly large Boxs, the calculation for the volume goes above double's limit ((2^63) - 1), and as such, the result has rolled over to negative values!

Luckily, we know that BigDecimal can help us store even larger values! So we update our Containers volume method to return a BigDecimal:


/**
* Container class to serve as a super class for different containers.
*/
public class Container {
    private String contents;

    /**
    * Creates a Container with the given contents.
    *
    * @param contents the contents of this container.
    * @throws IllegalArgumentException if contents is null.
    */
    public Container(String contents) {
        if (contents != null) {
            this.contents = contents;
        } else {
            throw new IllegalArgumentException(
                "Containers must have some contents.");
        }
    }

    /**
    * Calculates and returns the volume of this container;
    * defaults to zero.
    *
    * @return the volume of this container.
    */
    public BigDecimal volume() { // Corrected from Bigdecimal
        return BigDecimal.ZERO; // Corrected from 0.0
    }
}

"Awesome!", we think. We save our changes and then try to build our package, and it fails to compile. 😦

What broke?

When we change a superclass, that change affects all of its subclasses (and their subclasses, and the subclasses of those subclasses, etc., down the inheritance chain).

In this case, since we changed volume to return a BigDecimal in the Container superclass, the compiler expects the volume method of all classes that inherit from Container to also return BigDecimal.

So, let's take a look at our class diagram for Container, Box, Cylinder, and Sphere from our earlier reading.

UML class diagram for the Container, Box, Cylinder, and Sphere classes.

Figure 1

Figure 1: UML class diagram for the Container, Box, Cylinder, and Sphere classes.

From this diagram, we know that Box, Cylinder and Sphere have all overridden volume with their own implementations. Before our code can compile, we need to update the volume method of all these classes to return a BigDecimal. We'll also need to change our code wherever we rely on the result of volume to accept a BigDecimal rather than a double. While we're at it, we should change all of our double members within our Container classes to BigDecimal and use BigDecimals "safe" math operations, like we covered in the "Wrappers" lesson.

What else can break?

Whenever you change the signatures of public or protected methods of a superclass, you risk causing its consumers, its subclasses (through the entire inheritance chain), and consumers of its subclasses to fail to build. You run the same risk if you change the names or types of public or protected members of a superclass.

While it typically won't cause builds to fail, it's essential also to recognize that changing the implementation of methods (private, protected, or private) of a superclass might not cause build failures but will still affect all subclasses that rely on those methods.

Suppose you're modifying a superclass in any way. In that case, it's crucial to thoroughly investigate how it and its subclasses (through the inheritance chain) are used and implemented to avoid causing any undesired side effects.

What's the worst that can happen?

While the radius of the above fix isn't huge for our codebase as it is now, if we continued to expand with more Container subclasses, we would have a more significant number of places to update. If we exposed any of our Container classes publicly, then packages that depend on our classes will fail to build, too, causing a possible cascade of failures.

How do we fix it?

The best way to fix these kinds of issues is to nip them in design. Designing inheritance relationships often requires judgment calls as to the risk of needing to change your superclass and the intuitive hierarchy or your classes.

For example, if we had a Rectangle class which implements Shape, and we wanted to add a Square class, should it extend Shape or Rectangle? Or should we refactor so Rectangle extends Square? How do we expect either of these solutions to impact calculating a shape's area? There's no one correct answer and lots of wrong answers. We must consider what makes for an intuitive class hierarchy, what can be reused, and what will likely need to be changed later.

Following best practices from the start can also help reduce the need to make changes to your superclasses later. In this example, using "math-safe" operations and wrapper classes like BigDecimal from the start could have prevented the need for this change after that.

Of course, if you really need to update a superclass, there are ways to do so safely, such as changing major package versions and deprecating previous major versions of your library. We won't cover such approaches in more detail in this lesson, as each development team has its own standards and processes regarding such changes.

Mastery Task 3: Time is Marching On

Mastery Task Guidelines

Mastery Tasks are opportunities to test your knowledge and understanding through code. When a mastery task is shown in a module, it means that we've covered all the concepts that you need to complete that task. You will want to make sure you finish them by the end of the week to stay on track and complete the unit.

Each mastery task must pass 100% of the automated tests and code styling checks to pass each unit. Your code must be your own. If you have any questions, feel free to reach out for support.

This is the first Mastery Task in this Sprint associated with with the Sprint project. Be sure you have copied the Sprint Project code down to your local machine.

Currently, our code only supports a single type of packaging corresponding to corrugate boxes. Amazon's packaging options have expanded to include polybags and may expand to even more types in the future. Your PM has warned your team that the first FC with polybags is going to onboard soon, and we need to prepare.

Milestone 1: Design

You should plan for at least two new packaging types: Box and PolyBag. We'd like to update our service to support these new and future packaging types without requiring changes to the PackagingDao, FcPackagingOption, or the ShipmentService. We can do this by extending from the Packaging class. We know that no other teams use our types Java package, so it’s safe to change any of its classes. Both Box and PolyBag will need to implement the methods canFitItem() and getMass(), which are used to determine the best shipment option. Below are details about how to calculate each.

A Box is made of CORRUGATE; has fixed length, width, and height (all measured in centimeters); and can fit any item that is smaller in each dimension. (The Amazon catalog standard ensures that each Item is measured with its shortest dimension in width and its longest in height. Boxes are measured the same way, so we can tell an item won’t fit if any item dimension is larger than or equal to the same box dimension. Try it out until you’re convinced!) A Box has a mass that is about 1 gram per square centimeter. We’ll simplify the calculations by ignoring any overlapping flaps and only considering the exposed area, as represented by this pseudo-code:

endsArea = length * width * 2;
shortSidesArea = length * height * 2;
longSidesArea = width * height * 2;
mass = endsArea + shortSidesArea + longSidesArea;

HINT: Remember that with BigDecimals, we can't use +, *, -, /, etc operators. Instead, we must use the class methods add, multiply, etc. Additionally, we can turn an integer into a BigDecimal using the static valueOf method.

A PolyBag is made of LAMINATED_PLASTIC; has a fixed volume (in cubic centimeters); and can fit any Item whose volume (length * width * height) is smaller than the bag’s volume. (The actual formula is quite complicated, but volume is a reasonable approximation in most cases.) Note that a polybag, therefore, should not have a length, width, or height property, so we'll need to remove the fields from the Packaging parent class and move them to the new Box class.

A PolyBag has a mass that is a bit more complicated to derive. The Data Engineering team handed you this code, which they say gets "close enough":

mass = Math.ceil(Math.sqrt(volume) x 0.6);

Note: Math.sqrt() doesn’t support accepting a BigDecimal. We’ll have to make an approximation using double values, and covert that back to BigDecimal. The Data Engineering team confirms that’ll be sufficient.

Create a new smaller, focused class diagram with the changes you plan to make for this task. The diagram should include the changes you plan to make to the types package. This should include any classes you change or add, and any relationships between them. Create a new file in the src/resources directory called mastery_task_03_CD.puml and add the plant uml source code to the file.

You can run the MT3DesignIntrospection test to ensure you've met the requirements for this design. Either directly or by running ./gradlew -q clean :test --tests 'tct.MT3DesignIntrospectionTests'

Milestone 2: Implementation

Implement your new design. You likely will hit a point where you're not sure what to do about getMass() or canFitItem() in your Packaging class. One way to handle a method that needs to exist, but doesn't have any logical implementation is to just return some default value. However, this allows your methods to be used without the caller knowing they really shouldn't be calling these methods. Another way is to implement it by throwing an exception that says "This method is not supported!". We can do that by throwing a UnsupportedOperationException. Now, if anyone calls these methods, they'll get a strong signal that they shouldn't be!

In addition to implementing your design you'll need to make a few more changes. You'll need to update the PackagingDatastore. Its createFcPackagingOption() method creates Packaging objects that get used to create FcPackagingOptions. Our service will continue to use boxes as the only packaging option, so let's update the code to create Box objects to pass to the FcPackagingOptions. Remember though, above we mentioned that we did not want to change the FcPackagingOption class, so it will still need to accept an object that is-A Packaging.

Next, we'll need to update our ShipmentOption selection logic. We currently choose the option with the lowest monetary cost. However, we currently only know how to calculate the monetary cost of CORRUGATE packaging. We now have LAMINATED_PLASTIC as well. Let's open up MonetaryCostStrategy and make a couple updates so we can accurately calculate the cost of shipping polybags. The only method here, getCost() multiplies the cost per gram of the packaging material by the mass in grams of the packaging. It then adds in the cost of labor to get the total cost to ship. Next, let's take a look at the variables at the top of the file. We have a class constant, LABOR_COST, that stores the labor cost we mentioned above. You'll be learning about constants in our Statics lesson, but for now you should understand that it is a value that won't change per object. The cost of labor will always be 43 cents every time we use this class. There is also a Map called materialCostPerGram, which is used by the getMass() method to get the cost per gram of the packaging's material. We will also learn about Map in this unit! You might be doing this task before we get there though, so we will walk you through how we need to update it. A map has what we call a key and a value. This map has material type as its key, and a value that represents the cost per gram of that material. We will need to add a new key, value pair, LAMINATED_PLASTIC : 00.25. Polybags are much more expensive per gram, but weigh much less! To add a new key, value pair, or an entry, to the map, you will use the put() method. Add the following line to your constructor:

materialCostPerGram.put(Material.LAMINATED_PLASTIC, BigDecimal.valueOf(.25));

Before we are finished with this task we will need to make sure our code is properly tested. This means writing unit tests for new classes, and updating the existing unit tests. In any existing test classes that depended on Packaging you can take a similar approach to the changes you made to FcPackagingOption, using boxes as the packaging object. Please also include a new test in MonetaryCostStrategyTest validating the getCost() method for a polybag.

You may notice that the PolyBag class isn’t referenced anywhere in our code or data yet. That’s not a bad thing, since the existing fulfillment centers (FCs) can use our new code and get confident that it works before we introduce any other changes.

HINTS: There are some tests in ShipmentOptionTest that check for equality between Packages (which you will be instantiating as Boxs). For these tests to pass you will need to Generate equals and hashcode inside the Box class. Boxes with different dimensions are not equal.

Once your design is implemented, the MT3 tests should pass.

Exit Checklist

  • ./gradlew -q clean :test --tests 'tct.MT3*' passes
  • ./gradlew -q clean :test --tests 'com.amazon.ata.*' passes
  • You've pushed to github

Resources