This excercise continues the refactoring process that was begun in lectures, using the car rental application based on an example from Chapter 1 of Fowler’s Refactoring book (1st edition). We assume you have already done the Unit Testing With JUnit 5 exercise.
Download the code for this exercise and unzip the
archive. This should create a directory named exercise3
.
Study the code.
This is the car rental example explored in lectures, at the point where
the refactorings Extract Method, Move Method and
Inline Temp have already been used to simplify the statement()
method in the Customer
class. Review the video of the ‘Introduction
to Refactoring’ lecture so that you understand the changes that have
been made here.
If you’ve done the Creating Diagrams exercise then you will have
copies of the original classes in an exercise1
directory, so examine
that code if you need to see what the classes were originally like,
before any refactoring had been applied.
Run the tests.
As in the unit testing exercise, the code uses Gradle as the build system. You can compile and run tests from the command line or from within the IntelliJ IDE, as discussed in that exercise.
The implementation of the statement()
method in the Customer
class can
be simplified further by repeating the refactorings discussed in the lectures,
this time applying them to the calculation of frequent renter points.
Study the loop in the statement()
method, focusing on the code that
manipulates frequentRenterPoints
. Use Extract Method on this
code, creating a new method in Customer
that looks like this:
private int getFrequentRenterPoints(Rental rental) {
if (rental.getCar().getPriceCode() == Car.NEW_MODEL && rental.getDaysRented() >= 3) {
return 2;
}
return 1;
}
You can now remove the logic for computing frequent renter points from
the statement()
method. You will still need a line that updates
the frequentRenterPoints
variable:
frequentRenterPoints += getFrequentRenterPoints(rental);
Run the tests and make sure they all pass.
The calculation of frequent renter points involves details of a rental,
not of a customer – so our extracted method doesn’t really belong in
the Customer
class. Use Move Method to move it into the
Rental
class.
Remember that you need to do this in small steps. After duplicating
the logic of the method in the Rental
class, modify the private
getFrequentRenterPoints()
method still in Customer
so that it calls
the new version of the method in Rental
:
private int getFrequentRenterPoints(Rental rental) {
return rental.getFrequentRenterPoints();
}
Run the tests again. If they all pass, you can remove this old version
of the method and then update the relevant line of code in the
statement()
method so that it looks like this:
frequentRenterPoints += rental.getFrequentRenterPoints();
Again, run the tests and make sure they all pass.
Study the statement()
method again. Notice how it uses temporary
variables totalAmount
and frequentRenterPoints
to accumulate the
total amount to be charged to the customer and the number of frequent
renter points they have earned. Replace Temp with Query can be
used to eliminate these temporary variables.
This refactoring is essentially a combination of Extract Method and Inline Temp. The new methods that it creates will each contain loops that perform the necessary summations. Here’s the one to compute the total amount charged:
private int getTotalCharge() {
int total = 0;
for (Rental rental : rentals) {
total += rental.getCharge();
}
return total;
}
Add this to the class, then modify statement()
so that it uses this
method at the point where the total amount is needed. Run the tests
again and make sure they pass. Then follow the same approach to
eliminate frequentRenterPoints
.
At this point, it’s worth comparing the Car
, Customer
and Rental
classes with their original versions, before any refactoring has been done.
(Remember: copies of these were provided for the Creating Diagrams
exercise.)
Implement a new method named htmlStatement()
. This should behave
similarly to statement()
, except that it should return a statement
formatted as valid HTML rather than plain text.
You should find that this is relatively straightforward, and that you can reuse the methods introduced during the refactoring earlier. The refactoring has helped you to implement the new method without any unnecessary duplication of code.
Think about what this new method would have looked like if you hadn't bothered refactoring the classes first...
Optional: The API of these classes has changed a little, so some more unit tests are ideally needed. Add these now if you wish.
We refactored the calculation of rental charge by moving it from Customer
to Rental
, but further improvement of the design is possible. For example,
there is some conditional logic relating to the price code for different
types of car:
public int getCharge() {
int amount = 0;
switch (getCar().getPriceCode()) {
case Car.STANDARD:
amount += 30 * getDaysRented();
break;
case Car.LUXURY:
amount += 50 * getDaysRented();
break;
case Car.NEW_MODEL:
amount += 40 * getDaysRented();
break;
}
return amount;
}
It should be possible to replace this with polymorphic code.
Take a look at the switch
statement in the getCharge()
method above.
The decision is being made on price code, which is a field of Car
.
So it actually makes more sense for this logic to be part of Car
.
Use Move Method to move getCharge()
to Car
. The new version of
the method should accept the length of the rental (number of days) as
a parameter. The getCharge()
method in Rental
should be changed to
use the new method – e.g., like this:
public int getCharge() {
return car.getCharge(daysRented);
}
Remember to run the tests! If they all pass, commit your changes.
Now perform the same refactoring for the calculation of frequent renter
points. The idea here is to have both of the things that depend on the
type of car implemented in the Car
class.
You should end up with classes that look like this:
Classes after moving methods to Car
One possible way forward now would be to implement subclasses of Car
named LuxuryCar
, NewCar
and StandardCar
. Each could have its
own method for computing the rental charge. The problem with this is
that a car could change from being a new model to being a standard model,
but objects cannot change class during their lifetime.
We can solve this problem by using the State design pattern. Here,
this involves representing the price of renting a car using an abstract
class. The specific kinds of rental price are represented by subclasses,
each providing their own rules for calculating the amount that should
be charged to the customer. This allows the rental price for a Car
object to change over time. It also allows new pricing models to be
introduced, without requiring substantial changes to the rest of the code.
Use of State design pattern
Begin implementing the design above by applying the refactoring
Replace Type Code with State or Strategy. Start
by encapsulating the priceCode
field in Car
so that all access to
it is via the getter and setter methods. Run the tests to make sure you
haven’t broken anything.
Then add the following classes. Put them in separate .java
files and
make each of them part of the ase.rental
package.
public abstract class Price {
public abstract int getPriceCode();
}
public class StandardPrice extends Price {
@Override
public int getPriceCode() {
return Car.STANDARD;
}
}
Implement LuxuryPrice
and NewModelPrice
in a similar way. Run the
tests to make sure that everything still works.
Now alter Car
so that it uses Price
and its subclasses internally,
while still maintaining an API based on integer price codes. The class
will need these fields and methods:
private Price price;
public int getPriceCode() {
return price.getPriceCode();
}
public void setPriceCode(int code) {
switch (code) {
case STANDARD:
price = new StandardPrice();
break;
case LUXURY:
price = new LuxuryPrice();
break;
case NEW_MODEL:
price = new NewModelPrice();
break;
default:
throw new IllegalArgumentException("Invalid price code");
}
}
Run the tests and make sure they all pass.
Now apply Move Method once again to move the getCharge()
method
from Car
to Price
. You can, in fact, retain a getCharge()
method in Car
, but it should delegate to the new version that is now
part of the Price
class:
public int getCharge(int daysRented) {
return price.getCharge(daysRented);
}
Finally, it is time to make the code polymorphic, using the Replace Conditional with Polymorphism refactoring.
This is done by taking each case of the switch
statement in turn and
creating an overriding method in the relevant subclass of Price
.
Thus, for the STANDARD
case, we need the following in StandardPrice
:
@Override
public int getCharge(int daysRented) {
return 30 * daysRented;
}
Make the change described above, then run the tests. If all is well,
make similar changes for the other cases in the switch
statement,
thereby overriding getCharge()
in LuxuryPrice
and NewModelPrice
.
Make sure all the tests pass.
When this is done, make getCharge()
in Price
an abstract method:
public abstract getCharge(int daysRented);
Run the tests again. If they all pass, commit the changes.
Similar steps can be carried out for the calculation of frequent renter points.
You can use Move Method to move the getFrequentRenterPoints()
method from Car
to Price
– once again retaining a version in Car
that simply delegates to the new method in Price
.
After this, you can override the method in subclasses of Price
where
necessary. Keeping in mind that the number of frequent renter points
is always 1 except for long rentals of a new model, this leads to the
following implementation of the method in Price
:
public int getFrequentRenterPoints(int daysRented) {
return 1;
}
Plus the following in NewModelPrice
:
@Override
public int getFrequentRenterPoints(int daysRented) {
if (daysRented >= 3) {
return 2;
}
return 1;
}
And that’s it!
Remember to run the tests again; you haven’t finished unless they all pass.
Before moving on from this exercise, you should take take the time to compare the final version of the system with the original version. Here’s the UML class diagram for the final version:
Final result of refactoring
You can compare this with the diagram you drew for the Creating Diagrams exercise.
Here’s a UML sequence diagram showing how a statement is generated for a customer (click on it to see it at full size):
Sequence diagram for statement generation
Note the pattern of delegation that takes place here: a call to the
getCharge()
method on a Rental
object delegates to the associated Car
object, which in turn delegates to the associated Price
object.
The final version of the system has more classes but a cleaner, more object-oriented structure. Refactoring the code made it easier to introduce alternative rental statement formats without resorting to code duplication. It also resulted in a more flexible approach to how the prices for different rentals are managed.
In future, it will be relatively easy to change the pricing structure and alter the rules for charging customers or awarding them frequent renter points without forcing massive changes to the codebase.
□