C++ Error Handling RAII Design Patterns

Stop writing constructors that can fail

Once upon a time, I was reviewing some code with a colleague who was somewhat new to C++. And I just had to explain to him why what we looked at was not done well and how I think it should be done in modern C++. Just so he doesn't pick up bad habits.

When Constructors Fail

While looking at a crashdump and callstack from a crashed server system environment, we identified a pattern that's all too common in C++ codebases. This codebase had exactly the kind of use case you would traditionally use C++ for. Memory efficiency and performance are the most critical metrics. But so is reliability and maintainability, which it was lacking in this case.

The source of the crash looked something like this (not the real code for obvious reasons, comments by me):

class SharedMemoryBuffer {
private:
    int shmid_;
    void* data_;
    size_t size_;

public:
    // Which constructor does what? Need to read docs...
    SharedMemoryBuffer(size_t size) : size_(size) {
        key_t key = ftok("/tmp", 'S') + size / 1024;

        shmid_ = shmget(key, size, IPC_CREAT | 0666);
        if (shmid_ == -1) {
            abort();  // Crash the entire process!
        }

        data_ = shmat(shmid_, nullptr, 0);
        if (data_ == (void*)-1) {
            abort();  // Another potential crash point!
        }
    }

    // Attach to existing? What if key is invalid?
    SharedMemoryBuffer(key_t key) {
        // ... similar abort() calls on failure
        abort(); // If anything goes wrong
    }
};

On the surface the problem was pretty simple. Some Linux sys call failed and the program was "aborted" intentionally. We didn't know why it failed, because there was no logging or any kind of error code evaluation. Probably some kind of resource exhaustion or permission issue - what else would it be? So we wouldn't be able to tell the customer exactly what happened. Maybe it was just a fluke. But customers don't like to hear something like that. They typically rather want to know exactly what happened and what we are doing to prevent it from happening again. As is, the code didn't help with any of that.

To improve the situation, I started explaining what was wrong with the code and why it should be changed. There are some obvious points and some less obvious and maybe(?) a controversial one. And there is a pattern to avoid here. Something which a lot of C++ codebases - even the C++ standard got wrong historically.

  1. The return code of any syscall is not evaluated. It can give you at least some hint as to what is wrong. Some of it could potentially be even handled.
  2. There is not enough logging of what is happening, especially error state.
  3. The code just terminates the process with abort() leaving the caller no recourse to deal with a failure.

Points 1 and 2 are pretty obvious and I think everybody agrees that this can and should be done better. And probably also about how.

For point 3 this gets more interesting. What if the user of this class doesn't want to terminate when this fails? Too bad - this behavior is forced on everyone using this class. In my experience, many C++ developers historically did and might still suggest to fix problem 3 with an exception. Or give the object a valid/initialized flag to indicate if it was successfully constructed (looking at you, std::fstream). And I disagree with both. Let me show you the bad solutions before I show you a better one.

Common Bad "Solutions"

The "isValid" Pattern

class SharedMemoryBuffer {
private:
    int shmid_ = -1;
    void* data_ = nullptr;
    size_t size_ = 0;
    bool is_valid_ = false;

public:
    SharedMemoryBuffer(size_t size) : size_(size) {
        key_t key = ftok("/tmp", 'S') + size / 1024;
        shmid_ = shmget(key, size, IPC_CREAT | 0666);
        if (shmid_ == -1) return;  // Leave object in invalid state

        data_ = shmat(shmid_, nullptr, 0);
        if (data_ == (void*)-1) {
            shmctl(shmid_, IPC_RMID, nullptr);  // Cleanup
            shmid_ = -1;
            return;
        }
        is_valid_ = true;
    }

    SharedMemoryBuffer(key_t key) {
        shmid_ = shmget(key, 0, 0);
        if (shmid_ == -1) return;  // Leave invalid, user must check isValid()
        // ... more potential failure points
        is_valid_ = true;
    }

    bool isValid() const { return is_valid_; }

