Exercise 3: Introduction to Refactoring

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.

Preparation

  1. Download the code for this exercise and unzip the archive. This should create a directory named exercise3.

  2. 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.

  3. 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.

Simplifying Statement Generation

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.

  1. 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.

  2. 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.

  3. 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.)

Adding a New Feature

  1. 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.

  2. Optional: The API of these classes has changed a little, so some more unit tests are ideally needed. Add these now if you wish.

Introducing Polymorphism

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.

  1. 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.

  2. 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:

    UML class diagram

    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.

    UML class diagram

    Use of State design pattern

  3. 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.

  4. 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);
    }
    
  5. 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.

  6. 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.

Final Thoughts

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

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

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.