The debugger considered harmful
How often does this happen to you: you have to fix a bug in your application. So you fire up the debugger and step through the code, watching variables change. Step by step you move on, just to get a clue what is going wrong. After a hours of debugging, you finally find the location of the bug: a check was forgotten, or a variable was assigned the wrong value. So you change the initialization statement and, after a final quick check, you’re ready.
I think this is largely bad practice. We could have saved a lot of effort here, and have a better result too!
What is going wrong?
There are multiple approaches to find bugs much earlier and probably with a better solution. The main problem is that you’re starting the debugger session as a first reflex before you actually have a clue what the problem is. You may think “I’ll start a debugger session and that will show me the problem”, but this is not the most effective way. When you are running a debugger session, then you can watch variables change and see what statement is about to execute, but this is actually very low level information. Most bugs hide at a level where software parts are working together. Searching at a too low level is a waste of time because often you’re missing the big picture.
That leads to a related risk when using a debugger like this: you’re not thinking critically about the structure of the code (and, as a consequence, about a structural solution of the bug). I’ve seen often that people fixed what they saw going wrong and then claimed that the bug was fixed, but one day later receiving the next bug report of the same feature, for a bug that was just two lines further down the code from the previous one. A more global mindset would have caught that in advance; a structural solution can save therefore a lot of time.
Edit & continue is even more harmful
Modern IDEs have features called “Edit & Continue” (Visual Studio) or “Hot swap” (IntelliJ) that makes it possible to make changes to the source code while debugging. The function is recompiled and replaced within the debugging session. So you can make modifications to the source code and continue to run the application with the changes.
However, in my opinion, such features are even more harmful when used without thought. I have often seen people using Edit & Continue to make bug fixes to running code, and delivering a solution without adequately securing their work or thinking critically about their fixes. In other words, the ‘quick fix’ is the actual fix, which over time rots the code base as a whole. But Edit & Continue, used in this way, also reduces the perceived need to clean up the mess. It is symptom relief that allows developers to increase the code base even more, instead of fixing the underlying causes.
Edit & Continue can only work when the actual source code is available. I’ve met people that refuse to split off packages of their main program, only because it breaks Edit & Continue. Instead of setting proper boundaries, they increase the amount of code endlessly, resulting in a huge repository. Apart from the huge complexity, the code size also slows down everything, from the IDE to building, testing and deployment. This is clearly not the way to have maintainable software in the long run.
How to have a better debugging experience
Luckily, there are better alternatives. When you use the following set of guidelines, debugging will over time be much smoother. You’ll have fewer bugs, and you can solve them in less time.
- Whatever you do, first ensure that the bug is reproducible. Otherwise you’re lost: you don’t know where to look, and you even won’t know whether you fixed the bug or not.
- Prevent bugs by having a good test suite. Reproduce the failure with a new (automated) test (so that you have now a failing test). Fix the bug and prove this by the test which will now succeed. The automated test ensures that the bug is gone forever.
- Use logging to monitor what is going on. Good logging allows to go “back in time” to find out which events caused what to happen. This makes it much easier to come up with possible failure causes.
- Perform analysis to find possible failure causes. You can do this using the log files and reproduction steps. You can test any failure cause you can think of using the debugger. That is no problem since, in this case, you know exactly what you need to look at.
- In general, make clear beforehand what you have to build. Before you begin, you should make it crystal clear what features you’re expected to implement and what problem you have to solve. That will lead to a better design, leaving fewer bugs around.
- When you have no idea where the problem may be, try to reduce the problem space. To give an example: to find the cause of an issue in a system with a message flow like this:
input --> [Component A] --> [Component B] --> [Component C] --> [Component D]
, you can start analyzing the data flow between component B and C. If that looks good, you start analyzing the data between C and D, and otherwise the data flow between A and B. That way, you can exclude three of the four components when searching for the failure cause.
When to use a debugger
I am not saying that using the debugger is bad practice in general. When used at the right moment, it can provide a lot of information. But use it with consideration: only when you have a specific question to answer. You can view debugging sessions as running experiments: formulate a hypothesis of possible behavior and test using the debugger whether the hypothesis is valid or not. Since your question is specific, you are not running around without a clue what to do.
When you often have to find answers to using a debugger, I think it is wise to invest in preventive measures as listed above. When you have good logging or an extensive automated test suite, chances are high that you’ll find your answers there much faster than using the debugger!