CAS CS112 - Introduction to Computer Science II

LAB - Tips and Tricks for Debugging Java Programs

In this lab we will study an essential part of your skill set as a programmer: the ability to find errors (bugs) in your program and fix them.

Overview

As the code you develop gets more complicated, debugging strategies are going to become more important. There are a variety of techniques that programmers employ to find bugs in their code. In this lab we will survey four of the most important debugging techniques, and you will have a chance to try two of them out on some simple (but buggy) code. The three methods are

  1. Finding Bugs with Testing;
  2. Tracing Program Execution with println Statements; and
  3. Using the Eclipse Debugger.

Many programming environments, including Eclipse, have debuggers to help you. Although debuggers are useful, many programmers rely solely on print statements to debug their code because they are simple and flexible. After exploring these techniques, you be the judge of what works best for you!

For each of the three sections in the lab, you are expected to read through the material, and then perform a brief exercise on a program that finds the maximum integer in an array using binary search.

1. Finding Bugs with Testing

How do we know whether our code is correct? The only way to be sure is to try your code on representative examples of the kind of inputs you would expect. One way to do this is to write test code in your main method to run your program through different examples to see if it works properly.
For example, if we want to test our Binary Search algorithm, we would call the binary search method on various different numbers and confirm that it gives the correct result:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public static void main(String [] args) {

	int number = 15;
	int [] A = { 2, 7, 12, 15, 19, 25, 26, 38, 45, 78 };

	System.out.println("\nTesting " + number + " ... should be true");
	System.out.println(binarySearch(A,number,0,A.length-1));

	number = 2;
	System.out.println("\nTesting " + number + " ... should be true");
	System.out.println(binarySearch(A,number,0,A.length-1));

	number = 78;
	System.out.println("\nTesting " + number + " ... should be true");
	System.out.println(binarySearch(A,number,0,A.length-1));

	number = 20;
	System.out.println("\nTesting " + number + " ... should be false");
	System.out.println(binarySearch(A,number,0,A.length-1));

	number = 0;
	System.out.println("\nTesting " + number + " ... should be false");
	System.out.println(binarySearch(A,number,0,A.length-1));

	number = -5;
	System.out.println("\nTesting " + number + " ... should be false");
	System.out.println(binarySearch(A,number,0,A.length-1));

	number = 200;
	System.out.println("\nTesting " + number + " ... should be false");
	System.out.println(binarySearch(A,number,0,A.length-1));
}
			

Which prints out:

Testing 15 ... should be true
true

Testing 2 ... should be true
true

Testing 78 ... should be true
true

Testing 20 ... should be false
false

Testing 0 ... should be false
false

Testing -5 ... should be false
false

Testing 200 ... should be false
false

Notice how we have tested a variety of cases, both positive and negative, and especially we have tested true and false results in the middle and at each end of the array. The basic idea in designing tests is to explore all the normal cases and then all the "boundary cases" where something might go wrong because you were only thinking about the normal case. Here are some suggestions for designing test examples:

  1. Any time you have an array, it is a good idea to test what happens in the middle, of course, but especially at the beginning and the end, because that is where you get "array index out of bounds" errors.
  2. If your input is an array, make sure you test arrays of different lengths, especially even and odd lengths and arrays of length 1.
  3. If you have a program using Strings, you might want to test all of the following:
    • Simple Strings, e.g. "cs112"
    • Strings with weird characters, e.g. "%^&#@"
    • Strings with single characters, e.g. "r"
    • Empty Strings, e.g. ""
  4. Don't forget to test how your program behaves if the input array is empty!

Problem 1: Adding Test Cases

It is possible to use binary search to find the maximum number in an array, as long as the array contains numbers which increase up to a maximum, and then decrease. For example, the following array has this property:

[2, 4, 6, 9, 10, 12, 15, 18, 13, 8, 3] 

The maximum is the only number in the list which has the property that it is larger than the number before it, and larger than the number which follows it. Binary search can find this number by looking at the middle of the list, testing if this middle element is the maximum, and then going to the left or right depending on the relationship of the middle element to its neighbors:

There will always be a maximum in the examples we will try, so the algorithm will always terminate successfully, if it written correctly.

Create a new project hw3, containing a new class FindMaximum.java, and then cut and paste this algorithm into the file, and run it to see what it does:

