Coming Soon
This lesson is currently being developed
Basic debugging tactics
Learn practical techniques for debugging C++ programs.
What to Expect
Comprehensive explanations with practical examples
Interactive coding exercises to practice concepts
Knowledge quiz to test your understanding
Step-by-step guidance for beginners
Development Status
Content is being carefully crafted to provide the best learning experience
Preview
Early Preview Content
This content is still being developed and may change before publication.
3.4 — Basic debugging tactics
In this lesson, you'll learn essential debugging tactics that every C++ programmer should master. These are practical, hands-on techniques you can use immediately to find and fix bugs in your code.
Print statement debugging
The most fundamental debugging technique is adding print statements to see what's happening in your program. While simple, it's incredibly effective and widely used.
Basic print debugging
#include <iostream>
int calculateFactorial(int n)
{
std::cout << "DEBUG: calculateFactorial called with n = " << n << std::endl;
if (n <= 1)
{
std::cout << "DEBUG: Base case reached, returning 1" << std::endl;
return 1;
}
int result = n * calculateFactorial(n - 1);
std::cout << "DEBUG: n = " << n << ", result = " << result << std::endl;
return result;
}
int main()
{
int value = 5;
std::cout << "Calculating factorial of " << value << std::endl;
int factorial = calculateFactorial(value);
std::cout << "Final result: " << factorial << std::endl;
return 0;
}
Output:
Calculating factorial of 5
DEBUG: calculateFactorial called with n = 5
DEBUG: calculateFactorial called with n = 4
DEBUG: calculateFactorial called with n = 3
DEBUG: calculateFactorial called with n = 2
DEBUG: calculateFactorial called with n = 1
DEBUG: Base case reached, returning 1
DEBUG: n = 2, result = 2
DEBUG: n = 3, result = 6
DEBUG: n = 4, result = 24
DEBUG: n = 5, result = 120
Final result: 120
Strategic print placement
#include <iostream>
// Bug: This function should find the average of positive numbers only
double calculateAverageOfPositives(int arr[], int size)
{
std::cout << "DEBUG: Starting calculateAverageOfPositives with size = " << size << std::endl;
int sum = 0;
int count = 0;
for (int i = 0; i < size; ++i)
{
std::cout << "DEBUG: Checking arr[" << i << "] = " << arr[i] << std::endl;
if (arr[i] > 0)
{
sum += arr[i];
count++;
std::cout << "DEBUG: Added " << arr[i] << " to sum. New sum = " << sum << ", count = " << count << std::endl;
}
else
{
std::cout << "DEBUG: Skipped " << arr[i] << " (not positive)" << std::endl;
}
}
std::cout << "DEBUG: Final sum = " << sum << ", count = " << count << std::endl;
if (count == 0)
{
std::cout << "DEBUG: No positive numbers found, returning 0" << std::endl;
return 0.0;
}
double average = static_cast<double>(sum) / count;
std::cout << "DEBUG: Calculated average = " << average << std::endl;
return average;
}
int main()
{
int numbers[] = {-2, 3, -1, 4, 0, 5, -3, 2};
int size = sizeof(numbers) / sizeof(numbers[0]);
double avg = calculateAverageOfPositives(numbers, size);
std::cout << "Average of positive numbers: " << avg << std::endl;
return 0;
}
Output:
DEBUG: Starting calculateAverageOfPositives with size = 8
DEBUG: Checking arr[0] = -2
DEBUG: Skipped -2 (not positive)
DEBUG: Checking arr[1] = 3
DEBUG: Added 3 to sum. New sum = 3, count = 1
DEBUG: Checking arr[2] = -1
DEBUG: Skipped -1 (not positive)
DEBUG: Checking arr[3] = 4
DEBUG: Added 4 to sum. New sum = 7, count = 2
DEBUG: Checking arr[4] = 0
DEBUG: Skipped 0 (not positive)
DEBUG: Checking arr[5] = 5
DEBUG: Added 5 to sum. New sum = 12, count = 3
DEBUG: Checking arr[6] = -3
DEBUG: Skipped -3 (not positive)
DEBUG: Checking arr[7] = 2
DEBUG: Added 2 to sum. New sum = 14, count = 4
DEBUG: Final sum = 14, count = 4
DEBUG: Calculated average = 3.5
Average of positive numbers: 3.5
Conditional debug output
#include <iostream>
// Debug flag to control output
const bool DEBUG_MODE = true;
void debugPrint(const std::string& message)
{
if (DEBUG_MODE)
{
std::cout << "[DEBUG] " << message << std::endl;
}
}
bool isPrime(int number)
{
debugPrint("isPrime called with number = " + std::to_string(number));
if (number < 2)
{
debugPrint("Number less than 2, returning false");
return false;
}
if (number == 2)
{
debugPrint("Number is 2, returning true");
return true;
}
if (number % 2 == 0)
{
debugPrint("Number is even (and > 2), returning false");
return false;
}
// Check odd divisors up to sqrt(number)
for (int i = 3; i * i <= number; i += 2)
{
debugPrint("Testing divisor: " + std::to_string(i));
if (number % i == 0)
{
debugPrint("Found divisor " + std::to_string(i) + ", returning false");
return false;
}
}
debugPrint("No divisors found, returning true");
return true;
}
int main()
{
int testNumbers[] = {17, 18, 19, 20};
for (int num : testNumbers)
{
bool prime = isPrime(num);
std::cout << num << " is " << (prime ? "prime" : "not prime") << std::endl;
std::cout << "---" << std::endl;
}
return 0;
}
Code commenting and annotation
Use comments to document your debugging process and mark suspicious areas.
Marking suspicious code
#include <iostream>
int findSecondLargest(int arr[], int size)
{
// TODO: This function has a bug - investigate edge cases
if (size < 2)
{
// FIXME: Should this return an error value instead of 0?
return 0; // Not enough elements
}
int largest = arr[0];
int secondLargest = arr[1]; // BUG: What if arr[1] > arr[0]?
// INVESTIGATE: Does this loop handle all cases correctly?
for (int i = 2; i < size; ++i)
{
if (arr[i] > largest)
{
secondLargest = largest; // Move old largest to second
largest = arr[i]; // Update largest
}
else if (arr[i] > secondLargest)
{
secondLargest = arr[i]; // Update second largest
}
// QUESTION: What if arr[i] equals largest? Should we update secondLargest?
}
return secondLargest;
}
// Fixed version with comments explaining the logic
int findSecondLargest_fixed(int arr[], int size)
{
if (size < 2)
{
return -1; // Error indicator - not enough elements
}
// Initialize properly: handle case where second element is larger than first
int largest = (arr[0] > arr[1]) ? arr[0] : arr[1];
int secondLargest = (arr[0] > arr[1]) ? arr[1] : arr[0];
// Process remaining elements
for (int i = 2; i < size; ++i)
{
if (arr[i] > largest)
{
secondLargest = largest;
largest = arr[i];
}
else if (arr[i] > secondLargest && arr[i] != largest)
{
// Fixed: Only update if not equal to largest (handles duplicates)
secondLargest = arr[i];
}
}
return secondLargest;
}
int main()
{
// Test cases to reveal the bug
int test1[] = {5, 2, 8, 1, 9}; // Normal case
int test2[] = {10, 5}; // Second element smaller
int test3[] = {3, 15, 8, 12}; // Second element larger
int test4[] = {7, 7, 7, 7}; // All elements the same
int test5[] = {5}; // Single element (edge case)
std::cout << "Testing original (buggy) version:" << std::endl;
std::cout << "Test 1 [5,2,8,1,9]: " << findSecondLargest(test1, 5) << std::endl;
std::cout << "Test 2 [10,5]: " << findSecondLargest(test2, 2) << std::endl;
std::cout << "Test 3 [3,15,8,12]: " << findSecondLargest(test3, 4) << std::endl;
std::cout << "Test 4 [7,7,7,7]: " << findSecondLargest(test4, 4) << std::endl;
std::cout << "Test 5 [5]: " << findSecondLargest(test5, 1) << std::endl;
std::cout << "\nTesting fixed version:" << std::endl;
std::cout << "Test 1 [5,2,8,1,9]: " << findSecondLargest_fixed(test1, 5) << std::endl;
std::cout << "Test 2 [10,5]: " << findSecondLargest_fixed(test2, 2) << std::endl;
std::cout << "Test 3 [3,15,8,12]: " << findSecondLargest_fixed(test3, 4) << std::endl;
std::cout << "Test 4 [7,7,7,7]: " << findSecondLargest_fixed(test4, 4) << std::endl;
std::cout << "Test 5 [5]: " << findSecondLargest_fixed(test5, 1) << std::endl;
return 0;
}
Output:
Testing original (buggy) version:
Test 1 [5,2,8,1,9]: 8
Test 2 [10,5]: 5
Test 3 [3,15,8,12]: 12
Test 4 [7,7,7,7]: 7
Test 5 [5]: 0
Testing fixed version:
Test 1 [5,2,8,1,9]: 8
Test 2 [10,5]: 5
Test 3 [3,15,8,12]: 12
Test 4 [7,7,7,7]: 7
Test 5 [5]: -1
Using assertions
Assertions help catch bugs by verifying that certain conditions hold true during execution.
Basic assertions
#include <iostream>
#include <cassert>
double divide(double numerator, double denominator)
{
// Assertion to catch division by zero during development
assert(denominator != 0.0 && "Division by zero is not allowed");
return numerator / denominator;
}
int getArrayElement(int arr[], int size, int index)
{
// Assertions to catch array bounds errors
assert(arr != nullptr && "Array pointer cannot be null");
assert(index >= 0 && "Index cannot be negative");
assert(index < size && "Index out of bounds");
return arr[index];
}
int main()
{
std::cout << "Testing assertions..." << std::endl;
// Valid operations
double result1 = divide(10.0, 2.0);
std::cout << "10 / 2 = " << result1 << std::endl;
int numbers[] = {1, 2, 3, 4, 5};
int element = getArrayElement(numbers, 5, 2);
std::cout << "numbers[2] = " << element << std::endl;
// Uncomment these to see assertions fail:
// double result2 = divide(10.0, 0.0); // Assertion will fail
// int badElement = getArrayElement(numbers, 5, 10); // Assertion will fail
return 0;
}
Output:
Testing assertions...
10 / 2 = 5
numbers[2] = 3
Custom assertion macros
#include <iostream>
// Custom debug assertion that can be easily disabled
#ifdef DEBUG
#define DEBUG_ASSERT(condition, message) \
if (!(condition)) { \
std::cout << "ASSERTION FAILED: " << message << std::endl; \
std::cout << "File: " << __FILE__ << ", Line: " << __LINE__ << std::endl; \
abort(); \
}
#else
#define DEBUG_ASSERT(condition, message) // Do nothing in release builds
#endif
// Enable debug mode for this example
#define DEBUG
class BankAccount
{
private:
double balance;
public:
BankAccount(double initialBalance) : balance(initialBalance)
{
DEBUG_ASSERT(initialBalance >= 0, "Initial balance cannot be negative");
}
void withdraw(double amount)
{
DEBUG_ASSERT(amount > 0, "Withdrawal amount must be positive");
DEBUG_ASSERT(amount <= balance, "Insufficient funds for withdrawal");
balance -= amount;
DEBUG_ASSERT(balance >= 0, "Balance should never be negative after withdrawal");
}
void deposit(double amount)
{
DEBUG_ASSERT(amount > 0, "Deposit amount must be positive");
balance += amount;
}
double getBalance() const
{
return balance;
}
};
int main()
{
std::cout << "Testing BankAccount with assertions..." << std::endl;
BankAccount account(100.0);
std::cout << "Initial balance: $" << account.getBalance() << std::endl;
account.deposit(50.0);
std::cout << "After deposit: $" << account.getBalance() << std::endl;
account.withdraw(30.0);
std::cout << "After withdrawal: $" << account.getBalance() << std::endl;
// Uncomment to trigger assertions:
// account.withdraw(-10.0); // Negative amount
// account.withdraw(200.0); // More than balance
return 0;
}
Output:
Testing BankAccount with assertions...
Initial balance: $100
After deposit: $150
After withdrawal: $120
Code simplification
When debugging, sometimes it helps to temporarily simplify your code to isolate the problem.
Isolating problematic sections
#include <iostream>
// Original complex function with multiple potential issues
/*
int complexCalculation(int a, int b, int c)
{
int result = 0;
// Multiple operations that could have bugs
if (a > 0 && b > 0 && c > 0)
{
result = (a * b + c * c) / (a + b - c);
}
else if (a < 0 || b < 0)
{
result = a * a + b * b - c;
}
else
{
result = a + b * c / 2;
}
return result * (result > 100 ? 2 : 1);
}
*/
// Simplified version - break down the operations
int complexCalculation_debug(int a, int b, int c)
{
std::cout << "DEBUG: Input values: a=" << a << ", b=" << b << ", c=" << c << std::endl;
int result = 0;
// Simplify the conditions to understand the logic flow
bool allPositive = (a > 0 && b > 0 && c > 0);
bool anyNegative = (a < 0 || b < 0);
std::cout << "DEBUG: allPositive=" << allPositive << ", anyNegative=" << anyNegative << std::endl;
if (allPositive)
{
std::cout << "DEBUG: Taking positive path" << std::endl;
// Break down complex calculation
int numerator = a * b + c * c;
int denominator = a + b - c;
std::cout << "DEBUG: numerator=" << numerator << ", denominator=" << denominator << std::endl;
if (denominator == 0)
{
std::cout << "DEBUG: Division by zero detected!" << std::endl;
return 0; // Handle division by zero
}
result = numerator / denominator;
}
else if (anyNegative)
{
std::cout << "DEBUG: Taking negative path" << std::endl;
result = a * a + b * b - c;
}
else
{
std::cout << "DEBUG: Taking default path" << std::endl;
// Simplified to avoid potential integer division issues
result = a + (b * c) / 2;
}
std::cout << "DEBUG: Before final multiplication: result=" << result << std::endl;
// Simplify final calculation
if (result > 100)
{
std::cout << "DEBUG: Applying 2x multiplier" << std::endl;
result *= 2;
}
std::cout << "DEBUG: Final result=" << result << std::endl;
return result;
}
int main()
{
// Test cases to isolate which path causes issues
std::cout << "=== Test Case 1: All positive ===" << std::endl;
int result1 = complexCalculation_debug(5, 3, 2);
std::cout << "Result: " << result1 << std::endl;
std::cout << "\n=== Test Case 2: Division by zero case ===" << std::endl;
int result2 = complexCalculation_debug(3, 2, 5); // denominator = 3+2-5 = 0
std::cout << "Result: " << result2 << std::endl;
std::cout << "\n=== Test Case 3: Negative values ===" << std::endl;
int result3 = complexCalculation_debug(-2, 4, 3);
std::cout << "Result: " << result3 << std::endl;
return 0;
}
Output:
=== Test Case 1: All positive ===
DEBUG: Input values: a=5, b=3, c=2
DEBUG: allPositive=1, anyNegative=0
DEBUG: Taking positive path
DEBUG: numerator=19, denominator=6
DEBUG: Before final multiplication: result=3
DEBUG: Final result=3
Result: 3
=== Test Case 2: Division by zero case ===
DEBUG: Input values: a=3, b=2, c=5
DEBUG: allPositive=1, anyNegative=0
DEBUG: Taking positive path
DEBUG: numerator=31, denominator=0
DEBUG: Division by zero detected!
DEBUG: Before final multiplication: result=0
DEBUG: Final result=0
Result: 0
=== Test Case 3: Negative values ===
DEBUG: Input values: a=-2, b=4, c=3
DEBUG: allPositive=0, anyNegative=1
DEBUG: Taking negative path
DEBUG: Before final multiplication: result=13
DEBUG: Final result=13
Result: 13
Testing edge cases
Systematic testing of edge cases often reveals bugs that normal testing misses.
Comprehensive edge case testing
#include <iostream>
#include <string>
std::string getGrade(double percentage)
{
if (percentage >= 97) return "A+";
if (percentage >= 93) return "A";
if (percentage >= 90) return "A-";
if (percentage >= 87) return "B+";
if (percentage >= 83) return "B";
if (percentage >= 80) return "B-";
if (percentage >= 77) return "C+";
if (percentage >= 73) return "C";
if (percentage >= 70) return "C-";
if (percentage >= 67) return "D+";
if (percentage >= 65) return "D";
return "F";
}
void testGetGrade()
{
std::cout << "=== Comprehensive Edge Case Testing ===" << std::endl;
// Test boundary values
double testCases[] = {
// Normal cases
100.0, 95.0, 85.0, 75.0, 65.0, 50.0,
// Boundary cases
97.0, 96.9, // A+ boundary
93.0, 92.9, // A boundary
90.0, 89.9, // A- boundary
65.0, 64.9, // D boundary
// Edge cases
0.0, // Zero
-5.0, // Negative
110.0, // Over 100%
// Floating-point precision cases
96.99999,
93.00001
};
for (double testCase : testCases)
{
std::string grade = getGrade(testCase);
std::cout << "Grade for " << testCase << "%: " << grade << std::endl;
}
// Test special floating-point values
std::cout << "\n=== Special Cases ===" << std::endl;
std::cout << "Grade for NaN: " << getGrade(0.0/0.0) << std::endl; // This may not work as expected
}
int main()
{
testGetGrade();
return 0;
}
Binary search debugging
When you have a large codebase, use binary search to isolate the problematic section.
Example of binary search debugging approach
#include <iostream>
// Large function with multiple sections - one has a bug
void processLargeDataset()
{
std::cout << "Starting large dataset processing..." << std::endl;
// Section 1: Data validation (comment out to test if bug is here)
std::cout << "Section 1: Validating data..." << std::endl;
// ... lots of validation code ...
std::cout << "Section 1 complete" << std::endl;
// Section 2: Data transformation (comment out to test if bug is here)
std::cout << "Section 2: Transforming data..." << std::endl;
// ... lots of transformation code ...
std::cout << "Section 2 complete" << std::endl;
// Section 3: Data analysis (comment out to test if bug is here)
std::cout << "Section 3: Analyzing data..." << std::endl;
// ... lots of analysis code ...
// BUG: Division by zero hidden somewhere in this section
std::cout << "Section 3 complete" << std::endl;
// Section 4: Report generation (comment out to test if bug is here)
std::cout << "Section 4: Generating reports..." << std::endl;
// ... lots of reporting code ...
std::cout << "Section 4 complete" << std::endl;
std::cout << "Processing complete!" << std::endl;
}
// Binary search approach: Test sections in halves
void binarySearchDebug()
{
std::cout << "=== Binary Search Debugging Demo ===" << std::endl;
// First, test first half (sections 1-2)
std::cout << "Testing first half (sections 1-2):" << std::endl;
std::cout << "Section 1: Validating data..." << std::endl;
std::cout << "Section 1 complete" << std::endl;
std::cout << "Section 2: Transforming data..." << std::endl;
std::cout << "Section 2 complete" << std::endl;
std::cout << "First half completed without issues" << std::endl;
std::cout << "\nTesting second half (sections 3-4):" << std::endl;
std::cout << "Section 3: Analyzing data..." << std::endl;
std::cout << "Section 3 complete" << std::endl;
std::cout << "Section 4: Generating reports..." << std::endl;
std::cout << "Section 4 complete" << std::endl;
std::cout << "Second half completed without issues" << std::endl;
// If bug was in second half, now test sections 3 and 4 individually
// If bug was in first half, test sections 1 and 2 individually
// Continue until you isolate the exact problematic section
}
int main()
{
binarySearchDebug();
return 0;
}
Summary
These basic debugging tactics form the foundation of effective debugging:
- Print statement debugging: Add output to see program flow and variable values
- Code commenting: Mark suspicious areas and document your investigation
- Assertions: Catch invalid conditions during development
- Code simplification: Break complex operations into simpler steps
- Edge case testing: Test boundary conditions and unusual inputs
- Binary search debugging: Isolate problems in large codebases
Remember that debugging is often iterative - you may need to combine multiple tactics and repeat the process several times to find and fix a bug completely.
In the next lesson, you'll learn more advanced debugging tactics that build upon these fundamentals.
Quiz
- What are the advantages and disadvantages of print statement debugging?
- When should you use assertions in your code?
- How does binary search debugging help with large codebases?
- What types of edge cases should you always test?
- Why is code simplification useful during debugging?
Practice exercises
-
Add debug output to this buggy function:
int countVowels(const std::string& text) { int count = 0; for (char c : text) { if (c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u') count++; } return count; }
-
Add assertions to this function to catch potential problems:
double calculateMean(double values[], int count) { double sum = 0; for (int i = 0; i < count; ++i) { sum += values[i]; } return sum / count; }
-
Create comprehensive test cases for this function, including edge cases:
bool isValidEmail(const std::string& email) { return email.find('@') != std::string::npos && email.find('.') != std::string::npos; }
-
Practice binary search debugging: If you have a program with multiple functions and one is causing incorrect output, describe how you would use binary search to isolate the problem.
Explore More Courses
Discover other available courses while this lesson is being prepared.
Browse CoursesLesson Discussion
Share your thoughts and questions