Skip to content

Commit 64cbd2c

Browse files
committed
Rewrite text one more time
1 parent a7c563c commit 64cbd2c

File tree

1 file changed

+174
-73
lines changed

1 file changed

+174
-73
lines changed

lectures/variant.md

Lines changed: 174 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -6,45 +6,175 @@
66
<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>
77
</p>
88

9-
In the last lecture we talked about `std::optional` and `std::expected` types that make our life better when we want to handle errors. Both of these types can store multiple values in the same memory. 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.
9+
When people think about runtime polymorphism in C++ they usually think about virtual functions and pointers. But in modern C++ we seem to embrace value semantics more and more for its efficiency and clarity.
1010

11-
But, probably even more importantly, `std::variant` also happens to be the key to achieving a form of dynamic polymorphism when using templates.
11+
So the question is: what if we want that runtime power without giving up value semantics?
12+
13+
And, as you might have already guessed, there’s a modern, elegant solution for exactly that, and its name is `std::variant` class!
1214

1315
<!-- Intro -->
1416

15-
## Why use `std::variant`?
17+
Let's dive deeper into what exactly `std::variant` allows us to do, shall we?
1618

17-
`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.
19+
When we talked about static polymorphism we learnt how to use templates (and concepts) to be able to work with objects of different classes that all conform to some common interface.
1820

19-
For instance, if a variable can hold either an integer or a string, we can use `std::variant<int, std::string>` and put any value in it:
21+
Think of various image classes that all have a `Save` method and a function `SaveImage` taking a template that we assume to have a `Save` method:
22+
23+
```cpp
24+
#include <iostream>
25+
#include <string>
26+
27+
struct PngImage {
28+
void Save(const std::string& file_name) const {
29+
std::cout << "Saving " << file_name << " as PNG\n";
30+
}
31+
// Some private image data would go here.
32+
};
33+
34+
struct JpegImage {
35+
void Save(const std::string& file_name) const {
36+
std::cout << "Saving " << file_name << " as JPEG\n";
37+
}
38+
// Some private image data would go here.
39+
};
40+
41+
template <typename Image>
42+
void SaveImage(const Image& image, const std::string& file_name) {
43+
image.Save(file_name);
44+
}
45+
46+
int main() {
47+
SaveImage(PngImage{}, "diagram");
48+
SaveImage(JpegImage{}, "photo");
49+
}
50+
```
51+
52+
But this pattern is not very useful if we want, for example, to load our files from a folder at runtime - all the code with all the types needs to be visible to the compiler at compile time!
53+
54+
We would really like to store a bunch of different images into a vector, potentially populating it at runtime, and save them all using their common `Save` method. But our images have different types so we can't easily put them into a vector!
55+
56+
```cpp
57+
// ❌ Can't store objects of different types in a vector!
58+
const std::vector<???> images{PngImage{}, JpegImage{}, ...};
59+
```
60+
61+
Aha, I hear you say, we can create an interface class and store its pointer in vector, using *dynamic polymorphism* that we covered when we talked about inheritance! Indeed, we can create a class, say `Saveable`, that has a single pure virtual function `Save`. We can then inherit from this class in our `PngImage` and `JpegImage` that override `Save` with their respective implementations:
62+
63+
```cpp
64+
#include <iostream>
65+
#include <memory>
66+
#include <string>
67+
#include <vector>
68+
69+
// Interface
70+
struct Saveable : public Noncopyable {
71+
virtual void Save(const std::string& file_name) const = 0;
72+
virtual ~Saveable() = default;
73+
};
74+
75+
struct PngImage : public Saveable {
76+
void Save(const std::string& file_name) const override {
77+
std::cout << "Saving " << file_name << " as PNG\n";
78+
}
79+
// Some private image data would go here.
80+
};
81+
82+
struct JpegImage : public Saveable {
83+
void Save(const std::string& file_name) const override {
84+
std::cout << "Saving " << file_name << " as JPEG\n";
85+
}
86+
// Some private image data would go here.
87+
};
88+
89+
void SaveImage(const Saveable& image, const std::string& file_name) {
90+
image.Save(file_name);
91+
}
92+
93+
int main() {
94+
// A bunch of images that could be put here at runtime.
95+
const std::vector<std::unique_ptr<Saveable>> images {
96+
std::make_unique<PngImage>(),
97+
std::make_unique<JpegImage>()
98+
};
99+
for (const auto& image : images) SaveImage(*image, "output");
100+
}
101+
```
102+
103+
This *does* allow us to store a bunch of image pointers in a vector and process all of them in a for loop without regard to their actual type:
104+
105+
While cool, we did have to give up certain things. Now, our image classes have a common explicit interface which will be hard to change down the line if this was a bad design decision. They also now follow reference / pointer semantics rather than value semantics, meaning that our classes are now designed to be accessed by a pointer to them and we cannot copy or move the actual objects around. Finally, because of this, while we did gain the ability to put objects into a vector, we now have to allocate pointers to them and that means that we have to allocate them on the heap. Usually this is not a big issue but can become one if we need to allocate many objects in a performance-critical context.
106+
<!-- Watch a video on the heap for a more in-depth look into this topic. -->
107+
108+
This is where `std::variant` comes to the rescue. It allows us to keep using templates just like we originally wanted, but adds a twist. We can store a variant of our two types in a vector and use `std::visit` to call our `SaveImage` on any type from the ones we allow in the variant:
20109
21110
```cpp
22-
#include <variant>
23111
#include <iostream>
24112
#include <string>
113+
#include <variant>
114+
#include <vector>
115+
116+
struct PngImage {
117+
void Save(const std::string& file_name) const {
118+
std::cout << "Saving " << file_name << " as PNG\n";
119+
}
120+
// Some private image data would go here.
121+
};
122+
123+
struct JpegImage {
124+
void Save(const std::string& file_name) const {
125+
std::cout << "Saving " << file_name << " as JPEG\n";
126+
}
127+
// Some private image data would go here.
128+
};
129+
130+
using Image = std::variant<PngImage, JpegImage>;
131+
132+
void SaveImage(const Image& image, const std::string& file_name) {
133+
std::visit([&](const auto& img) { img.Save(file_name); }, image);
134+
}
135+
136+
int main() {
137+
const std::vector<Image> images = {PngImage{}, JpegImage{}};
138+
for (const auto& image : images) SaveImage(image, "output");
139+
}
140+
```
141+
142+
Note, how we create the vector in exactly the way that we dreamed about before! We also call the same `SaveImage` function just like we did when using `virtual` functions and pointers!
143+
144+
However, it is hard not to notice that there is a bit more syntax present here. There is the `std::variant` as well as `std::visit` being used and we have not really looked into those before. So let's do so now.
145+
146+
## Basics of what `std::variant` is
147+
148+
The class `std::variant` is a so-called type-safe `union` type introduced in C++17. It allows a variable to hold one value out of a defined set of types.
149+
150+
For instance, if a variable can hold either an `int` or a `double`, we can use `std::variant<int, double>` and put any values of these types in it:
151+
152+
```cpp
153+
#include <variant>
154+
#include <iostream>
25155