    void* getData() const {
        if (!is_valid_) return nullptr;  // Silent failure
        return data_;
    }

    void write(const void* src, size_t offset, size_t len) {
        if (!is_valid_) return;  // Performance cost + silent failure
        // ... actual work
    }
};

Well this "works". But it incurs a performance cost on each operation due to the extra condition of being "valid". And if it isn't done implicitly but left to the user to do it, you will either provoke crashes (accessing an invalid resource id) if they forget to check if the instance was created in a valid state. The next problem for the user is: if it was valid once, will it always stay valid? What makes it invalid? Suddenly you create lots of insecurity on the user's side and require lots more documentation that wouldn't be needed otherwise. The interface gets more complicated and more error prone.

This is what that would look like if you use it correctly:

// You have to remember to check validity every time
SharedMemoryBuffer buffer1(gigabytes(2));        // Create new
SharedMemoryBuffer buffer2(existing_key);        // Attach to existing

if (!buffer1.isValid()) {
    std::cerr << "Failed to create shared memory buffer" << std::endl;
    return -1;
}

if (!buffer2.isValid()) {
    std::cerr << "Failed to attach to existing buffer" << std::endl;
    return -1;
}

// Every single usage requires vigilance for both buffers
void* data1 = buffer1.getData();  // Could return nullptr!
void* data2 = buffer2.getData();  // Could return nullptr!
// ... more defensive checks everywhere

"Just use an exception"

Let's look at the option using C++ exceptions next. I've got to admit, this is better than the previous "solution".

class SharedMemoryBuffer {
private:
    int shmid_;
    void* data_;
    size_t size_;

public:
    SharedMemoryBuffer(size_t size) : size_(size) {
        key_t key = ftok("/tmp", 'S') + size / 1024;
        shmid_ = shmget(key, size, IPC_CREAT | 0666);
        if (shmid_ == -1) {
            throw std::runtime_error("Failed to create: " + std::string(strerror(errno)));
        }

        data_ = shmat(shmid_, nullptr, 0);
        if (data_ == (void*)-1) {
            shmctl(shmid_, IPC_RMID, nullptr);  // Cleanup
            throw std::runtime_error("Failed to attach: " + std::string(strerror(errno)));
        }
    }

    SharedMemoryBuffer(key_t key) {
        shmid_ = shmget(key, 0, 0);
        if (shmid_ == -1) {
            throw std::runtime_error("Failed to find existing segment");
        }

        data_ = shmat(shmid_, nullptr, 0);
        if (data_ == (void*)-1) {
            throw std::runtime_error("Failed to attach to existing segment");
        }
        // ... get size from segment info
    }

    void* getData() const noexcept { return data_; }
    void write(const void* src, size_t offset, size_t len) noexcept {
        // No validity check - object is always valid if it exists
        // ... actual work
    }
};

And here is how you would use it correctly. But you have to know that both constructors can throw, and what they throw. You need to read documentation or look into the implementation of the class.

try {
    SharedMemoryBuffer buffer1(gigabytes(2));      // Create new
    SharedMemoryBuffer buffer2(existing_key);      // Attach to existing

    // Clean usage - no validity checks needed
    void* data1 = buffer1.getData();
    void* data2 = buffer2.getData();
    buffer1.write(some_data, 0, size);

} catch (const std::runtime_error& e) {
    std::cerr << "Shared memory operation failed: " << e.what() << std::endl;
    return -1;
}

Great. We got an error message, we throw - so no more invalid state instances. But... as a user, I now need to know and consider that this constructor might throw. C++ doesn't really have checked exceptions. So I'm relying on documentation here. I need to write it and I need to hope that it is being read and not forgotten every time it is used. Creating this object in places in which exceptions are bad practice is extra risky now (destructors). And of course you get all the other risks associated with exceptions.

Named constructors

Back to our example. So, how would you do it without exceptions? I propose what some call "named constructors" but really, they are just member factory functions.

The pattern

