Sometimes you want to change from using a basic type (such as Int or String) to a type that contains the basic type as a value. (See Refactoring in the References.) The change itself isn’t hard, but then you have to deal with the consequences: there are a lot of places that use the basic type that really should use the new type.
Why introduce a new type? There are several forces that might push you that way:
- The same basic type is used for several different things, and you’re getting confused about which is which.
- A new type can reduce duplicate code: all the places that manipulate a value in similar ways can call methods on a single class.
- A wrapped type can provide a place to put related information. For example, rather than just handing an order number around and looking everything up, you can create an Order object that keeps track of key data.
The Problem
You’re faced with changing code that looks like this:
Row 1 shows a number of places where the value was defined – perhaps read in or created. There are many of these, and also many places that have nothing to do with the new type (that we can ignore).
In row 2, the created values may be passed through one or more layers, down to row “n”, where the values are used. (We don’t really have all uses at the exact same level, but they’re all some distance from the original definition.) Depending on the situation, “use” may just mean comparing or looking up in a map, or it may require getting back the original basic value and using that.
A Non-Scalable Approach (to Avoid)
The simplest approach (in some sense) is to dive in, start changing all the methods’ parameters to match the new data type, and don’t stop until you’ve got everything consistent again.
The problem with this approach is that part in the middle – “don’t stop” – your system may have many places that need to be changed. Instead, we want to find incremental approaches that let us change a little at a time.
The Challenge
A method that has a parameter taking the original basic type looks like this (the one on the bottom):
Some of its callers may define/create the value, others are passthrough. The method we’re focused on may pass the value through or use it; it doesn’t matter which.
It doesn’t matter if this method is near the top, middle, or bottom: we’ve decided that its callers should have the new type. How are we going to make that happen, and how are we going to keep track of what to do next?
We’ll look at three approaches; each might make sense under different circumstances.
Assumptions
- You’re working in a typed language. (If not, you have to be much more careful.)
- NewType is the type we’re changing to.
- It has a constructor that takes the original basic type.
- It has a toRepresentation() method to get back the original value. (The name doesn’t matter.)
Approach 1 – Manual [barely acceptable]
To make the change manually:
- Change the signature to take the new type:
func f(…, i: int, …) … => func f(…, n: NewType, …) …
You’ll temporarily have syntax errors wherever “i” was used, and wherever the function is called.
2. Add a new first line where you assign the value from NewType to a local variable named as the original:
var i = n.toRepresentation()
3. Fix each call site by wrapping its argument in the new type:
… obj.f(…, some-value, …) => … obj.f(…, NewType(some-value), …)
Note: If the call-site already has access to the value in the new type, it should use that rather than create a new one.
4. You’re done with this method when all call-sites are wrapped. Tests should all pass.
This approach is reasonably efficient if this method only has a handful of callers, but slow (and boring!) if it has a lot of callers. Plus, it leaves all the parts on the floor until you fix all call-sites for a given method.
Approach 2 – Automated Wrapping
Assuming you have an IDE that can inline and extract methods, you can let the tool do much of the work. If your IDE supports “Change Signature”, see if it can automatically wrap an argument in a new type. If not, you can follow the steps below.
- Hand-alter the name of the parameter to an unused name.
func f(…, i: int, …) … => func f(…, i99: int, …) …
You will have syntax errors at each use of the original name.
2. Add two statements at the start of the method:
var newType = NewType(i99) var i = newType.toRepresentation()
All the syntax errors should go away. Tests should pass.
3. Automatically extract a method starting from “var i…” to the end of the method. Call it the same name as the original function (if your language allows overloading). It should have all the original arguments, except the one with the new type. You may choose to adjust the parameter order during the change.
4. Depending on your tool, you probably want to inline newType, so your original function looks like this:
func f(…, i99: int, …) … { return f(NewType(i99)) }
(Inlining the call may help the next step generate better-looking code.)
5. Inline and delete the original function. Each original call site now constructs the new object.
6. You’ll probably want to go to each call site and make sure it’s not doing the unwrap/re-wrap dance.
The benefit of this approach is that it lets you handle a large number of callers at once. The downside is that you don’t have control over how much “debt” you create.
Approach 3 – Semi-Automatic Wrapping
This final approach combines the earlier approaches.
1. Follow steps 1-3 of the previous approach. You now have the original method, with all its callers, and the new method, only called by the original method.
2. Update callers one at a time: Check whether it has a value of the new type (which can just be used to call the new method), or if you need to wrap the argument to let it call the new method. You can test after updating each method.
3. When there are no more callers of the original method, delete it.
This approach is definitely slower, more like the first approach, but it lets you stop in the middle: everything either calls the old method or the new one, so your tests (and system) never stop working.
You can also control which changes are made – you can focus on one end-to-end path with the new type, moving on to the next method call before you’ve fully eliminated the original method.
What’s Next? Top-Down or Bottom-Up?
You can work up from here: each call site is either a definition or a passthrough. If it’s a definition, assuming you didn’t unwrap and re-wrap it, you’re done. If it’s a passthrough, you’re facing the same situation, and can fix it the same way (change the signature and wrap the call sites).
You can work down from here: each reference is a use, or passed through to another method. A use requires no further work. A passthrough takes you to another method, and again you’re facing the same situation and solve it the same way.
I don’t think there’s an inherent benefit to either direction – top-down or bottom-up. You could even have a case where middle-out makes the most sense – perhaps at a function that’s a bottleneck for information flowing further down. I default to top-down.
One way to keep track of which methods are done or still need work is to manage a stack or queue (on paper or in a separate document). When you finish a method, add its callers to the list, and the methods it calls where it passes down the value.
If you lose track of where you are: you may have to find all uses of the new type, and figure out which ones pass along the representation instead of the new type.
Or, you can look for places that call newType.toRepresentation()
. If this is a place that gets the value back out to use it, then no further work is needed. If it’s a place where we are re-wrapping, the we just need to use the already-wrapped value.
Conclusion
Wrapping a type is a fairly simple change, but if you wait too long, you end up having to change many, many places. We looked at a few systematic ways to pass a wrapped value around: manual, automatic, or combined. Once the new type is propagated, you can extend the type with additional data, and/or reduce duplication where code was manipulating the basic value.
References
Fowler, Martin. Refactoring: Improving the Design of Existing Code. 1999. (Java examples.)
Fowler, Martin. Refactoring, 2/e. 2018. (JavaScript examples.)