Representing Optional Values with std::optional
Safely represent values that may or may not exist without using null pointers.
What Is std::optional?
std::optional<T> is a class template that holds either a value of type T or nothing at all. It was added in C++17 and lives in <optional>.
It exists to answer a question every codebase runs into: how does a function report that it has no answer? A lookup asked for an identifier that does not exist has nothing to hand back, and neither does a reading taken from a sensor that is offline. The function itself has no business deciding what to do about that, so the outcome has to travel back to the caller.
The Options Before C++17
Every pre-C++17 approach has a real drawback:
| Approach | Problem |
|---|---|
Return bool for success, write the result through an out parameter |
The result and the success flag are separate, and the caller must supply a variable up front |
Return a sentinel value such as -1.0 |
The caller has to know the magic value, and it differs from function to function |
| Throw an exception | Carries complications and costs that are out of proportion to an ordinary "no result" |
Return a struct pairing a bool with the value |
The best of the four, but before C++17 you had to build it yourself, differently every time |
The sentinel approach is the one that looks reasonable until it does not. Consider a lookup that returns how many items a warehouse bay is holding:
int bayContents(int bayNumber)
{
if (bayNumber < 1 || bayNumber > 40)
return -1;
return 17;
}
This works only because a stock count can never legitimately be negative, which is a fact about this particular quantity rather than about the technique. Change the function to report a temperature from a sensor and it collapses immediately: readings can be negative, zero, or anything else, so no int is left over to mean "failed".
You can reach for something obscure, such as the most negative int:
#include <limits>
int sensorReading(int sensorId)
{
if (sensorId < 1 || sensorId > 8)
return std::numeric_limits<int>::min();
return 21;
}
std::numeric_limits<T>::min() gives the most negative value of type T, the counterpart to max(). But now every call site has to compare against that expression to find out whether the call worked, which is verbose and easy to forget. Worse, it is a semipredicate problem: if the real answer ever happens to be that same value, success and failure become indistinguishable.
Returning an Optional
std::optional makes "no value" part of the return type, so nothing has to be smuggled through the value space:
#include <iostream>
#include <optional>
std::optional<int> sensorReading(int sensorId)
{
if (sensorId < 1 || sensorId > 8)
return {};
return 18 + sensorId;
}
int main()
{
std::optional<int> roof{ sensorReading(3) };
if (roof)
std::cout << "Roof sensor: " << *roof << " degrees\n";
else
std::cout << "Roof sensor: unavailable\n";
std::optional<int> basement{ sensorReading(19) };
if (basement)
std::cout << "Basement sensor: " << *basement << " degrees\n";
else
std::cout << "Basement sensor: unavailable\n";
return 0;
}
Output:
Roof sensor: 21 degrees
Basement sensor: unavailable
Returning {} produces an optional holding nothing, and returning an int produces one holding that value. The caller checks before reading. No magic constant appears anywhere, and the signature now states that failure is possible.
Using One
Creating, testing, and reading an optional:
| Task | Ways to do it |
|---|---|
| Create with a value | std::optional<int> opt{ 10 }; |
| Create empty | std::optional<int> opt{}; or std::optional<int> opt{ std::nullopt }; |
| Report no value from a function | return {}; or return std::nullopt; |
| Test for a value | if (opt.has_value()) or if (opt) |
| Read the value | *opt, opt.value(), or opt.value_or(fallback) |
The three ways of reading differ in what happens when the optional is empty, and the difference matters:
*optis undefined behavior. Only use it once you have checked.opt.value()throwsstd::bad_optional_access.opt.value_or(fallback)returns the fallback, and never fails.
#include <iostream>
#include <optional>
int main()
{
std::optional<int> reading{ 21 };
std::optional<int> missing{};
std::cout << "Dereferenced: " << *reading << '\n';
std::cout << "value(): " << reading.value() << '\n';
std::cout << "value_or on a reading: " << reading.value_or(-273) << '\n';
std::cout << "value_or on a missing reading: " << missing.value_or(-273) << '\n';
std::cout << "has_value(): " << missing.has_value() << '\n';
return 0;
}
Output:
Dereferenced: 21
value(): 21
value_or on a reading: 21
value_or on a missing reading: -273
has_value(): 0
Why It Is Not Just a Pointer
The syntax looks like a pointer. Both can be empty, both convert to bool, and both are read by dereferencing. The semantics are entirely different, and that difference is the reason std::optional exists.
| Pointer | std::optional |
|
|---|---|---|
| Semantics | Reference: refers to an object stored elsewhere | Value: contains the object itself |
| Copying it copies | The address | The contained value |
| Safe to return from a function | Not if it points at a local | Yes, the value travels with it |
A pointer to a local variable dangles the moment the function returns, because only the address came back and the object it named is gone. An optional carries the object home with it, which is what makes it usable as a return type at all.
Costs and Benefits
Returning std::optional documents in the signature that a call may not produce a value, removes the need to remember any sentinel, and reads naturally at the call site.
Two limitations are worth knowing. You must confirm a value is present before dereferencing, or you get undefined behavior. And an optional records only that there was no value, never why.
For a function that may fail, return a
std::optional rather than a sentinel value, unless the caller needs to know why it failed.
std::expected, added in C++23, covers the case an optional cannot: returning either a value or an error describing the failure.
Optional Function Parameters
std::optional can also express a parameter the caller may omit. The older way is a pointer defaulting to nullptr, which works but forces any real argument to be an lvalue, since you need something to take the address of. An optional parameter accepts an rvalue too, because it copies.
That copy is the catch. std::optional<T> holds a T by value, and as of C++23 it cannot hold a reference, so an expensive-to-copy type such as std::string gets copied on every call. std::reference_wrapper can work around it, at the cost of introducing two extra types into a signature that then reads worse than the pointer version it replaced.
Usually there is a better answer than either, which is to write two overloads:
#include <iostream>
#include <string>
struct Sensor
{
std::string location{};
int channel{};
};
void describeChannel()
{
std::cout << "Channel: unassigned\n";
}
void describeChannel(const Sensor& sensor)
{
std::cout << "Channel: " << sensor.channel << " at " << sensor.location << '\n';
}
int main()
{
describeChannel();
Sensor roof{ "roof", 3 };
describeChannel(roof);
describeChannel({ "cellar", 7 });
return 0;
}
Output:
Channel: unassigned
Channel: 3 at roof
Channel: 7 at cellar
Each overload says exactly what it takes, nothing is copied unnecessarily, and rvalues work.
Prefer
std::optional for optional return types. For optional parameters, prefer function overloading where you can. Failing that, use std::optional<T> when T would normally be passed by value, and const T* when T is expensive to copy.
Summary
What it is: std::optional<T> from <optional>, added in C++17, holds either a T or nothing.
Why it exists: sentinel return values require the caller to know a magic constant, and break entirely when every possible value is a legitimate result, which is the semipredicate problem.
Creating and reporting emptiness: {} or std::nullopt, both as an initializer and as a return.
Checking: has_value() or the implicit conversion to bool.
Reading: *opt is undefined behavior when empty, value() throws std::bad_optional_access, and value_or(fallback) substitutes the fallback.
Value semantics: unlike a pointer, an optional contains its value rather than referring to one, so returning it from a function is safe.
Limitations: it cannot say why something failed, which is what std::expected in C++23 is for, and it cannot hold a reference.
Parameters: prefer overloads, then std::optional<T> for cheap-to-copy types, then const T* for expensive ones.
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.
Representing Optional Values with std::optional - Quiz
Test your understanding of the lesson.
Practice Exercises
std::optional for Optional Values
Learn to use std::optional to return values that may or may not exist. Understand how to create, check, and extract values from optionals, and when to use them instead of sentinel values.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!