The Hidden Culprits of Code Decay: Change Preventers and How to Fix Them
Code quality is paramount in software development, yet developers often fall into the trap of becoming overly attached to their creations. This attachment can be detrimental, leading to resistance against necessary changes and refactoring. Refactoring is essential for maintaining code health, enhancing readability, and ensuring long-term maintainability. This article will explore three common "change preventers" in refactoring: Divergent Change, Shotgun Surgery, and Parallel Inheritance Hierarchies.
Change Preventers are a category of code smells that indicate potential obstacles or barriers that hinder the ability to change the codebase easily. These code smells often indicate design flaws or implementation choices that make the code less flexible and more resistant to modifications.
1- The Problem: Divergent Change
Divergent Change occurs when a single class or module suffers many different kinds of changes. When a particular class needs to be modified in several different ways for different reasons, it indicates the class likely has multiple responsibilities, a violation of the Single Responsibility Principle (SRP). This makes the class harder to maintain, understand, and extend.
class User {
String name;
DateTime dateOfBirth;
User(this.name, this.dateOfBirth);
String formatName() {
return name.toUpperCase();
}
String formatDateOfBirth() {
return "${dateOfBirth.day}-${dateOfBirth.month}-${dateOfBirth.year}";
}
void saveUser() {
// Save logic
}
void deleteUser() {
// Delete logic
}
}
Here, the User class is doing too much. It manages user data, formats user information for display, and handles persistence operations. If the formatting rules or the persistence mechanism change, the User class will require modifications for each of these aspects, leading to increased complexity.
Solution:
Refactor the class by separating data processing and report layout into different classes.
class User {
String name;
DateTime dateOfBirth;
User(this.name, this.dateOfBirth);
}
class UserFormatter {
String formatName(User user) {
return user.name.toUpperCase();
}
String formatDateOfBirth(User user) {
return "${user.dateOfBirth.day}-${user.dateOfBirth.month}-${user.dateOfBirth.year}";
}
}
class UserPersistence {
void saveUser(User user) {
// Save logic
}
void deleteUser(User user) {
// Delete logic
}
}
With this refactored approach, each class does one thing: User handles user data, UserFormatter handles formatting, and UserPersistence handles persistence. This makes the code more modular and easier to maintain.
2- The Problem: Shotgun Surgery
Shotgun Surgery occurs when a single change requires modifying many different classes. This typically happens when related data or behavior is dispersed across multiple classes, violating the principle of Cohesion.
Imagine we have a library management system where various classes need to update a book's availability:
class Borrower {
void borrowBook(Book book) {
book.isAvailable = false;
// Additional logic
}
}
class Librarian {
void returnBook(Book book) {
book.isAvailable = true;
// Additional logic
}
}
class Book {
String title;
bool isAvailable;
Book(this.title, this.isAvailable);
}
Here, both Borrower and Librarian classes need to change the isAvailable property of the Book class. If the logic for changing availability becomes more complex, all relevant classes need to be updated, scattering the change across the codebase.
Solution:
Encapsulate the availability logic within the Book class:
领英推荐
class Book {
String title;
bool isAvailable;
Book(this.title, this.isAvailable);
void borrow() {
isAvailable = false;
// Additional logic
}
void returnBack() {
isAvailable = true;
// Additional logic
}
}
class Borrower {
void borrowBook(Book book) {
book.borrow();
// Additional logic
}
}
class Librarian {
void returnBook(Book book) {
book.returnBack();
// Additional logic
}
}
Now, if the availability logic changes, it only needs to be updated in the Book class, reducing the number of modifications required.
3- The Problem: Parallel Inheritance Hierarchies
Parallel Inheritance Hierarchies occur when every time you create a subclass in one class hierarchy, you also have to create a corresponding subclass in another hierarchy. This duplication indicates an overly complex design and can lead to maintenance headaches.
Consider a payroll system with parallel hierarchies for employees and their corresponding data handling:
abstract class Employee {
double salary;
Employee(this.salary);
}
class Developer extends Employee {
Developer(double salary) : super(salary);
}
class Manager extends Employee {
Manager(double salary) : super(salary);
}
abstract class EmployeeData {
void save(Employee employee);
}
class DeveloperData extends EmployeeData {
@override
void save(Developer developer) {
// Save logic for Developer
}
}
class ManagerData extends EmployeeData {
@override
void save(Manager manager) {
// Save logic for Manager
}
}
Here, we have to create a corresponding DeveloperData for Developer and ManagerData for Manager. Creating new roles means duplicating classes in both hierarchies.
Solution:
Use a more generic design to eliminate the need for parallel hierarchies:
abstract class Employee {
double salary;
Employee(this.salary);
}
class Developer extends Employee {
Developer(double salary) : super(salary);
}
class Manager extends Employee {
Manager(double salary) : super(salary);
}
class EmployeeRepository {
void save(Employee employee) {
// Save logic for any Employee instance
}
}
With this approach, EmployeeRepository can save any Employee instance, removing the need for parallel subclasses and simplifying the design.
Identifying and addressing these Change Preventers through refactoring and architectural improvements can help make the codebase more flexible, adaptable, and easier to modify. It allows for smoother maintenance, evolution, and enhancement of the software.