Why this matters
A method sometimes cannot do its job. A withdrawal may be larger than the balance. A piece of text may not be a number. A file may be missing. The method has to tell its caller that something went wrong. There are two standard ways to do this: return a value that means "failed", or throw an exception.
The choice changes how the class is used. A caller who receives a bad return value must check for it, or the error goes unnoticed and the program keeps running with wrong data. A caller who receives an exception does not have to check after every call. The exception stops the normal flow and goes to a handler, or it ends the program if no handler exists. Picking the wrong style causes bugs that are hard to find. One kind of mistake produces wrong numbers without any message. The other kind stops the program with a message that does not explain the cause.
This lesson covers three things: how to write your own exception classes so the error has a clear name and useful data, how to decide between throwing and returning, and what goes wrong when either choice is made badly. The Invariants and validation lesson earlier in this module defines what a valid object is. This lesson uses that idea to decide when a method should report a problem.
Some terms used below:
- Exception: an error object that the program throws. It is an ordinary object with a type, such as
InsufficientFundsError, and it can hold data. - Throw: start an exception. Control leaves the current function at once.
- Catch: a block of code that receives an exception of a certain type and handles it.
- Error value: an ordinary return value that means failure, such as
-1,null, or an empty optional. - Optional: a C++ box that either holds a value or holds nothing. The standard name is
std::optional.
Two ways to report a failure
Return an error value
Start with a small problem. A user types an age, and the program must turn the text into a number. The text may be "42", which is valid, or "abc", which is not.
C++ can convert text to an integer with std::from_chars. The function below wraps it. It returns std::optional<int>. An optional that holds a number means success. An empty optional means "there is no valid age in this text". An invalid age is an ordinary answer for this function, so the empty optional is the right way to report it.
Expected output:
42 -> 42
abc -> not a valid age
-5 -> not a valid ageThe caller checks if (age) before it uses the value. The *age takes the number out of the box. If the caller skips the check and uses *age on an empty optional, the behaviour is undefined, which means the program may do anything. The safer call, age.value(), throws std::bad_optional_access when the box is empty.
Compare this with a function that returns -1 for failure. The number -1 is an ordinary int, so the type says nothing about failure. A caller can forget the check, and then -1 is used as an age. An optional puts the possibility of "no value" in the return type itself, so the reader sees it without reading the code inside the function.
Throw an exception
Now take the same situation and let the function throw instead. The standard function std::stoi converts text to an integer, and it throws std::invalid_argument when the text is not a number. The caller does not get a return value at all when this happens. The rest of the calling function is skipped, and control moves to a catch block that accepts that exception type.
Throwing is useful when the failure is unusual, or when the caller cannot continue in the normal way and must stop or take a different path. It also keeps the happy path short, because the code does not need a check after each call. The cost is that the caller must know the exception exists. If no catch accepts it, the program ends.
When a throw happens, C++ leaves each function on the path back to the handler, one at a time. Objects created inside those functions are destroyed on the way out. The later lesson on RAII and try-with-resources explains how this cleanup works.
A custom exception in C++
The standard library has several exception types. std::runtime_error is for failures that depend on the run, such as bad input. std::invalid_argument is for a bad value passed in. std::out_of_range is for an index or key that is outside a valid range. Use these when a message is enough.
Write your own class when the caller needs more than a message. A withdrawal that fails should say how much was requested and how much was available. A caller can print a clear report or decide to withdraw a smaller amount. Those values belong in fields of the exception class.
The rules for a C++ exception class are short:
- Inherit from a standard exception class, usually
std::runtime_errororstd::logic_error. - Pass a message to the base class. The base class stores the message and returns it from
what(). - Add fields for the extra data, and give them getter functions.
- Catch the exception by
constreference, writtenconst T&. This avoids a copy and stops the handler from changing the exception.
Here is a bank account with its own exception class.
Expected output:
balance: 70
error: insufficient funds: requested 500, available 70
short by: 430Read the class from the top. The name ends in Error so a reader knows what it is. The constructor passes a text message to std::runtime_error through the part after the colon. That part is called the initializer list. The constructor then stores the two numbers in its own fields. The std::to_string function turns each number into text.
The withdraw method checks the amount first and the balance second. When the withdrawal is too large, it creates a new InsufficientFundsError and throws it. The balance is not changed, because the throw happens before the subtraction.
The first withdrawal of 30 succeeded, and the balance became 70. The program printed that before the second withdrawal. An exception does not undo earlier changes. The object keeps every change made before the throw, so a method should check for errors before it changes anything when it can.
The catch block names the exception type and takes it by const reference. The variable e refers to the exception object that was thrown. The caller can call e.what() for the message, and e.requested() for the number that was requested.
A custom exception in Java
Java has the same idea, with one extra rule. Every exception class extends Throwable. Two branches of that family matter for class design.
- A checked exception extends
Exception. The compiler forces each method that can throw it to list it in its signature withthrows. The compiler also forces each caller to either catch it or list it too. Use a checked exception when the caller can respond to the problem, for example by trying a smaller amount. - An unchecked exception extends
RuntimeException. The compiler does not force any listing or catching. Use it for failures that come from a bug, or for failures the caller usually cannot repair.
This is the account example in Java, with a checked exception. The Java file is Main.java. It is compiled with javac Main.java and run with java Main.
class InsufficientFundsException extends Exception {
private final int requested;
private final int available;
InsufficientFundsException(int requested, int available) {
super("insufficient funds: requested " + requested + ", available " + available);
this.requested = requested;
this.available = available;
}
int getRequested() {
return requested;
}
int getAvailable() {
return available;
}
}
class Account {
private int balance;
Account(int balance) {
this.balance = balance;
}
void withdraw(int amount) throws InsufficientFundsException {
if (amount > balance) {
throw new InsufficientFundsException(amount, balance);
}
balance -= amount;
}
int getBalance() {
return balance;
}
}
public class Main {
public static void main(String[] args) {
Account account = new Account(100);
try {
account.withdraw(30);
System.out.println("balance: " + account.getBalance());
account.withdraw(500);
System.out.println("not printed");
} catch (InsufficientFundsException e) {
System.out.println("error: " + e.getMessage());
System.out.println("short by: " + (e.getRequested() - e.getAvailable()));
}
}
}Expected output:
balance: 70
error: insufficient funds: requested 500, available 70
short by: 430The Java version matches the C++ version in shape. The constructor passes the message to super(...), which is the Java word for the base class constructor. Java uses getMessage() where C++ uses what(). The throws InsufficientFundsException part on withdraw is the declaration that the compiler checks.
If the exception should not be forced on every caller, extend RuntimeException instead. The rest of the class stays the same. The Practice section includes an exercise on that choice.
Throw or return: the rule
Use this table to choose. The right column gives the reason, so the choice can be explained in a code review.
| Situation | Better choice | Why |
|---|---|---|
| A lookup finds nothing, such as a user id with no user | Return an empty result (optional or null) | Not finding something is a normal answer. Callers expect it. |
| The caller passed a value that breaks a rule, such as a negative amount | Throw | This is a bug in the caller. A return value is easy to ignore. |
| The operation fails for a reason outside the object, such as a file that cannot be read | Throw | The caller usually cannot continue the normal way and must stop or retry. |
| A constructor cannot build a valid object | Throw | A constructor has no return value, so throwing is the only way to report the failure. |
A simple test helps. Ask whether the caller should check the result after every call, as a normal part of the work. If yes, return an error value. Ask whether the case means the caller made a mistake that should be fixed in the calling code. If yes, throw. A throw costs more than a normal return and makes the flow of the code harder to follow, so do not use exceptions for ordinary branches that happen often.
Constructors can only throw
Constructors show clearly why throwing is sometimes the only option. A constructor has no return value. If the constructor cannot make a valid object, it has nothing to return, so it must throw.
The class below holds an email address. A valid address must contain an @ sign. The constructor checks the text and throws when the check fails.
Expected output:
ana@example.com
error: not an email address: ana.example.comThe bad object never exists. The throw happens inside the constructor, so the caller never gets an Email object to use. This is the same guarantee the Invariants and validation lesson describes. A value of type Email is always a valid address, because there is no other way to create one.
The std::move in the constructor moves the string into the field instead of copying it. It needs the <utility> header.
A hierarchy of errors
A larger program has many kinds of errors. A bank might have frozen accounts, insufficient funds, and invalid amounts. Create one base class for all bank errors, and derive each specific error from it. A caller can then catch every bank error with one handler, or catch one kind on its own.
In this example, BankError is the base class. It uses using std::runtime_error::runtime_error;, which tells the compiler to reuse the base class constructors that take a message. The derived classes pass their messages up to BankError.
Expected output:
withdrew 30, balance 70
rejected: insufficient funds: requested 500, available 70
rejected: account is frozenThe function try_withdraw catches const BankError&. Both derived exceptions match that handler, because an InsufficientFundsError is also a BankError. This is the same substitution rule covered in the Inheritance and Polymorphism lessons: a derived object can be used wherever its base type is expected.
If a caller needs different handling for one kind, it adds a handler for the derived type. The derived handler goes before the base handler, because catch blocks are checked in the order they are written. The first matching block runs, and the rest are skipped.
Common mistakes
Catching the base type and using derived members
A common error is to catch the base type and then call a method that only the derived type has. The program below throws InsufficientFundsError but catches it as std::runtime_error. The catch block then asks for the requested value, which the base type does not have.
#include <iostream>
#include <stdexcept>
#include <string>
class InsufficientFundsError : public std::runtime_error {
public:
InsufficientFundsError(int requested, int available)
: std::runtime_error("insufficient funds: requested " + std::to_string(requested)
+ ", available " + std::to_string(available)),
requested_(requested) {}
int requested() const { return requested_; }
private:
int requested_;
};
int main() {
try {
throw InsufficientFundsError(500, 70);
} catch (const std::runtime_error& e) {
std::cout << e.what() << '\n';
std::cout << "requested: " << e.requested() << '\n';
}
}Running g++ -std=c++20 -Wall on this file gives this error:
error: 'const class std::runtime_error' has no member named 'requested'The fix is to catch the derived type, const InsufficientFundsError&, when that member is needed. Catching the base type is right only when the code really does not need the derived data.
Catching by value in C++
Catching by value looks like this: catch (std::runtime_error e). The handler then gets a copy of the exception. The copy has only the base part, so any extra fields are lost. This is called object slicing. The Object slicing and the Object class lesson in this module covers it in detail. The fix is to catch by const reference.
Forgetting the throws clause in Java
A checked exception must be caught or declared. This Java program throws it from withdraw but does not declare it.
class InsufficientFundsException extends Exception {
InsufficientFundsException(String message) {
super(message);
}
}
class Account {
private int balance = 100;
void withdraw(int amount) {
if (amount > balance) {
throw new InsufficientFundsException("insufficient funds");
}
balance -= amount;
}
}
public class Main {
public static void main(String[] args) {
new Account().withdraw(500);
}
}The compiler reports this error. The folder in the path depends on where the file is saved:
javaerr/Main.java:12: error: unreported exception InsufficientFundsException; must be caught or declared to be thrown
throw new InsufficientFundsException("insufficient funds");
^
1 errorThere are two fixes. Add throws InsufficientFundsException to the signature of withdraw, so the caller must handle it. Or change the class to extend RuntimeException, if the failure is a bug that callers should not be forced to handle.
Letting an exception leave main
An exception that no handler catches ends the program. The message it prints is the only report the user sees. This C++ program throws an error and has no catch.
#include <stdexcept>
#include <string>
class InsufficientFundsError : public std::runtime_error {
public:
InsufficientFundsError(int requested, int available)
: std::runtime_error("insufficient funds: requested " + std::to_string(requested)
+ ", available " + std::to_string(available)) {}
};
int main() {
throw InsufficientFundsError(500, 70);
}Running it prints this on the error output:
terminate called after throwing an instance of 'InsufficientFundsError'
what(): insufficient funds: requested 500, available 70On Linux the program then stops with exit code 134. The Java version of the same mistake is an unchecked exception that reaches main:
class InsufficientFundsException extends RuntimeException {
InsufficientFundsException(String message) {
super(message);
}
}
public class Main {
public static void main(String[] args) {
throw new InsufficientFundsException("insufficient funds: requested 500, available 70");
}
}Its output is:
Exception in thread "main" InsufficientFundsException: insufficient funds: requested 500, available 70
at Main.main(Main.java:9)The Java program exits with code 1. The line number in the last line points at the throw statement, so the stack trace is the first place to look.
Throwing from a destructor in C++
A destructor is the function that runs when an object is removed. Since C++11, destructors are noexcept by default. That means a destructor is expected not to throw. If an exception leaves a destructor while the program is running normally, C++ calls std::terminate, and the program stops in the same way as an uncaught exception. Put any cleanup that can fail inside the destructor's own try block, and handle the error there.
Practice
Exercise 1. Write a Stack class with push and pop. The pop method must throw an EmptyStackError when the stack is empty. The error type must inherit from std::runtime_error. Write a main function that pops twice from a stack that holds one number, and catches the error.
Show the answer
Expected output: 7, then error: pop called on an empty stack. The first pop returns the 7. The second pop finds the stack empty and throws, which moves control to the handler.
Exercise 2. A method find_user(id) searches a list of users. It returns the user with that id, or nothing if no user has that id. Should the method throw or return an error value?
Show the answer
It should return an empty result, such as std::optional<User> in C++ or null in Java. A missing user is a normal answer. Many programs ask "does this id exist" as part of their work, so the caller should expect the empty case and check for it. An exception would make the normal case look like a failure.
Exercise 3. This code does not compile. Explain why, and write the fix.
try {
throw InsufficientFundsError(500, 70);
} catch (std::runtime_error e) {
std::cout << e.requested() << '\n';
}Show the answer
The catch block names the base type std::runtime_error. That type has no requested method. The catch also takes the exception by value, which copies only the base part. The fix is to catch the derived type by const reference:
} catch (const InsufficientFundsError& e) {
std::cout << e.requested() << '\n';
}Exercise 4. Java: write a custom exception that callers are not forced to catch or declare. Show the class declaration and explain the one line that makes the difference.
Show the answer
Extend RuntimeException instead of Exception:
class InsufficientFundsException extends RuntimeException {
InsufficientFundsException(String message) {
super(message);
}
}The compiler checks only checked exceptions, which are the subclasses of Exception that are not also subclasses of RuntimeException. Changing the parent class removes the requirement for throws on every method that can throw it. The cost is that a caller who does not expect the exception will see it end the program, so use this choice for failures that are bugs or cannot be repaired by the caller.
Exercise 5. Write a Person class whose constructor takes a name and an age. The age must be from 0 to 150. If it is not, the constructor must throw an InvalidAgeError that holds the bad age, and the error must inherit from std::invalid_argument.
Show the answer
Expected output:
age must be between 0 and 150, got 200 (age 200)The check runs before the constructor finishes, so an invalid Person never exists. The InvalidAgeError keeps the bad value in its own field, so the handler can show it without parsing the message.
Summary
- Return an error value, such as an empty optional, when a missing result is a normal answer.
- Throw an exception when the caller broke a rule, when the failure comes from outside the object, or when a constructor cannot build a valid object.
- A custom C++ exception inherits from a standard class, passes a message up, stores its extra data in fields, and is caught by
constreference. - In Java, a checked exception (extends
Exception) must be declared or caught. An unchecked exception (extendsRuntimeException) is not forced. - Put derived exception handlers before base handlers, and catch by reference so no data is lost.
- An exception that reaches
mainends the program. Put handlers where the caller can do something useful.