Lab 3: The Builder pattern
Starter files: code.zip
Due: Friday Sept 20 at 08:59PM
Objectives
The objectives of this lab are:
Use the builder pattern to help create objects in a multi-step process.
Use the builder pattern to help write immutable objects.
Compare and contrast designs with and without builders.
Note: the final design in this lab is inspired from the book Effective Java by Joshua Bloch.
1 Introduction
Instantiating an object is one of the basic operations in object-oriented programming. However there are several situations where this basic operation is complicated and error-prone. In other situations we are faced with objects that should not be mutable, but have to be in order to customize them before use. In this lab we will use the builder pattern to address these issues in a given design.
The provided starter code defines several classes to represent pizzas. A pizza
is represented by the Pizza
interface, and implemented by the
AlaCartePizza
class. This class is a general-purpose implementation that
can be used to create pizzas with different crusts, sizes and an arbitrary
number of toppings (the “build-your-own” pizza). Two other classes
(CheesePizza
and VeggiePizza
) represent specific kinds of pizzas
(fixed menu options). A simple test class has also been provided, that shows
how to create different pizzas and determine their cost.
Please create a new project, load in these files, verify that the test runs successfully and read through the provided code to understand the design.
2 Problem
Your local pizzeria store hired a programmer to implement their system, and
this code was part of it. You inherited this code when you were hired by
them. You understood that the addTopping
and removeTopping
methods allowed a client to customize toppings when assembling the pizza. But
you also found a flaw: a pizza could still be modified after its assembly is
“complete”. For example, a Pizza
object can be assembled using these
methods, and then passed to a method that may modify it using the same
methods. This should not be allowed.
Your first instinct was to replace these two methods with a constructor that takes in the crust type, size and a list of toppings. Although this would prevent unwanted mutation, you concluded that such a constructor may be cumbersome and error-prone. For example, one would need to assemble all toppings in a list before calling the constructor. This assembly would be more commplicated if pizza creation is an interactive process.
In this lab, you will use the builder design pattern to address this issue.
3 What to do
3.1 Part 1: A better pizza
We first remove the possibility of mutating a pizza.
Create a new package called betterpizza. This package will contain a “better” version of this design.
Create a new interface called
ObservablePizza
in this package. This interface will contain only those methods from thePizza
interface that do not mutate (cost and hasTopping). Copy their signatures and documentation exactly from the givenPizza
interface into the newObservablePizza
interface.Remove these two methods from the
Pizza
interface, and make thePizza
interface extend theObservablePizza
interface.Create a copy of the provided
AlaCartePizza
class in the betterpizza package (keep the name of the class the same). We will modify it in the next part.
In this change we have refactored the Pizza
interface without changing
the methods it declares. This means that all existing implementations of the
Pizza
interface will remain unchanged, even though we segregated the
functionality into operations that mutate and operations that observe.
3.2 Part 2: A better Ala-carte pizza
We now re-engineer the new AlaCartePizza
class kept in the
betterpizza
package, to disallow mutation and facilitating a multi-step
pizza assembly process. We achieve this by using the Builder design pattern.
All the changes in the remainder of this lab apply to the classes in the
betterpizza
package. All provided code should continue to remain in the
pizza
package.
Modify the code so that the (new)
AlaCartePizza
class implements theObservablePizza
interface from the same package.Remove all
public
methods from the (new)AlaCartePizza
class that are not present in theObservablePizza
interface.Create a new abstract class named
PizzaBuilder
in thebetterpizza
package. This class represents a pizza builder.Add a public, static inner class called
AlaCartePizzaBuilder
to theAlaCartePizza
class. This builder class should extend thePizzaBuilder
class.Add a new protected constructor in the (new)
AlaCartePizza
that takes in the size, crust and a map of toppings to direct set its fields. It should throw IllegalArgumentException objects if either of the parameters are null. We will call this constructor from the builder.
Our end goal is to allow creating the equivalent of the pizza created in the given test class, using code like this:
ObservablePizza alacarte = new AlaCartePizza.AlaCartePizzaBuilder()
.crust(Crust.Classic)
.size(Size.Medium)
.addTopping(ToppingName.Cheese, ToppingPortion.Full)
.addTopping(ToppingName.Sauce,ToppingPortion.Full)
.addTopping(ToppingName.GreenPepper,ToppingPortion.Full)
.addTopping(ToppingName.Onion,ToppingPortion.Full)
.addTopping(ToppingName.Jalapeno,ToppingPortion.LeftHalf)
.build();
As you can see, such code (if it works) allows us to assemble a pizza one topping at a time. Each line above makes it clear which topping is being added to the pizza (the code is styled so that calls are one per line, to make it look like a to-do list). This increased readability also reduces the possibility of making errors.
Carefully look at this example, and add methods to the PizzaBuilder
or
AlaCartePizzaBuilder
classes so that this code compiles. Remember:
methods that are identical should be in the abstract class, while methods that
are unique should be in the sub-classes. Furthermore, the build
method
should throw an IllegalStateException
if one attempts to build a pizza
without specifying the size.
You should start by pasting the above code snippet in a new test class (in the
default package). It will produce compiling errors. Read the error messages to
determine what you are missing, and modify the code accordingly. Again,
remember that this test is using classes from the betterpizza
package:
the original code should remain in the pizza
package.
3.3 Part 3: A better Cheese and Veggie pizza
Create better versions of the provided CheesePizza
and
VeggiePizza
classes by following the same methodology as above: create
classes with identical names in the betterpizza
package, create the protected
constructors that take in size, crust, and toppings, write example
code to create a cheese and veggie pizza similar to above, and modify your code
so that your example works. You must create the counterpart
CheesePizzaBuilder
and VeggiePizzaBuilder
classes similar to
above. For example, here is how one could create a large, thin crust cheese
pizza:
ObservablePizza cheese = new CheesePizza.CheesePizzaBuilder()
.crust(Crust.Thin)
.size(Size.Large)
.build();
Observe in the above code snippet that the builder should start with the proper default configuration for the pizza (e.g. you should not have to use the builder to explicitly add cheese or sauce to a cheese pizza).
Add examples to your tests for a medium, stuffed crust pizza and a large, thin crust veggie pizza. Write the test that is the counterpart of the one provided in the starter code to verify the costs of all your pizzas (they should cost exactly the same!)
3.4 Questions to ponder/discuss
Discuss these questions with the person next to you, and report to the course staff.
How would you add a new pre-configured pizza in both designs: pepperoni pizza? Which design allows you to add this more conveniently?
Beyond ensuring that the pizzas do not have to mutate, what are some other advantages of using the builder pattern in this specific problem?
Has using the builder pattern this way made some things more complicated? What are they, and could you have implemented things differently to improve?
3.5 Part 4: A pizza that can be customized more easily
What if you wanted a cheese pizza, but with white sauce instead? Or a veggie
pizza but without tomatoes? Such “small customizations” from pre-defined
pizzas are common. However in our design, doing this would be inconvenient or
more error-prone. In the current design the only way to achieve this is to use
an alacarte pizza, which foregoes the advantages of starting with a
pre-configured pizza. It would be nice if each type of pizza builder offered
customized methods to modify its toppings. For example, the
CheesePizzaBuilder
can offer a method called leftHalfCheese
which
will put cheese only on the left half of the pizza instead of the whole pizza.
Add the following public methods to the CheesePizzaBuilder
:
noCheese()
, leftHalfCheese()
, rightHalfCheese()
. Now try
to construct a medium cheese pizza with a thin crust, that has cheese only in
its left half. What happens? Think about why this is happening before you
proceed.
As you may have discovered, the problem is that these methods are defined
exclusively in the CheesePizzaBuilder
class. However the crust()
and size()
do not return CheesePizzaBuilder
objects! Most likely
they return AlaCartePizzaBuilder
or PizzaBuilder
objects, depending
on how you remove the duplicate code. Therefore these new methods cannot be called
on the objects returned by these inherited methods!
We have encountered a possible limitation in our design: the builder works correctly in a class hierarchy so long as all builders offer exactly the same methods. Let us enhance our design so that builders can offer customized methods depending on what they are building. The below steps will work regardless of which class holds your deduplicated code!
In the
AlaCartePizzaBuilder
class, write a protected methodAlaCartePizzaBuilder returnBuilder()
that returnsthis
. Use this method in each of its methods that modify the pizza, instead of directly returningthis
.Write the same method in the
CheesePizzaBuilder
class, but with a return type ofCheesePizzaBuilder
. Use it in the same way in theCheesePizzaBuilder
class. Note that these methods are identical, except for their return types. If only we can capture this commonality while retaining their ability to return the type of builder they were implemented in...Abstract the
returnBuilder
method into thePizzaBuilder
class, by changing the return type to a genericT
. Make this methodabstract
. Make thePizzaBuilder
itself generic onT
by changing its declaration topublic abstract class PizzaBuilder<T>
.What should
T
be? For it to represent bothAlaCartePizzaBuilder
andCheesePizzaBuilder
it should be...something that extends thePizzaBuilder
! This leads to the following doozy of a syntax:public abstract class PizzaBuilder<T extends PizzaBuilder<T>>
.Java allows us to place restrictions on a generic parameter this way (for example, we have restricted T to be only PizzaBuilder-type things). Make sure you understand exactly what this syntax means before proceeding. It is simpler to understand it if you think about why it came to be this way.
(Note: For situations like this, where we need to use a type parameter to indicate “the current class, itself”, I often like to name the type parameter
Self
, or by the initials of the current class:PizzaBuilder<Self extends PizzaBuilder<Self>>
, orPizzaBuilder<PB extends PizzaBuilder<PB>>
, and I leave a Javadoc comment @param PB the specific type of PizzaBuilder being defined, to indicate to implementors how to properly use this type parameter.)At this point, if you have implemented methods in your PizzaBuilder class that would return
this
, rewrite them so they returnreturnBuilder
instead.Change your concrete pizza builder classes to properly define the generic parameter. For example, it should be
public static class AlaCartePizzaBuilder extends PizzaBuilder<AlaCartePizzaBuilder>
.Implement the abstract method
returnBuilder
in each of your builder classes. This should only involve minor changes to the protected methods you wrote in the first two steps.
Your example of constructing a medium cheese pizza with a thin crust and cheese only in its left half, should now succeed. Write tests for such a pizza.
3.6 Questions to ponder/discuss
Explain to the person next to you how and why your code works (or seek their help for why it does not). Similarly, listen to their explanation. Finally explain this to the TA.
3.7 Optional Part 5: A customizable veggie pizza
Use the same design as above to enhance the builder for the veggie pizza. In addition to the methods offered by all builders, this builder should offer extra methods to remove each topping, sauce or cheese from this pizza. Add tests for this pizza.
4 Optional Part 6: Enhanced builders
Create a “3-topping pizza”, that allows you to create a pizza that has at most 3 toppings on a pizza. The user should be able to remove a topping (e.g. to replace with another topping).