Why Exceptions Exist
The oldest way to report an error is a return value: return -1, or false, or a status code. It works, and it has three problems that get worse as a program grows.
The first is that return codes are easy to ignore. Nothing forces a caller to check, and a function whose error is silently discarded fails invisibly. The second is that they consume the return value, so a function that has a real answer to give has to report errors some other way — through an output parameter, or by reserving a magic value that then cannot be a legitimate result. The third is the awkward middle: when a calls b calls c and c fails, every function in between has to notice the failure and pass it up, even though none of them can do anything about it.
There is also one situation where return codes simply cannot work: a constructor has no return value. If a constructor is handed an invalid opening balance, it has no way to say so. Its only options are to throw, or to leave a broken object in existence for somebody to discover later.
An exception solves all four. throw abandons the current function immediately and hands an object describing the problem to the nearest enclosing catch that can handle that type — however many function calls up the chain that happens to be. The functions in between are not involved and need no error-handling code at all. And an unhandled exception terminates the program loudly rather than letting it continue with wrong data.
The design principle that follows: use exceptions for conditions that are genuinely exceptional and that the immediate caller usually cannot fix — a missing file, a corrupt record, an impossible argument. Use a return value for outcomes that are a normal part of the job, such as a search that found nothing or a login that was refused.
#include <iostream>
#include <stdexcept>
#include <string>
class Account {
std::string owner_;
double balance_;
public:
Account(std::string owner, double opening)
: owner_(std::move(owner)), balance_(opening) {
// A constructor cannot return an error code. It throws.
if (opening < 0) throw std::invalid_argument("negative opening balance");
}
void withdraw(double amount) {
if (amount <= 0) throw std::invalid_argument("amount must be positive");
if (amount > balance_) throw std::runtime_error("insufficient funds");
balance_ -= amount;
}
double balance() const { return balance_; }
};
int main() {
try {
Account a("Ananya", 1000.0);
a.withdraw(200.0);
std::cout << a.balance() << '\n'; // 800
a.withdraw(5000.0); // throws
std::cout << "never reached\n";
} catch (const std::exception& e) {
std::cerr << "Transaction failed: " << e.what() << '\n';
}
} - An object whose constructor throws never comes into existence, so its destructor never runs. That is exactly right — there was never a valid object to clean up — and it is why throwing from a constructor is the correct way to refuse invalid input.
throw, try, catch — and Catching Correctly
throw expr; creates an exception object and starts unwinding. try { } marks a region whose exceptions you want to handle, and one or more catch blocks follow it, each naming a type it can deal with. The first handler whose type matches wins, and the rest are skipped.
Between the throw and the catch, C++ performs stack unwinding: it exits each function in turn and runs the destructor of every fully-constructed local object on the way. This is the guarantee that makes RAII work, and it is worth appreciating how much it does for you — every std::string, std::vector, smart pointer and file stream between the throw site and the handler is cleaned up correctly with no code from you.
Two rules about writing catch blocks matter enormously. The first: always catch by const reference. Catching by value copies the exception object into a variable of the declared type, which for an exception hierarchy means slicing — your carefully written InsufficientFunds is sliced down to a plain std::runtime_error, and any extra information it carried is gone. Catching by reference refers to the real object and preserves it. This is exactly the object-slicing problem from the inheritance lesson, in the place where it is most likely to be missed.
The second: order handlers from most specific to most general. Matching stops at the first suitable handler, so a catch (const std::exception&) written before catch (const std::invalid_argument&) swallows everything and the specific handler is dead code. Compilers warn about this, and the warning is worth heeding.
catch (...) catches anything at all. It is useful as a last resort at the top of a program, but it gives you no object and therefore no information, so it should not be your normal handler. Inside any handler, a bare throw; re-throws the current exception unchanged, which is how you log an error at one level and still let a higher level decide what to do about it.
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
void parseAge(const std::string& text) {
int age = std::stoi(text); // may throw
if (age < 0 || age > 120) throw std::out_of_range("age out of range");
std::cout << "age " << age << '\n';
}
int main() {
for (const std::string& input : {"25", "abc", "500", "99999999999999"}) {
try {
parseAge(input);
}
// most specific first
catch (const std::invalid_argument& e) {
std::cerr << input << ": not a number (" << e.what() << ")\n";
}
catch (const std::out_of_range& e) {
std::cerr << input << ": out of range (" << e.what() << ")\n";
}
// most general last
catch (const std::exception& e) {
std::cerr << input << ": " << e.what() << '\n';
}
catch (...) {
std::cerr << input << ": unknown failure\n";
}
}
// catch (std::exception e) <- BY VALUE: slices, loses the real type
// catch (const std::exception& e) <- correct
} - Every uncaught exception calls
std::terminate, which ends the program. That is usually the right outcome — a program that cannot handle a failure should stop rather than continue with corrupt state — but it meansmainis a sensible place for a finaltry/catchthat prints something a user can act on.
The Standard Exception Types
C++ lets you throw any type at all — an int, a std::string, anything. Do not. Throwing values with no common base means every handler has to guess what types might arrive, and nothing composes. Everything you throw should derive from std::exception, directly or through one of the standard classes in <stdexcept>.
std::exception is the root of the hierarchy and provides one member function, what(), returning a const char* describing the problem. A single catch (const std::exception& e) therefore catches anything standard, including things thrown by the library itself, and can always print something useful.
The hierarchy splits into two families, and the distinction is a genuinely helpful design guide. std::logic_error and its descendants describe mistakes in the program — conditions that could have been prevented by checking first. std::runtime_error and its descendants describe conditions only discoverable while running, which no amount of checking could have avoided.
In practice: an invalid argument passed by your own code is a logic_error; a file that has gone missing since you last looked is a runtime_error. Choosing correctly is not enforced by anything, but it tells the next reader whether they are looking at a bug to fix or a condition to handle.
You will also meet these thrown by the library itself, which is worth knowing so that the exceptions do not look mysterious: vector::at() and string::at() throw std::out_of_range; std::stoi throws std::invalid_argument or std::out_of_range; and new throws std::bad_alloc when memory runs out.
std::exception— the root; provideswhat()std::logic_error— a mistake in the program that a check could have preventedstd::invalid_argument— an argument that makes no sense for this functionstd::out_of_range— an index or value outside the permitted range; thrown byat()std::length_error— an attempt to create something larger than the type allowsstd::runtime_error— a condition only detectable while runningstd::overflow_error/std::range_error— arithmetic results that cannot be representedstd::bad_alloc— allocation failed; thrown bynew
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
// A custom exception: inherit from the closest standard type
class InsufficientFunds : public std::runtime_error {
double shortfall_;
public:
explicit InsufficientFunds(double shortfall)
: std::runtime_error("short by Rs " + std::to_string(shortfall)),
shortfall_(shortfall) {}
double shortfall() const { return shortfall_; } // extra, typed information
};
void withdraw(double amount, double balance) {
if (amount > balance) throw InsufficientFunds(amount - balance);
}
int main() {
try {
withdraw(5000.0, 1200.0);
} catch (const InsufficientFunds& e) {
std::cerr << e.what() << '\n'; // short by Rs 3800.000000
std::cerr << "top up " << e.shortfall() << '\n'; // 3800
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
}
std::vector<int> v = {1, 2, 3};
try {
std::cout << v.at(10) << '\n'; // throws std::out_of_range
} catch (const std::out_of_range& e) {
std::cerr << "bad index: " << e.what() << '\n';
}
} Exception Safety, RAII and When Not to Throw
Exceptions and manual memory management do not mix. A function that calls new, does some work and then calls delete leaks the moment anything in the middle throws, because the delete line is simply skipped. Handling that by hand means a try/catch around every allocation, and getting it right on every path in a large function is genuinely difficult.
RAII removes the problem completely. Stack unwinding runs the destructor of every local object between the throw and the handler, so anything held by a std::vector, a std::string, a std::unique_ptr or a std::ifstream is released correctly whatever happens. This is the strongest practical argument for the Rule of Zero from the constructors lesson: code built out of self-managing types is exception-safe by default, without a single catch block.
Two rules protect the machinery itself. Never let an exception escape a destructor. If one exception is already unwinding the stack and a destructor throws a second, the program terminates immediately. Destructors are implicitly noexcept since C++11, so this is enforced rather than merely advised. If a destructor must do something that can fail, wrap it in a try/catch that swallows the failure, or provide a separate close() the user calls deliberately.
Mark functions noexcept when they genuinely cannot throw, particularly move constructors and move assignment. It is a promise to the compiler, which uses it to optimise — std::vector will only use your type's move constructor while reallocating if it is noexcept. If a noexcept function does throw, the program terminates, so do not use the keyword hopefully.
Finally, know when not to use exceptions at all. They are close to free when nothing is thrown but comparatively expensive when one is, so they are the wrong tool for a condition that occurs on most iterations of a loop. Do not use them for ordinary control flow — a search that finds nothing should return an empty result, not throw. And in competitive programming, where input is guaranteed well-formed and every microsecond counts, plain checks are the norm.
#include <fstream>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>
#include <vector>
// Leaks if process() throws: the delete is skipped
void unsafe() {
int* buffer = new int[1000]{};
if (buffer[0] == 0) throw std::runtime_error("bad data");
delete[] buffer; // never reached
}
// Cannot leak: every local is cleaned up during unwinding
void safe() {
std::vector<int> buffer(1000);
auto note = std::make_unique<std::string>("working");
std::ifstream in("data.txt");
if (buffer[0] == 0) throw std::runtime_error("bad data");
// vector freed, string freed, file closed — automatically
}
struct Handle {
~Handle() {
try {
// something that might fail
} catch (...) {
// swallow: a destructor must never let an exception escape
}
}
};
class Buffer {
std::vector<int> data_;
public:
Buffer(Buffer&& other) noexcept = default; // promise: cannot throw
Buffer& operator=(Buffer&& other) noexcept = default;
Buffer() = default;
};
int main() {
try {
safe();
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
}
} - The quickest sanity check on any function that can throw: read every line between the allocation and the release and ask whether any of them could throw. If the answer is yes and the resource is not held by an RAII object, you have a leak on that path — and the fix is to change the type, not to add a
catch.
