9.6. Method-Level Refactoring: Replacing Dependencies With Seams¶
2. Increasing Complexity - As [a software] system evolves, its complexity increases unless work is done to maintain or reduce it.
—Lehman’s second law of software evolution
With the characterization specs developed in Section 9.3, we have a solid foundation on which to base our refactoring to repair the problems identified in Section 9.5. The term refactoring refers not only to a general process, but also to an instance of a specific code transformation. Thus, just as with code smells, we speak of a catalog of refactorings, and there are many such catalogs to choose from. We prefer Fowler’s catalog, so the examples in this chapter follow Fowler’s terminology and are cross-referenced to Chapters 6, 8, 9, and 10 of his book Refactoring: Ruby Edition (Fields et al. 2009). While the correspondence between code smells and refactorings is not perfect, in general each of those chapters describes a group of method-level refactorings that address specific code smells or problems, and further chapters describe refactorings that affect multiple classes, which we’ll learn about in Chapter 11.
Each refactoring consists of a descriptive name and a step-by-step process for transforming the code via small incremental steps, testing after each step. Most refactorings will cause at least temporary test failures, since unit tests usually depend on implementation, which is exactly what refactoring changes. A key goal of the refactoring process is to minimize t he amount of time that tests are failing (red); the idea is that each refactoring step is small enough that adjusting the tests to pass before moving on to the next step is not difficult. If you find that getting from red back to green is harder than expected, you must determine if your understanding of the code was incomplete, or if you have really broken something while refactoring.
Getting started with refactoring can seem overwhelming: without knowing what refactorings exist, it may be hard to decide how to improve a piece of code. Until you have some experience improving pieces of code, it may be hard to understand the explanations of the refactorings or the motivations for when to use them. Don’t be discouraged by this apparent chicken-and-egg problem; like TDD and BDD, what seems overwhelming at first can quickly become familiar.
As a start, Figure 9.15 shows four of Fowler’s refactorings that we will apply to our code. In his book, each refactoring is accompanied by an example and an extremely detailed list of mechanical steps for performing the refactoring, in some cases referring to other refactorings that may be necessary in order to apply this one. For example, Figure 9.16 shows the first few steps for applying the Extract Method refactoring. With these examples in mind, we can refactor Figure 9.6.
Long method is the most obvious code smell in Figure 9.6, but that’s just an overall symptom
to which various specific problems contribute. The high ABC score (23) of convert
suggests
one place to start focusing our attention: the condition of the if in lines 6–7 is difficult
to understand, and the conditional is nested two-deep. As Figure 9.15 suggests, a hard-to-read
conditional expression can be improved by applying the very common refactoring Decompose
Conditional, which in turn relies on Extract Method. We move some code
into a new method with a descriptive name, as Figure 9.17 shows. Note that in addition to making
the conditional more readable, the separate definition of leap_year?
makes the leap year
calculation separately testable and provides a seam at line 6 where we could stub the method
to simplify testing of convert
, similar to the example in the Elaboration at the end of
Section 8.4. In general, when a method mixes code that says what to do with code that says
how to do it, this may be a warning to check whether you need to use Extract Method in order
to maintain a consistent level of Abstraction.
1# NOTE: line 7 fixes bug in original version
2class TimeSetter
3 def self.convert(d)
4 y = 1980
5 while (d > 365) do
6 if leap_year?(y)
7 if (d >= 366)
8 d -= 366
9 y += 1
10 end
11 else
12 d -= 365
13 y += 1
14 end
15 end
16 return y
17 end
18 private
19 def self.leap_year?(year)
20 year % 400 == 0 ||
21 (year % 4 == 0 && year % 100 != 0)
22 end
23end
The conditional is also nested two-deep, making it hard to understand and increasing convert
’s
ABC score. The Decompose Conditional refactoring also breaks up the complex condition by
replacing each arm of the conditional with an extracted method. Notice, though, that the two
arms of the conditional correspond to lines 4 and 6 of the pseudocode in Figure 9.14, both
of which have the side effects of changing the values of d
and y
(hence our use of !
in the
names of the extracted methods). In order for those side effects to be visible to convert
, we
must turn the local variables into class variables throughout TimeSetter
, giving them more
descriptive names @@year
and @@days_remaining
while we’re at it. Finally, since @@year
is now
a class variable, we no longer need to pass it as an explicit argument to leap_year?
. Figure
9.18 shows the result.
1# NOTE: line 7 fixes bug in original version
2class TimeSetter
3 ORIGIN_YEAR = 1980
4 def self.calculate_current_year(days_since_origin)
5 @@year = ORIGIN_YEAR
6 @@days_remaining = days_since_origin
7 while (@@days_remaining > 365) do
8 if leap_year?
9 peel_off_leap_year!
10 else
11 peel_off_regular_year!
12 end
13 end
14 return @@year
15 end
16 private
17 def self.peel_off_leap_year!
18 if (@@days_remaining >= 366)
19 @@days_remaining -= 366 ; @@year += 1
20 end
21 end
22 def self.peel_off_regular_year!
23 @@days_remaining -= 365 ; @@year += 1
24 end
25 def self.leap_year?
26 @@year % 400 == 0 ||
27 (@@year % 4 == 0 && @@year % 100 != 0)
28 end
29end
As long as we’re cleaning up, the code in Figure 9.18 also fixes two minor code
smells. The first is uncommunicative variable names: convert
doesn’t describe very
well what this method does, and the parameter name d
is not useful. The other is
the use of “magic number” literal constants such as 1980 in line 4; we apply Replace
Magic Number with Symbolic Constant (Fowler chapter 8) to replace it with the more
descriptive constant name STARTING_YEAR
. What about the other constants such as 365
and 366? In this example, they’re probably familiar enough to most programmers to
leave as-is, but if you saw 351 rather than 365, and if line 26 (in leap_year?
) used
the constant 19 rather than 4, you might not recognize the leap year calculation for
the Hebrew calendar. Remember that refactoring only improves the code for human readers;
the computer doesn’t care. So in such cases use your judgment as to how much refactoring is enough.
In our case, re-running flog on the refactored code in Figure 9.18 brings the ABC score
for the newly-renamed calculate_current_year
from 23.0 down to 6.6, which is well below
the suggested NIST threshold of 10.0. Also, reek now reports only two smells. The first is
“low cohesion” for the helper methods peel_off_leap_year
and peel_off_regular_year
; this
is a design smell, and we will discuss what it means in Chapter 11. The second smell is
declaration of class variables inside a method. When we applied Decompose Conditional and
Extract Method, we turned local variables into class variables @@year
and @@days_remaining
so that the newly-extracted methods could successfully modify those variables’ values. Our
solution is effective, but clumsier than Replace Method with Method Object (Fowler chapter 6).
In that refactoring, the original method convert
is turned into an object instance (rather
than a class) whose instance variables capture the object’s state; the helper methods then
operate on the instance variables.
1# An example call would now be:
2# year = TimeSetter.new(367).calculate_current_year
3# rather than:
4# year = TimeSetter.calculate_current_year(367)
5class TimeSetter
6 ORIGIN_YEAR = 1980
7 def initialize(days_since_origin)
8 @year = ORIGIN_YEAR
9 @days_remaining = days_since_origin
10 end
11 def calculate_current_year
12 while (@days_remaining > 365) do
13 if leap_year?
14 peel_off_leap_year!
15 else
16 peel_off_regular_year!
17 end
18 end
19 return @year
20 end
21 private
22 def peel_off_leap_year!
23 if (@days_remaining >= 366)
24 @days_remaining -= 366 ; @year += 1
25 end
26 end
27 def peel_off_regular_year!
28 @days_remaining -= 365 ; @year += 1
29 end
30 def leap_year?
31 @year % 400 == 0 ||
32 (@year % 4 == 0 && @year % 100 != 0)
33 end
34end
Figure 9.19 shows the result of applying such a refactoring, but there is an important
caveat. So far, none of our refactorings have caused our characterization specs to fail,
since the specs were just calling TimeSetter.convert
. But applying Replace Method With
Method Object changes the calling interface to convert
in a way that makes tests fail.
If we were working with real legacy code, we would have to find every site that calls convert
,
change it to use the new calling interface, and change any failing tests accordingly. In a
real project, we’d want to avoid changes that needlessly break the calling interface, so we’d
need to consider carefully whether the readability gained by applying this refactoring would
outweigh the risk of introducing this breaking change.
Self-Check 9.6.1. Which is not a goal of method-level refactoring: (a) reducing code complexity, (b) eliminating code smells, (c) eliminating bugs, (d) improving testability?
(c). While debugging is important, the goal of refactoring is to preserve the code’s current behavior while changing its structure.