and non-POD C++ types
C++ 17 has introduced the
std::optional<T> template class that is
analogous to the Maybe/Optional monad implementation in many other
languages. “Analogous” is doing a lot of work in this statement because the C++
type checker is not going to help you avoid dereferencing an empty
optional like Rust, Haskell, Scala, Kotlin, TypeScript and many other languages
That does not make it useless. As with many things in C++, we will be careful™ when using it and write only programs that do not dereference an empty optional.
In languages that deal mostly with reference types, an optional type
can be implemented as an object that wraps a reference and a tag bit that tells
if the optional has some data or nothing.1 In C++ on the other hand,
std::optional<T> will inline the value type
T onto itself. That means
the general behavior of an optional of
T depends a lot on the specifics of
the type it’s wrapping.
For integer, floats, characters, the use of
std::optional<T> doesn’t bring
many surprises. In this post I want to look at what happens when non-POD
types are wrapped in an optional. For this, I will write a class that prints a
different message on each special function call:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
And write a function that returns an optional of this class — a common use-case
of optionals. The returned optional will contain a value if the string argument
is non-empty, and be
1 2 3 4 5 6
When using a
std::optional<Object>, neither the
Object constructors or
destructors have to be called if the variable never gets populated with a value.
To see this in action, consider
1 2 3 4 5 6 7 8
and its output when called with an empty string
Things get more involved when
program1 is called with
"Hello!" and the
optional gets populated
1 2 3 4 5
return Object(s) line in
Object::Object(const std::string &) to create the
Object that then gets
moved into the storage within the
have a move-constructor, it would be copied here. At the end of the scope of
maybe, the “moved-from” temporary
Object instance is destroyed, and at the
Object::~Object has to
be implicitly called again to destroy the
Object within the
This situation can be improved if we tell
forward the arguments to
Object::Object so it can construct
Object in the optional’s storage area right away without a temporary
1 2 3 4 5
With this change, the output of
1 2 3
Only one constructor invocation and one destructor invocation. A win!
However, most functions returning a
std::optional<T> are calling some function
T in the code path that instantiates and returns the optional.
That takes us back to the same situation of duplicated
1 2 3 4 5 6 7 8
To improve this and keep the logic of
makeObject separate from
would have to change
makeObject to allow the perfect-forwarding
of the parameters from
Another common way of writing these functions is by declaring a variable of type
T, performing some operations on it, and then returning it wrapped in an
optional. This has the same problem we started with.
1 2 3 4 5 6 7 8
These problems might not be a big deal in most situations, but if you insist on returning non-PODs wrapped in a optional, make sure that:
- The wrapped type should be cheaply movable, otherwise your program might be copying it on every function call due to innocent-looking code;
- Define your destructors outside the class declaration so they don’t get inlined by the compiler in both functions — the caller and the callee that returns the optional — to avoid binary size increase.
The specific situation in which I’ve seen this really affect binary size and possibly performance is when functions are written to return instances of classes generated by the Google Protocol Buffers compiler.
Google Protocol Buffers for C++ was designed before C++11 (i.e. before move-semantics was added to the language). Its APIs and generated code are designed to make it possible to use the classes without ever invoking copy constructors. It’s a good API.
If you never invoke a function, it doesn’t need to be in the compiled binary. You can notice a sudden increase in the binary size of your program when a single call to a big function is added to the codebase. Returning an optional of a Protocol Buffers object is enough to instantiate a lot of code that could otherwise never be needed.
Let’s take a look at the generated code based on a Protocol Buffers message
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
The move-constructor (by calling
operator=(Date &&)) can potentially call
CopyFrom. The latter is called when the objects can’t be
swapped because they are allocated in different arenas and have to be to be
copied instead. By using the move-constructor of this object, both the moving
(swapping) and copying functions are instantiated in the binary. This explains
why returning the optional of a Protocol Buffers class increases the binary size
of a program that, before doing that, didn’t have a need for
By adopting a more C-like way of initializing structures, the unnecessary use of move-constructors and extraneous destructor calls can be avoided. This pattern fits nicely with the code generated by Protocol Buffers.
Let’s add a new member function to the
and write an alternative to
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
maybe function was rewritten to take an output parameter and return a
out_object is changed in-place and doesn’t have to be
moved into an optional and then destroyed within
maybe. As expected,
program2("Hello!") generates a cleaner output
1 2 3
program2 does not have to ever call the move-constructor, so it can be
discarded by the linker and the destructor is called only once. If the
destructor was inlined, it would be inlined once in the program, not twice.
Optionals are far from zero-cost abstractions in C++ and if this cost matters to
you, taking output parameters and returning non-discardable booleans is an
advantageous alternative solution to a function returning
when objects of type
T are expensive2 to move and/or destroy.
Languages like TypeScript can statically determine if an object is set based on its position in the control flow of the program. ↩
In the sense of run-time and size of the code in the binary. ↩