std::optional<T> is a small wrapper that holds either a value of type T or nothing at all. It replaces sentinel values, output parameters, nullable pointers, and other ad-hoc patterns that used to encode "this function might not produce a result". The header is <optional>, the type lives in namespace std, and the entire chapter assumes C++17 or later. Compile examples with g++ -std=c++17.
A large fraction of API design comes down to a single question: "I might have a value, or I might not." Look up a product by name: it could exist, or it could be missing. Compute a discount from a code: maybe the code is valid, maybe it isn't. Parse a price from user input: success or failure. Before C++17, every available answer to this question was awkward in some way.
A common approach is the sentinel value: return -1 for "not found", or an empty string for "no result". The pattern works only if there's a value in the type's range that can never be legitimate. As soon as -1 becomes a real answer (a refund balance, a relative price change, a credit), the sentinel collides with valid data. Worse, the compiler can't tell the difference, so a caller who forgets to check the sentinel uses the bogus value as if it were real.
The function returns -1.0 when the lookup fails, the caller multiplies that by 2, and the program reports a total of -2.0. No type error, no warning, no crash. The bug only surfaces when someone notices that the cart has a negative total.
Other workarounds have their own costs. Output parameters (bool find(const std::string&, double& out)) work but force callers to declare a variable to receive the result, and the call site reads backwards. Exceptions are heavyweight for a normal control-flow path and require every caller to wrap the call in try/catch. Returning a pointer (Product*, with nullptr for missing) works but conflates "nullable" with "owned", which is the very confusion that smart pointers try to remove.
std::optional<T> makes the missing case part of the type. It either holds a T or it doesn't, and the value can't be read without first checking presence.
The return type states the contract. There's no sentinel to remember. Forgetting to check before reading produces a clear bug (a dereference of an empty optional) instead of using garbage data.
An optional can be created several ways.
Output:
std::nullopt is the canonical "no value" literal. Returning it from a function that returns std::optional<T> says "no result" without naming the type. It's especially useful when the value type is long or generic.
An optional can also be constructed in-place using std::in_place. This skips the temporary that the regular constructor would build.
Output:
The constructor for std::string that takes (size_t, char) builds a string of that many copies of the character. With std::in_place, those arguments go straight to the string's constructor, no temporary string is ever materialized, and the result lives inside the optional from the start.
Quick Check: What does this print?
<details> <summary>Answer</summary>
0 1. stock was default-constructed and is empty. ordered holds the value 0, which is a real value, so has_value() is true. Holding 0 is different from holding nothing.
</details>
An optional has a presence check and several ways to read the value. They behave differently when the optional is empty.
| Operation | Returns | If empty |
|---|---|---|
o.has_value() | bool | false |
bool(o) | bool | false |
*o | reference to value | undefined behavior |
o.value() | reference to value | throws std::bad_optional_access |
o.value_or(fallback) | value or fallback (by copy) | returns fallback |
o->member | member access | undefined behavior |
The implicit bool conversion makes if (o) the idiomatic presence check.
Output:
The pointer-style access (*o, o->member) is fast and unchecked. The reader is responsible for ensuring the optional is non-empty before using either one. The checked access, o.value(), throws std::bad_optional_access when the optional is empty. The throwing version is useful when "missing" indicates a bug rather than a normal outcome.
Output:
For "use this default when missing", value_or(fallback) returns a value by copy (or move) and never throws.
Output:
value_or returns a copy, not a reference. Calling giftMessage(1002).value_or("") builds a temporary std::string from the literal every time, which costs an allocation. For tight loops, if (auto m = giftMessage(id)) { use(*m); } else { useDefault(); } skips the copy.
Quick Check: Which line is undefined behavior?
<details> <summary>Answer</summary>
(3). Dereferencing an empty optional with * is undefined behavior. (2) is safe because a holds a value; if a were empty, value() would throw, not crash. (4) returns -1 because b is empty, no UB.
</details>
The contents of an optional can be replaced, cleared, or constructed in place. Each operation correctly destroys whatever value was there before.
Output:
Assigning 0 does not make the optional empty. An optional holding 0 and an optional holding nothing are different states, which is what makes optional<int> clearer than a plain int with -1 as the "missing" sentinel.
emplace is the in-place constructor. It takes the same arguments the contained type's constructor would take, and builds the value directly inside the optional's storage. This skips a copy or move when the contained type is expensive to construct.
Output:
The Address constructor runs exactly once. Without emplace, writing shipping = Address("221B Baker St", "London") would build a temporary Address, then move-construct it into the optional. For a type made up of two std::strings, that's an extra move per string. emplace avoids both.
Optionals can be compared to each other and to plain values.
Two optionals of the same type compare equal when:
Output:
For ordering, an empty optional is treated as smaller than any non-empty one. Between two non-empty optionals, the underlying values are compared.
Output:
An optional can also be compared directly against a value of its contained type. The value side is treated as a non-empty optional holding that value.
Output:
An empty optional is never equal to any value, not even 0 or an empty string. That distinction is what optional provides: making "absent" different from "default".
Quick Check: What does this print?
<details> <summary>Answer</summary>
0 0 1. a is empty; b holds an empty string. The two optionals are not equal because one is missing and the other holds a value. Comparing a to "" is also false because a holds nothing. Comparing b to "" is true because b holds a value that equals "".
</details>
Bringing the pieces together, here's a small checkout flow that uses std::optional to model "discount applied or not" without sentinels.
Output:
The arrow operator (d->code) does member access on the contained Discount, just like a pointer. The boolean check (if (!d)) uses the implicit bool conversion. The function signature, std::optional<Discount> lookupDiscount(...), makes the missing case explicit: a caller cannot accidentally use a default-constructed Discount because no such value is returned.
A std::optional<T> stores a T inline along with a small bool flag that records whether the value is present. There's no heap allocation. The size is roughly sizeof(T) + 1 rounded up for alignment, so optional<int> on a 64-bit platform is typically 8 bytes (4 for the int, 1 for the flag, 3 padding) and optional<double> is typically 16 bytes (8 for the double, 1 for the flag, 7 padding).
The empty optional still occupies the full size; the T storage just isn't initialized. Construction of the T happens only when a value is assigned or emplaced. Destruction of the T happens on reset(), on reassignment to nullopt, or when the optional itself goes out of scope.
optional<T> adds a small flag overhead, never allocates, and never constructs the T unless asked to. For a function that returns std::optional<Order> where Order is a large struct, no Order work happens when the result is empty.
A few common mistakes are worth calling out.
Dereferencing an empty optional. *o on an empty optional is undefined behavior. The program might print garbage, might crash, or might appear to "work". Use if (o) first, or use value() if a throw is acceptable.
`std::optional<bool>` is awkward. It works (the three states are "true", "false", and "empty"), but reading if (flag) ambiguously means either "the optional has a value" or "the contained value is true". For three-state booleans, a named enum like enum class Answer { Yes, No, Unknown }; is clearer at every call site.
Storing a reference is not allowed. std::optional<T&> does not compile in C++17. For "maybe a reference", use T* (where nullptr is the empty state) or std::optional<std::reference_wrapper<T>>. The latter is uncommon and usually a sign the design needs adjustment.
`value_or` returns by value, not by reference. Each call copies the value out (or copies the fallback). For an optional<std::string> accessed in a loop, this allocation per call can dominate.
`value_or` evaluates its argument unconditionally. Even when the optional has a value, the fallback expression is evaluated, because function arguments are evaluated before the call. If the fallback is expensive, an if/else is better.
**Assigning nullptr doesn't empty an optional<T*>.** It just sets the contained pointer to null. To empty an optional, use reset() or assign std::nullopt.
p = nullptr means "assign the value nullptr", not "make the optional empty". A nullptr-valued optional is still a present value, just one whose value happens to be a null pointer.
Use std::optional<T> when a function may not produce a result, when a struct field may legitimately be absent, or when a value's presence carries information that "default-constructed" can't.
| Situation | Good fit? | Alternative if not |
|---|---|---|
| Lookup that may miss | Yes | find + end() iterator on containers |
| Optional configuration field | Yes | Separate bool isSet flag (worse, easy to desync) |
| Parsing that may fail | Yes | Exception (heavyweight) or status code |
| Nullable owning resource | No | std::unique_ptr<T> (owns + nullable) |
| Nullable non-owning reference | No | T* (or std::reference_wrapper) |
| Closed set of alternative types | No | std::variant<Ts...> |
| Always-present value | No | Plain T |
A useful sanity check: if a function ever uses a magic sentinel value to mean "no result", that function should return std::optional<T> instead. The sentinel makes the failure mode invisible at the call site; the optional makes it visible in the type.
Q1: When should you use `std::optional<T>` instead of returning a sentinel value like `-1` or an empty string?
Use std::optional<T> whenever "no result" is a meaningful outcome and the function's return type doesn't have a value range with a guaranteed-unused slot. A sentinel works only when the unused slot is permanent, which often isn't true (negative numbers become meaningful when you add refunds, empty strings become valid when you allow blank fields). std::optional<T> makes the failure mode part of the type, forces callers to check, and removes the silent-corruption failure mode where a caller forgets the sentinel rule.
**Q2: What's the difference between *o and o.value() on a std::optional<T>?**
*o is unchecked: it returns a reference to the contained value, and dereferencing an empty optional is undefined behavior. o.value() is checked: it throws std::bad_optional_access when the optional is empty. Use *o after a presence check (if (o)), and use o.value() when "empty here means a bug" so a throw is the right response.
Q3: Why doesn't C++17 allow `std::optional<T&>`?
The standard committee deliberately excluded reference types because the semantics are ambiguous: should assigning to the optional rebind the reference, or assign through it? std::optional chose to disallow the case rather than pick a confusing answer. For "maybe a reference", use a pointer (T* with nullptr for absent) or std::optional<std::reference_wrapper<T>>, both of which make the rebinding semantics explicit.
Q4: Does `std::optional<int>` allocate memory on the heap?
No. std::optional<T> stores its value inline next to a small boolean flag. The size is roughly sizeof(T) plus the flag plus padding. Construction and destruction of the contained T happen inside the optional's own storage, so no heap activity is involved at any point.
Q5: What's the difference between an empty `std::optional<int>` and a `std::optional<int>` holding `0`?
They are two different states. An empty optional has has_value() == false, and reading the value through * is undefined behavior. An optional holding 0 has has_value() == true, and *o returns a reference to the integer 0. Comparison treats them differently too: empty is less than any non-empty value, and empty != 0 is true. This distinction is what optional provides: separating "absent" from "default" without inventing a sentinel.
Exercise 1: Write a function findProductPrice that takes a product name and returns std::optional<double> with the price, or std::nullopt if the product isn't in the catalog. Call it with one name that exists and one that doesn't, printing the result in each case.
Expected Output:
<details> <summary>Solution</summary>
</details>
Exercise 2: What does this code print?
Expected Output:
<details> <summary>Solution</summary>
a is empty, so value_or(-1) returns the fallback -1. b holds the value 0, which is a real value, so value_or(-1) returns 0 (not the fallback). value_or only uses the fallback when the optional has no value, never when the contained value happens to equal the fallback.
</details>
Exercise 3: Write a function parseQuantity that takes a string and returns std::optional<int> with the parsed integer, or std::nullopt if the string contains anything other than digits or is empty. Test it with "42", "", and "3x".
Expected Output:
<details> <summary>Solution</summary>
</details>
Exercise 4: Fix the bug. This code is supposed to print the gift message if one was set, or "(no message)" otherwise. It crashes for orders with no message.
Expected Output:
<details> <summary>Solution</summary>
The original code dereferenced an empty optional, which is undefined behavior. value_or returns the fallback when the optional is empty, fixing the bug in a single call.
</details>
Exercise 5: Predict the output.
Expected Output:
<details> <summary>Solution</summary>
Assigning b (empty) into a makes a empty too. The contained 5 is destroyed. Both optionals are now empty, so both has_value() calls return false (printed as 0).
</details>
Exercise 6: Write a function safeDivide(double, double) that returns std::optional<double> containing the quotient, or std::nullopt when the divisor is zero. Test it with (10, 2), (7, 0), and (0, 5).
Expected Output:
<details> <summary>Solution</summary>
Real-life output may print 5.000000 from std::to_string. To match exactly, use std::cout << *a inside the conditional rather than std::to_string.
</details>
Exercise 7: What does this code print, and why?
Expected Output:
<details> <summary>Solution</summary>
std::optional<int> cannot be initialized from nullptr, because nullptr is not an int and optional<int> has no constructor that accepts it. The compile error mentions no matching constructor. To create an empty optional<int>, write std::optional<int> p; or std::optional<int> p = std::nullopt;.
The mistake here is treating optional<T> as a pointer: nullopt empties an optional, nullptr is the null state of a pointer. They are not interchangeable.
</details>
Exercise 8: Write a small program that stores an std::optional<std::string> "coupon code" for a customer. Initially the optional is empty (no coupon). Then assign "SAVE10", print it, then reset it back to empty and print again.
Expected Output:
<details> <summary>Solution</summary>
</details>
10 quizzes