Skip to Content
Course content

97: Exceptions and Exception Safety

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

Imagine you're a chef preparing a complex five-course meal. You've got the appetizers plated, the roast is in the oven, and you're just starting the sauce. Suddenly, you realize you're completely out of heavy cream. This is an "exceptional" event—something you didn't plan for, but you can't just ignore it and keep cooking the sauce with water and hope for the best.

Now, how you handle this defines your professionalism. A sloppy chef might just leave the stove on, leave the half-chopped vegetables on the counter, and walk out of the kitchen in a panic. The kitchen is now a mess, and the roast is burning. A professional chef, however, handles the crisis: they turn off the heat, clear the workspace, and either find a substitute or inform the guests that the course is changed. They ensure that even though a failure occurred, the kitchen isn't left in a dangerous or unusable state.

In C++, an exception is that "out of cream" moment. Exception safety isn't just about using try and catch blocks to stop the program from crashing; it's about ensuring your "kitchen" (your memory, file handles, and object states) stays clean when things go south.

Knowing When to Actually Throw

I've seen a lot of developers use exceptions for everything—even common logic errors. Don't do that. Exceptions are expensive in terms of performance, and they make the control flow harder to follow. Use them for things that shouldn't happen under normal operation, but might due to external factors: a network timeout, a corrupted config file, or running out of memory.

#include <stdexcept>
#include <string>

void connectToDatabase(const std::string& connectionString) {
    if (connectionString.empty()) {
        // This is a programmer error, but we'll throw to signal the failure
        throw std::invalid_argument("Connection string cannot be empty!");
    }
    // Simulate a failure to connect
    bool success = false; 
    if (!success) {
        throw std::runtime_error("Database server unreachable.");
    }
}

The Three Levels of Safety

When I'm reviewing code, I'm usually looking for which of these three guarantees the author is providing. You don't always need the highest level, but you need to be intentional about it.

  • The Basic Guarantee: This is the bare minimum. If an exception is thrown, no memory is leaked, and all objects are still in a valid state (even if they aren't in the state you intended). You can still call the destructor or assign a new value to the object.
  • The Strong Guarantee: This is the "transactional" approach. Either the operation succeeds completely, or the state of the program is rolled back to exactly where it was before the function was called. It's all or nothing.
  • The Nothrow Guarantee: The function is guaranteed not to throw an exception. This is critical for destructors—if a destructor throws during an existing stack unwind, your program will call std::terminate() immediately. Always mark these with noexcept.

Preventing the "Half-Baked" State with Copy-and-Swap

Achieving the strong guarantee is where most people struggle. If you start modifying an object's members and then an exception hits halfway through, you've left that object in a "half-baked" state. The secret to avoiding this is to perform all the risky work on a temporary copy first.

Take a look at this pattern. Instead of modifying the current object directly, we make a copy, modify the copy, and then swap the data using a non-throwing swap function. I call this the "commit" phase.

#include <vector>
#include <algorithm>

class DataBuffer {
    std::vector<int> data;

public:
    // This assignment operator provides the Strong Guarantee
    DataBuffer& operator=(const DataBuffer& other) {
        if (this != &other) {
            // 1. Create a temporary copy (The "Risky" part)
            // If this throws std::bad_alloc, the original 'data' is untouched.
            std::vector<int> temp = other.data;

            // 2. Swap the contents (The "Commit" part)
            // std::swap for vectors is noexcept.
            std::swap(this->data, temp);
        }
        return *this;
    }
};

By the time we reach the std::swap, all the operations that could possibly fail have already finished. If temp = other.data fails, the function exits, the temporary is cleaned up, and our original DataBuffer remains exactly as it was. That's how you keep your kitchen clean.




📋 Practical Task

Implementing a Transactional User Profile Updater

You are building a system that updates user profiles in a database. The UserProfile class contains several fields. If any part of the update process fails (e.g., a validation error), the profile must remain in its original state. You cannot have a user with a new email address but an old phone number.

Your Task: Implement the updateProfile method in the UserRegistry class using the "copy-and-swap" logic to ensure the Strong Exception Guarantee.

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

struct UserProfile {
    std::string username;
    std::string email;
    std::string phoneNumber;
};

class UserRegistry {
    UserProfile currentProfile;

public:
    UserRegistry(UserProfile p) : currentProfile(p) {}

    void updateProfile(const std::string& newEmail, const std::string& newPhone) {
        // TODO: Implement this method to provide the Strong Exception Guarantee.
        // 1. Create a copy of the currentProfile.
        // 2. Update the copy's email and phone.
        // 3. Simulate a failure: if newEmail is "fail@test.com", throw a std::runtime_error.
        // 4. If no exception occurred, swap the copy with the currentProfile.
    }

    void display() const {
        std::cout < "User: " < currentProfile.username 
                  < " | Email: " < currentProfile.email 
                  < " | Phone: " < currentProfile.phoneNumber < "\n";
    }
};

int main() {
    UserRegistry registry({"DevUser", "old@test.com", "123-456"});
    
    try {
        std::cout < "Attempting valid update... ";
        registry.updateProfile("new@test.com", "987-654");
        std::cout < "Success!\n";
    } catch (...) {
        std::cout < "Failed!\n";
    }
    registry.display();

    try {
        std::cout < "Attempting invalid update... ";
        registry.updateProfile("fail@test.com", "000-000");
        std::cout < "Success!\n";
    } catch (const std::exception& e) {
        std::cout < "Caught expected error: " < e.what() < "\n";
    }
    
    // This should still show the "new@test.com" data, not the "000-000" phone number.
    registry.display(); 
    
    return 0;
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.