26156
int main() {
27-
// This compiles
28-
std::variant<int, std::string> value;
29-
value = 42; // value holds an int.
157+
// There can be many more types in std::variant.
158+
std::variant<int, double> value;
159+
value = 42; // Value holds an int.
30160
std::cout << "Integer: " << std::get<int>(value) << '\n';
31-
value = "42" // value now holds a string.
32-
std::cout << "String: " << std::get<std::string>(value) << '\n';
161+
value = 42.42 // Value now holds a double.
162+
std::cout << "Double: " << std::get<double>(value) << '\n';
33163
return 0;
34164
}
35165
```
36166

37167
Do note though, that once we put one type into variant, `get`ting another type is undefined behavior, so don't do that.
38168

39-
<!-- TODO: talk about memory? -->
169+
The values of different types occupy the same memory, which means that the amount of memory allocated for the whole variant needs to be enough to store the biggest type stored in it. In our previous example, even when we write an `int` into our variant object, the object still allocates 8 bytes as needed for a `double`.
40170

41-
<!-- Can we store more than one same type? -->
171+
By the way, this is also how `std::optional` and `std::expected` that we talked about in the previous lecture work too.
42172

43-
## How `std::variant` is used in practice?
173+
## How to use `std::variant` with `std::visit` in practice
44174

45-
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, well, it does. If we need to know the type we want to use at compile time, we could as well just use that type and not bother with `std::variant` at all.
175+
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, well, it does. If we need to know the type we want to use at compile time, we could as well just use that type and not bother with `std::variant` at all.
46176

47-
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:
177+
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 that we've already seen in our original example:
48178

49179
```cpp
50180
#include <variant>
@@ -69,34 +199,20 @@ int main() {
69199
}
70200
```
71201
72-
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, the operator that accepts an integer is called instead.
202+
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 - the operator that accepts an integer is called instead.
73203
74-
<!-- Talk about it all happening at runtime and the cost -->
204+
And while we used an explicit function object here, we could as well use a lambda function of course.
75205
76-
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_.
206+
It is important to remember that the selection of the function to be called happens at runtime! To quote cppreference.com:
77207
78-
<!-- TODO: change the below to just drop one operator from `Printer` -->
79-
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 would not compile even though we only ever actually store a `std::string` in our variant:
208+
> Implementations usually generate a table equivalent to an (possibly multidimensional) array of n function pointers for every specialization of `std::visit`, which is similar to the implementation of virtual functions.
209+
> On typical implementations, the time complexity of the invocation of the callable can be considered equal to that of access to an element in an (possibly multidimensional) array or execution of a switch statement.
80210
81-
```cpp
82-
#include <variant>
83-
#include <iostream>
84-
#include <string>
85-
86-
struct LengthPrinter {
87-
void operator(const std::string& value) const {
88-
std::cout << "String length: " << value.size() << '\n';
89-
}
90-
};
211+
That is to say: it is usually pretty fast but still takes *some* tiny amount of time.
91212
92-
int main() {
93-
// ❌ Does not compile!
94-
std::variant<int, std::string> value = "Hello, Variant!";
95-
std::visit(LengthPrinter{}, value);
96-
}
97-
```
213+
However, and this is an important pitfall that I see many beginners struggle with, we need to ensure that *all* the types in a variant are covered in the function object we provide into the `std::visit`. The code won't compile otherwise.
98214
99-
This _is_ confusing. It might seem strange that we have to cover a case that we never aim to use. However, the reason why it was designed the way it was designed becomes easier to see if we look at a slightly more complex example.
215+
This *is* confusing. It might seem strange that we have to cover a case that we never aim to use. However, the reason why it was designed the way it was designed becomes easier to see if we look at a slightly more complex example.
100216
101217
Imagine that our `variant` is part of some class `Foo` that we design. The header file `foo.hpp` contains a declaration of our `Foo` class with a function `Print`:
102218
@@ -144,7 +260,7 @@ void Foo::Print() const { std::visit(BadPrinter{}, value); }
144260

145261
```
146262
147-
If we try to compile this code it won't - the compiler wants us to cover all the types of our variant in the `BadPrinter` class. However, let us for a moment assume that it _would_ be allowed by the standard and we would be able to compile this code into a library.
263+
If we try to compile this code it won't - the compiler wants us to cover all the types of our variant in the `BadPrinter` class. However, let us for a moment assume that it *would* be allowed by the standard and we would be able to compile this code into a library.
148264
149265
In that case, the code that we compile would be generated and stored in our library binary file. Without the code for handling `std::string` in `BadPrinter` that is.
150266
@@ -160,41 +276,9 @@ int main() {
160276
}
161277
```
162278

163-
The behavior of this code would be undefined as our library's binary file would have no way to print a string stored in the variant inside the `Foo` class.
164-
165-
To the degree of my understanding this is the reason why the standard requires a function object passed into `std::visit` to be able to handle all the types that can be stored inside of a given `std::variant` object.
166-
167-
## Use it with an `std::vector`
168-
169-
Now with that out of the way, I want to talk about arguably the most important thing that `std::visit` allows us to do with `std::variant`. You see, we can now have a **vector of variants**.
170-
171-
Think about [dynamic polymorphism](inheritance.md#simple-polymorphic-class-example-following-best-practices) that we talked about before. The coolest thing about it was our ability to put multiple pointers to some interface class into a vector and then work with them without regard of which concrete type we're using. Here, with variant, we can do a very similar thing!
172-
173-
```cpp
174-
#include <iostream>
175-
#include <string>
176-
#include <variant>
177-
#include <vector>
178-
179-
struct Printer {
180-
void operator()(int v) const { std::cout << "int: " << v << '\n'; }
181-
void operator()(float v) const { std::cout << "float: " << v << '\n'; }
182-
void operator()(const std::string& v) const {std::cout << "str: " << v << '\n';}
183-
};
184-
185-
int main() {
186-
std::vector<std::variant<int, float, std::string>> stuff{};
187-
stuff.emplace_back(42);
188-
stuff.emplace_back(42.42F);
189-
stuff.emplace_back("Some string");
190-
const Printer printer{};
191-
for (const auto& element : stuff) {
192-
std::visit(printer, element);
193-
}
194-
}
195-
```
279+
The behavior of this code would be undefined as our library's binary file would have no idea about how to print a string stored in the variant inside the `Foo` class.
196280

197-
We get to store any type from the allowed list of types and then we can process them in a uniform way using the `std::visit` pattern. Note that all of these values are set at runtime! This means that we can set these values from user input or from reading some file etc. And so we achieve dynamic polymorphism while using strong types and templated code.
281+
To the degree of my understanding this is *the* reason why the standard requires a function object passed into `std::visit` to be able to handle all the types that can be stored inside of a given `std::variant` object.
198282

199283
## `std::monostate`
200284

@@ -209,6 +293,23 @@ std::variant<std::monostate, SomeType, SomeOtherType> value{};
209293

210294
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.
211295

296+
## Use it with an `std::vector`
297+
298+
And this brings us back to our original example. We now know that declaring `Image` to be a variant allows us to store any kind of image in a vector of `Image`s:
299+
300+
```cpp
301+
using Image = std::variant<PngImage, JpegImage>;
302+
const std::vector<Image> images = {PngImage{}, JpegImage{}};
303+
```
304+
305+
At the same time, the use of `std::visit` with a tiny lambda allows us to call `Save` on any concrete image class, thus achieving dynamic polymorphism keeping our value semantics completely intact:
306+
307+
```cpp
308+
void SaveImage(const Image& image, const std::string& file_name) {
309+
std::visit([&](const auto& img) { img.Save(file_name); }, image);
310+
}
311+
```
312+
212313
## **Summary**
213314

214315
Overall, `std::variant` is extremely important for modern C++. If we implement our code largely using templates or concepts and need to enable dynamic polymorphism based on some values provided at runtime, there is probably no way around using `std::variant`. Which also means that we probably also need to use `std::visit`. These things might well be confusing from the get go but I hope that the explanations that we've covered just now will suffice to break the ice and using `std::variant` and `std::visit` in actual code will clear up any remaining issues.

0 commit comments

Comments
 (0)