Nullable Values, std::optional
and Monadic Operations
A comprehensive guide to using std::optional
to represent values that may or may not be present.
When creating classes, a common situation we'll run into is the concept of an optional field.
For example, if we're creating a Character
class, our character may, or may not, belong to a guild:
#include <iostream>
class Character {
public:
std::string Name;
std::string Guild;
};
int main(){
Character Bob{"Bob", "The Fellowship"};
std::cout << "Bob's Guild: "
<< Bob.Guild;
Character Anna{"Anna"};
std::cout << "\nAnna's Guild: "
<< Anna.Guild;
}
Bob's Guild: The Fellowship
Anna's Guild:
In this example, our optional field is a simple string, but potentially any data type can be optional.
We'd like a generic, reusable way of handling such situations, and also making it explicit when a piece of data is optional. The standard library provides a container that can help us here: std::optional
Creating std::optional
Objects
The std::optional
container is available by including the <optional>
header and can be created like any other object. We pass a template parameter to denote what type of data it will contain:
#include <optional>
std::optional<std::string> Name;
When declaring our std::optional
container, we can provide an initial value:
#include <optional>
#include <string>
std::optional<std::string> Name{
std::string("Bob")};
When providing an initial value, we can remove the template parameter. This asks the compiler to infer the correct type, using class template argument deduction (CTAD):
#include <optional>
#include <string>
std::optional Name{std::string("Bob")};
Retrieving Optional Values
We can check if a std::optional
container contains a value using the has_value()
method, and we can access its value using the value()
method:
#include <iostream>
#include <optional>
class Character {
public:
std::string Name;
std::optional<std::string> Guild;
};
int main(){
Character Bob{"Bob", "The Fellowship"};
if (Bob.Guild.has_value()) {
std::cout << "Bob's Guild: "
<< Bob.Guild.value();
}
}
Bob's Guild: The Fellowship
As a syntactic shortcut, we can implicitly convert a std::optional
to a boolean value, which will have the same return value as the has_value()
method. This removes the need to call has_value()
in most situations:
#include <iostream>
#include <optional>
class Character {
public:
std::string Name;
std::optional<std::string> Guild;
};
int main(){
Character Anna{"Anna"};
if (!Anna.Guild) {
std::cout << "Anna has no guild";
}
}
Anna has no guild
The std::bad_optional_access
Exception
If we're using exceptions, we can just call the value()
method without necessarily being sure the std::optional
currently contains a value. If it doesn't, a std::bad_optional_access
exception will be thrown:
#include <iostream>
#include <optional>
int main() {
std::optional<int> OptionalInt;
try {
OptionalInt.value();
} catch (std::bad_optional_access& e) {
std::cout << "Caught an exception: "
<< e.what();
}
}
Caught an exception: Bad optional access
Exceptions: throw
, try
and catch
This lesson provides an introduction to exceptions, detailing the use of throw
, try
, and catch
.
Exception Types
Gain a thorough understanding of exception types, including how to throw and catch both standard library and custom exceptions in your code
The *
and ->
Operators
The std::optional
type has overloaded the ->
and unary *
operator to provide access to the underlying value.
#include <iostream>
#include <optional>
int main() {
std::optional<int> OptionalInt{42};
std::cout << "Value: " << *OptionalInt;
}
Value: 42
The ->
operator gives access to class members of the underlying type:
#include <iostream>
#include <optional>
struct SomeType {
int Value;
};
int main() {
std::optional<SomeType> Optional{42};
std::cout << "Value: " << Optional->Value;
}
Value: 42
Unlike the value()
method, these operators will not check if the std::optional
contains a value. When we build our software with debug flags enabled, most compilers will include runtime checks to alert us if we use these operators on an empty std::optional
.
With release configurations, these checks are removed to optimize performance, and any attempt to use these operators on an empty container will have undefined behaviour.
The value_or()
Method
The value_or()
method will return the value contained in the optional. But, if the optional doesn't have a value, it will return what we passed as an argument to value_or()
:
#include <iostream>
#include <optional>
class Character {
public:
std::string Name;
std::optional<std::string> Guild;
};
int main(){
Character Anna{"Anna"};
std::cout << "Anna's Guild: "
<< Anna.Guild.value_or("[None]");
}
Anna's Guild: [None]
Updating Values
In some ways, we can treat the std::optional
wrapper as invisible, and write code in the same way we would if it were a regular variable of the underlying type.
The assignment (=
) operator is an example of this. We can update the value contained in a std::optional
container using the =
operator, passing a value of the type the container is storing:
#include <iostream>
#include <optional>
int main() {
std::optional<std::string> Name{"Bob"};
std::cout << "Name: " << Name.value();
Name = "Anna";
std::cout << "\nName: " << Name.value();
}
Name: Bob
Name: Anna
For non-trivial types, constructing them outside of the std::optional
container, and then moving them in using the =
operator has a performance cost. Instead, we can construct them in place, using the emplace()
function in the same way we've seen with other containers.
Any arguments we pass to this function are forwarded to the constructor for the underlying type our std::optional
is using:
#include <iostream>
#include <optional>
class Character {
public:
Character(std::string Name, std::string Guild)
: Name{Name}, Guild{Guild}{}
std::string Name;
std::optional<std::string> Guild;
};
int main(){
std::optional<Character> Player;
Player.emplace("Bob", "The Fellowship");
std::cout << "Name: " << Player.value().Name;
}
Name: Bob
Resetting Optionals
We can remove the value within a std::optional
using the reset()
method:
#include <iostream>
#include <optional>
int main(){
std::optional<std::string> Name{"Bob"};
std::cout << "Name: " << Name.value();
Name.reset();
std::cout << "\nName: "
<< Name.value_or("[None]");
}
Name: Bob
Name: [None]
Comparing Optionals
Comparison operators are another scenario where the wrapping std::optional
can become invisible. We can compare values with values contained in std::optional
containers using comparison operators like ==
and <
in the usual way.
This generally means we can use ==
and !=
without any additional syntax:
#include <iostream>
#include <optional>
int main(){
std::optional Optional{5};
if (Optional == 5) {
std::cout << "That's a five";
}
}
That's a five
An "empty" std::optional
will always be not equal to any other object of the underlying type.
#include <iostream>
#include <optional>
enum class Faction {
Human,
Elf,
};
class Character {
public:
std::optional<Faction> Faction;
};
int main(){
Character Player;
if (Player.Faction != Faction::Human) {
std::cout << "Not Human";
}
}
Not Human
Less usefully, an empty std::optional
will always be less than other values. But in general, this type of comparison operation involving an empty std::optional
container is very uncommon.
#include <iostream>
#include <optional>
class Character {
public:
std::optional<int> Health;
};
int main(){
Character Player;
if (Player.Health <= 0) {
std::cout << "Not Alive";
}
}
Not Alive
Creating Empty Optionals
When we want to return an empty std::optional
, the std::nullopt
token is the typical approach we use:
#include <iostream>
#include <optional>
std::optional<int> GetInt(){
return std::nullopt;
}
int main(){
if (!GetInt().has_value()) {
std::cout << "That's a null";
}
}
That's a null
A more complex example is below:
#include <iostream>
#include <optional>
class Character {
public:
bool hasGuild{false};
std::string GuildName;
};
std::optional<std::string> GetGuildName(
const Character& Character){
if (Character.hasGuild) {
return Character.GuildName;
}
return std::nullopt;
}
int main(){
Character Player;
std::cout << "Guild: "
<< GetGuildName(Player).value_or("[None]");
}
Guild: [None]
Monadic Operations (C++23)
As of C++23, the std::optional
container supports three monadic operations: or_else()
, and_then()
and transform()
.
These methods all accept another function as an argument. This may not be something we've seen before, but we will cover it in more detail later in the course.
Additionally, all of the methods return a std::optional
. This allows them to be chained together, as we'll demonstrate later.
The behavior of these methods is easier to explain by example, so if these descriptions don't make sense, scroll ahead to the following code snippet that shows them all being used.
or_else()
- if thestd::optional
contains a value, return thestd::optional
. Otherwise, invoke the provided function, and return what it returns. This should also be astd::optional
.and_then()
- if thestd::optional
is empty, return it. If thestd::optional
contains a value, invoke the provided function, passing the value as an argument. Then return thestd::optional
transform()
- if thestd::optional
is empty, return it. If thestd::optional
contains a value, invoke the provided function. The function will receive the current value as an argument and will return a new value. Thetransform()
function will then return astd::optional
containing that new value.
These methods were added to support what is known as a functional programming style, which looks a bit different from what we may be used to. We cover topics related to this in a little more detail in our dedicated chapter on functions, which is coming up later.
For now, an example that uses all these methods, and the programming style they are designed to support, is highlighted below:
#include <iostream>
#include <optional>
using OptInt = std::optional<int>;
// For use with or_else()
OptInt GetDefaultValue(){ return OptInt{1}; }
// For use with transform()
int Increment(int x){ return ++x; }
int Double(int x){ return x * 2; }
// For use with and_then()
OptInt Log(OptInt x){
std::cout << "Value: " << x.value() << '\n';
return x;
}
int main(){
OptInt x;
x.or_else(GetDefaultValue)
.transform(Increment)
.and_then(Log)
.transform(Double)
.and_then(Log);
}
Value: 2
Value: 4
Summary
In this lesson, we introduced std::optional
, which provides an explicit and type-safe way to work with values that may or may not be present. It helps avoid bugs and makes it clear when a value is optional. The key takeaways are:
std::optional
is a container that represents an optional value - it may or may not contain a value- You can check if an optional contains a value using
has_value()
or by converting it to a bool - Access the value in an optional using
value()
,operator*
,operator->
, orvalue_or()
value()
will throw astd::bad_optional_access
exception if the optional is empty- You can reset an optional to an empty state with
reset()
- Optionals support comparison operators with the underlying type
- An empty optional can be created using
std::nullopt
- C++23 adds monadic operations to optional:
and_then()
,or_else()
andtransform()
Constrained Dynamic Types using Unions and std::variant
Learn how to store dynamic data types in C++ using unions and the type-safe std::variant