I have learned a few different things through trial and error over the course of my software developer career. The most prominent of which is how to work incrementally. I got this phrase from agile though my use of it here has nothing to do with that methodology. What I am referring to is the mindset and workflow that I use when developing.
Before defining what I mean in abstract and general terms, allow me to present an example of what I’m talking about. Suppose you have a major refactoring task. Several hours in you try running the code for the first time and find that it doesn’t even build. Frowning, you dive deeper into the compiler errors and emerge with buildable code an hour later. At this point you finish working for the day thinking that all is well. The next day you log several more hours into refactoring, fix all the compiler errors, and finally finish with refactoring the code. The code is now, of course, a perfection the likes of which no one has ever seen before nor will ever see again. You commit your code into git and sit back thinking that all is right with the world again. Belatedly, you realize you haven’t run any unit tests yet. Doing so produces more red than green (i.e., you’ve broken so many tests that more fail now than pass). Panic begins to settle in. You frantically look through the errors and try to debug the problem, but now you’re dealing with tests and code you had no part in writing. It’s not as easy to figure out how to fix the problem. It’s now the end of the day and you haven’t made any progress fixing the unit tests. You spend the entire next day attempting to fix them with limited success. At this point you begin to wonder if it wouldn’t be easier just to start over again from scratch.
Ever been in a similar situation before? I have on more than one occasion. If my example didn’t convey a sense of dread, then let me say outright that being in that situation sucks. The solution is, as the title and intro suggested, to work incrementally. Instead of waiting until several hours have lapsed to try building the code, build it frequently. Instead of waiting two days to run the existing unit tests, run them frequently. And instead of waiting until you’re finished to commit your code, commit frequently. TDD advocates a similar approach of building and running unit tests often, but what I’m talking about is more generic and also relies heavily on version control (specifically git, though by no means does it have to be).
My approach to a giant refactoring task involves a frequent cycle of building (assuming I’m working with a compiled language), running the unit tests, and committing my code. In particular, I make sure my commits are atomic. This means that the code in each of my commits is doing one thing only (sort of like the single responsibility principle for git commits). Much like SRP, the granularity can be taken to an excessive degree, but what I generally mean is that I limit myself to, for example, refactoring one class at a time or even one method at a time. And also be aware that I don’t mean you should be committing one file at a time either. If refactoring a class requires making changes to 20 files, then that is totally fine. Atomic commits do not mean small commits. Relative to size, atomic commits mean changing only one thing at a time (which implies that the change will be as small as possible, but doesn’t make being small a goal).
Atomic commits also mean that I could check out any commit in my git history and I would have code that is in a working state. It builds and all the unit tests pass. Sometimes, however, it isn’t practical to have every unit test passing at all times. You might go an entire day refactoring and still not have code that passes every unit test simply because of the nature of the refactor. It is still useful to frequently commit your code in these instances as well. These commits are called work-in-progress commits. I prefix a
WIP to the commit title to differentiate them from regular commits. I also don’t push them to the branch I’m working on. Either don’t push them at all, or push them to a temp branch. An example of how I might use these work-in-progress commits is to commit after every several tests I fix. The important thing is that once I get all the tests back in a passing condition, I squash all those work-in-progress commits into one atomic commit.
Being this fastidious might seem like major overkill, but believe me when I say that it is a tremendous help when you need to check out or revert an old commit. The person in my example scenario was contemplating having to revert the entirety of their changes. Wouldn’t it be way more preferable to only have to reset to the last commit? This approach also makes finding where a bug was introduced way easier. Start from a commit where you know the bug didn’t exist, and work your way up, commit by commit, checking to see if the bug was introduced there or not. Once you find the offending commit there is a drastically smaller diff you have to pour over to find the bug than if you were going into it blind.
I’ve even been in a non-coding situation where I used this “working incrementally” mindset. I was helping a friend set up some furniture and we had to use this tool we were unfamiliar with. I approached the problem methodically, seeing how the tool worked on different surfaces and limiting myself to changing only one variable at a time in my “unit tests.” In a sense what I’m describing is just the scientific method.
Following my “working incrementally“ guidelines might not make much sense to you if you’re still new in your programming career. If someone else had written this blog post and I had tried to read it when I was in college I probably would’ve fallen asleep midway through. It also might feel like a huge pain to constantly run unit tests and commit your code. To which I would respond that putting forth some effort upfront is worth it to save yourself a whole lot of pain down the road.