« blog

Three-Step Lifted Interfaces

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.

Example: refactoring a logger

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:

A happy little green diagram. Logger is used in three ways: in a constructor in main, as a function argument type in foo and bar, and via method calls in those functions.

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:

  1. Lift an interface capturing the existing logger’s public surface (Info and Error functions).

  2. Update the file-writing logger to implement that interface.3

  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.

Step 1: break everything

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!

After Step 1 (“break everything”), everything is broken.

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.

Step 2: lift the interface

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.

Reintroducing a type called Logger satisfies the function signature usage (and the method calls downstream of those). There’s a new type error for the constructor.

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.

Step 3: fix the build

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. ☺”

Now that main constructs an implementation, all the type references in our program refer to appropriate type definitions.

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.

Shortest paths

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.