The Compiler as a Refactoring Aid

Recently, I sat down to refactor a Go application with a high-level design objective in place. The application had two conceptually separate entities implemented in different files but mashed into a single package. I needed to separate them out into their own packages. I wasn’t using an IDE — just Emacs with basic formatting and non-contextual auto-complete aids.

I started out by creating a new directory for the package to be split out and moved the files that contained most of the relevant code into that directory, without thinking of the consequences. I could just invoke the compiler and let it guide me through the process of fitting the pieces of the puzzle together. One of the nice features of modern compilers is that they don’t continue dumping out errors beyond a limit. This allows fixing a program in small steps, going by the changes in errors produced by the compilers.

The first thing the compiler told me about was all the variables that got hidden due to things being moved into a new package. While working that out, it also helped me discover that the interface being used by Entity A to access Entity B (one that moved to the new package) only had private methods. Whoa! This is a semantic issue, which automatic refactoring tools that help with moving code around or creating new classes etc., can’t deal with.

Next, I tried to access the invisible variables by importing the new package, but the compiler complained, “import cycle not allowed“. Nice. I could work out the dependency tree and information passing after having separated the packages, instead of first figuring out the dependencies and then moving code. See how the compiler is guiding me toward better design as well?

At this point, some of you might think this is a daft way of going about refactoring and I should have worked out the entire design on paper before touching the code. But is it daft, really? Here’s what refactoring guided by a “good” compiler is allowing me to do:

  • It lets me access code as I’m working out a design. Any design or refactoring done without referring to code is prone to be erroneous.
  • It ensures that I don’t miss out on a code path. The compiler checks more code paths than I can bother to follow in my mind and it reveals problems with those paths.
  • It allows me to do top-down refactoring. I make the big, disruptive change that satisfies my design objective first, and the compiler guides me through the details of making that change work.
  • It allows me to evaluate the impact of different design decisions on code immediately, instead of having to guess.
  • The best thing, though, is that I reduce the mental context to be carried. This is a significant benefit since it allows me to refactor in smaller sessions. I can commit partially fixed code and carry on from where I left the last time. I don’t need to remember what needed to be done because the compiler reminds me about it.

I followed roughly the same refactoring practices while coding with interpreted languages, but having to execute the program to find errors added a level of complexity, not to mention more of print/step-through debugging. It also left more for manual code inspection to ensure all corners were covered since having 100% code coverage in tests is not always feasible.

Using a compiler that does static analysis just improves the whole process considerably. Using a compiler along with a high productivity language like Go makes it fun as well!

  • Swaroop

    I’m just imagining what would the equivalent process be in an interpreted language… and my process would be to have a running test and doing refactoring so that the test would run again, but then again, the test(s) would have to ensure that all the code paths are fixed as you mentioned. I guess that is one advantage of a compiled language I had not realized, so thanks for the article.

    • TDD and code coverage tools might help. One problem with tests is that they tell what’s broken from a functional perspective while the compiler shows what’s broken from a structural perspective. The latter is what you need for refactoring.

      • Gokce

        I think you need both of them. While refactoring, you have to ensure that the functionality stays the same. Your tests will ensure the functionality.

        • Yes, indeed. The compiler can’t substitute functional testing. It just makes the refactoring process a bit more guided and quick.

  • Jason

    I guess this isn’t really well understood in general, the point you make. But to me it is the most natural thing. I think to myself “of course!” The compiler is awesome during refactoring. I don’t even remember when I realized this. But then again, TDD is now so merged with my consciousness that I can’t imagine developing any other way now. Why go back to slow and risky when you can be sure there have been no regressions, and always make forward progess.

    It boggles my mind how much developers in industry resist such advances in mental technology.

    • I think I understand why TDD is entering the discussion – safe refactoring is one of the promises of TDD and it does deliver on that promise. However, TDD itself doesn’t do much for the actual moving around and rewriting of code while the compiler does. It’s only when a unit successfully compiles that you can run tests on it.

      In summary, it’s not an either-or or a-better-than-b situation. The compiler and tests come into play and different points in the refactoring process. I do contend that “compiler + tests > tests alone”.