Look — I might just be a types person. I admit it! Mea maxima culpa! Strong static types are a major factor in how quickly I learn a programming language and how confidently I come to use it. There are myriad other factors, but many of those factors themselves rely on static types (or a very convincing simulation thereof).
Thankfully, I’m in okay company. Today I revisited a Jonathan Blow stream in which Jon pauses to gloat about how much C++’s static types simplify his work.
You just break your program and then you fix all the compile errors. When the compile errors are fixed, you know your program works again. This is something that these dynamic languages don’t let you do, and it’s actually one of the most powerful programming techniques. No troll.
We’re just doing a bunch of not-very-interesting but also not-very-scary drudge work, but we’re using this to migrate us from one place in the space of all possible programs to another one, by a safe route where we don’t smash everything up.1
Jon demonstrates one such route: rooting out all inner usage of an outer-scope variable by defining a conflicting variable with an incompatible type (2:46).
I also lean on statically typed languages to guide my refactoring. That reliance is especially pronounced when I’m “lifting interfaces” out of single-class implementations, a precursor step to introducing alternative implementations or test doubles. My one weird trick for three-step lifted interfaces is… broken partial renames.
You’ve implemented a cute little Logger
that writes to a
file. Your application — main
— passes it around as a value
(to functions foo
and bar
).
package main
import "os"
type Logger struct {
file *os.File
}
func (l Logger) Info(message string) {
l.file.WriteString("[INFO] " + message + "\n")
}
func (l Logger) Warning(message string) {
l.file.WriteString("[WARN] " + message + "\n")
}
func main() {
var f *os.File
logger := Logger{file: f}
foo(logger)
bar(logger)
}
func foo(l Logger) {
l.Info("Called foo")
bar(l)
}
func bar(l Logger) {
l.Warning("Called bar")
}
Ah, single-implementation serenity. go build demo.go
works, foo
and bar
chug cheerfully along
logging things. Imagine a happy little green diagram — the
Logger
type and the three ways this code uses it:
It’s almost too serene. For one reason or another, you want more from this program.
Maybe you want to selectively use a different logger with
the same main
routine, one that routes your messages to a
log server or stdout
.
Maybe you need a mock logger for your unit tests, to confirm
foo
and bar
emit key log messages without
simultaneous tests racing for the file.2
If you want main
to work with a variety of loggers
interchangeably, you have to drive an abstracting wedge between the
implementation and its users: an interface. A refactor like this has
three goals:
Lift an interface capturing the existing logger’s public surface
(Info
and Error
functions).
Update the file-writing logger to implement that interface.3
Update functions to ambivalently receive any implementation of the logger interface — a logger for tests, a logger for your log server, whatever!
Just like Jon Blow’s C++ compiler, the Go compiler won’t let you build confused code where you’re accidentally passing one type in lieu of another. In theory you could refactor manually and unsystematically, and when eventually something builds you’ve likely achieved your goals. Like Jon, you’d be using type safety “to migrate […] from one place in the space of all possible programs to another one, by a safe route.”
Of course, some routes are shorter than others. I take this one because it’s short.
Seriously, break everything. Maybe you proceed cautiously when you adding new functionality to a program — incrementally adding bits and pieces, careful to resolve issues whenever you introduce them. Refactoring working code is a fundamentally different flow: break the build confidently and selectively, then let the type system guide you back to a correct program.
Step 1 is to rename Logger
to something (almost
anything!) else. FileLogger
is a fine name here.
The key is to rename it locally, not to reach for an IDE-automated
“Refactor → Rename” that updates every reference to Logger
.
Let those references break; you want them to be broken.
Break everything.
// FileLogger is Logger after a rename; otherwise unchanged.
type FileLogger struct { … }
func (l FileLogger) Info(message string) { … }
func (l FileLogger) Warning(message string) { … }
func main() {
var f *os.File
logger := Logger{file: f} // 🚨 undefined: Logger
foo(logger)
bar(logger)
}
func foo(l Logger) { … } // 🚨 undefined: Logger
func bar(l Logger) { … } // 🚨 undefined: Logger
Where it once succeeded, go build main.go
lists compiler
errors (undefined: Logger
) at each broken reference to the
old name. FileLogger
is fine: unused, but internally
consistent. Everything else needs you to define a type called
Logger
— the very definition you just removed!
In his example, Jon Blow uses the compiler’s list of broken references as a checklist for manual corrections: he investigates the compiler errors one by one, resolves them sensibly, and then he’s done (“safe route”).
Your safe route is slightly different. The rename doesn’t just break everything to yield a checklist; it also opens space for a new type, one that will clear most of that checklist at once.
Instead of getting bogged down in the compiler error nitty-gritty,
focus on the goal of the refactor: you’re driving an interface, like a
wedge, between FileLogger
and its callers.
Step 2 is to introduce that interface — in this example, it’s just
the FileLogger
methods Info
and
Warning
.
Here’s the trick: call the new interface what FileLogger
used to be called.
// Logger is the lifted interface.
type Logger interface {
Info(message string)
Warning(message string)
}
// FileLogger implicitly implements Logger: it defines Info and Warning.
type FileLogger struct { … }
func (l FileLogger) Info(message string) { … }
func (l FileLogger) Warning(message string) { … }
func main() {
var f *os.File
logger := Logger{file: f} // 🚨 invalid composite literal type Logger
foo(logger)
bar(logger)
}
func foo(l Logger) { … } // ✅ Fixed!
func bar(l Logger) { … } // ✅ Fixed!
By reoccupying the vacated typename Logger
, lifting the
interface next to the implementation instantly resolves the
undefined: Logger
build errors.
You didn’t have to track down foo
and bar
,
much less edit them — they just receive interfaces now (tada!) because
the name of the type they already received, Logger
, now
references a compatible interface. This is the chief advantage of the
three-step approach: in the real world you might have hundreds of
individual functions analogous to foo
and
bar
.
main
, of course, is still broken. The build error,
invalid composite literal type Logger
, means that
Logger
is an interface, but you’re trying to instantiate it
as if it were a concrete class.
At this point, you’ve achieved all three of the goals laid out at the
beginning of the refactor: you’ve lifted an interface, the old
file-writing logger implements that interface, and the functions
main
calls are ambivalent about what kind of
Logger
you give them!
Now all you need is a program that builds.
This is it: the “not-very-interesting but also not-very-scary drudge work.”
Because introducing the interface resolved all the references in
foo
and bar
, there’s not much left for you to
fix: just the constructor call in main
is broken. You can’t
directly construct the Logger
interface, so you have to
construct something that implements it.
Great news: there’s no wrong choice! The only available
Logger
implementation is FileLogger
with the
original file-writing behavior.
Step 3 is to replace the broken constructor reference with the new
implementation name FileLogger
.
type Logger interface {
Info(message string)
Warning(message string)
}
type FileLogger struct { … }
func (l FileLogger) Info(message string) { … }
func (l FileLogger) Warning(message string) { … }
func main() {
var f *os.File
logger := FileLogger{file: f} // ✅ Fixed!
foo(logger)
bar(logger)
}
func foo(l Logger) { … }
func bar(l Logger) { … }
With that manual update, everything clicks satisfyingly into place.
go build demo.go
succeeds. All three of your goals for the
refactor are satisfied. The diagram is green. To quote the
fortune-cookie fortune taped to my monitor at home, “☺ The job is well
done. ☺”
At this point you’re free to proceed with what you set out to
achieve: additional Logger
implementations.
You’re also free to swap in different names (e.g. reverting
FileLogger
to Logger
and naming the interface
ILogger
if that’s more your style). Now that you have the
type relationships you wanted, your IDE’s “Refactor → Rename”
functionality can take care of the semantic fixes.
This was a simple example, but this three-step method works for complex interfaces, with more diverse users, in far, far gnarlier codebases.
But beware! Selectively vacating names before redefining them with compatible types is a neat trick, but it isn’t the right path for every refactor. Static type-checking provides “safe route[s] where we don’t smash everything up,” but it’s up to you to pick a short one.
In this logger interface example, you break references to the
implementation because, after the migration, you expect to have many
more references to the interface Logger
(hundreds of
function signatures) than to the implementation FileLogger
(a handful of constructors: easy to update manually).
Were that relationship were reversed — if you were interfacing a type that’s frequently constructed and infrequently received — manually reconnecting the constructors would be tedious. You would actually save time by updating the few function signatures without renaming. Then again, if something’s frequently constructed and infrequently received, you’re less likely to be replacing it with an interface!
To be sure, you can follow this three-step refactor path in a language without static types, but you have to very cautiously assess whether you’re actually done. Jon Blow again:
Yeah, the type safety part of Python — or the lack thereof — is huge. A lot of the stuff that we did today, these grunt work refactors I’m doing, would be a lot scarier in Python. You’d be like, “I don’t know if I just broke something.” We’ve done ten things today, at least, where you’d be like “I don’t know if that broke something,” where in C++ you’re like “yeah, I know I didn’t break something.” It’s very different.4
Every broken build is a blessing in disguise: it could’ve been a runtime exception. So long as your language has your back, break things with confidence. You might just be a types person.