The compiler component produces error messages that alert us to the presence of syntax errors, and that help us locate them. The linker also produces error messages that alert us to the presence of link errors and typically (but not always) provide some indication of the problem. On the other hand, logical errors do not produce any error messages - the program compiles and runs. However, programs with logical errors produce incorrect output, no output, or never terminate. The underlying error may be a large, significant misunderstanding about how to solve the problem, a misplaced parenthesis, or any of a myriad of problems in between.
Every program must be tested and validated before it is considered correct. Developers use sets of test cases to validate programs. A test case is nothing more than a set of input values and the expected program output; in the "real world," developers design test cases simultaneously with the software itself. If a program produces the correct output for every test case, then there is a good chance that the program is correct - but it is still possible that the program contains an error, and one more test case may have identified it. Once a failed test case indicates a program has a logical error, the developer's next task is locating and identifying it.
Manually Locating A Logical Error
An example makes the three-step process much easier to understand. The first (primitive) approach is conceptually easy to follow, can be used with any development environment, and will help us understand how the debugger operates. The first version of the ftoc.cpp program written earlier had a logical error, and we can use that version to demonstrate how to localize such an error.
\[c = {5 \over 9} (f - 32) \]
Input
Expression
Expected
Output
32
f
32
5/9
0.555556
f-32
0
5/9*(f-32)
0
0
Input
Expression
Expected
Output
212
f
212
5/9
0.555556
f-32
180
5/9*(f-32)
100
100
Formula
Test Case 1
Test Case 2
Step 1: Calculate intermediate values. Begin by calculating the intermediate results by hand, which, in the case of the Fahrenheit-to-Celsius conversion program, are the sub-expressions appearing in the formula. From the formula, we identify four sub-expressions of interest:
f, the program input
5/9
f-32
5/9*(f-32), the program output
It may seem like a waste of time to check the input value because it doesn't represent a significant computation. However, it is possible to make a simple error on the data input - for example, two input operations targeting the same variable (in a more complex program) or simply entering some data incorrectly. If we ignore that possibility and focus on the following code, we may never find an input error. Once the input is validated, then we KNOW that we have not made such an error and can safely focus our attention on the more complex computations that follow.
When we first ran the test cases, before instrumenting the code, the first test case "passed" in the sense that it produced the correct output. Nevertheless, there was a bug in the first version of the program, which underscores the importance of using multiple test cases to test and validate even simple code. Comparing the output from the instrumented code with the expected intermediate values will detect the error using either test case.
Expression
Expected
Observed
f
212
212
5/9
0.555556
0
f-32
180
180
5/9*(f-32)
100
0
Step 3: Trace the calculations. Finally, we compare the program output with the test cases. Programmers can use either test case to locate the error, but it was the second case that alerted us to the bug, so it is natural to begin with it. Comparing the output from the instrumented code with the expected output calculated in Step 1, it's clear that the calculation of 5/9 is incorrect. This technique locates the bug but does not specify the cause or the solution - programmers are responsible for understanding the code's behavior. The program evaluates 5/9 using integer arithmetic, resulting in 0 and creating the fundamental program bug.
Debugging With A Debugger
Manually instrumenting code always works the same on every computer and with every compiler, so it doesn't require us to learn how to use a specific debugger. But its most significant advantage is that it's easy to understand. Nevertheless, manually instrumenting code is tedious in many ways. First, we must edit and recompile the program whenever we want to examine a different part of it. Second, it's difficult to control the output, particularly in loops. Finally, we must remove the instrumenting code after finishing the debugging session because the extraneous output could confuse a user or cause problems if other programs process the output.
Most modern integrated development environments (IDEs) include a debugger that allows developers to step through the code and examine the values stored in variables or calculated by expressions. In this way, a debugger automates step 2 of the three-step process outlined above for localizing a bug in a program. The following demonstrations illustrate how to manually instrument code and use a debugger to follow program execution and view the results of intermediate program calculations. Although the debugger examples demonstrate the Visual Studio debugger, you will find that other debuggers are similar.
Examining Expressions
Examining expression values with the debugger. The arrow inside the red dot indicates that program execution has paused at the breakpoint and is ready for debugging.
Highlight (left-click and drag) the expression whose value you want to see.
Place the mouse pointer inside the highlighted region.
The expression and its current value appear next to the cursor.
The debugger displays the current values of two sub-expressions that are a part of the temperature conversion formula. From the displayed values, it's easy to see which expression is incorrect and is therefore programmed incorrectly. To end the debugging session:
Open "Debug" from the main menu, choose one of "Continue" or "Stop Debugging," and close the console window.
Right-click at the beginning of the Celsius calculation, select "Breakpoint," and then "Delete Breakpoint."
(a)
double c = 5 * (f - 32) / 9;
(b)
(c)
(d)
Examining evaluation order with the debugger. One way of correcting the error in the temperature conversion program, illustrated in the original example, is to change the order of operations by rewriting the code. The debugger allows us to examine each sub-expression as the program evaluates it. Modify the code and build the project. As before, set a breakpoint and run the program.
The rearranged and corrected code.
The parentheses cause the subtraction operation to take place first. The sub-expression is double-valued because the variable f is type double, causing the program to promote 32 to 32.0 before performing the subtraction. The debugger demonstrates that the operation is correct.
The multiplication operation takes place next. Again, the sub-expression is double-valued, causing the program to promote 5 to 5.0 before the multiplication occurs. The debugger again demonstrates that the sub-expression is correct.
The division operation runs last, producing another double-valued result as verified by the debugger.
Some Debugger Shortcuts
(a)
(b)
(c)
Debugger context controls. Programmers can access all debugger controls through the "Debug" menu, as in the previous example. But many of the most common operations are also available through a set of context controls appearing in the second row. Context controls can change depending on what Visual Studio is doing at any given time.
The "Run" button (green arrow) appears before the program begins execution.
While the program is paused at a breakpoint, the green arrow becomes the "Continue" button.
The "Stop" button (red square) also appears while the program is paused at a breakpoint
Following Program Execution With Breakpoints
In the special case of the error in the ftoc program, the debugger quickly exposes the error without any additional preparation. In more general cases, programmers must still manually carry out steps 1 (calculate intermediate results) and 3 (compare the calculated and program results). But a debugger allows us to skip step 3 (instrumenting the code) and makes step 3 much easier.
To debug a program, we often need to follow or trace the execution of individual statements as they run. Tracing a program gives us an idea of "where the program is going and what it is doing." Consider a simple if-else statement: the program can take one of two distinct execution paths. Which path does the program follow? What values has the program saved in the statement's variables? We often think we know the answers to those questions but are too often wrong. Sometimes, calculations before the if-statement are wrong, or there is an unnoticed error in the if-test. Either of these errors can send the program down the wrong path. Following the program's execution helps us to detect and locate these kinds of errors.
Both the necessity and the difficulty of following a program's execution increase as the program's size and complexity increase. We begin by exploring how to manually follow a program by viewing the values saved in variables and calculated by expressions and sub-expressions. Then, we automate the process with a debugger. The examples presented in the following figures revisit the payment.cpp example.
int n = years * 12;
cout << "n = " << n << endl;
double r = apr / 12;
cout << "r = " << r << endl;
cout << "p * r = " << p * r << endl;
cout << "pow = " << pow(1 + r, -n) << endl;
double payment = p * r / (1 - pow(1 + r, -n));
cout << "payment = " << payment << endl;
(a)
(b)
Manually instrumenting code. Programmers instrument code by adding temporary output statements, highlighted in yellow. The output does not satisfy any program requirements or provide any end-user information. However, their output establishes the program's statement execution sequence and the values each sub-expression produces.
Instrumenting code that displays which execution path a program takes. Programmers can use this information to debug the if-test (represented by ellipses). Similar code can help debug loops.
Programmers can check the value of variables or expressions by comparing the values they calculate with the output of the instrumenting code.
Examining Statements With Tracepoints
Using tracepoints is quite easy. The next example appears lengthy only because of the many illustrations. But the screen captures make it easier to see the critical steps.