2.9.1. Debugging Logical Errors

logical error, test case (definition), intermediate calculation, instrument code, debugger

The compiler component produces diagnostics announcing and roughly locating syntax errors, while the linker or loader announces assembly errors but with little location information. Both errors prevent the compiler from creating an executable file. Logical errors, however, are different: the program compiles and runs, but its actions or output are incorrect. Programmers either failed to solve the original problem correctly or failed to translate the solution into a program correctly. In short, the program does exactly what it was "told" to do, putting the error beyond the compiler's detection.

A program's logical correctness assumes a meaning that lies within the software developer's mind, above a program's arithmetic and logical operations. Consequently, only a developer can determine if a program is logically correct. Developers test and validate program operation against a set of test cases - a set of input values and their corresponding output. However, when a program fails a test case, the failure only indicates a logical error, not where it's located. Software developers must locate logical errors.

  1. Manually calculate the intermediate results leading to the final output.
  2. Trace the calculations through program execution with one of the following techniques:
    1. Instrument the code (i.e., temporarily add output statements) to display intermediate calculations.
    2. Use a debugger, often part of an IDE, to view intermediate calculations.
  3. Compare the manual and program calculated values to locate the next error position.
Steps for locating a logical error. Steps 1 and 3 are tedious but inescapable. Step 2 requires information beyond a program's normal output. Developers can extract the additional information with a debugger or by instrumenting the code with temporary output statements. The former technique is more efficient, but the latter is easier to learn and understand because it is straightforward and independent of a specific tool. However, once developers locate and correct the logical errors, they must remove the instrumenting code.

A simple but concrete example illustrates the three-step process, making it easier to understand. The first version of the ftoc.cpp program had a logical error. Locating the logical error demonstrates the three steps. The first step, calculating intermediate values throughout the program, is necessary for both the manual and debugger approaches. For the simple ftoc demonstration, the example can calculate all the values. For larger programs, developers may calculate a few values in an attempt to bracket the error - identify a location where the values are correct and a location where they are not. Once bracketed, they calculate more detailed values within the brackets.

