By Christian Clausen Flexibility is essential for long-lived codebases, however, it comes at a price. It requires effort to maintain flexibility and improperly-implemented flexibility can actually make a codebase harder to maintain. In this article, I show how good flexibility can be extracted from the structure of our code with minimal effort. But before we get into that, let’s discuss what flexibility in a codebase means. |
Take 40% off Five Lines of Code by entering fccclausen into the discount code box at checkout at manning.com.
The green reed which bends in the wind is stronger than the mighty oak which breaks in a storm.
–Confucius
Definition: Flexibility
Flexibility is an expression of how easily a codebase accepts desired changes. If developers have a good sense of what changes are coming in the future, they can prepare for these changes. This makes changes easy to implement and makes the codebase flexible. Some changes may fall outside of what developers have prepared for, and may require major adjustments or even rewrites of big parts of the codebase. Flexibility, therefore, can be thought of as a ratio of easy changes to difficult ones.
Flexibility is inevitably linked to something called coupling. Two components are coupled if changing one requires changing the other. Let me illustrate this with an example of tight coupling vs loose coupling.
Before |
After |
```Typescript for (let i = 0; i < as.length; i++) as[i] += 2; ``` |
```Typescript as = as.map(x => x + 2); ``` |
In the tight-coupling example, we can infer that the variable `as` is an array, because of the `.length` and the direct indexing. We say the code is coupled to `as` being an array, because if we wanted to change the data structure we would need to change this code. On the other hand, the loose-coupled example has only one coupling to `as`: a `map` function. This is a looser coupling because most data structures satisfy it, so we can change the type of `as` without changing this code at all.
Pros and cons of flexibility
With experience comes nuances, and throughout my career I have learned that nothing is universally good or bad. Everything has its use case, although some are more common than others. This is also the case for flexibility. Depending on the circumstances, it can be good or bad. Let’s discuss some of the important pros and cons.
The advantage of flexible code is that it allows us to implement changes faster, and also fix bugs faster. If we couple things just right, then fixing a bug in one place will fix it everywhere. Similarly, if two functions share a common base, it is typically easy to implement a third version on top of the same base too. This is the motivation for the famous coding heuristic: Don’t Repeat Yourself (DRY).
On the other hand, as I explained in the definition section, flexibility has a lot to do with upcoming changes. Since the future is unpredictable, we risk spending a lot of effort preparing code for changes that never come. This is bad. A bigger issue, however, is when our couplings or preparations actually make future changes harder to implement. Let’s look at a common example of this.
We are working on a library system to keep track of books. We predict that, in addition to regular paper books, the library will probably also include ebooks in its catalogue one day. Therefore, we add a boolean column `is ebook` to the database-table for books. We have now prepared the code to easily accept the change of supporting ebooks. However, in reality, the next change that will be needed is the ability to register the books’ physical location in the library. Ebooks won’t have this attribute, meaning we have to spend extra effort to work around them.
This situation is very common, which is the reason I recommend avoiding any premature generalization, i.e. unnecessary implementation of flexibility. So, how do we decide the appropriate level of flexibility?
Lifting flexibility out of code
As I hinted in the beginning, the right balance lies in the structure of the code. Now, I’ll show how to take advantage of this fact, and expose the appropriate flexibility for this particular code through small safe steps. Imagine we have two functions, then by unifying them we will get the flexibility that we want. The two functions find the maximum element and the sum of an array, respectively.
```Typescript function maxArr(arr: number[]) { let maxSoFar = -Infinity; for (let i = 0; i < arr.length; i++) { if (maxSoFar < arr[i]) maxSoFar = arr[i]; } return maxSoFar; } function sumArr(arr: number[]) { let total = 0; for (let i = 0; i < arr.length; i++) { total += arr[i]; } return total; } ```
First, we determine that these two have the same domain, so we should encapsulate them in a class. This also allows us to simplify their names by removing the `Arr`.
Before |
After |
```Typescript function maxArr(arr: number[]) { let maxSoFar = -Infinity; for (let i = 0; i < arr.length; i++) { if (maxSoFar < arr[i]) maxSoFar = arr[i]; } return maxSoFar; } function sumArr(arr: number[]) { let total = 0; for (let i = 0; i < arr.length; i++) { total += arr[i]; } return total; } ``` |
```Typescript class Arr { max(arr: number[]) { let maxSoFar = -Infinity; for (let i = 0; i < arr.length; i++) { if (maxSoFar < arr[i]) maxSoFar = arr[i]; } return maxSoFar; } sum(arr: number[]) { let total = 0; for (let i = 0; i < arr.length; i++) { total += arr[i]; } return total; } } ``` |
The two methods have a common argument, so we can further simplify by extracting this into a field, and pass it to the constructor.
Before |
After |
```Typescript class Arr { max(arr: number[]) { let maxSoFar = -Infinity; for (let i = 0; i < arr.length; i++) { if (maxSoFar < arr[i]) maxSoFar = arr[i]; } return maxSoFar; } sum(arr: number[]) { let total = 0; for (let i = 0; i < arr.length; i++) { total += arr[i]; } return total; } } ``` |
```Typescript class Arr { private arr: number[]; constructor(arr: number[]) { this.arr = arr; } max() { let maxSoFar = -Infinity; for (let i = 0; i < this.arr.length; i++) { if (maxSoFar < this.arr[i]) maxSoFar = this.arr[i]; } return maxSoFar; } sum() { let total = 0; for (let i = 0; i < this.arr.length; i++) { total += this.arr[i]; } return total; } } ``` |
We want these methods to be as similar as we can get them when we start the unification. Therefore, we begin by making three trivial changes. We replace the `if` part of `max` with a function call, and we replace `+=` in `sum` with its long form. We also rename the return variable to `result`.
Before |
After |
```Typescript class Arr { // ... max() { let maxSoFar = -Infinity; for (let i = 0; i < this.arr.length; i++) { if (maxSoFar < this.arr[i]) maxSoFar = this.arr[i]; } return maxSoFar; } sum() { let total = 0; for (let i = 0; i < this.arr.length; i++) { total += this.arr[i]; } return total; } } ``` |
```Typescript class Arr { // ... max() { let result = -Infinity; for (let i = 0; i < this.arr.length; i++) { result = Math.max(result, this.arr[i]); } return result; } sum() { let result = 0; for (let i = 0; i < this.arr.length; i++) { result = result + this.arr[i]; } return result; } } ``` |
There are no more trivial ways to make the methods more similar, so now we begin the unification. We start by copying one of them, and then we put holes (i.e. underscores) wherever it differs from the other one. Doing so results in this:
Max |
Sum |
Common structure |
```Typescript max() { let result = -Infinity; for (let i = 0; i < this.arr.length; i++) { result = Math.max(result, this.arr[i]); } return result; } ``` |
```Typescript sum() { let result = 0; for (let i = 0; i < this.arr.length; i++) { result = result + this.arr[i]; } return result; } ``` |
```Typescript ___() { let result = ____; for (let i = 0; i < this.arr.length; i++) { result = ______________; } return result; } ``` |
This code has three holes; that is, three places where the seed methods differ in non-trivial ways. One of these, the initial value of the return variable, is easy to fill by introducing a new parameter: `start`. In both seed methods, the return variable is a `number`, so we use that as the new parameter’s type in the spirit of avoiding unnecessary generality.
Before |
After |
```Typescript ___() { let result = ____; for (let i = 0; i < this.arr.length; i++) { result = ______________; } return result; } ``` |
```Typescript ___(start: number) { let result = start; for (let i = 0; i < this.arr.length; i++) { result = ______________; } return result; } ``` |
The middle hole is a bit more advanced, since the methods do not simply have a static value there. To remedy this, we introduce an interface to represent any of the two operations in the seed methods. Notice that in both `sum` and `max` we perform an operation on two values: the return variable `result` and the current list element `arr[i]`. As before, we add a new parameter, `op`, to fill the hole.
Before |
After |
```Typescript class Arr { // ... ___(start: number) { let result = start; for (let i = 0; i < this.arr.length; i++) { result = ______________; } return result; } } ``` |
```Typescript interface Operator { calc(result: number, elem: number): number; } class Arr { // ... ___(start: number, op: Operator) { let result = start; for (let i = 0; i < this.arr.length; i++) { result = op.calc(result, this.arr[i]); } return result; } } ``` |
The final hole is trivial to fill. We simply need to give this new generalized method a name. As it turns out, this generalization is very common and is known by different names in different languages. In Javascript (and therefore also Typescript) is it called `reduce`. Because we lifted this method out of `max`, we can express `max` as a simple call to `reduce`.
Before |
After |
```Typescript class Arr { // ... max() { let result = -Infinity; for (let i = 0; i < this.arr.length; i++) { result = Math.max(result, this.arr[i]); } return result; } } ``` |
```Typescript class MaxOperator implements Operator { calc(result: number, elem: number) { return Math.max(result, elem); } } class Arr { // ... max() { return this.reduce(-Infinity, new MaxOperator()); } } ``` |
Similarly, we can express `sum` as a simple call to `reduce`.
Before |
After |
```Typescript class Arr { // ... sum() { let result = 0; for (let i = 0; i < this.arr.length; i++) { result = result + this.arr[i]; } return result; } } ``` |
```Typescript class SumOperator implements Operator { calc(result: number, elem: number) { return result + elem; } } class Arr { // ... sum() { return this.reduce(0, new SumOperator()); } } ``` |
When an interface has only one method, as in the case of `Operator`, we can take advantage of the modern arrow notation. This does not affect the functionality, but we can skip naming the classes, and therefore also the interface.
Before |
After |
```Typescript class Arr { // ... reduce(start: number, op: Operator) { let result = start; for (let i = 0; i < this.arr.length; i++) { result = op.calc(result, this.arr[i]); } return result; } max() { return this.reduce(-Infinity, new MaxOperator()); } sum() { return this.reduce(0, new SumOperator()); } } ``` |
```Typescript class Arr { // ... reduce(start: number, op: (a: number, b: number) => number) { let result = start; for (let i = 0; i < arr.length; i++) { result = op(result, arr[i]); } return result; } max() { return this.reduce(-Infinity, (a, b) => Math.max(a, b)); } sum() { return this.reduce(0, (a, b) => a + b); } } ``` |
The transformation the code has undergone is extraordinarily powerful. This is one of the most powerful refactoring patterns, and one of my favorites. Its formal name is Introduce Strategy Pattern. The most powerful part is the idea of using a parameter to extract arbitrary code, which itself has a fancy name: dependency injection.
NOTE: Dependency injection should not be confused with dependency injection containers, which are related to instantiation in some modern frameworks such as ASP.NET MVC and Spring Boot. Dependency injection is awesome, but dependency injection containers are bad because they delocalize invariants. That’s a topic for another article, however.
Conclusion
I hope you have enjoyed this unusual presentation of Introduce Strategy Pattern, and a few other refactoring patterns. You will find deeper explorations of these and many more practices to improve your code in my book Five Lines of Code (available on Amazon.com and Manning.com). You can also find rules for when and how to apply them, making refactoring even the most complicated codebases a breeze.
Thanks for reading!