#include <expected>  // C++23 - use boost::expected or custom Result<T,E> for older standards

class SharedMemoryBuffer {
private:
    int shmid_;
    void* data_;
    size_t size_;

    // Private constructor - can't fail, just sets member variables
    SharedMemoryBuffer(int shmid, void* data, size_t size) noexcept
        : shmid_(shmid), data_(data), size_(size) {}

public:
    [[nodiscard]] static std::expected<SharedMemoryBuffer, std::string>
    createWithSize(size_t size) {
        key_t key = ftok("/tmp", 'S') + size / 1024;

        int shmid = shmget(key, size, IPC_CREAT | 0666);
        if (shmid == -1) {
            return std::unexpected("Failed to create: " + std::string(strerror(errno)));
        }

        void* data = shmat(shmid, nullptr, 0);
        if (data == (void*)-1) {
            shmctl(shmid, IPC_RMID, nullptr);  // Cleanup
            return std::unexpected("Failed to attach: " + std::string(strerror(errno)));
        }

        return SharedMemoryBuffer(shmid, data, size);
    }

    [[nodiscard]] static std::expected<SharedMemoryBuffer, std::string>
    createFromExistingKey(key_t key) {
        int shmid = shmget(key, 0, 0);
        if (shmid == -1) {
            return std::unexpected("Failed to find segment: " + std::string(strerror(errno)));
        }

        void* data = shmat(shmid, nullptr, 0);
        if (data == (void*)-1) {
            return std::unexpected("Failed to attach: " + std::string(strerror(errno)));
        }

        return SharedMemoryBuffer(shmid, data, segment_size);
    }

    void* getData() const noexcept { return data_; }
    void write(const void* src, size_t offset, size_t len) noexcept {
        // No validity checks needed - object is always fully initialized
        // ... actual work
    }
};

I'm using std::expected in the examples below which is a C++23 feature. But this approach has been around for much longer - boost::outcome, various Result<T,E> implementations, and similar error-as-value types have existed for over a decade. We built our own Result type back in 2008. The pattern matters more than the specific implementation.

Advantages of named constructors

We now have multiple public named constructor functions and a simple private constructor which can't possibly fail. And for which the compiler forces me to decide what to do in an error case. And that means when an instance of the object exists, it is fully initialized. No need for an invalid state flag which needs to be checked everytime we do something with the object. There is no extra control flow to consider, I can be sure what the next line of code is that is being executed no matter what happened. It is always safe to use this everywhere. As a user I can decide if I want to crash my application, try again, or ignore the problem. My decision will be documented clearly at the place where I am creating the object.

Furthermore, I can now give constructors descriptive names. I don't know about you, but I dearly missed that option. How often have we all seen classes with half a dozen or more constructors with different parameters which require documentation comments only because we didn't have the option to give them a name? Compare these hypothetical traditional constructors:

// Traditional approach - confusing overloads
SharedMemoryBuffer(size_t size);                    // Create new with a size. Probably.
SharedMemoryBuffer(key_t key);                      // Is this attaching or something else?
SharedMemoryBuffer(size_t size, bool readonly);     // Is passing "false" the same as calling the other constructor?
SharedMemoryBuffer(size_t size, key_t key, int permissions);     // attaching and resizing? Are those access permission for myself or what?

With named constructors, the intent is crystal clear without any documentation needed:

// Named constructor approach - self-documenting
SharedMemoryBuffer::createWithSize(gigabytes(2));
SharedMemoryBuffer::createFromExistingKey(my_key);
SharedMemoryBuffer::createReadOnly(megabytes(512));

It's just functions now and you can and should give them good, descriptive names that make the code self-documenting.

// Example 1: Create new shared memory
auto buffer_result = SharedMemoryBuffer::createWithSize(gigabytes(2));
if (!buffer_result) {
    std::cerr << "Critical error: " << buffer_result.error() << std::endl;
    std::exit(1);  // Explicit decision to terminate
}
auto buffer = std::move(buffer_result.value());

