std::optional vs Nullable Pointer vs Out-Parameter in C++: Choosing How to Return 'Maybe a Value'

Key takeaways

Return std::optional<T> when the function produces a new value that may be missing. Return const T* when it finds an existing object the caller should observe without copying. Use bool plus an out-parameter mainly for C-style or allocation-reusing APIs. The article covers the traps of each: operator* on an empty optional, optional<bool> in an if, optional<T&> not compiling before C++26, and pointers into containers that move their elements.

C++ has no single “nullable” type, so a function that might not produce a result has several ways to say so:

std::optional<User> findUser(int id);        // a value or nothing
const User*         findUser(int id);        // an address, or nullptr
bool                findUser(int id, User& out);   // success flag + out-parameter

All three can express the same idea. They differ in who owns the result, whether it gets copied, how long it stays valid, and which mistakes the compiler can catch. This article is about choosing among them. For the full std::optional API (emplace, comparisons, C++23 and_then/transform), see std::optional in practice. Snippets were compiled with g++ 10.3, -std=c++17 -Wall -Wextra.

Decide by what the function is doing

The function…ReturnWhy
computes or parses a new value (parsePort, toInt)std::optional<T>Nothing to point at, and the result is owned by the caller
finds an existing object in storage it ownsconst T* / T*No copy, and the caller can modify it when non-const
must say why it failedstd::expected<T, E> (C++23) or a result typenullopt / nullptr carry no reason
fills a caller-provided buffer, or crosses a C boundarybool + T& out-paramLets the caller reuse allocations, and C has no optional
returns a polymorphic objectstd::unique_ptr<Base>optional<Base> would slice or not compile

The most common mistake is using optional where the real question is “why did it fail?”. A std::optional<Config> from loadConfig(path) can’t distinguish “file missing” from “syntax error on line 12”, and the caller ends up logging “failed to load config” with no detail. std::expected fits that case.

std::optional: for values that are produced, not found

std::optional<int> parsePort(std::string_view s) {
    int v = 0;
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), v);
    if (ec != std::errc{} || ptr != s.data() + s.size() || v < 1 || v > 65535)
        return std::nullopt;
    return v;
}
// "8080" -> 8080, "0" -> nullopt, "80x" -> nullopt, "70000" -> nullopt

The value lives inside the optional, so no heap allocation is involved (unless T allocates itself) and nothing can dangle. The optional adds an “engaged” flag, padded to T’s alignment. On x86-64 with g++ 10.3:

sizeof(int)=4           sizeof(optional<int>)=8
sizeof(double)=8        sizeof(optional<double>)=16
sizeof(std::string)=32  sizeof(optional<string>)=40
sizeof(Large)=1000      sizeof(optional<Large>)=1001   // Large is char[1000]

The overhead only matters when you store many of them. A std::vector<std::optional<double>> is twice the size of a std::vector<double>, which is when a NaN sentinel or a separate bitmask starts to look attractive. An optional<Large> always reserves space for the full Large even when empty. That is fine for a return value, but questionable as a member of something you keep a million of.

*opt is unchecked, value() is checked

std::optional<int> empty;
int a = *empty;          // undefined behavior: no check, reads garbage or worse
int b = empty.value();   // throws std::bad_optional_access ("bad optional access")
int c = empty.value_or(0);

operator* and operator-> are deliberately unchecked so that code which has already tested the optional pays nothing twice. The consequence is that optional doesn’t force you to check. It makes the check easier to write and easier to spot in review. If “empty here” would be a bug, value() turns silent UB into an exception with a clear type.

optional<bool> and optional<pointer> in an if

std::optional<bool> readFlag(bool present, bool v) {
    if (!present) return std::nullopt;
    return v;
}
auto flag = readFlag(true, false);
if (flag) { /* runs: the optional is engaged, even though the flag is false */ }
flag.value_or(true);   // false

if (flag) asks “is there a value?”, not “is the value true?”. The same confusion happens with std::optional<int*>, where there are two different kinds of “null”. Write if (flag == true) or if (flag.value_or(false)) so a reader can see which question is being asked. Better still, use a three-state enum class when “unset” is a real state in your domain.

No optional references (until C++26)

int x = 1;
std::optional<int&> r = x;
error: non-static data member 'std::_Optional_payload_base<int&>::_Storage<int&, true>::_M_value'
       in a union may not have reference type 'int&'
error: static assertion failed

C++17 through C++23 forbid optional<T&>, and C++26 finally adds it. Until then, a plain T* already is an optional reference, and it’s the idiomatic choice. std::optional<std::reference_wrapper<T>> works but reads worse (ref->get() = 20) without being any safer.

Pointers: for objects that already exist somewhere else

