-
-
Notifications
You must be signed in to change notification settings - Fork 18
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
1 changed file
with
55 additions
and
103 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,146 +1,98 @@ | ||
|
||
**`std::optional` and `std::variant` in Modern C++** | ||
`std::variant` in Modern C++ | ||
-- | ||
|
||
<p align="center"> | ||
<a href="https://youtu.be/dummy_link"><img src="https://img.youtube.com/vi/dummy_link/maxresdefault.jpg" alt="Video Thumbnail" align="right" width=50% style="margin: 0.5rem"></a> | ||
</p> | ||
|
||
When working with modern C++ (C++17 and beyond), we often need tools to handle optional values or represent data that can take one of several types. That’s where `std::optional` and `std::variant` come into play. Today, we’ll explore what these features are, why they’re useful, and how to use properly them. | ||
In the last lecture we talked about `std::optional` and `std::expected` types that make our life better. It might be useful to understand _how_ they can store two values of different types in the same memory. We can get a glimpse into this by understanding how `std::variant` works. Furthermore, we can store many more types than two in it. This, incidentally also happens to be the key to mimicking dynamic polymorphism when using templates. | ||
|
||
<!-- Intro --> | ||
|
||
## Why use `std::optional`? | ||
To understand why we need `std::optional` I believe its best to start with an example. | ||
|
||
Let's say we have a function `GetAnswerFromLlm` that, getting a question, is supposed to answer all of our questions using some large language model. | ||
```cpp | ||
#include <string> | ||
|
||
std::string GetAnswerFromLlm(const std::string& question); | ||
``` | ||
In a normal case, this is a good-enough interface, we ask it things and get some answers. But what happens if something goes wrong within this function? What if it _cannot_ answer our question? What should it return so that we know that an error has occurred. | ||
Largely speaking there are two school of thought here: | ||
- It can throw an **exceptions** to indicate that some error has happened | ||
- Or it can return a special value to indicate a failure | ||
I will not talk too much about exceptions today, I will just mention that in many codebases, especially those that contain safety-critical code, exceptions are banned altogether due to the fact that there is, strictly speaking, no way to guarantee their runtime performance because of their dynamic implementation. | ||
This prompted people to think our of the box to avoid using exceptions but still to know that something went wrong during the execution of their function. | ||
In the olden days (before C++17), people would return a special value from the function. For example, we could just return some pre-defined string, for example an empty one, should something have gone wrong. But what if we ask our LLM to actually return an empty string and it would fail to do so? What should it return then? | ||
This is where `std::optional` comes to the rescue. We can now return a `std::optional<std::string>` instead of just returning a `std::string`: | ||
```cpp | ||
#include <optional> | ||
#include <string> | ||
## Why use `std::variant`? | ||
|
||
std::optional<std::string> GetAnswerFromLlm(const std::string& question); | ||
``` | ||
Now it is super clear when reading this function that it might fail because it only optionally returns a string. | ||
`std::variant` is a type-safe `union` type introduced in C++17. It allows a variable to hold one value out of a defined set of types. | ||
|
||
`llm.hpp` | ||
For instance, if a variable can hold either an integer or a string, you can use `std::variant<int, std::string>` and put any value in it: | ||
```cpp | ||
#include <optional> | ||
#include <variant> | ||
#include <iostream> | ||
#include <string> | ||
|
||
std::optional<std::string> GetAnswerFromLlm(const std::string& question); | ||
``` | ||
So let's see how we could work with such a function! For this we'll call it a couple of times with various prompts and process the results that we're getting: | ||
`main.cpp` | ||
```cpp | ||
#include "llm.hpp" | ||
int main() { | ||
const auto suggestion = GetAnswerFromLlm( | ||
"In one word, what should I do with my life?"); | ||
if (!suggestion) return 1; | ||
const auto further_suggestion = GetAnswerFromLlm( | ||
std::string{"In one word, what should I do after doing this: "} + suggestion.value()); | ||
if (!further_suggestion.has_value()) return 1; | ||
std::cout << | ||
"The LLM told me to " << *suggestion << | ||
", and then to " << *further_suggestion << std::endl; | ||
// This compiles | ||
std::variant<int, std::string> value; | ||
value = 42; // value holds an int. | ||
std::cout << "Integer: " << std::get<int>(value) << '\n'; | ||
value = "42" // value now holds a string. | ||
std::cout << "String: " << std::get<std::string>(value) << '\n'; | ||
return 0; | ||
} | ||
``` | ||
In general, `std::optional` provides an interface in which we are able to: | ||
- Check if it holds a value by calling its `has_value()` method or implicitly converting it to `bool` | ||
- Get the stored value by calling `value()` or using a dereferencing operator `*`. Beware, though that getting a value of an optional that holds no value is undefined behavior, so _always check_ that there is actually a value stored in an optional. | ||
|
||
There are many use-cases for `optional` in situations where we want to be able to handle a case where a value might exist but also might be missing under certain circumstances. | ||
### How `std::variant` is used in practice? | ||
While cool already, the current tiny example might feel quite limited. Think about it, we somehow have to _know_ which type our `std::variant` holds to use it. Which almost feels like it defeats the purpose. And to a degree it does. | ||
|
||
<!-- TODO: talk about how it is implemented through variant and maybe std expected, also get_value_or --> | ||
But we should not despair, this is C++ after all, there are options for us to use to make sure that we can work with _any_ type that the variant holds. This option is to use a visitor pattern through the use of the `std::visit` function: | ||
|
||
## Why use `std::variant`? | ||
|
||
`std::variant` is a type-safe union introduced in C++17. It allows a variable to hold one value out of a defined set of types. Think of it as a more flexible alternative to `enum` or `std::any`, but with static type checking. | ||
|
||
For instance, if a variable can hold either an integer or a string, you can use `std::variant` instead of rolling your own solution with `void*` or `boost::variant`. | ||
|
||
### Examples of `std::variant` in action | ||
|
||
#### Basic usage | ||
|
||
````cpp | ||
```cpp | ||
#include <variant> | ||
#include <iostream> | ||
#include <string> | ||
|
||
int main() { | ||
std::variant<int, std::string> value; | ||
|
||
value = 42; // Assign an integer | ||
std::cout << "Integer: " << std::get<int>(value) << '\n'; | ||
struct Printer { | ||
void operator(int value) const { | ||
std::cout << "Integer: " << value << '\n'; | ||
} | ||
void operator(const std::string& value) const { | ||
std::cout << "String: " << value << '\n'; | ||
} | ||
}; | ||
|
||
value = "Hello, std::variant!"; // Assign a string | ||
std::cout << "String: " << std::get<std::string>(value) << '\n'; | ||
int main() { | ||
std::variant<int, std::string> value = "Hello, Variant!"; | ||
std::visit(Printer{}, value); | ||
value = 42; | ||
std::visit(Printer{}, value); | ||
} | ||
```` | ||
#### Pattern matching with `std::visit` | ||
``` | ||
Here, `std::visit` applies a [function object](lambdas.md#before-lambdas-we-had-function-objects-or-functors) to the value contained in the variant. Should our variant hold a string, the operator that accepts a string is called and should it hold an integer instead, the operator that accepts an integer is called instead. | ||
Note, that a typical pitfall that beginners make is to forget that all of the checks for this code happen at compile time without taking into account the runtime logic of our code. | ||
````cpp | ||
If, for example, we would change our `Printer` function object to a `LengthPrinter` function object that only knows how to print length of objects, our code will not compile even though we only ever actually store an `std::string` in our variant: | ||
```cpp | ||
#include <variant> | ||
#include <iostream> | ||
#include <string> | ||
struct LengthPrinter { | ||
void operator(const std::string& value) const { | ||
std::cout << "String length: " << value.size() << '\n'; | ||
} | ||
}; | ||
int main() { | ||
// ❌ Does not compile! | ||
std::variant<int, std::string> value = "Hello, Variant!"; | ||
|
||
std::visit([](auto&& arg) { | ||
using T = std::decay_t<decltype(arg)>; | ||
if constexpr (std::is_same_v<T, int>) { | ||
std::cout << "Integer: " << arg << '\n'; | ||
} else if constexpr (std::is_same_v<T, std::string>) { | ||
std::cout << "String: " << arg << '\n'; | ||
} | ||
}, value); | ||
std::visit(LengthPrinter{}, value); | ||
} | ||
```` | ||
Here, `std::visit` applies a visitor (a callable object) to the value contained in the variant. | ||
``` | ||
This happens because the compiler must guarantee that all the code paths compile because it does not know which other code might be called. This might happen if some dynamic library gets linked to our code after it gets compiled. If that dynamic library actually stores an `int` in our variant the compiled code must know how to deal with it. | ||
|
||
--- | ||
Many people find this confusing and get burned by this at least a couple of times until it becomes very intuitive and please remember that it just takes time. | ||
|
||
## **Key differences and common use cases** | ||
## `std::monostate` | ||
Whenever we create a new `std::variant` object we actually initialize it to storing some uninitialized value of the type that is first in the list of types that the variant can store. Sometimes it might be undesirable and we want the variant to be initialized in an "empty" state. For this purpose there is a type `std::monostate` in the standard library and we can define our variant type using `std::monostate` as its first type in the list. | ||
```cpp | ||
std::variant<std::monostate, SomeType, SomeOtherType> value{}; | ||
// value holds an instance of std::monostate now. | ||
``` | ||
|
||
| Feature | `std::optional` | `std::variant` | | ||
|--------------------|------------------------------------------------------|------------------------------------------------| | ||
| Purpose | Represents optional values (may or may not exist). | Represents one of several types. | | ||
| Typical Use Case | Returning a value or "nothing" from a function. | Handling inputs or data with multiple types. | | ||
| Type Safety | Yes. | Yes. | | ||
| Pattern Matching | Not applicable. | Supported via `std::visit`. | | ||
Note that it probably means that we'll need to differentiate between our variant holding the `std::monostate` value or some other value in the `std::visit` that we will inevitably use at a later point in time. | ||
|
||
--- | ||
|
||
## **Summary** | ||
|
||
`std::optional` and `std::variant` are two powerful tools in the C++ toolbox that greatly enhance type safety and code readability. | ||
|
||
- Use `std::optional` when a value might be absent. | ||
- Use `std::variant` when a value can be one of several types. | ||
|
||
These features enable us to write cleaner, more expressive code while avoiding common pitfalls. Experiment with them in your projects and see how they can simplify your development workflow! | ||
Overall, `std::variant` is extremely important for modern C++. If we implement our code largely using templates or concepts and need to enable polymorphic behavior based on some values provided at runtime, there is probably no way for us to avoid using it. Which also means that we probably also will need to use `std::visit`. These things might well be confusing from the get go but after we've looked into how function objects and lambdas work we should have no issues using all of this machinery. |