Last Update:
C++ Return: std::any, std::optional, or std::variant?
Table of Contents
What should happen when the data returned from a function is not valid? It might be an error or just how the system operates (embedded environment, a timeout). In this article, you’ll see a practical example from the robotics area where the vocabulary types from C++17 play important roles.
This is a guest post written by Rud Merriam:
Rud is a retired software engineer with interest in C++, embedded / robotic systems, and general hacking. He’s written about C++ for embedded systems for Hackaday and is currently working to catch up on C++20 capabilities and write about them. Reach him on Twitter at @r_merriam.
This text was initially published @Medium - see here.
Exploring C++ STL for Function Return Status
Sphero released the RVR robot above in 2019. Before its release, I worked to build a robot with similar capabilities. I abandoned my efforts for an RVR. That lead to an expedition in reverse engineering their serial port communications protocol.
As work progressed, it became clear that the data from the RVR might or might not be available. (I explain later.) The code requesting the data via a function call needed to know if there was or wasn’t data. Current best practices in C++ advise that all function outputs be via the function return and not through output parameters. I wanted to create a class Result
that would return the data or its absence. The creation ofResult
required a dive into the nooks and crannies of C++17 and C++20. Exploration led to three possibilities: std::any
, std::optional
, and std::variant
. Herein lies the result (sorry!) of that work.
Why Class Result?
Sphero provides a Python SDK, but I work in C++. I plan to use an Intel based single board computer (SBC), the Up board, running Linux.
The documentation provided details on the serial channel message packet structure and values. It describes the commands as function calls. Anything more, like command and response format details, needed to be gleaned from the Python code.
The protocol considers the SBC the host and the RVR the target. The host sends a message to the target while the RVR responds when it can. When the host sends a request, it cannot expect an immediate response. Additionally, if the host sends multiple requests, the responses might not come back in order. For example, if the host sends A, B, and C, the responses might be B, C, and A. Also, a streaming mode periodically sends data, i.e., the RVR may repeat the response to B every 50 milliseconds.
Consider a more concrete example. One request is to stream the ambient light sensed by the RVR. (There is a light sensor on its top.) The host software must store the ambient light value when it is received. Once requested, the RVR continues to report this value periodically, say every 50 ms. Application code asking for the stored data before reception requires a No Data response. Eventually, the data becomes available.
To illustrate, here’s a higher-level code snippet showing the desired design use:
rvr::SensorsStream& sen_stream...
Result<float> ambient { sen_stream.ambient()};
if (ambient.valid()) {...}
This code instantiates the SensorStream
class and invokes sen_stream.ambient()
to get the stored ambient light value. The Result<float>
type will either contain the data or an indication of No Data. The last line tests to see if Result<float>
contains data, that is, the data is valid.
At the level of sen_stream.ambient()
the code looks like:
Result<float> rvr::SensorStream::ambient() {
std::string msg {...request response message from a map...};
Result<float> res;
if (msg) {
// ...decode msg
res = ...decoded msg
}
return res;
}
This is the pattern for all data requests. The raw message is kept in an std::map
with the key based on the command codes and other information in the response. If the message in the map is empty, an empty string is returned. The definition of res
creates a Result
with no data. If the msg contains data, it is decoded and the data assigned to res
.
The need for a test after calling a function is a drawback and hassle in most programming languages. As above, it isn’t bad for one call, but looking at the calls for 20 sensor values and their validation checks makes the code nearly unreadable. Maybe I’ll find a solution later, but probably not. All I can do is create Result<float>
and all the other possible return values. As I do, we’ll explore three interesting capabilities of C++.
Three Candidates: std::any
, std::optional
, std::variant
There may be other possibilities, but the three we’ll look at are:
std::any
- can contain a value of any type without any specification of the type,std::optional<T>
- can hold a value of type T or no value,std::variant<T, S…>
- can contain any type in the list*T, S, …*
Of the three, std::optional
is the obvious one to consider. If data from the RVR is available, it is returned; otherwise, the return indicates no data.
I started with std::optional
but ran into a problem. I tried std::variant
And it worked. Getting std::variant
to work showed me what was wrong with my attempt at std::optional
so I reverted to it. That’s software. If at first you don’t succeed, try something else. Frequently it shows you what was wrong the first time.
A complicating factor is the need to return many types: Result<float>, Result<uint16_t>, Result<string>...
One possible advantage of std::any
or std::variant
is they can handle multiple types. A drawback of std::variant
and std::optional
is the need to specify the types. A std::any
disadvantage is it dynamically allocates space for its contents, although it also may use short value optimization. Both std::optional
and std::variant
cannot, per the C++ specification, use dynamic allocation. Still, since std::any
might have an advantage due to flexibility on type specification, I explored using it.
A Skeleton Design for Result
The overall implementation of the Result
class is similar, regardless of the underlying alternative used. Here’s the outline of the code:
template <typename T>
struct Result : protected <<one of the alternatives>> {
explicit constexpr Result() noexcept = default;
constexpr Result(T&& t) noexcept: <<one of the alternatives>>{t}{ }
constexpr bool valid() const noexcept;
constexpr bool invalid() const noexcept;
constexpr auto const get() const noexcept -> T;
};
using ResultInt = Result<int>;
using ResultString = Result<std::string>;
It turns out we can’t avoid using a template class with std::any
so that eliminates its big advantage. In the method get(),
a type is needed for the return value, otherwise the method doesn’t know what to return. See the std::any
section below for details.
The STL classes are base classes for the Result
class. (But see a late change below.) The inheritance is protected
to allow Result
to access the base methods but prevent their exposure to the user. On this, I may be overly cautious. Specifically, I wanted to block a user from circumventing the use of the get()
method by accessing the underlying data access methods. Some of them throw exceptions if data is not available, a situation I wanted to prevent.
Result Methods
Ignoring the constructors for a moment, the three methods provide the working details of the class. Both valid()
and invalid()
report whether a value is stored. The method get()
returns the value or a default constructed version of the value. This avoids exception throwing by the underlying class when there is no value present.
There are two approaches to getting the value. The simplest is to use get()
and somehow allow for the default value. In some cases, this may work okay, so the class provides for that possibility.
The more complex approach is to first test for valid()
and only use get()
if there is data. The function get()
works this way internally, as you’ll see.
The method invalid()
is for convenience as in while(some_var.invalid()) {...}
Constructors
Now for the constructors. They are needed to handle a couple of situations illustrated by:
ResultInt func(bool const test) {
ResultInt res; // Result() constructor
if (test) {
res = 42; // Result(T const&&) constructor
}
return res;
}
In the function, the default constructor — Result()
- is required for the definition of res
in func().
This creates a ResultInt
with no value. The state of test
determines if data is assigned to res
. When test
is false, no data is assigned; when true, data is assigned. The assignment uses the conversion constructor to create a Result
— actually a ResultInt
— with the value. The conversion constructor’s single parameter is an rvalue reference that accepts rvalues and values.
Type Aliases
The using
expressions create convenient aliases for results of different types. Their use is illustrated in func()
.
With the general discussion done, we go into the details for each alternate implementation.
Result Based on std::any
The use of std::any
started as an attempt to avoid specifying a type. Unfortunately, it doesn’t work because when returning data from Result
the type is needed. That’s software development. Research, experiment, and learn.
Here’s the std::any
version:
template <typename T> // constant size of 16
struct Result final : protected std::any {
constexpr Result(T const&& t) noexcept
: std::any { t } {
}
explicit constexpr Result( ) noexcept = default;
Result(Result const& other) = default;
Result& operator=(Result const& other) = default;
constexpr bool valid( ) const noexcept {
return has_value( );
}
constexpr bool invalid( ) const noexcept {
return !valid( );
}
constexpr auto const get( ) const noexcept -> T {
return (valid( ) ? std::any_cast<T>(*this) : T( ));
}
};
This fills out the skeleton Result
using std::any
. There are only three implementation details specific to std::any.
- The use of
std::any
as the base class and in the conversion constructor. - Using
has_value()
to test if a value exists. - Using non-member function
std::any_cast<T>
to obtain the actual value.
Note the default constructor is created by specifying = default.
This is the case in all the implementations.
In Result.get()
the invocation of valid()
determines if there is data. If there is, it uses the std::any_cast<T>
function to obtain the data. Otherwise, a default constructed value is used.
Result Based on std::variant
With the std::any
version of Result
requiring a type specification, it fell to the bottom of possible solutions. That left std::variant
as a possibility instead of using std::optional.
. Here is its implementation:
template <typename T> // size 8 for int, 40 for string
struct Result : protected std::variant<std::monostate, T> {
explicit constexpr Result( ) noexcept = default;
constexpr Result(T const&& t) noexcept
: std::variant<std::monostate, T> { t } {
}
constexpr bool valid( ) const noexcept {
return std::holds_alternative<T>(*this);
}
constexpr bool invalid( ) const noexcept {
return !valid( );
}
constexpr auto const get( ) const noexcept -> T {
return (valid( ) ? std::get<T>(*this) : T( ));
}
};
An std::variant
is analogous to a union
. It allows multiple different types to reside in a single memory space. The basics of this version are the same as the std::any
version. The specific std::variant
methods used in this implementation changed, but they are equivalent to those in all the other STL alternatives. Somewhat different is std::holds_alternative
to test for the presence of data. It is a non-member template function that looks for the type in the std::variant
instance.
Of note is std::monostate
used as the first type. This type contains no data. Its main purpose is to provide a constructible type as the first type for std::variant
. It is a requirement that the first type in std::variant
is constructible to allow default construction of an instance, i.e., an instance with no data. In this case, it works like a flag to indicate there is no data.
The best I can say about this version is it works. Nothing is actually wrong, but it doesn’t fit the requirements quite as well as std::optional
might. If I’d gotten std::optional
working at first it wouldn’t have been considered, and I wouldn’t have material for an article.
Side-note: In early implementations of std::variant
you might have issues when you inherit from it and then tried to use std::visit
- LWG3052. But it’s resolved in P2162 and applied against C++20.
Result Based on std::optional
Basing Result
on std::optional
always was the top choice. Misadventure led to exploring the alternatives. So here is the version using the top choice: No big surprises. It looks like the other implementations except using different methods for the internals. An interesting method in std::optional
is the operator bool
conversion method as an alternative to the has_value()
method. I find it strange or inconsistent not to provide that method in all these classes. Another interesting method is value_or()
which handles the test used in the other implementations.
template <typename T> // size 8 for int, 40 for string
struct Result : protected std::optional<T> {
explicit constexpr Result( ) noexcept = default;
constexpr Result(T const&& t) noexcept
: std::optional<T> { t } {
}
[[nodiscard]] constexpr bool valid( ) const noexcept {
// return bool( *this);
return std::optional<T>::has_value( );
}
[[nodiscard]] constexpr bool invalid( ) const noexcept {
return !valid( );
}
[[nodiscard]] constexpr auto get( ) const noexcept -> T {
return std::optional<T>::value_or(T( ));
}
};
As expected, this version works fine and being based on std::optional
it feels like an elegant solution. At least until various thoughts occurred while writing this article.
Another Result Using std::optional
As I wrote this article, I considered three issues:
- It felt vaguely wrong to inherit from a standard library class, although all these implementations worked fine.
- Should
Result
be markedfinal
so it could not serve as a base class? - Returning a default constructed value removed a decision from the user of the class.
Below is the implementation that does not use inheritance. Instead, std::optional
is a class member. A get_or()
method is added that returns the default value of the type if no data is available. The get()
method will throw an exception if there is no data. The Result
user needs to do the checking.
template <typename T> // size 8 for int, 40 for string
class Result {
public:
constexpr Result(T const&& t) noexcept
: mOptional { t } {
}
explicit constexpr Result( ) noexcept = default;
[[nodiscard]] constexpr bool valid( ) const noexcept {
return mOptional.has_value( );
}
[[nodiscard]] constexpr bool invalid( ) const noexcept {
return !valid( );
}
[[nodiscard]] constexpr auto get( ) const -> T {
return mOptional.value( );
}
[[nodiscard]] constexpr auto const get_or( ) const noexcept -> T {
return mOptional.value_or(T( ));
}
private:
std::optional<T> mOptional;
};
I’m still debating on the final. A final class is possibly more efficient due to compiler optimization. Maybe looking at the code on CppInsights will provide some information.
There is not a lot of difference between this and the inheritance version. It changed to a class
since there is a need for a private:
section to contain a data member mOptional
. This is likely the version I will use in the RVR library because its elegance exceeds the other std::optional
version.
Some even argue that’s not safe to inherit from standard library types (see Don’t inherit from standard types – Arthur O’Dwyer), as it’s safer to keep them as data members.
An executable version is available on Compiler Explorer.
Why Not Use Exceptions?
That is a good question on a controversial point. Any response I make is ripe with peril. My answer is based on C++ and may not be generalizable to other languages.
First, exceptions are expensive. They add both code and additional processing.
Second, this is not a situation where exceptions apply. To me, exceptions are exceptions, not status or expected error conditions.
I use exceptions based on the state of the object. Every object has a state, that is, the variables in the class. One rule is that the state of an object is always valid on entry to or exit from a class method. A violation of that requirement is my definition of an exceptional condition.
The Proposed std::expected
There is a proposal, P0323r10, to add std::expected
to the C++ library. This class adds capabilities beyond my Result
class. The class would be similar to std::variant<ReturnType, Error>
but with additional capabilities. It would be easy to add some of the proposed capabilities to Result
or even to implement the class. However, as my examples above show, my requirement is for a simple valid/invalid capability. As I use Result
, requirements may suggest switching to this proposed class.
My thanks to Bartek Filipek from C++ Stories for telling me about this proposal. Bartek’s blog is a great source for learning about C++. He also has two books that I highly recommend: C++17 in Detail and C++ Lambda Story.
Wrap-Up and Call for Comments
There are the multiple versions of Result
. It was a good exercise in exploring those three alternatives and modern C++. They share the ability to contain multiple values and provide similar interfaces for retrieving the data but std::optional
proved the more elegant approach. Perhaps sharing the process of their development will show some their own process is valid.
Here’s a visual reference for these and other @HackingCpp: special containers.
A third book deserving mention is C++20 by Rainer Grimm. The only problem is I shouldn’t read it while writing articles. I end up changing the code based on new information from the book. Then I have to revise the article.
Back to you
Would you mind using the comment capability if you have ideas about how this could be improved or done differently? As I’m not a C++ language lawyer, feel free to suggest where I misstated something.
I've prepared a valuable bonus if you're interested in Modern C++!
Learn all major features of recent C++ Standards!
Check it out here: