Martin Fowler writes, “Refactoring is the process of changing a software system in such a way that it does not alter the external behavior of the code yet it improves its internal structure.” [Refactoring, 1999, p. xvi] But what does external behavior mean?
Programming languages have different approaches to what they mean (their behavior). In early days, the meaning was “whatever it does”. Over time, programming languages have moved to more formal definitions.
Formal definitions abstract away some things, and often describe the effects of a program in terms of some model of what happens when it runs.
A simple, single-threaded program that only does limited output can be described in a relatively simple way. Realistic programs are harder to explain.
We’ll look at situations where simple models are not enough. These are edge cases for refactoring, where simplistic definitions of behavior aren’t enough to let you refactor safely.
Reflection
Reflection is the ability of a program to find out about itself. For example, you can print a list of the method names associated with an object. However, if you rename one of the methods (a basic refactoring), the program will print something different.
Dynamic Class Loading
Similarly, renaming a class sounds harmless, but it changes a program’s behavior if you are loading that class by name.
It’s not just the dynamic loading that matters: you also have to be careful when class names appear in (non-code) configuration files.
Performance
Formal language definitions don’t usually define performance. (At most, they might commit to a particular order of magnitude, e.g., in C++ a vector promises to insert a value in O(n) time.)
Real programs have access to the computer’s clock, and thus can detect their own performance.
Consider this program fragment:
time1 := now() x := 3 * a + 3 * b time2 := now() if (time2 - time1 < epsilon) { print ("fast") }
If you were to change the assignment of x
, using the distributive law:
x := 3 * (a + b)
you might affect the timing, and print something different.
The choice of a compiler flag (to optimize or not) might make an equivalent change behind the scenes.
Concurrency
Refactorings that are valid in sequential code can create problems in concurrent code. One place this happens is shared state:
x := 1 calculate foo (doesn’t mutate x; doesn’t involve y) y := x
If all we had were sequential code, we might refactor it to:
x := 1 y := 1 calculate foo (doesn’t mutate x; doesn’t involve y)
But imagine concurrent code where we had this going on at the same time:
x := 2
Our refactoring forces the y
value to be 1 always, where the original could sometimes be 2.
This is an odd situation. With threading, we can't depend on getting a particular interleaving of threads, so the refactoring is valid. But in practice, we might be surprised if y
came out 1 every time; it would be a change of behavior.
The Text
You might think that while a method or class name can be publicized, the name of a local variable shouldn't matter, or an extra space between tokens.
But it can: some programs are quines: their job is to reproduce the text of their own code. In these cases, any change (including renaming a variable) can cause the program to change behavior.
That may sound academic, but the text of a program can matter in more everyday cases. Consider a language such as JavaScript, where the text of a program is sent to be run. We can imagine a system that relies on a checksum of that text for security purposes. In that case, even a tiny text change can break the code.
Summary: Avoiding Changes in Behavior
To be as safe as possible, your refactorings should:
- Never change field, method, or class names
- Never change anything that might affect performance
- Never change code involving threads or concurrency
- Never change the text of a program
These are of course ridiculous limitations. We're not going to stop refactoring (or changing code) because of them.
The bottom line is that you must have some awareness of these issues and when you're approaching the danger zone. Your refactoring tool (or habits) might not check all the edge conditions.
So think twice before you just make a change: are any tricky refactoring boundaries involved? Exercise extra care if so!