/*
* File: FindMaximum
* Author: Wayne Snyder
* Date: 5/30/17
* Purpose: Example program for Lab 2 in CS 112, Summer I, 2017, using a
* binary-search-like method to find the maximum value in an array. The
* array must increase up to a single maximum value and then decrease.
*/ package hw3; import java.util.Arrays; public class FindMaximum { private static int[] A = {2, 4, 6, 9, 10, 12, 15, 18, 13, 8, 3}; // Return the maximum value in the array A using binary search private static int findMax(int[] A) { int low = 0; int high = A.length - 1; int mid = (low + high) / 2; while (low <= high) { mid = (low + high) / 2; if (mid == 0 || mid == A.length - 1) { // maximum is at one end or the other of the array return A[mid]; } else if (A[mid - 1] < A[mid] && A[mid] > A[mid - 1]) { // Found the maximum return A[mid]; } else if (A[mid - 1] > A[mid]) { // maximum must be to the left high = mid - 1; } else { // A[mid] < A[mid-1] low = mid + 1; } } return 0; // only way the program reaches this point is if array is empty } public static void main(String[] args) { System.out.println("\nArray is: " + Arrays.toString(A)); System.out.println("Maximum is: " + findMax(A)); } }

What to do: Add tests to the main method to see what the algorithm does on the following arrays:

Array B:  [2, 4, 6, 9, 10, 12, 15, 18]
Array C:  [18, 13, 8, 3]
Array D:  [18]
Array E:  []

You should get out something like the following:

Clearly, the program does not work! What to do? Read on!


2. Tracing Program Execution with Println Statements

What if you test your program and something does not work, but you can't see the problem? The eternal problem with programming is that programs never work correctly the first time you run them, and 75% of your time is usually spent chasing down bugs.
How to debug? You can stare at your program, pray, hope someone else can help you find the problem (which only works in when you are first starting out!), or... roll up your sleeves and trace the execution of your program by printing out the different values at various critical places.
For example, if you are doing binary search on a array, you could print out the number you are comparing at every step:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
	int[] A = { 2, 4, 6, 7, 9, 10, 14, 16 };
        int n = 15;
        int left = 0;
        int right = A.length - 1;
        while( left <= right ) {
            int mid = (left + right) / 2;
            System.out.println("Comparing " + A[mid] );
            if( A[mid] == n ) {
                System.out.println("Number found!");
                break;
            }
            else if( A[mid] < n )
                left = mid + 1;
            else
                right = mid - 1;
	}

For n = 15, this would print out:

Comparing 7
Comparing 10
Comparing 14
Comparing 16

By doing this, you can systematically explore what your program is doing, which is infinitely better than just trying random things in the hope that something will work.

In any code that you are investigating, you can simply place a System.out.println() statement in the code where you think there might be an error and see what it prints out. This is most effective when you print out a lot of information: the name of the method, the parameters with which the method was instantiated, and any relevant variables. In this way, we may trace the flow of information in the program, instead of "flying blind" and trying to guess what is going on from the end behavior or examination of the code.

Consider the recursive version of binary search; a couple of print statements will show exactly what the method is doing:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public static boolean binarySearch(int[] A, int k, int left, int right) {

     System.out.println("Looking for " + k + " in A[" + left + ".." + right + "]");

     if(left > right)
       return false;

     int middle = (left + right) / 2;
     System.out.println("Comparing with " + A[middle]);
     if(A[middle] == k)
         return true;
     else if(k < A[middle])
         return binarySearch(A, k, left, middle - 1);
     else
         return binarySearch(A, k, middle + 1, right);
}

We can start with printing out the number we are searching for and the array, and then see what happens in the method:

number = 15
A = [2, 7, 12, 15, 19, 25, 26, 38, 45, 78]

Looking for 15 in A[0..9]
Comparing with 19
Looking for 15 in A[0..3]
Comparing with 7
Looking for 15 in A[2..3]
Comparing with 12
Looking for 15 in A[3..3]
Comparing with 15
Number 15 found!

Controlling the Trace of Your Program

You will want to do both, printing out traces and running your program through tests, but you don't want to get confused with hundreds of println() statements. Trying to wade through a mixture of test outputs and trace statements is a horrible experience!
So, you need to be able to "toggle" on and off the detailed tracing of your code with the testing.
Finally, when you DO find the bugs, and before turning in your program, you would want to remove all the debug and testing code. The exception to this is when you will keep developing your code later and want to save the tests, or when you are asked to turn in the testing and tracing code as part of the assignment.

In order to control this two-sided process, the following paradigm is quite useful.
You can use a boolean variable debug to control whether we want to trace (set debug = true) or ignore the tracing code (set debug = false and just observe the normal behavior of the program.
Thus, you will put an instance or class variable at the top of your class (where it is easy to find), and create a simple method db(...) in the class which prints out its argument if debug is set to true. If you don't want to trace, just set debug to false.
In addition, we can precede all our print statements with tabs ("\t") so that the statements are indented and appear shifted to the right, away from normal print statements.

	1
	2
	3
	4
	5
	6
	private static boolean debug = true;     // set this to true if you want to trace your execution

	private static void db(String s) {
		if(debug)                        
		  System.out.println("\t" + s);      // tab will move s to right
	}                                       // so you don't confuse it with your normal test cases
  

In a piece of code that you want to debug, you can now type db( someVariable ) for any variable you want to trace (easier than typing if(debug) System.out.println( someVariable ) each time). For example, the earlier code can now look like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public static boolean binarySearch(int[] A, int k, int left, int right) {

     db("Looking for " + k + " in A[" + left + ".." + right + "]");

     if(left > right)
       return false;

     int middle = (left + right) / 2;
     db("Comparing with " + A[middle]);
     if(A[middle] == k)
         return true;
     else if(k < A[middle])
         return binarySearch(A, k, left, middle - 1);
     else
         return binarySearch(A, k, middle + 1, right);
}

Again, if we print out the array and number first, and then trace the program with debug = true, the output will be:

number = 15
A = [2, 7, 12, 15, 19, 25, 26, 38, 45, 78]
        Looking for 15 in A[0..9]
        Comparing with 19
        Looking for 15 in A[0..3]
        Comparing with 7
        Looking for 15 in A[2..3]
        Comparing with 12
        Looking for 15 in A[3..3]
        Comparing with 15
Number 15 found!

if we set debug = false, our console will only print the final result:

number = 15
A = [2, 7, 12, 15, 19, 25, 26, 38, 45, 78]

Number 15 found!

Basically, we are passing in the concatenated String that we want to print to the console as an argument to the db(...) method.

NOTE: To print entire arrays, use the toString() method provided by the in-built Arrays class.

db(Arrays.toString(A))

where A is the array we defined in the previous examples.

Conclusion on debugging using println statements

The places to think about utilizing these techniques are:

Exercise 2: Adding Trace Statements to our Sample Program

What to do: Put the field debug and the method db(....) in your program, and try setting the debug variable to true and false to see what happens in the loop. You should get something like the following when you set debug to true:

Obviously, some cases work, but the first two do not! Set debug back to false and read on!

3. Using the Eclipse Debugger

Eclipse has a Debug Perspective that can provide a number of different ways to trace your program by setting breakpoints (places to stop execution), providing different ways to step through execution after a breakpoint, allowing you to see what methods are currently on the "run-time stack" of all executing methods, and, finally, to examine the values of variables.

To enter Debug Perspective, click on the Open Perspective button on the upper right-hand side of the Eclipse window:

Then select Debug (with the cute picture of a green bug) and click on Ok. You will now enter the Debug Perspective for the files you were working on in the Java Perspective. You can toggle back and forth between the various perspectives by clicking on their icons in the upper right corner:

In Debug Perspective, you be able to edit your program as before, but also to run it in "debug mode," where you can set breakpoints to pause execution.

Double-click just to the left of line 37, where the while loop starts: this will set a breakpoint, which, when you run the program in the Debug Perspective, will pause the program when it gets to this line; you can see where the breakpoint is by the little blue dot where you clicked:

Now click on the green bug at the top of the window:

This will run the program until it hits the breakpoint, at which point it will pause, showing useful information about the progress of the execution:

You can also move your cursor over a variable in the editor window, and it will show the value of a variable. You can edit your program as well.

Mostly, what you will do is to move through the execution, watching the values of the variables, and using the buttons at the top of the window:

The exact way these buttons work is best found out by experimenting with them. Since the breakpoint is in the only method in the program, you will just use the middle one ("step over") for now. Click on it repeatedly and watch what happens.

Exercise 3: Debug the program!

Now it is time to fix this program! Using the Eclipse debugger, step through the program, observing what it does. You should see that there is something wrong with the test for when a number is the maximum. Fix this problem and confirm that all the test cases work as they should.

You will hand in this fixed program as part of Homework 3.

Further Reading

  1. Java Debugging with Eclipse - Tutorial - Vogella
  2. Java.util.Arrays.toString(int[ ]) Method


Sources and Tools Used