Skip to content
VLSI Mentor

C++ Part I · Module 21 · Modern C++ Type and Utility Tools

std::optional: Representing No Value Honestly

A function that looks something up has to be able to say it did not find it, and C++ offered three ways to do that before C++17, all of which push work onto the caller. A sentinel value steals one value from the range and silently fails when the data legitimately contains it. An output parameter forces the caller to declare an uninitialised variable, lets the return value be ignored, and prevents the function from being used in an expression. A pair of value and boolean is honest but splits one fact across two things the caller can read independently and in the wrong order. std::optional replaces all three with a type that carries both the value and its presence, and the frozen purpose names exactly that. The chapter makes the case by measurement rather than assertion, and the sentinel demonstration is the sharpest of them: a register map where one register genuinely holds the value 0xFFFFFFFF, which is the same value the sentinel version had reserved to mean absent — so the sentinel function returns an answer that is simultaneously correct and indistinguishable from failure, while the optional version reports a present value of 0xFFFFFFFF with no ambiguity at all. The chapter also measures what optional costs, which is four bytes and no allocation, and what happens when you dereference an empty one, where the detection result is unusual enough to be worth knowing before you need it.

Intermediate18 min readC++Part IFundamentalsModern C++

Part I · Module 21 · Chapter 21.5 · std::optional: Representing No Value Honestly

1. The Frozen Signature

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
std::optional<std::uint32_t> lookup(std::string_view name) const;

