Documenting Exception Guarantees
Learn exception specifications and noexcept for robust error handling.
What Are Exception Specifications?
An exception specification is the part of a function's declaration that says something about the exceptions the function may let escape. Modern C++ has exactly one of these left: the noexcept specifier, which declares that a function will not let an exception reach its caller.
The problem it solves is visible in any declaration you have ever read. A declaration such as int wheelsOnRack(int rackNumber); tells you the name, the parameters, and the return type. It does not tell you the one thing you need to know before calling it from a destructor, from a cleanup path, or from anywhere else that an in-flight exception would wreck. Comments can claim an answer, but comments drift away from the code and the compiler never checks them. noexcept puts the answer where the compiler, the standard library, and every reader can see it.
#include <iostream>
// No specifier, so this function is potentially throwing.
int wheelsOnRack(int rackNumber);
// The noexcept specifier sits to the right of the parameter list.
int cellarCapacity(int rackCount) noexcept;
int main()
{
std::cout << "rack 3 holds " << wheelsOnRack(3) << " wheels\n";
std::cout << "the cellar holds " << cellarCapacity(9) << " wheels\n";
return 0;
}
int wheelsOnRack(int rackNumber)
{
return rackNumber * 4;
}
// The specifier has to be repeated here; omitting it is a compile error.
int cellarCapacity(int rackCount) noexcept
{
return rackCount * 12;
}
Output:
rack 3 holds 12 wheels
the cellar holds 108 wheels
Two Categories, One Keyword
Every function in a C++ program sits in one of two buckets.
| Category | Meaning | How you get it |
|---|---|---|
| Non-throwing | Promises not to throw an exception that is visible to the caller | Write noexcept (or noexcept(true)) after the parameter list, or let an implicit rule below apply |
| Potentially throwing | May throw an exception that is visible to the caller | The default for ordinary functions; also written explicitly as noexcept(false) |
noexcept takes an optional Boolean argument, so noexcept(true) and a bare noexcept mean the same thing, and noexcept(false) restates the default. Spelling the argument out is only worth doing when the value is computed rather than typed, which is what templates need and which we come to shortly.
One consequence of the specifier living in the declaration is easy to trip over: overload resolution does not look at it. Declare two functions whose only difference is the specifier and you have not written an overload pair, you have written one function twice. Here is that mistake, which does not compile:
#include <iostream>
void sealDoor() noexcept
{
std::cout << "door sealed\n";
}
void sealDoor()
{
std::cout << "door left ajar\n";
}
int main()
{
sealDoor();
return 0;
}
The compiler rejects it as a duplicate, not as an ambiguity:
s.cpp:8:6: error: redefinition of 'void sealDoor()'
8 | void sealDoor()
| ^~~~~~~~
s.cpp:3:6: note: 'void sealDoor()' previously defined here
3 | void sealDoor() noexcept
| ^~~~~~~~
Reading a Specification With the noexcept Operator
The same keyword also works as an operator, and this is the tool you use to inspect everything else in the lesson. noexcept(expression) yields true or false at compile time, reporting whether the compiler considers that expression non-throwing.
#include <iostream>
#include <stdexcept>
struct Wheel
{
};
void restockBrine() { throw std::runtime_error{ "tank dry" }; }
void tallyRacks() { }
void sealDoor() noexcept { }
constexpr const char* verdict(bool nonThrowing)
{
return nonThrowing ? "non-throwing" : "potentially throwing";
}
int main()
{
// Both of these are settled while the program is being compiled.
static_assert(noexcept(6 + 3));
static_assert(!noexcept(restockBrine()));
std::cout << "6 + 3 -> " << verdict(noexcept(6 + 3)) << '\n';
std::cout << "restockBrine() -> " << verdict(noexcept(restockBrine())) << '\n';
std::cout << "tallyRacks() -> " << verdict(noexcept(tallyRacks())) << '\n';
std::cout << "sealDoor() -> " << verdict(noexcept(sealDoor())) << '\n';
std::cout << "Wheel{} -> " << verdict(noexcept(Wheel{})) << '\n';
return 0;
}
Output:
6 + 3 -> non-throwing
restockBrine() -> potentially throwing
tallyRacks() -> potentially throwing
sealDoor() -> non-throwing
Wheel{} -> non-throwing
Two results deserve a second look. restockBrine() reports potentially throwing, and tallyRacks() reports exactly the same thing even though its body is empty. The operator is not reading either body. It reads declared specifications, and neither function carries one, so both are potentially throwing as far as the language is concerned. The static_assert lines prove the other half of the story: the answers are available to the compiler, so nothing was executed to obtain them. restockBrine() throws unconditionally and the program still ran to completion, because the operand of noexcept is never evaluated.
The
noexcept operator answers "what does this expression's declaration promise?", not "will this expression actually throw?". A function whose body cannot possibly throw still reports potentially throwing until somebody writes the specifier on it.
What Is Non-Throwing Without Being Asked
Some functions are non-throwing whether or not you type the keyword, which is why sprinkling noexcept everywhere adds less than beginners expect.
| Function | Default category |
|---|---|
| Destructors | Implicitly non-throwing |
| Implicitly declared or defaulted default, copy, and move constructors | Non-throwing |
| Implicitly declared or defaulted copy and move assignment operators | Non-throwing |
| Defaulted comparison operators (C++20) | Non-throwing |
| Ordinary functions | Potentially throwing |
| User-defined constructors | Potentially throwing |
| User-defined operators | Potentially throwing |
The defaults in the top half are conditional. A compiler-generated function is only non-throwing if everything it calls is non-throwing, so one potentially throwing data member is enough to demote the whole set. The operator lets us watch that happen:
#include <iostream>
#include <string>
#include <type_traits>
struct RackTag
{
int slot{};
bool operator==(const RackTag&) const = default;
};
class BrineLog
{
public:
BrineLog() { } // user-provided, so potentially throwing
~BrineLog() { } // user-provided, but destructors are non-throwing anyway
};
struct CrateLabel
{
std::string text{}; // copying a std::string allocates, and allocation can throw
};
constexpr const char* verdict(bool nonThrowing)
{
return nonThrowing ? "non-throwing" : "potentially throwing";
}
int main()
{
RackTag tag{ 4 };
CrateLabel label{ "cave 2" };
std::cout << "RackTag{} -> " << verdict(noexcept(RackTag{})) << '\n';
std::cout << "RackTag{ tag } -> " << verdict(noexcept(RackTag{ tag })) << '\n';
std::cout << "tag == tag -> " << verdict(noexcept(tag == tag)) << '\n';
std::cout << "BrineLog{} -> " << verdict(noexcept(BrineLog{})) << '\n';
std::cout << "~BrineLog() -> " << verdict(std::is_nothrow_destructible_v<BrineLog>) << '\n';
std::cout << "CrateLabel{ label } -> " << verdict(noexcept(CrateLabel{ label })) << '\n';
return 0;
}
Output:
RackTag{} -> non-throwing
RackTag{ tag } -> non-throwing
tag == tag -> non-throwing
BrineLog{} -> potentially throwing
~BrineLog() -> non-throwing
CrateLabel{ label } -> potentially throwing
RackTag gets non-throwing construction, copying, and comparison for free because its only member is an int and every one of those operations is compiler-generated. BrineLog loses the constructor default the moment somebody writes a body for it, however empty that body is, but keeps the destructor default: destructors are implicitly non-throwing as long as every member's destructor is too. CrateLabel is the contamination case. Its copy constructor is compiler-generated and would have been non-throwing, except that copying a std::string allocates memory, allocation can throw, and so the whole copy constructor is potentially throwing.
What the Promise Does and Does Not Cover
noexcept says nothing about what happens inside the function. A non-throwing function is free to throw, to call potentially throwing functions, and to run try blocks. The only requirement is that nothing escapes.
#include <iostream>
#include <stdexcept>
class RindBrush
{
public:
~RindBrush()
{
std::cout << " rind brush stowed\n";
}
};
void checkBrine(int level)
{
std::cout << " brine reads " << level << " units\n";
if (level < 40)
throw std::runtime_error{ "brine below the rack line" };
}
// Potentially throwing: checkBrine() can throw and this function does not stop it.
void scrubWheel(int level)
{
RindBrush brush{};
checkBrine(level);
std::cout << " wheel scrubbed\n";
}
// Non-throwing: the try block keeps every failure inside this function.
void runShift(int level) noexcept
{
std::cout << "shift opens\n";
try
{
scrubWheel(level);
}
catch (const std::runtime_error& fault)
{
std::cout << " shift logged a fault: " << fault.what() << '\n';
}
std::cout << "shift closes\n";
}
int main()
{
runShift(55);
runShift(18);
return 0;
}
Output:
shift opens
brine reads 55 units
wheel scrubbed
rind brush stowed
shift closes
shift opens
brine reads 18 units
rind brush stowed
shift logged a fault: brine below the rack line
shift closes
runShift is marked noexcept and it still sits directly above a throwing call. The second run shows the machinery working exactly as it should: the exception is raised in checkBrine, the stack unwinds out of scrubWheel and destroys brush on the way (that is the rind brush stowed line arriving before the fault is logged), the handler in runShift catches it, and main never learns that anything went wrong. Nothing crossed the boundary of the noexcept function, so nothing was violated.
Breaking the Promise
Move the specifier one function down, onto scrubWheel, and the same exception now has to cross a boundary it was promised it would never cross. The program below is wrong on purpose:
#include <iostream>
#include <stdexcept>
class RindBrush
{
public:
~RindBrush()
{
std::cout << " rind brush stowed\n" << std::flush;
}
};
void checkBrine(int level)
{
std::cout << " brine reads " << level << " units\n" << std::flush;
if (level < 40)
throw std::runtime_error{ "brine below the rack line" };
}
// Broken: marked non-throwing, but checkBrine() can throw straight past it.
void scrubWheel(int level) noexcept
{
RindBrush brush{};
checkBrine(level);
std::cout << " wheel scrubbed\n" << std::flush;
}
int main()
{
try
{
scrubWheel(18);
}
catch (const std::runtime_error& fault)
{
std::cout << "main handled: " << fault.what() << '\n' << std::flush;
}
return 0;
}
It compiles without a single warning. Running it produces this, with the last two lines arriving on standard error:
brine reads 18 units
terminate called after throwing an instance of 'std::runtime_error'
what(): brine below the rack line
The process is killed by SIGABRT, so the shell reports an exit status of 134.
Three details in that transcript are worth naming.
The handler in main never ran. It is a perfectly good handler for exactly this type, and it was skipped anyway. The moment an exception reaches the edge of a noexcept function, std::terminate runs. Nothing keeps searching upward for somebody who might have coped.
rind brush stowed never printed. When std::terminate fires from inside a noexcept function, the standard does not require the stack to be unwound first, and this compiler chose not to unwind. Local objects, including ones holding files or memory, may simply never be destroyed.
The explicit std::flush calls are in that program for a reason worth knowing. SIGABRT does not flush the output buffer, so without them the program's own output is lost entirely and only the runtime's message survives.
The
noexcept promise is contractual, not compiler-enforced. Nothing above was diagnosed at compile time. A single exception-handling bug inside a function you marked noexcept turns a recoverable failure into an abrupt process death, possibly with destructors skipped.
Prefer
noexcept functions that have nothing to do with exceptions at all. Code with no exceptions flowing through it gives an exception-handling bug nowhere to hide.
Specifications That Depend on a Type
The Boolean argument to noexcept becomes useful when the right answer is not known until a template is instantiated. Wrapping the operator inside the specifier gives you the classic noexcept(noexcept(...)) shape: the outer pair is the specifier, the inner pair is the operator computing its value.
#include <iostream>
struct PlainTag
{
int slot{};
};
class WaxedRind
{
public:
WaxedRind() = default;
WaxedRind(const WaxedRind&) { } // user-provided, so potentially throwing
};
// The specification is computed from the argument type.
template <typename T>
T duplicate(const T& item) noexcept(noexcept(T{ item }))
{
return T{ item };
}
constexpr const char* verdict(bool nonThrowing)
{
return nonThrowing ? "non-throwing" : "potentially throwing";
}
int main()
{
PlainTag tag{ 6 };
WaxedRind rind{};
std::cout << "duplicate(tag) -> " << verdict(noexcept(duplicate(tag))) << '\n';
std::cout << "duplicate(rind) -> " << verdict(noexcept(duplicate(rind))) << '\n';
std::cout << "copied slot: " << duplicate(tag).slot << '\n';
return 0;
}
Output:
duplicate(tag) -> non-throwing
duplicate(rind) -> potentially throwing
copied slot: 6
One template, two different specifications, decided per instantiation. Hard-coding noexcept on duplicate would have been a lie for WaxedRind, and leaving it off would have thrown away a true guarantee for PlainTag.
The Four Exception Safety Guarantees
An exception safety guarantee is a promise, published as part of an interface, about the state your program is in once a throw has passed through. Four levels are commonly named, and each one is stronger than the one above it.
| Level | What it promises when an exception is thrown |
|---|---|
| No guarantee | Anything at all. Resources may leak and the object may be wrecked beyond further use |
| Basic guarantee | Every resource is still accounted for and the object is still usable, though its contents may have shifted to some other valid value |
| Strong guarantee | Nothing leaks and nothing observably changed. The call is all or nothing: it finishes, or the program looks as though it was never made |
| No-throw / no-fail | The work always completes (no-fail), or a failure is dealt with without any exception reaching the caller (no-throw). This is the level noexcept declares |
The strong guarantee is the interesting one to implement, because "no side effects on failure" is easy when the failure happens before you touch anything and hard otherwise. The standard technique is to do all the risky work on a copy and make the final commit a step that cannot fail:
#include <iostream>
#include <string>
#include <vector>
class ShiftSheet
{
public:
// Strong guarantee: either every line lands, or the sheet is exactly as it was.
void logBatch(const std::vector<std::string>& lines)
{
std::vector<std::string> draft{ m_lines }; // may throw; m_lines is untouched
for (const std::string& line : lines)
draft.push_back(line); // may throw; only the draft is at risk
m_lines.swap(draft); // no-fail: the commit step cannot throw
}
void print() const
{
for (const std::string& line : m_lines)
std::cout << " " << line << '\n';
}
private:
std::vector<std::string> m_lines{};
};
int main()
{
ShiftSheet sheet{};
sheet.logBatch({ "rack 3 turned", "rack 3 brushed" });
sheet.logBatch({ "rack 7 rebrined" });
std::cout << "shift sheet:\n";
sheet.print();
return 0;
}
Output:
shift sheet:
rack 3 turned
rack 3 brushed
rack 7 rebrined
Every allocation in logBatch happens on draft. If any of them throws, the exception propagates out and m_lines still holds precisely the lines it held before the call. The whole design rests on the last statement being no-fail: std::vector::swap exchanges two pointers and cannot throw, so there is no window in which the sheet is half-updated. Swap a throwing commit step in there and the strong guarantee collapses to the basic one.
Note the difference between the two halves of that bottom row. No-throw means that if the function fails it will not throw; it reports the problem some other way, such as an error code, or absorbs it. No-fail is slightly stronger: the function always succeeds, so the question of reporting failure never arises. Different operations are held to different ones:
| Operation | Expected guarantee | Why |
|---|---|---|
| Destructors, deallocation, and cleanup functions | No-throw | They run during stack unwinding, when an exception is already in flight |
| Anything a no-throw function calls | No-throw | A guarantee is only as good as its weakest call |
| Move constructors and move assignment operators | No-fail | A half-finished move has already gutted the source, leaving nothing to roll back to |
| Swap functions | No-fail | They are the commit step other code relies on, as ShiftSheet does |
clear, erase, and reset on containers |
No-fail | Discarding state does not need to acquire anything |
Operations on std::unique_ptr |
No-fail | Ownership transfer is pointer bookkeeping |
| Anything a no-fail function calls | No-fail | Same reasoning as no-throw |
| Constructors that acquire resources | Basic or strong | Acquisition genuinely can fail, and saying otherwise would be a lie |
| File parsing and file I/O | Basic or strong | Input and hardware fail all the time |
Deciding Where noexcept Belongs
Two real benefits justify the keyword. First, a non-throwing function can be called safely from code that is not exception-safe, destructors above all. Second, because a noexcept function cannot throw past its own boundary, the compiler does not have to keep the runtime stack in an unwindable state across that call, and can emit faster code.
There is a third benefit that costs nothing and is easy to miss: the standard library reads your specifications. When std::vector reallocates, it runs the noexcept operator over your type's move constructor first. A move constructor that carries the specifier gets used; one that does not gets bypassed in favour of the copy constructor, because a move that throws part way through would leave the container with no way back. The same test drives std::move_if_noexcept, which the next lesson covers.
That is the payoff for marking the right functions. Marking everything is a different matter. Most functions are potentially throwing by default, and any function that calls one of them is potentially throwing too, so a blanket noexcept policy mostly produces promises you cannot keep. The standard library is deliberately stingy with the keyword, reserving it for operations where a throw would be a defect rather than a possibility. Anything that merely avoids throwing under today's implementation goes unmarked, because implementations get rewritten.
| Kind of function | What to do |
|---|---|
| Move constructors, move assignment operators, swap functions | Always mark noexcept |
| Copy constructors and copy assignment operators that genuinely cannot throw | Mark noexcept when you can, to unlock the same optimizations |
| Destructors | Already implicitly noexcept when every member's destructor is; marking it is documentation |
| Functions you want to advertise as no-throw or no-fail, such as ones a destructor calls | Mark noexcept deliberately |
| Ordinary functions that merely happen not to throw today | Leave unmarked |
Here is the first row applied to a small class. The move operations and the swap all carry the specifier, and the operator confirms the result:
#include <iostream>
#include <utility>
#include <vector>
class Rack
{
public:
Rack() = default;
explicit Rack(std::vector<int> wheelIds)
: m_wheelIds{ std::move(wheelIds) }
{
}
Rack(const Rack&) = default;
Rack& operator=(const Rack&) = default;
Rack(Rack&& other) noexcept
: m_wheelIds{ std::move(other.m_wheelIds) }
{
}
Rack& operator=(Rack&& other) noexcept
{
m_wheelIds.swap(other.m_wheelIds);
return *this;
}
void swapWith(Rack& other) noexcept
{
m_wheelIds.swap(other.m_wheelIds);
}
void print(const char* name) const
{
std::cout << name << ':';
for (int wheelId : m_wheelIds)
std::cout << ' ' << wheelId;
std::cout << '\n';
}
private:
std::vector<int> m_wheelIds{};
};
constexpr const char* verdict(bool nonThrowing)
{
return nonThrowing ? "non-throwing" : "potentially throwing";
}
int main()
{
Rack north{ { 4, 11, 19 } };
Rack south{ { 23, 28 } };
north.swapWith(south);
north.print("north");
south.print("south");
std::cout << "moving a Rack is " << verdict(noexcept(Rack{ std::move(north) })) << '\n';
north.print("north");
return 0;
}
Output:
north: 23 28
south: 4 11 19
moving a Rack is non-throwing
north: 23 28
The last two lines are a reminder from the earlier section: noexcept(Rack{ std::move(north) }) did not move anything. The operand was never evaluated, so north still holds the wheel ids it got from the swap.
Move construction, move assignment, and swapping should carry the specifier every single time. Copy operations deserve it too, whenever the copy genuinely cannot fail. Everywhere else, add it only when publishing a no-fail or no-throw guarantee is a decision you have actually made and mean to honour.
When you are unsure, leave the specifier off. Adding it later only strengthens what you promise, which is safe. Taking it away later retracts something callers were entitled to rely on, and their code can break as a result.
The Syntax That Was Removed
Before C++11 the language had a different mechanism, called a dynamic exception specification, which listed types after the throw keyword: void sealDoor() throw(); claimed to throw nothing, void sealDoor() throw(std::runtime_error); named the types it might throw, and void sealDoor() throw(...); allowed anything. Compiler support was patchy, the interaction with templates was awkward, the semantics were widely misunderstood, and the standard library largely ignored them. They were deprecated in C++11 and removed in C++17, and no C++20 compiler will accept them. You will only meet them in old code, and noexcept is what replaced the one form that was worth keeping.
Summary
The specifier: noexcept after a parameter list declares a function non-throwing, meaning it promises not to let an exception reach its caller. Without it, a function lands in the potentially throwing bucket by default. noexcept(true) and noexcept(false) spell the two states out explicitly.
Not part of the signature: write the same function twice, differing only in the specifier, and the compiler reports a redefinition rather than giving you an overload pair.
What the promise permits: a noexcept function may throw internally, call potentially throwing functions, and catch. It just may not let anything out.
What breaking it costs: an exception that reaches the edge of a noexcept function triggers std::terminate on the spot, and a matching handler further up the stack never gets consulted. Unwinding is not guaranteed to happen first, so destructors may be skipped. The process aborts with a non-zero exit status.
Not enforced: the promise is contractual. The compiler will not diagnose a noexcept function that throws, so the failure shows up at run time as a dead process.
The operator: noexcept(expression) resolves to true or false while the program is being compiled, and never runs its operand. It reports declared specifications rather than analysing bodies, and it powers conditional specifications of the form noexcept(noexcept(...)) in templates.
Implicit defaults: a destructor is non-throwing without being told, provided each member's destructor is too. The same free pass covers implicitly declared and defaulted constructors, assignment operators, and C++20 comparison operators, until one of the things they call turns out to be potentially throwing.
The four guarantees: no guarantee, basic (nothing leaks, the object stays usable, contents may differ), strong (nothing leaks, nothing observably changed, all or nothing), and no-throw / no-fail, the level noexcept declares. No-throw handles failure without letting an exception out; no-fail does not fail at all.
Where it belongs: move construction, move assignment, and swap without exception; copy operations and destructors wherever the guarantee genuinely holds; anywhere else only as a deliberate advertisement. A function that happens not to throw today has not earned it. Copy the standard library's restraint here.
Why it pays: across a noexcept call the compiler can drop the bookkeeping that keeps a stack unwindable, and a reallocating container picks the fast relocation path only for types whose move constructor carries the specifier.
How did you find this lesson?
Your rating helps us improve the content.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
Documenting Exception Guarantees - Quiz
Test your understanding of the lesson.
Practice Exercises
Noexcept Functions
Create functions with noexcept specifiers to indicate they don't throw exceptions. Demonstrate using noexcept for optimization and documentation.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!