// Example 2: Attach to existing shared memory by key
key_t existing_key = ftok("/tmp", 'D');  // From some database process
auto existing_buffer = SharedMemoryBuffer::createFromExistingKey(existing_key);
if (existing_buffer) {
    process_existing_data(existing_buffer.value());
} else {
    std::cerr << "Could not attach: " << existing_buffer.error() << std::endl;
}

Why This Is Better

The compiler won't let you forget dealing with errors here. There won't be instances of objects in invalid states. You don't need to look into the documentation as much and even less so in the internals of the code of the class you are using. The named constructors which are marked "noexcept" tell you all you need to know in their function signature.

This pattern also fits perfectly with RAII principles. The whole point of RAII is that resource acquisition happens in constructors and resource release happens in destructors. But if your constructor can fail after partially acquiring resources, you break this guarantee. With the factory pattern, once you have an object, you know all resources were successfully acquired. The destructor can simply release them without worrying about partial initialization states or resource leaks.

You should actually be familiar with this pattern already, as even the C++ standard has adopted it for similar reasons. Ever heard of std::make_unique and std::make_unique_for_overwrite? Memory is also just another resource but there is even more reasons to do it here. The famous Raymond Chen explains it here better than I ever could.

How I started doing this

I gotta admit that I am fundamentally opposed to using exceptions in C++ since this was a decision which was originally forced onto me around 2008 when I was part of a team building a 3D game engine from scratch for about 3 years. At the very beginning my boss decided that exceptions are not going to be used for performance reasons. Instead we are going to handle errors as values. We are going to have a result type. Like std::expected - just in 2008.

Named constructors are a similarly acquired taste. Except that I probably also got influenced here by working in some other more functional languages which simply do not have the constructor concept in the same way that C++ has it.

And together, these things work really well. The game engine I mentioned before would pretty much only crash due to programming errors. Not due to any kind of resource issues, random OS events or invalid inputs. It would just refuse to crash unless we as engineers made a mistake. Sure, we'd write more error handling code than "traditionally" would be the case but we also got a lot of value from local reasoning and we were building an engine that would use multi-threading for physics, graphics and asset loading etc. which was much easier when you didn't have to worry about exceptions.

After that, I just continued without exceptions and with named constructors whenever possible. Occasionally you have to catch exceptions because I'd be using std and other third party code but I'd soon make sure to wrap, replace or convert any code that could throw. But I never looked back. I didn't miss exceptions at all during over a decade of producing C++ code. Which to me, was proving that this is a feature that simply wasn't needed. And the code worked well and was easier to maintain once you didn't have to worry about exceptions anymore.

... I've been doing it like this for over a decade now and I never regretted it.

When Exceptions Still Make Sense

Now, to be fair, there are still places where exceptions might make sense in C++. I'm thinking of truly exceptional situations - programming errors like array bounds violations, null pointer dereferences, or violated contracts that indicate programming errors rather. I'd use asserts instead but you might want to use exceptions in order to try to recover even in those scenarios. But these problems are fundamentally different from resource exhaustion or syscall failures. You typically don't want to handle programming errors locally anyway - you want them to propagate up and terminate or restart the affected subsystem. But for any kind of expected failure mode - network timeouts, file not found, out of memory, permission denied - I'd argue errors as values are vastly superior.

Yet, particularly the idea of errors as values has gained a lot of traction during the last decade. New systems programming languages like Rust, Go or Zig are designed with this idea as a critical language feature from ground up. Exceptions are phased out and for good reason. There are many great C++ engineers and members of the C++ standard committee like Herb Sutter who are pushing for this style of error handling to become the preferred way of dealing with errors.

Feedback

Let me know what you think. I've met quite a number of C++ developers which are used to these patterns. But I've seen only few codebases which consistently do it.

Further Reading / Watching

Comments

Loading security question...
This name is not protected - only the secret is
Also makes your name unique
No comments yet. Be the first to comment!