That is the curriculum's own example, and it reads as what it is: this returns a uint32_t, or nothing. The absence is in the type, so the caller cannot fail to notice it and the compiler will not let them pretend otherwise.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
std::optional<std::uint32_t> RegisterFile::lookup(std::string_view name) const
{
    auto it = byName.find(std::string(name));
    if (it == byName.end()) return std::nullopt;
    return it->second;
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
lookup("CTRL")  -> 0x40000000
lookup("MISSING") -> empty (no value)

And at the call site:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
if (auto v = rf.lookup("CTRL")) use(*v);

The if (auto v = ...) form declares, tests and binds in one expression, which is the idiom worth learning first. std::optional converts to bool, so the condition is the presence check and *v inside the body is safe.

2. What It Replaces: A Sentinel

A sentinel takes one value out of the range and gives it a second meaning. That works until the data contains it.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
std::uint32_t RegisterFile::lookupSentinel(std::string_view name) const
{
    auto it = byName.find(std::string(name));
    return it == byName.end() ? 0xFFFFFFFFu : it->second;
}

Now a register map in which one register legitimately holds 0xFFFFFFFF:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
lookupSentinel("CTRL")    = 0x40000000
lookupSentinel("MISSING") = 0xFFFFFFFF  <- means ABSENT
lookupSentinel("SCRATCH") = 0xFFFFFFFF  <- a REAL value, indistinguishable
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
lookup("SCRATCH").has_value() = 1, value = 0xFFFFFFFF

The sentinel function returns the same thing for a register holding all-ones and for a register that does not exist. There is no bug in the function; the problem is that the return type has fewer states than the situation. The optional version reports has_value() == 1 with a value of 0xFFFFFFFF, and the two cases are distinct.

0xFFFFFFFF is a particularly bad sentinel for hardware data, because all-ones is what you read back from an unmapped address on many buses — so it is simultaneously the sentinel, a legitimate register value, and the signature of a different failure. But every sentinel has this shape: -1 for an index, 0 for a pointer-like handle, nullptr, NaN, an empty string. Each steals a value, and each is wrong as soon as that value is meaningful.

3. What It Replaces: An Output Parameter

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
bool RegisterFile::lookupOutParam(std::string_view name, std::uint32_t& out) const;
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
bool lookupOutParam(name, out) -> ok=1 out=0x40000004

It works, and it costs four things.

The caller must declare out before the call, uninitialised, which is Chapter 3.8 §3's indeterminate value sitting in scope waiting to be read on the failure path.

The return value can be ignored. lookupOutParam("MISSING", out); compiles, and out is then whatever it was. [[nodiscard]] helps and is not the default.

The function cannot be used in an expression. No use(lookup(name)), no chaining, no passing the result straight to something else.

And the signature reads backwards: the thing the function is for is a parameter, and the thing it returns is a status.

std::optional collapses all of it into one returned object that carries both facts and cannot be read in the wrong order.

The third alternative, returning std::pair<std::uint32_t, bool>, is honest about the two facts but still lets the caller read .first without checking .second — which is the same failure with better documentation. Chapter 21.4 §6's structured bindings make it readable; they do not make it safe.

4. What It Costs

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
sizeof(std::uint32_t)                =  4
sizeof(std::optional<std::uint32_t>) =  8  (+4 for the flag, padded)
sizeof(std::optional<char>)          =  2
sizeof(std::string)                  = 24
sizeof(std::optional<std::string>)   = 32
allocations for an optional<uint32_t>: 0  (it stores INLINE)

An optional<T> is T plus a bool, padded to T's alignment. For a uint32_t that is four bytes of overhead; for a char it is one; for a 24-byte std::string it is eight, because the alignment is 8.

No heap allocation and no indirection. The value lives inside the optional, constructed in place when present and not constructed at all when absent. That is the critical design property: std::optional<T> is not a pointer and not a wrapper around one, so there is no extra dereference and no lifetime to manage.

The overhead is not always necessary in principle — a pointer type already has a spare state, and a dedicated optional-pointer could be the same size — but std::optional is uniform rather than specialised, so it pays the flag every time. For the cases where that matters, a T* already is an optional reference, which is §9.

5. Reading It: Four Ways, Three Safe

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
has_value()/operator bool : 1 and 0
*v after checking         : 0x40000000
v.value()                 : 0x40000000  (throws if empty)
e.value_or(0xDEADBEEF)    : 0xDEADBEEF  (a default, stated at the use site)
e.value() threw std::bad_optional_access: Bad optional access

has_value() or operator bool asks the question. The if (auto v = ...) form from §1 uses the second.

*v and v->m access the value, and are undefined behaviour if empty — §6.

.value() accesses it and throws std::bad_optional_access if empty. Safe, and the right choice at a boundary where emptiness would be a programming error you want reported.

.value_or(d) returns the value or the given default. This is often the best option because the default is written at the point of use, where a reader can see it, rather than being baked into the function.

The practical division: if (auto v = ...) when you branch on presence, value_or when a default is sensible, .value() when emptiness means a bug, and *v only inside a block that has already checked.

6. Dereferencing an Empty One

*v on an empty optional is undefined behaviour, not an exception. Measured, with the usual question of who notices:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
compile warnings: 0

MODE                                         EXIT    VALUE
-O0                                          0       1793156864
-O2                                          0       0
-fsanitize=address -g -O1                    0       0
-fsanitize=undefined -g -O1                  0       0
_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG   134   (aborted)
    Hardening assertion this->has_value()

Only libc++'s hardening mode caught it. AddressSanitizer and UndefinedBehaviorSanitizer both ran it to completion and reported nothing, and the value read back as garbage at -O0 and as zero at -O2.

ASan's silence has the same mechanical cause as Chapter 20.3 §5's erase bug: the optional's storage is a member of a live object, so reading it does not cross any allocation boundary. There is no red zone to step on. The byte is simply uninitialised, and ASan does not track that — MemorySanitizer does, and is not available on this platform.

The zero at -O2 is the more dangerous of the two readings, because zero is a plausible register value, a plausible count and a plausible index. Garbage at least looks wrong.

So the practical conclusion is specific: if you use std::optional, build your test configuration with _LIBCPP_HARDENING_MODE set. It is the only tool measured here that turns this class of mistake into a failure, and it costs a bounds check in a build you were not shipping anyway. The alternative is to prefer .value() and value_or over * so that the library checks for you.

7. The Rest of the Interface

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
default-constructed: has_value=0
after o = 5        : 5
after o.reset()    : has_value=0
after o.emplace(9) : 9
after o = nullopt  : has_value=0
comparison: (a<b)=1 (empty<a)=1 (empty==nullopt)=1

std::nullopt is the explicit empty value, usable for construction, assignment and comparison. reset() empties it; emplace(args...) constructs the value in place from the arguments, which matters when the value type is expensive or not copyable.

The comparison behaviour is worth internalising: an empty optional compares less than any value, and two empties compare equal. That makes std::optional usable as a sortable key and occasionally surprising — sorting a range of optionals puts all the empties first.

std::make_optional<T>(args...) is the factory, and std::optional supports operator-> so v->member works directly.

8. Where It Is the Wrong Answer

Five cases, and they come up often enough to be worth naming.

When absence is an error the caller must handle. std::optional says there is no value and nothing about why. A parse that failed, a file that could not be opened, a device that timed out — those have reasons, and the caller usually needs them. An exception or an expected-style type carries the reason; Chapter 22 covers the choice.

As a parameter for an optional argument. void configure(std::optional<int> timeout) is worse than a default argument or two overloads, because every call site now constructs an optional and the function body has to unwrap it.

std::optional<bool>. Three states, and every reader has to stop and work out which is which. Use an enum with three named values.

As an optional reference. std::optional<T&> does not exist in C++17 — a raw T* already carries exactly that meaning, which is Chapter 14.7 §2's observer pointer, and the nullable-pointer idiom is well understood.

When the empty case carries data. If "not found" needs to come with a nearest match, a retry count or a diagnostic, the empty state has a payload and std::optional has nowhere to put it. That is a variant or a struct — Chapter 21.6.

9. optional, Pointer, or variant?

The three tools overlap and the distinction is clean once stated.

std::optional<T> — a value that may be absent, owned, stored inline. Use it for a return type that may have nothing to return.

T* — a reference to something that may be absent, not owned, no storage. Use it for an optional reference to an existing object, and for the case std::optional<T&> would have covered.

std::variant<Error, T> or a dedicated result type — a value or an alternative that carries its own information. Use it when the failure has content.

std::optional<T*> is almost always a mistake: two levels of absence, three meaningful states, and no clarity about which is which.

10. The Comparison

approachstates representablecaller can skip the checkusable in an expressionextra storage
sentinel valuerange − 1 (measured colliding)yes, silentlyyesnone
output parameter + boolallyes — return value ignorablenoan uninitialised local
pair<T, bool>allyes — .first without .secondyessame as optional
std::optional<T>allonly via * (UB)yesflag, padded (measured 4 / 1 / 8)
exceptionall, with a reasonnoyesnone on the happy path
variant<Error, T>all, with a payloadno — must be visitedyeslargest + tag

11. What to Actually Do

  • Return std::optional<T> when a function may have nothing to return and the absence needs no explanation.
  • Use if (auto v = f()) to declare, test and bind in one expression.
  • Prefer .value_or(d) when a default is sensible, so the default is visible at the use site.
  • Prefer .value() to * when emptiness would be a programming error — it throws instead of being undefined.
  • Set _LIBCPP_HARDENING_MODE in your test build. Measured: it is the only tool that catches an empty dereference.
  • Never use a sentinel from the value's own range. Measured: it collides with real data.
  • Use T* for an optional reference, not optional<T&>, which does not exist.
  • Use an exception or a result type when the failure has a reason.
  • Avoid std::optional<bool> — use a three-valued enum.
  • Avoid std::optional as a parameter — use a default argument or an overload.
  • Remember an empty optional sorts first.

12. Mistakes Worth Making Once

A sentinel that the data later contains. Measured: two different situations, one answer, no diagnostic.

*v without checking. Measured: 0 warnings, garbage at -O0, 0 at -O2, ASan and UBSan silent, caught only by libc++ hardening.

Ignoring an output parameter's return value. Compiles, and the out-parameter is whatever it was.

optional<T> where the caller needs to know why. They will reverse-engineer a reason from context, and get it wrong.

optional<bool>. Three states, no names.

optional<T*>. Two levels of absence and no clear meaning for either.

Assuming optional allocates. Measured zero allocations; the value is inline.

Forgetting that an empty one compares less than everything. Surprising the first time you sort.

13. Interview Relevance

The frozen curriculum marks this chapter High, and the sentinel question is the one that separates a memorised answer from an understood one.

"What problem does std::optional solve?" Representing absence in the type rather than in a stolen value, an output parameter or a second boolean. The strong answer leads with the sentinel collision — a value the data legitimately contains — because that is a correctness argument rather than a style one.

"What does it cost?" The value plus a flag, padded — measured 8 bytes for a uint32_t — and no heap allocation, because the value is stored inline.

"What happens if you dereference an empty one?" Undefined behaviour with *; std::bad_optional_access with .value(). Knowing that ASan and UBSan do not catch the first and libc++ hardening does is a strong detail.

"When would you not use it?" When the failure has a reason, as a parameter, for bool, for an optional reference, or when the empty case carries data.

"optional<T&>?" Does not exist in C++17; use T*.

"How do you give a default?" value_or, at the use site — which is better than baking the default into the function.

14. What You Should Be Able to Say Now

  • That the frozen signature puts the absence in the return type, where the caller cannot miss it.
  • That a sentinel collides with real data — measured, a register holding 0xFFFFFFFF against a sentinel of 0xFFFFFFFF.
  • That 0xFFFFFFFF is especially bad for hardware because it is also what an unmapped read returns.
  • The four costs of an output parameter, and that pair<T,bool> fixes the readability and not the safety.
  • That optional<T> is T plus a padded flag — measured 8, 2 and 32 — with zero allocations and no indirection.
  • The four ways to read one, and that only * is unsafe.
  • That dereferencing an empty one is caught only by libc++ hardening — ASan and UBSan silent, 0 compile warnings, and the value reads as 0 at -O2.
  • The five cases where something else is better, and the optional / T* / variant division.

15. Exercises

Exercise 1 — the collision. Reproduce §2 with a dataset where the sentinel appears. Then pick a different sentinel and find a dataset that breaks that one too.

Exercise 2 — the detection matrix. Reproduce §6's five configurations. Then add .value() and show which configurations now report something.

Exercise 3 — four readings. Write one function returning optional and call it four ways — if (auto), value_or, .value() in a try block, and * after a check. State when each is right.

Exercise 4 — the output parameter. Convert an out-parameter function to optional and count the lines removed at three call sites. Then find a call site that ignored the return value.

Exercise 5 — the sizes. Measure sizeof(std::optional<T>) for char, int, double, a 3-byte struct and a std::string. Explain each number from alignment.

Exercise 6 — sorting. Sort a vector of optional<int> containing empties and explain the order without looking it up.

Exercise 7 — the wrong tool. Take a function returning optional whose failure has a reason and rewrite it with a two-alternative variant. Compare the call sites.

16. Summary

  • std::optional<T> puts absence in the type. The frozen signature std::optional<std::uint32_t> lookup(std::string_view) const; reads as a value, or nothing.
  • Measured: a sentinel collides with real data. A register legitimately holding 0xFFFFFFFF and a register that does not exist both returned 0xFFFFFFFF; the optional version reported has_value() == 1 with that value, keeping the two distinct.
  • A sentinel is a correctness assumption about the data, which nothing checks and a future dataset can invalidate.
  • An output parameter costs four things: an uninitialised local, an ignorable return value, no use in an expression, and a backwards signature. pair<T,bool> fixes the readability, not the safety.
  • Measured cost: the value plus a padded flag — 8 bytes for a uint32_t, 2 for a char, 32 for a std::string — and 0 heap allocations, stored inline with no indirection.
  • Four ways to read it: if (auto v = ...), value_or, .value() which throws std::bad_optional_access, and * which is undefined when empty.
  • Measured: dereferencing an empty one gave 0 compile warnings, garbage at -O0, 0 at -O2, and silence from BOTH ASan and UBSan — caught only by _LIBCPP_HARDENING_MODE, for the same allocation-boundary reason as Chapter 20.3 §5.
  • So set the hardening mode in your test build, or prefer .value() and value_or over *.
  • Measured: an empty optional compares less than any value and equal to nullopt.
  • Use something else when the failure has a reason, for a parameter, for bool, for an optional reference (T*), or when the empty case carries data.

Absence is one state to add to a type. The next chapter is the general case: not one value or nothing, but one value out of several possible types. Chapter 21.6 is std::variant and std::any, and the comparison turns on exactly one question — whether the set of possible types is written down, and therefore checkable, or open, and therefore not. The chapter measures three formulations of the same mistake and finds the detection escalating from a compile error to a warning to nothing at all.

Standards & specifications

Governing standard
ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)

Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.

This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.

Where this fits

Part of the C++ Programming curriculum.