class Users {
    std::map<int, User> byId_;
public:
    const User* find(int id) const {
        auto it = byId_.find(id);
        return it == byId_.end() ? nullptr : &it->second;
    }
};
if (const User* u = users.find(1)) std::cout << u->name << "\n";

Returning std::optional<User> here would copy the User, including its strings, on every lookup, and the caller couldn’t modify the stored user through it. The pointer version is free and allows User* for mutation. What you give up is lifetime safety. The pointer is a loan from the container, valid only while the container doesn’t move that element.

That rule depends on the container. std::map, std::set and std::unordered_map are node-based, and pointers and references to their elements stay valid across inserts, even across an unordered_map rehash (only its iterators are invalidated). std::vector, std::deque insertions, std::string, and open-addressing hash maps like absl::flat_hash_map do move elements, and a pointer taken before a reallocating insert dangles afterwards:

std::vector<User> v{{"Alice", 30}};
const User* alice = &v[0];
v.push_back({"Bob", 25});      // may reallocate: alice now dangles
std::cout << alice->name;      // undefined behavior

This is the variant of the bug I find hardest to catch in review. A T* lookup over a vector-backed store passes every test, because the test data never grows the vector past its capacity. Then a larger input triggers a reallocation while some caller is still holding a pointer from an earlier lookup. The symptom is usually a garbled value or a crash far from the lookup that caused it. That is why I document pointer-returning lookups with “valid until the next insert”, and switched the store to a node-based container (or stored indices) where callers really needed to hold results across mutations. Iterator invalidation lists the exact rules per container.

Dangling through an optional temporary

optional avoids dangling for the value it owns, but not for things you borrow from it:

std::optional<std::string> getString() { return "hello"; }

const char* p = getString()->c_str();   // the temporary optional dies at the ';'
// p now points into a destroyed std::string

g++ 10.3 compiles this with -Wall -Wextra -O2 and prints no warning. Keep the optional in a named variable (auto s = getString(); if (s) use(s->c_str());). The same applies to std::string_view built from *getString().

A range-for over a returned optional has the same problem, and it looks even more innocent: for (char c : *getString()) and for (auto& x : getConfig()->items) both destroy the temporary optional before the loop body runs. Lifetime extension only applies when a reference binds directly to the temporary, and here it binds to whatever operator* or operator-> returned. C++23 extends the lifetime of all temporaries in a range-for initializer (P2718), but on C++17 and C++20 compilers the loop iterates over a destroyed string.

Three smaller optional traps

value_or evaluates its argument every time. cfg.value_or(loadDefaultConfig()) calls loadDefaultConfig() even when cfg holds a value, because the argument is an ordinary function argument. For a cheap literal that doesn’t matter. For something that reads a file or allocates, write cfg ? *cfg : loadDefaultConfig(), or use C++23’s or_else with a lambda, which is only called when empty.

A moved-from optional is still engaged. After auto s = std::move(opt);, opt.has_value() is still true; it now holds a moved-from std::string (typically empty, but unspecified). Code that uses “the optional is empty” to mean “the value was already consumed” breaks here. Call opt.reset() after moving if emptiness carries meaning, or use std::exchange(opt, std::nullopt).

std::optional<T> as a parameter copies. void log(std::optional<std::string> tag) constructs a new optional, and therefore a new string, from whatever the caller passes, including a plain std::string lvalue. For an optional input that you only read, const std::string* (null for “not given”) or an overload without the parameter is cheaper and says the same thing. const std::optional<std::string>& looks like it avoids the copy, but passing a std::string still creates a temporary optional that copies it.

The first one is the trap I have seen survive the longest in real code, because it produces no wrong results, only wasted work. A default that builds a large object or logs “falling back to default” on every call is the usual clue: the log line appears even on the happy path.

Out-parameters: still useful at two boundaries

bool tryParsePort(std::string_view s, int& out);   // returns true and writes out on success
int port = 0;
tryParsePort("443", port);                         // true, port == 443

Return values beat out-params almost everywhere since C++17. Guaranteed copy elision means returning an optional<std::string> doesn’t copy, and the call site can’t accidentally read an unwritten out. Out-params remain reasonable in two places. One is a C API, which has neither optional nor exceptions. The other is a hot loop that reuses one buffer: bool readLine(std::string& line) lets the string keep its capacity between calls, while returning std::optional<std::string> builds a new string every time. If you use an out-param, say in the name what happens on failure (tryX) and whether out is left unchanged.

Choosing quickly

  • New value that may be missing, and cheap enough to return by value: std::optional<T>.
  • Existing object owned elsewhere, no copy wanted: const T* or T*, documented as “valid until the next insert”.
  • Failure needs a reason: std::expected<T, E> or a result type.
  • Polymorphic result: std::unique_ptr<Base>, where nullptr means none.
  • C boundary or buffer reuse: bool plus out-parameter.