Skip to Content
Course content

103: The Three Levels of Exception Safety Guarantees

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen this exact bug surface in production more times than I care to admit. It usually happens when a developer thinks, "I'll just clear the old state and load the new state," without considering what happens if the loading process fails halfway through. In C++, this is where exception safety guarantees become the difference between a recoverable error and a corrupted application state.

The Vanishing Settings Bug

Imagine you're writing a SettingsManager that holds a map of configuration values. You want a method to update these settings from a data source. Here is a common, but dangerous, way to implement that:

struct SettingsManager {
    std::map<std::string, std::string> config;

    void updateSettings(const std::map<std::string, std::string> नए_settings) {
        // Step 1: Clear existing settings
        config.clear(); 

        // Step 2: Populate with new settings
        for (const auto& [key, val] : नए_settings) {
            // Imagine this throws a std::bad_alloc or a custom validation exception
            config.insert({key, val}); 
        }
    }
};

At first glance, this looks fine. But look closer at the sequence. If config.insert throws an exception on the fifth element of a ten-element map, your SettingsManager is now in a "zombie" state. You've already cleared the old, working settings, and you only have half of the new settings. The object is still "valid" in the sense that it hasn't crashed the CPU, but your application's logic is now broken because the configuration is incomplete. This is a classic failure of exception safety.

Implementing the Strong Guarantee with Copy-and-Swap

To fix this, we need to move from a "Basic Guarantee" (where we don't leak memory but the state is unpredictable) to a "Strong Guarantee" (where the operation either succeeds completely or leaves the state exactly as it was). The industry standard for this is the Copy-and-Swap idiom.

Instead of mutating the live state immediately, we perform the "risky" work on a temporary copy. If the copy process throws, the original state remains untouched. Only when we know the new state is fully constructed do we swap it into place using a non-throwing operation.

struct SettingsManager {
    std::map<std::string, std::string> config;

    void updateSettings(const std::map<std::string, std::string> new_settings) {
        // Create a temporary copy of the current state (or start fresh)
        // If this throws, 'this->config' is still safe and sound.
        std::map<std::string, std::string> temp_config = new_settings;

        // Perform any additional validation or modification on temp_config here...
        // If an exception happens here, we still haven't touched the real config.

        // Finally, swap the temporary with the real state.
        // std::swap for maps is guaranteed not to throw.
        std::swap(this->config, temp_config);
    }
};

By delaying the mutation until the very last moment, we've ensured that the SettingsManager is never left in a half-baked state. This is the essence of the Strong Guarantee.

Navigating Basic, Strong, and No-throw Guarantees

When you're designing a class, you don't always need the Strong Guarantee—it can be expensive because of the extra copying. You just need to know which level you are promising the user of your code.

  • The Basic Guarantee: This is the bare minimum. If an exception is thrown, no resources (memory, file handles) are leaked, and all objects are still in a valid state. You can still call member functions on the object, but the data might not be what you expect. Most C++ Standard Library containers provide this by default.
  • The Strong Guarantee: This is "commit-or-rollback" semantics. If the operation fails, the state of the program is exactly as it was before the call. This is what we achieved with Copy-and-Swap. It's highly desirable for critical state changes.
  • The No-throw (Nothrow) Guarantee: The function promises it will never throw an exception. This is critical for destructors, move constructors, and swap functions. If you're writing a move constructor, mark it noexcept; otherwise, std::vector might refuse to use it during a resize to avoid breaking the Strong Guarantee.

My rule of thumb: Start with the Basic Guarantee to prevent leaks, upgrade to the Strong Guarantee for public-facing API methods that change state, and use noexcept for the low-level plumbing that makes the Strong Guarantee possible.




📋 Practical Task

Exercise: Hardening the TransactionJournal

You are maintaining a TransactionJournal class that keeps a history of financial transactions. The current implementation of appendBatch is dangerous: it reserves space and then pushes elements into a vector. If a push fails, the vector may have been resized, but the batch is only partially added, leaving the journal in an inconsistent state.

Your Task: Rewrite the appendBatch method to provide the Strong Exception Guarantee. Ensure that if any part of the batch addition fails, the journal remains exactly as it was before the function was called.

#include <vector>
#include <string>
#include <stdexcept>
#include <iostream>

struct Transaction {
    int id;
    double amount;
};

class TransactionJournal {
    std::vector<Transaction> history;

public:
    // This is the BROKEN method. Fix it!
    void appendBatch(const std::vector<Transaction>& batch) {
        // CURRENT BROKEN LOGIC:
        // for (const auto& tx : batch) {
        //     history.push_back(tx); // If this throws, history is partially updated.
        // }
        
        // TODO: Implement Strong Guarantee here
    }

    size_t size() const { return history.size(); }
};

// Test harness
int main() {
    TransactionJournal journal;
    std::vector<Transaction> good_batch = {{1, 10.0}, {2, 20.0}};
    
    try {
        journal.appendBatch(good_batch);
        std::cout < "Journal size: " < journal.size() < " (Expected 2)" < std::endl;
    } catch (...) {
        std::cout < "Failed!" < std::endl;
    }
    return 0;
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.