FormulaTest Case 1Test Case 2
\[c = {5 \over 9} (f - 32) \] c = 5/9 times (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
Step 1: Calculate intermediate values. The original demonstration identified two test cases. Before debugging the program, it's unclear which case will be the most helpful, so developers calculate intermediate values for both. For the Fahrenheit-to-Celsius conversion program, the intermediate values are the sub-expressions implementing the conversion formula:
  1. f, the program input
  2. 5 / 9
  3. f - 32
  4. 5 / 9 * (f - 32), the program output
Checking the input may seem unnecessary. However, as programs become larger and more complex, input errors become more common - possibly storing data in the wrong variable or in the wrong format, or mistyping during entry. Ignoring the input may misdirect the search and frustrate the correction process.

Locating A Logical Error: Manually Instrumenting The Code

logical error, instrument code (definition), test case, trace calculations

The first (primitive) approach is conceptually easy to follow, can be used with any development environment, and illuminates the operations that the debugger automates. To instrument code means to add statements to it that support operations beyond its primary purpose.

#include <iostream>
using namespace std;

int main()
{
	double	f;
	cout << "Enter a temperature in Fahrenheit: ";
	cin >> f;

	cout << "f = " << f << endl;
	cout << "5/9 = " << 5 / 9 << endl;
	cout << "f-32 = " << f - 32 << endl;
	double c = 5 / 9 * (f - 32);
	cout << "c = " << c << endl;

	cout << "The temperature in Celsius = " << c << endl;

	return 0;
}
Step 2.a: Instrument the code. Temporarily instrument the code (highlighted in green) with output statements displaying the intermediate calculations. This output is confusing and unnecessary for end-users, and it may disrupt the designed output format. Therefore, developers must remove the instrumentation once debugging is complete.

The original example ran the test cases with uninstrumented code. Despite having a logical error, the first test case produced the correct output and "passed" the test. However, the second test case failed, initiating the current debugging activity. This situation underscores the importance of using multiple test cases to validate even simple code. Comparing the instrumented code's output with the expected intermediate values for either test case locates the error.

  Test Case 1 Test Case 2
Expression Expected Observed Expected Observed
f 32 32 212 212
5/9 0.555556 0 0.555556 0
f-32 0 0 180 180
c = 5/9*(f-32) 0 0 100 0
Step 3: Trace the calculations. Comparing the program output with the test cases locates the causal error: truncating integer division (highlighted in red). Nevertheless, the second case detected the error, so it's important to include it in the debugging activity. This technique locates the bug but does not specify the cause or suggest a solution.

Locating A Logical Error: Using A Debugger

logical error, breakpoint, debugger

Manual instrumentation and the debugger each have their relative advantages and disadvantages. Manual instrumentation doesn't require programmers to learn a new tool, but it does require them to change the code, independently of correcting it, before and after locating the error. Furthermore, they must edit and recompile the program whenever they want to examine a different part of it - a tedious process. They must also remember to remove the instrumentation before deploying the program.

Most modern integrated development environments (IDEs) include a debugger that allows developers to examine the values stored in variables or calculated by expressions, automating step 2 of the three-step process. Debuggers require programmers to learn a complex tool. Although most debuggers are similar, each one may have a unique interface and behavior. IDEs typically provide controls that switch the final executable between a release and debugging version. The compiler automatically instruments the debugging version, which the debugger runs as an interpreter, eliminating the needs to recompile the program to examine different parts of it, and to "clean up" the program once debugging is finished.

The following examples use one version of Visual Studio to demonstrate various ways to examine code with a debugger. However, most debuggers use the same terminology and provide similar features.

Examining Expressions

logical error, breakpoint, trace calculations, debugger
#include <iostream>
using namespace std;

int main()
{
    double  f;
    cout << "Enter a temperature in Fahrenheit: ";
    cin >> f;

    double  c = 5 / 9 * (f - 32);

    cout << "The temperature in Celsius = " << c << endl;

    return 0;
}
  1. In the editor, right-click the highlighted statement.
  2. Select "Breakpoint" from the pop-up menu and "Insert Breakpoint" from the fly-out menu.
  3. A red dot appears on the left edge of the editor window adjacent to the highlighted statement, denoting a breakpoint. When a program runs in debug mode, it pauses at each breakpoint.
  4. Select "Debug" from Visual Studio's main menu and "Start Debugging" from the fly-out menu.
  5. The console window opens and displays the prompt. Type 212 and press Enter.
Setting a breakpoint and starting the debugger. The debugger does not require programmers to alter the program, so the demonstration begins with the original, uninstrumented code with the logical error. All debugging operations occur in the yellow-highlighted statement. Visual Studio typically has two or more controls for each operation: some have shortcuts, but the demonstration takes a longer approach to introduce steps required for later demonstrations.

 

A screen capture of the payment.cpp program. The image shows a large red dot with a yellow arrow on the same line as the statement 5/9*(f-32). The programmer highlights 5/9 in blue, hovers the mouse pointer over the highlighted code, and the debugger displays 5/9|0, indicating that 5/9 evaluates to 0.
  1. Highlight (left-click and drag) the expression whose value you want to see.
  2. Place the mouse pointer inside the highlighted region.
  3. The expression and its current value appear next to the cursor.
In this screen capture, (f-32) is highlighted, the mouse pointer again hovers over the highlighted expression, and the debugger displays (f-32)|180.00000000000, which indicates that f-32 or 212-32 is 180.
Examining expressions with the debugger. The arrow inside the red dot indicates that program execution has paused at the breakpoint, allowing developers to examine any part of the paused statement. Following the steps above, the debugger displays the current values of the two selected sub-expressions, helping developers locate the logical error. Developers can examine any expression to the right of the assignment operator. But the debugger pauses before running the statement, so the value in variable c is undefined. To end the debugging session:
  1. Open "Debug" from the main menu, choose either "Continue" or "Stop Debugging," and close the console window.
  2. Right-click at the beginning of the Celsius calculation, select "Breakpoint," and then "Delete Breakpoint."

Debugger Shortcuts

debugger, shortcuts
A screen capture showing a green-arrow play button. The button's operation changes based on the debugger's current state. The green-arrow button is now labeled Continue. The screen capture shows that the debugger also has a stop button indicated by a red square in its center.
(a)(b)(c)
Debugger context controls. All debugger controls are accessible through the "Debug" menu at the top of the main window, and many of the most frequently used operations are also available through a set of context controls appearing in the second row. Context controls change depending on what Visual Studio is doing at any given time.
  1. The "Run" button (green arrow) appears before the program begins execution.
  2. While the program is paused at a breakpoint, the green arrow becomes the "Continue" button.
  3. The "Stop" button (red square) also appears while the program is paused at a breakpoint

One Breakpoint, Multiple Statement Values

debugger, breakpoint

Although the ftoc program contained multiple expressions to explore, it only had one statement that could harbor the sought-after logical error. For longer programs, it's easier to start with complete statements. Consequently, we interpret "intermediate results" as each statement that calculates a significant value. Once this process identifies a failing statement, we can focus on its individual expressions as needed. The following figures use an abridged version of the payment.cpp program to illustrate manual instrumentation and using the debugger to examine intermediate statements.

int  n = years * 12;
double  r = apr / 12;

double	payment = p * r / (1 - pow(1 + r, -n));
cout << "Monthly Payment: " << payment << endl;

return 0;
int	n = years * 12;
cout << "n = " << n << endl;
double	r = apr / 12;
cout << "r = " << r << endl;

double	payment = p * r / (1 - pow(1 + r, -n));
cout << "Monthly Payment: " << payment << endl;
(a)(b)
Manually instrumenting code. Statements excerpted from the payment program used to demonstrate manual instrumentation and debugger breakpoints further.
  1. Uninstrumented code.
  2. Instrumenting code highlighted in yellow. Although the last output statement is a part of the original program, it also serves as an instrumenting statement.

 

A screen capture showing the payment.cpp program running in the debugger. Execution pauses on the return statement at the end of the program. The mouse pointer hovers over the variable payment, and the debugger displays payment|733.76457387937808.
Debugging with a breakpoint. The key to understanding how to debug statements with a breakpoint is knowing how a program behaves when it encounters one: the debugger pauses execution before running the statement on the breakpoint line. For example, if a programmer sets a breakpoint on line 29, the debugger would pause before storing a value in the variable payment.
  1. Set a single breakpoint on the return 0; statement: right-click the mouse pointer in the grey bar adjacent to the line number.
  2. Run the program from the menu: "Debug → Start Debugging." Use principle = 100000, apr = 0.08, and years = 30.
  3. The program pauses after all calculations are complete, but before returning (i.e., before executing return 0;).
  4. Hover the pointer over any variable you wish to examine, as is illustrated for payment.
  5. To remove the breakpoint: (a) right-click the large red dot, or (b) right-click in front of the return statement and remove the breakpoint through the menus as was done in Figure 6.

Examining Statements With Tracepoints

debugger, tracepoint, tracepoint action, action

Using tracepoints to examine program statements has advantages and disadvantages. Creating them requires the same effort as creating breakpoints following Figure 5, steps 1-3, but they don't support the shortcut described in Figure 9, step 1. Where breakpoints only pause a program to let programmers examine it, tracepoints allow more precise control: programmers can set conditions that control tracepoint activation and the actions the tracepoints take. The following figures illustrate tracepoint actions.

A sequence of configured tracepoints can display information like the manually instrumented code illustrated in Figure 8(b). The configuration uses a simple but arcane language, and configuring each tracepoint is a bit tedious; but unlike manually instrumented statements, tracepoints are quick and easy to remove. The language lets programmers specify labeling text that the debugger displays verbatim and variables whose values replace them in the output. Tracepoints are most useful for debugging branches, loops, and functions - topics covered in later chapters.

A screen capture showing how to set a tracepoint. Programmers right-click the line where they want to set the tracepoint, and a pop-up window opens. Selecting Breakpoint from the menu opens a second menu, where they select Insert Tracepoint.
Setting a tracepoint. Set a tracepoint on line 27. (Note that this is after the calculation of n but before the calculation of r.) Right-click on line 27 and select "Breakpoint" followed by "Insert Tracepoint."

 

This screen capture shows the tracepoint configuration window. Check two boxes: Actions and Continue code execution. Locate the text field labeled Show a message in the Output Window. The next screen capture demonstrates a simple action that a programmer can enter in the text field: n = {n}.
(a)(b)
Specifying tracepoint actions. Studio represents tracepoints with a red diamond in the grey column.
  1. Set a tracepoint action by checking the box marked "Actions," which opens a text field. Actions can vary from simple to complex.
  2. In the text field, enter the output message: n = {n}. The debugger displays any text outside the braces verbatim; it evaluates the text inside the braces and displays the result. The debugger writes these messages to the Output window, allowing programmers to watch any variable or expression. This technique separates debugging output from the program's normal output that still goes to the console. Press the "Close" button when finished.

 

This screen capture shows that the programmer has created two more tracepoints for the statements that calculate payment and print payment to the console.
Create tracepoints for additional variables. Repeat the steps demonstrated in Figures 10 and 11 for lines 29 and 30, adding tracepoints for r and payment.

 

The clear button is on the Output window's toolbar and is decorated with lines representing lines of text with an X in the upper left-hand corner. Hover the mouse pointer over the button to see a textual label.
Clear the output window. Tracepoint output accumulates in the Output window. By scrolling through it, programmers can compare the output of multiple iterations or loops, or output for different inputs. They can also clear the output at any time by clicking the "Clear" button at the top left of the Output window.

 

The screen capture shows the debugger messages that display the values for the three variables.
Tracepoint messages. Run the program in debug mode, as in the previous examples. Use the scrollbar or buttons on the right of the Output window to scroll the contents until the trace messages become visible. Alternatively, resize the window by dragging the top edge upwards. The trace messages show the values stored in n, r, and payment.