C++ Debugging, Testing and Sanitizers

Build C++ regression checks, inspect state with a debugger, use supported sanitizers, and distinguish undefined behavior from ordinary validation failures.

Lesson 43 of 44Advanced10 min read

A program printing the expected text once is weak evidence when the defect appears only on an empty input or near a numeric limit. A useful workflow reproduces the failure, identifies the violated rule, changes the implementation and preserves a regression check. Compiler diagnostics, a debugger and sanitizers provide complementary evidence, but none of them automatically proves every path correct.

This guide uses C++17. The complete programs include small explicit test harnesses that remain active in release builds. Debugger commands use LLDB terminology, while sanitizer commands assume Clang with compatible runtime support on the selected platform. Tool availability varies, so an unsupported sanitizer build should be reported as a verification limit rather than described as a passing check.

Reproduce the smallest meaningful failure

Record the input, build configuration and observed outcome. Reduce the case until it still demonstrates the broken behavior without unrelated application state. An empty vector, a missing record or a boundary count often explains more than a large production dump.

State the expected contract in words before editing code. Does an empty batch total zero or fail? Is the final index permitted? Does an excessive quantity throw or return a validation result? A regression test should exercise that contract, not merely duplicate the implementation line by line. Avoid retaining sensitive production payloads unnecessarily. A minimal synthetic fixture can reproduce the same structural error while remaining safe to share and review.

Warnings identify suspicious assumptions

Useful Clang or GCC warning flags include Wall and Wextra, with conversion and shadow warnings added under an appropriate project policy. A warning can reveal signedness changes, unused variables or hidden names. It is evidence to investigate, not proof that the program is incorrect in every case.

Keep flags compatible with the chosen compiler. Do not paste GCC options into an unrelated toolchain and assume they mean the same thing. Treat warnings at a deliberate boundary, especially for third-party headers. Fix the underlying range or ownership assumption when possible rather than inserting a cast solely to silence a diagnostic. The code should explain why a conversion is valid for accepted input.

A debugger explains the actual execution path

Build with debug information and modest or disabled optimization when inspecting a simple source-level flow. Set a breakpoint at the operation, run the program and inspect the relevant locals. Step through the check that decides whether mutation is allowed. A backtrace identifies the call path when execution stops inside a deeper helper.

For the main program, an LLDB session can begin with these commands. Paths and displayed addresses depend on the environment.

SHELL
clang++ -std=c++17 -g -O0 -Wall -Wextra main.cpp -o checks
lldb ./checks
Text
breakpoint set --name total_quantities
run
frame variable
next
thread backtrace
continue

An optimized build can reorder or remove locals, so the displayed source state may be less direct. Use the debugger to test a hypothesis about state, then retain the discovered requirement as a regression check.

Sanitizers detect selected runtime violations

AddressSanitizer instruments supported builds to detect many memory errors, including certain out-of-bounds and use-after-free accesses. UndefinedBehaviorSanitizer checks selected undefined operations, such as relevant signed overflow or invalid alignment cases. Their exact capabilities and platform support differ.

With compatible Clang runtimes, a useful diagnostic build can use the following command. The final link must include the relevant instrumentation support, so use the compiler driver consistently.

SHELL
clang++ -std=c++17 -O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined main.cpp -o checks-sanitized

The safe main program should still print three passing checks without a sanitizer diagnostic. An unavailable runtime or unsupported flag is a tool configuration failure, not a result proving the code safe. Never ship a sanitizer runtime as an assumed production security boundary.

Undefined behavior is not an expected alternative output

An invalid operation may crash, appear to work or change behavior under optimization. There is no portable expected value to document for undefined behavior. Explain the violated rule and show a valid replacement instead of presenting one observed result as a language guarantee.

A sanitizer's silence means no supported violation was detected on the executed paths. It does not prove all paths or all contracts correct. Combine type and lifetime reasoning with relevant tests, warnings and instrumentation. For iterator invalidation, some bad accesses still touch mapped memory, so dedicated debug iterator modes can reveal problems a memory sanitizer misses.

Keep the final verification relevant

After fixing the cause, run the regression and the checks appropriate to affected behavior. Preserve exact inputs and meaningful assertions. A deterministic boundary test is stronger than adding arbitrary sleeps or retrying until the run happens to pass.

Use a test framework when the project needs fixtures, parameterized cases, discovery and clear reports. GoogleTest distinguishes fatal and nonfatal assertions, allowing a test to stop when later checks would depend on a failed precondition. Keep test dependencies isolated from the production interface. Tests should explain the promised behavior to a reader who did not witness the original failure.

Official references

C++ Debugging, Testing and Sanitizers Examples

Each example shows the code, the output it produces and what each part does.

Example 1

A release-active regression harness

