Appearance
Refactoring is the process of improving on the implementation of an existing design by restructuring the source code to alleviate existing shortcomings and ease future development. Conceptually, refactoring tasks do not change the semantics of the program (e.g., add new features or fix defects) but instead make the code more amenable to future feature additions and defect fixes. Most refactoring operations consolidate duplicate code, reduce coupling, move elements to make them more cohesive, or otherwise improve the understandability and maintainability of the system. Refactoring is one mechanism used to handle emergent design: as a system evolves, we learn more about what kinds of abstractions are most appropriate. Through refactoring, we can incorporate these emergent abstractions into a system's design.
Development teams often think of refactorings as a mechanism for paying technical debt. Technical debt is a metaphor for thinking about how development decisions can influence the long-term viability of a software system. The technical debt metaphor acknowledges that while quick solutions are often attractive (and the right thing to do), they accrue debt in the overall maintainability of the system by degrading or obfuscating the original design. Technical debt often triggers developers to think about refactoring in one of three main situations:
- When adding a new feature is much harder than expected.
- When fixing a bug (that should be cohesive) requires changes that are scattered across the system.
- When performing a code review for a simple feature/defect that requires complex changes that are hard to understand.
These all represent changes that should be easy, but it turns out to be challenging. The technical debt metaphor encourages longer-range thinking about system health while allowing the quick path to be taken sometimes when it is deemed to be worth the future risk.
One way to avoid extraneous refactorings is to think about the 'rule of three.' This says that the first time you add a new feature, you just do it the simplest way you can. The second time you need to make the same kind of change, you do it again (but you cry inside and make a mental note of the duplicate change). The third time you encounter the same change, you refactor. This prevents preemptive refactoring, which can sometimes make the system harder to understand, even if it makes it easier to extend in the future (this can be thought of as just-in-time abstraction).
How to refactor
Refactoring is a risky activity: the internal structure of the system is being changed (sometimes drastically) without adding any new features or fixes. From a customer's perspective, this means refactoring is all risk and no reward as the direct beneficiaries of a refactoring are usually only the development team itself. Effective testing is crucial for being able to confidently perform refactorings. The typical refactoring process looks like the following:
- Identify the property of the code you want to improve and the transformations required to solve the problem.
- Run the test suite to ensure the system is fully working before the change (sometimes saving the output/logs of the test suite can also be helpful).
- Perform the refactoring.
- Run the full test suite again and ensure the system is functioning the same as before the refactoring.
Downsides of refactoring
Beyond customer risk, there are other negative consequences for performing a refactoring:
- Refactorings can impair existing developer mental models of the system (wide-ranging refactorings can impact many people) and can make the system harder to understand if they add new abstraction layers.
- Refactorings are expensive since they take developer time that could otherwise be spent developing new features.
- It is easy to get carried away when refactoring (similar to the second-system effect). Instead of a simple refactoring, a developer can engage in a refactoring campaign that can become much larger than is necessary to provide the original intended benefits.
Kinds of Refactors
Martin Fowler has documented the most common refactorings in his comprehensive book. While he details dozens of these, some of the most common refactorings are:
- Rename (class/field/method).
- Move (class/field/method).
- Extract class/interface/method.
- Push down/pull up field/method.
- Replace magic number/string with constant.
- Replace inheritance with delegation.
For example, the code below evolved from being just about printing a value to also computing it. To address this, the developer decided to refactor it so the method has a more specific responsibility and performs an extract method refactoring; before the code looked like this:
typescript
class Invoice {
public printOwing() {
printBanner();
let owing = 0;
for (const t of this.tasks) {
owing += t.getValue();
}
// print details
Log.info("amount: " + owing);
}
}After, a private method getOwing has been extracted and printOwing has been simplified to:
typescript
class Invoice {
public printOwing() {
printBanner();
// print details
Log.info("amount: " + this.getOwing());
}
private getOwing() {
let owing = 0;
for (const t of this.tasks) {
owing += t.getValue();
}
return owing;
}
}In another instance, a developer realizes upon adding a new feature (CourseProcessor) that it shares similar structure to another existing feature and having both complicated the client code.
typescript
class RoomsParser {
public parseRooms(id: string, zip: JSZip) {
// ...
}
}
class CourseProcessor {
public processCourses(id: string, zip: JSZip) {
// ...
}
}To simplify the client, the developer performs three refactorings. First, they perform an extract interface refactoring. Then they add a return type to parse, and finally they perform a rename refactoring on CourseProcessor:
typescript
interface IParser {
parse(id: string, zip: JSZip): boolean;
}
class RoomsParser implements IParser {
public parse(id: string, zip: JSZip): boolean {
// ...
}
}
class CourseParser implements IParser {
public parse(id: string, zip: JSZip): boolean {
// ...
}
}Each refactor is motivated by and affects cohesion, coupling, or both. For example, in the Invoice example, the main problem is cohesion: printOwing() executes two concepts (printing the banner and calculating owing that linked by logic because they should happen at the same time, and timing, because the banner should be printed before the owing). However, the calculation of the owing only owes cohesion to the printing of the owing and not the printing of the banner. This motivates our refactor, which is to extract the calculation of the owing to its own method and improve our cohesion.
The refactoring In the IParser, the main problem is coupling. The refactoring creates RoomsParser and CourseParser which are linked to any client code by a connascence of Type: any client code now only has a bond to the IParser type, instead of each individual parser's code. Therefore, we can say that the coupling has improved (become less tightly coupled).
Code Readability
References
- Martin Fowler's technical debt discussion.