Theory:Debugging techniques
Debugging is the process of finding and fixing bugs in a program. Some bugs, like those that prevent the program from compiling, can be fixed easily since the compiler or an IDE can tell you what's wrong. Other bugs are trickier and may require you to put a lot of effort into detecting them.
In this topic, we will consider the most popular ways that programmers use to debug a program:
- Logging
- Assertions
- Attaching a debugger
# Logging/'printf' debugging
One way to track the changes in the program state is to insert additional print statements in the code. When executed, they will inform you about what's happening under the hood at runtime.
For example, you can insert a line just before a method returns that will print the return value to the console. This way, in addition to seeing the final result, you will also be able to understand what happens at a certain stage of data processing.
Let's look at the following code snippet, which hangs indefinitely when run:
class UnexpectedResults {
public static void main(String[] args) {
count(1, 10);
}
public static void count(int start, int to) {
while (start < to); {
System.out.println(start);
start++;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
While the error is not hard to catch, we can still add some print statements that would clearly indicate where the hanging happens:
class UnexpectedResults {
public static void main(String[] args) {
System.out.println("main() started");
count(1, 10);
System.out.println("main() complete");
}
public static void count(int start, int to) {
System.out.println("count() started");
while (start < to); {
System.out.println(start);
start++;
}
System.out.println("count() complete");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Now, instead of just hanging, the program will output:
main() started
count() started
2
This output shows us that the program reaches the start of the count()
method, but never reaches its end, which means that the problem is in the while
loop. After we take a closer look at the construct, we see that there is an extra semicolon.
Inserting print statements is the most basic way to debug your code, however, we provide it just so you know this technique. You should not use this method in real projects because modern debuggers can do the same in a much more convenient way and because you would not be able to do that everywhere. For example, if you want to get information from some library code, this would be a problem because you cannot modify compiled code.
Be patient, we will cover the nice way shortly.
# Assertions
In order to detect bugs in the program at earlier development stages, you can use assertions. The assertion is a mechanism that monitors the program state, but unlike additional print statements, it terminates the program in a fail-fast manner when things go wrong.
Fail-fast is an approach when errors that could otherwise be non-fatal are forced to cause an immediate failure, thus making them visible.
You may wonder why one would want to crash the production code, and the answer is: one wouldn't. Assertions are meant for testing/debugging and should never be used in production code.
Let’s take a look at the following program:
class BrokenInvariants {
public static void main(String[] args) {
Cat casper = new Cat("Casper", -1);
}
}
class Cat {
String name;
int age;
public Cat(String name, int age) {
this.name = name;
this.age = age;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
This code creates a Cat
object. This would be fine if it wasn't for the negative age value that makes no sense. Naturally, in a more complex program, this may lead to various bugs. Such an object may be passed around for a long time before we see a problem, and when a problem arises, it is not always obvious what was the cause.
To prevent that from happening, we can use an assertion right in the Cat
constructor:
public Cat(String name, int age) {
assert (age >= 0) : "Invalid age";
this.name = name;
this.age = age;
}
2
3
4
5
The part before the colon specifies the boolean expression that should be checked, and when it evaluates to false
, an error is thrown. The part after it specifies the message that describes the error.
Now, if we run the code with the -ea
flag (java -ea BrokenInvariants
), the program will throw an error and terminate right in the Cat
constructor:
Exception in thread "main" java.lang.AssertionError: Invalid age
at Cat.<init>(scratch_1.java:11)
at BrokenInvariants.main(scratch_1.java:3)
2
3
You may have noticed that we used the word invariants and are curious what it means. Invariants are constraints that must be met for a program to function properly. In the code above, positive age
is an example of an invariant. Using a negative age
is asking for a problem. Enforcing invariants is exactly why we need assertions.
We can also use assertions to check method preconditions and postconditions, that is conditions that must be met before or after a method is invoked.
The only and very important limitation that should be observed when using assertions is that they should never produce side effects and change the way a program operates. In other words, the assertion should not affect a program in any way other than throwing an error.
Below is an example of an assertion that violates this rule:
assert (age++ >= 0) : "Invalid age";
this.name = name;
this.age = age;
2
3
Besides checking the condition, the assertion also increments the age
value, which means that when assertions are disabled, the program will operate differently, invalidating the results of testing and probably introducing new bugs. Clearly, you want to avoid such situations in production.
# Attaching a debugger
A debugger is a tool that interferes with the normal program execution allowing you to get runtime information and test different scenarios to diagnose bugs. This is the most popular use of debuggers. However, when you grow more experienced with them, you'll see that they can be helpful in various situations, not necessarily related to bugs.
Modern debuggers provide a vast variety of tools that can be used to diagnose the most intricate failure conditions, so they definitely warrant a section of their own. In the next topics, we will get started with IntelliJ IDEA debugger and learn how to debug simple code.
# Conclusion
In this topic, you have learned about different debugging techniques you can use to ensure that your code is error-free. The most basic method is inserting print statements to keep track of the values and the execution order of your program. Also, you may add assertions using the assert
keyword to make potential hidden errors visible at an early stage of development. Such assertions may be enabled and disabled by adding or removing the -ea
JVM flag. Finally, you may attach a debugger to the program to examine its internal state at runtime. These techniques will help you to detect and fix bugs in your code efficiently.