The complete program checks an ordinary total, an empty total and rejection of a negative quantity. Its require helper throws on failure, so defining NDEBUG does not erase the checks as it can with ordinary assert.

A failing check returns a nonzero process status. This compact harness illustrates the mechanism; a larger project should use a maintained framework with named cases and reporting, such as GoogleTest.

C++
#include <iostream>
#include <limits>
#include <stdexcept>
#include <vector>

int total_quantities(const std::vector<int>& quantities) {
    int total = 0;
    for (int quantity : quantities) {
        if (quantity < 0)
            throw std::invalid_argument("negative quantity");
        if (total > std::numeric_limits<int>::max() - quantity)
            throw std::overflow_error("total overflow");
        total += quantity;
    }
    return total;
}

void require(bool condition, const char* message) {
    if (!condition)
        throw std::runtime_error(message);
}

int main() {
    try {
        require(total_quantities({2, 3}) == 5, "ordinary total");
        require(total_quantities({}) == 0, "empty total");
        bool rejected = false;
        try {
            (void)total_quantities({-1});
        } catch (const std::invalid_argument&) {
            rejected = true;
        }
        require(rejected, "negative input must be rejected");
        std::cout << "3 checks passed\n";
        return 0;
    } catch (const std::exception& error) {
        std::cerr << "Check failed: " << error.what() << '\n';
        return 1;
    }
}
Output
3 checks passed

C++ Debugging, Testing and Sanitizers in Real Projects

Worked exercise: preserve a range-error regression

The task is to verify valid lookup of the first and last quantity and rejection at exactly size. The function uses checked vector access. The harness confirms that the boundary throws the intended exception rather than accepting an invalid position. All three checks execute without intentionally dereferencing invalid storage.

C++
#include <cstddef>
#include <iostream>
#include <stdexcept>
#include <vector>

int quantity_at(const std::vector<int>& values, std::size_t index) {
    return values.at(index);
}

void require(bool condition, const char* message) {
    if (!condition)
        throw std::runtime_error(message);
}

int main() {
    try {
        const std::vector<int> values{4, 8};
        require(quantity_at(values, 0) == 4, "first value");
        require(quantity_at(values, values.size() - 1) == 8, "last value");
        bool rejected = false;
        try {
            (void)quantity_at(values, values.size());
        } catch (const std::out_of_range&) {
            rejected = true;
        }
        require(rejected, "size is not a valid index");
        std::cout << "Boundary checks passed\n";
        return 0;
    } catch (const std::exception& error) {
        std::cerr << "Check failed: " << error.what() << '\n';
        return 1;
    }
}
Output
Boundary checks passed

A general test suite should also cover an empty vector through the rejection path. Avoid computing size minus one for an empty unsigned size, because that expression would wrap before the lookup contract is evaluated.

C++ Debugging, Testing and Sanitizers FAQ

Does a passing sanitizer run prove the program correct?

No. It establishes that the supported instrumentation detected no violation in that executed run. Other paths, semantic errors or unsupported categories can remain. Use sanitizers alongside lifetime reasoning, compiler diagnostics and meaningful contract tests. Record the toolchain and instrumentation flags so the evidence is reviewable, and report unsupported runtime configurations honestly.

Why not use assert for every regression check?

Assert is useful for internal assumptions, but it can be disabled when NDEBUG is defined. A regression harness should continue to test its contract in the intended build configurations. Use explicit checks or a maintained test framework. Keep production input validation separate from assertions, because invalid external input must still be handled when debug assertions are absent.

Why can a debugger show surprising local values?

Optimization can remove, combine or reorder operations and variables while preserving valid program behavior. Debug information may not map every source variable to a live location. Start with a suitable debug build for source-level investigation, then verify the fixed code under the real optimized configuration too. A defect involving undefined behavior can also make ordinary source assumptions unreliable.

Should I execute bad pointer code to learn its output?

Do not treat an undefined operation as having a portable expected output. Explain the lifetime or bounds violation and use a safe corrected example. A controlled diagnostic experiment can demonstrate a tool's detection ability, but its observed result is not a language contract. Ordinary guide fixtures should remain valid and verify the intended behavior without invoking undefined access.

C++ Debugging, Testing and Sanitizers Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Multiple choicemedium

A sanitizer build cannot link its runtime on the current platform. What should the report say?

C++ Debugging, Testing and Sanitizers Quiz

2 questions to check what you learned.

  1. What should a regression check preserve?

    • The exact sequence of implementation statements.
    • The input condition and promised observable behavior.
    • Every memory address from one run.
    • A passing result after arbitrary retries.
  2. Why is checking signed overflow only after addition unsound?

    • Examining a wrapped result makes the preceding signed overflow defined.
    • Every overflow necessarily throws std::overflow_error automatically.
    • The undefined operation has already occurred.
    • The same post-addition check is safe merely because the debug build passed.