Skip to content

Commit 4d7a94f

Browse files
committed
Minor update
1 parent 152536a commit 4d7a94f

File tree

1 file changed

+54
-47
lines changed

1 file changed

+54
-47
lines changed

lectures/variant.md

Lines changed: 54 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88

99
When people think about runtime polymorphism in C++ they usually think about `virtual` functions and pointer or reference semantics. But in modern C++ we seem to embrace *value* semantics more and more for its efficiency and clarity.
1010

11-
So a natural question comes up: what if we want that runtime power without giving up value semantics?
11+
So a natural question comes up: what do we do if we want that runtime power without giving up value semantics?
1212

1313
And, as you might have already guessed, there’s a modern, elegant solution for exactly that, and its name is `std::variant`!
1414

15-
Oh, and as a bonus, remember `std::optional` and `std::expected` from the previous lecture? It turns out those can be implemented with `std::variant` too!
15+
Oh, and as a bonus, remember `std::optional` and `std::expected` from the previous lecture? It turns out those can be implemented using `std::variant` too!
1616

1717
<!-- Intro -->
1818

@@ -25,27 +25,27 @@ Think of various image classes that all have a `Save` method and a function `Sav
2525
#include <string>
2626

2727
struct PngImage {
28-
void Save(const std::string& file_name) const {
29-
std::cout << "Saving " << file_name << ".png\n";
30-
}
31-
// Some private image data would go here.
28+
void Save(const std::string& file_name) const {
29+
std::cout << "Saving " << file_name << ".png\n";
30+
}
31+
// Some private image data would go here.
3232
};
3333

3434
struct JpegImage {
35-
void Save(const std::string& file_name) const {
36-
std::cout << "Saving " << file_name << ".jpg\n";
37-
}
38-
// Some private image data would go here.
35+
void Save(const std::string& file_name) const {
36+
std::cout << "Saving " << file_name << ".jpg\n";
37+
}
38+
// Some private image data would go here.
3939
};
4040

4141
template <typename Image>
4242
void SaveImage(const Image& image, const std::string& file_name) {
43-
image.Save(file_name);
43+
image.Save(file_name);
4444
}
4545

4646
int main() {
47-
SaveImage(PngImage{}, "diagram");
48-
SaveImage(JpegImage{}, "photo");
47+
SaveImage(PngImage{}, "output");
48+
SaveImage(JpegImage{}, "output");
4949
}
5050
```
5151
@@ -74,6 +74,7 @@ struct Noncopyable {
7474
Noncopyable(Noncopyable&&) = delete;
7575
Noncopyable& operator=(const Noncopyable&) = delete;
7676
Noncopyable& operator=(Noncopyable&&) = delete;
77+
~Noncopyable() = default;
7778
};
7879

7980
$PLACEHOLDER
@@ -112,20 +113,22 @@ void SaveImage(const Saveable& image, const std::string& file_name) {
112113
}
113114

114115
int main() {
115-
// A bunch of images that could be put here at runtime.
116+
// A bunch of image pointers that can be put here at runtime.
116117
std::vector<std::unique_ptr<Saveable>> images;
117118
images.push_back(std::make_unique<PngImage>());
118119
images.push_back(std::make_unique<JpegImage>());
119120
for (const auto& image : images) SaveImage(*image, "output");
120121
}
121122
```
122123
123-
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!
124+
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! Is it polymorphism? Yes! Is it dynamic? Yes again!
124125
125-
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 turns out a bad design decision. We also had to embrace 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 easily 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 as they can land in different areas of our memory and finding a good place for them in memory takes some time.
126+
But we *did* have to give up certain things. Now our image classes inherit from a common rigid interface class. This dependency will be hard to change down the line if it turns out to be a bad design decision. We are also forced to embrace reference and pointer semantics rather than value semantics, meaning that our classes are now designed to be accessed by a pointer to them and we cannot easily copy or move the actual objects around, thus the use of `Noncopyable` base class. For a refresher on this, please refer to our lecture about [inheritance](inheritance.md#delete-other-special-methods-for-polymorphic-classes).
127+
128+
Another potential issue with having to allocate *pointers* rather than concrete objects is that it usually means that we have to allocate them on the heap. Typically this is not a big issue but can become one if we need to allocate many objects in a performance-critical context as they can land in different areas of our memory and finding a good place for them in memory takes some time.
126129
<!-- Watch a video on the heap for a more in-depth look into this topic. -->
127130
128-
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:
131+
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 types in a vector and use `std::visit` to call our `SaveImage` on any type from the ones we allow in the variant:
129132
130133
```cpp
131134
#include <iostream>
@@ -165,7 +168,7 @@ However, it is hard not to notice that there is a bit more syntax present here.
165168

166169
## Basics of what `std::variant` is
167170

168-
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.
171+
The class `std::variant` is a so-called type-safe `union` type introduced in C++17. A variable of such a variant type holds one value out of a defined set of types.
169172

170173
For instance, if a variable's type is `std::variant<int, std::string>` it means that this variable can hold either an `int` or a `std::string` value. We can set our variable's value by simply assigning it a value of any of our selected types with a value of the *first* type being stored by default:
171174

@@ -190,7 +193,7 @@ Do note though, that once we put one type into variant, `get`ting another type i
190193

191194
<img src="images/variant_memory.png" alt="Variant memory" align="right" width=50% style="margin: 0.5rem">
192195

193-
The values of different types, when stored as a variant, 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 (plus some memory to store an index of which value is stored, more on that later). In our previous example, even when we write an `int` into our variant object, the object still allocates enough memory needed for a `std::string`.
196+
The values of different types, when stored as a variant, 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 potentially stored in it (plus some memory to store an index of which value is stored, more on that later). In our previous example, even when we write an `int` into our variant object, the object still allocates enough memory needed for a `std::string`.
194197

195198
And just as I hinted at in the intro, this is also how `std::optional` and `std::expected` that we talked about in the previous lecture work too.
196199

@@ -235,9 +238,7 @@ And while we used an explicit function object here, we could as well use a lambd
235238
#include <variant>
236239
237240
int main() {
238-
const auto Print = [](auto value) {
239-
std::cout << value << std::endl;
240-
};
241+
const auto Print = [](auto value) { std::cout << value << std::endl; };
241242
242243
std::variant<int, std::string> value{};
243244
std::visit(Print, value);
@@ -248,20 +249,24 @@ int main() {
248249
}
249250
```
250251

251-
Such selection might seem magical, but, as always, it is nothing but a clever implementation. The exact details of how `std::visit` is implemented are probably beyond the scope of today's lecture, but we can quote cppreference.com to get the gist of how the fitting function is selected when `std::visit` is called:
252+
Note how in this example we use `auto` for a type of our `value` in a lambda. This `auto` will become different types depending on which type is actually stored in a variant and the whole example works here because `std::cout` is able to accept any of the types we use here.
253+
254+
Such almost automatic type selection might seem magical, but, as always, it is nothing but a clever implementation. The exact details of how `std::visit` is implemented are probably beyond the scope of today's lecture, but we can quote cppreference.com to get the gist of how the appropriate function is selected when `std::visit` is called:
252255

253256
> Implementations usually generate a table equivalent to a possibly multidimensional array of function pointers for every specialization of `std::visit`, which is similar to the implementation of virtual functions.
254257
> On typical implementations, the time complexity of the invocation of the callable can be considered equal to that of access to an element in a possibly multidimensional array or execution of a switch statement.
255258

256-
That is to say: selecting the right function is usually pretty fast but still takes *some* tiny amount of time **at runtime**. Which means that with this tiny example, we *did* implement dynamic polymorphism that still lets us use value semantics (and even built-in types)!
259+
That is to say: selecting the right function is usually pretty fast but still takes *some* tiny amount of time **at runtime**. And the more different `std::visit` calls there are the slower every call will become.
260+
261+
But the main thing here is that with this tiny example we *did* implement dynamic polymorphism that still lets us use value semantics (and even built-in types)! 🎉
257262

258263
### All paths must be covered
259264

260-
One important pitfall of `std::visit` that I see many beginners struggle with, is that 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.
265+
One important pitfall of `std::visit` that I see many beginners fall into, is that 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.
261266

262-
This might seem confusing at the beginning: why do we have to cover cases 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.
267+
This might seem confusing at the beginning: why do we have to cover cases that we never aim to use? However, the reason why it was designed this way becomes easier to see if we look at a slightly more complex example.
263268

264-
Imagine that our `variant` is part of some class `Foo`. The header file `foo.hpp` contains a declaration of our `Foo` class with a function `Print`:
269+
Imagine for a moment that we have a class `Foo` that holds some `std::variant` member variable and a function `Print`. Let's say the declaration of this class lives in a `foo.hpp` file:
265270

266271
`foo.hpp`
267272
<!--
@@ -284,7 +289,7 @@ struct Foo {
284289

285290
```
286291
287-
We implement this `Print` function in a corresponding `foo.cpp` file and, because we want to print the value stored in a `std::variant` we need to use `std::visit` with, say, `BadPrinter` function object passed to it. We call it "bad" because it does not handle all of the types in our variant.
292+
We implement its `Print` function in a corresponding `foo.cpp` file and, because we want to print the value stored in a `std::variant` we need to use `std::visit` with, say, `BadPrinter` function object passed to it. We call it "bad" because this particular printer class does not handle all of the types that we can store in our variant:
288293
289294
`foo.cpp`
290295
<!--
@@ -311,7 +316,7 @@ struct BadPrinter {
311316
void Foo::Print() const { std::visit(BadPrinter{}, value); }
312317
```
313318

314-
If we try to compile this code we won't succeed with an error that says something about not being able to find a "matching function for call to `invoke(BadPrinter, const std::__cxx11::basic_string<char>&)`". This happens because the compiler wants us to cover all the types of our variant in the `BadPrinter` class.
319+
If we try to compile this code we won't succeed with an error that says something along the lines of not being able to find a "matching function for call to `invoke(BadPrinter, const std::string&)`". This happens because the compiler wants us to cover *all* the types of our variant in the `BadPrinter` class.
315320

316321
<details>
317322
<summary>Approximate error message</summary>
@@ -399,11 +404,11 @@ Compiler returned: 1
399404

400405
</details>
401406

402-
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.
407+
To understand why the compiler insists on it, let us for a moment assume that it *would* be allowed to compile this code into a library without covering all the variant types in the provided function object.
403408

404-
In that case, the code that we compile would be generated and stored in our library binary file. Without the code for handling `double` in `BadPrinter` that is.
409+
In that case, there will be no way for the compiled library binary file to handle the missing `std::string` type as the binary code for this would never be generated.
405410

406-
Now imagine what would happen if at some point down the line someone would write an executable, link it to our code, try to store a `double` in the variant, and call `Print` on our `foo` object!
411+
Now imagine what would happen if at some point down the line someone would write an executable, link it to our code, try to store a `std::string` in the variant, and call `Print` on our `foo` object!
407412

408413
<!--
409414
`CPP_SETUP_START`
@@ -418,8 +423,8 @@ struct Printer {
418423
void operator()(int value) const {
419424
std::cout << "Integer: " << value << '\n';
420425
}
421-
void operator()(double value) const {
422-
std::cout << "Double: " << value << '\n';
426+
void operator()(const std::string& value) const {
427+
std::cout << "String: " << value << '\n';
423428
}
424429
};
425430

@@ -436,12 +441,12 @@ $PLACEHOLDER
436441

437442
int main() {
438443
Foo foo{};
439-
foo.value = 42.42;
444+
foo.value = "Hello, variant!";
440445
foo.Print();
441446
}
442447
```
443448

444-
The behavior of this code would be undefined as our library's binary file would have no idea about how to print a double stored in the variant inside the `Foo` class.
449+
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 of the `Foo` class object.
445450

446451
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.
447452

@@ -491,31 +496,33 @@ At the same time, the use of `std::visit` with a tiny lambda allows us to call `
491496
#include <vector>
492497

493498
struct PngImage {
494-
void Save(const std::string& file_name) const {
495-
std::cout << "Saving " << file_name << ".png\n";
496-
}
497-
// Some private image data would go here.
499+
void Save(const std::string& file_name) const {
500+
std::cout << "Saving " << file_name << ".png\n";
501+
}
502+
// Some private image data would go here.
498503
};
499504

500505
struct JpegImage {
501-
void Save(const std::string& file_name) const {
502-
std::cout << "Saving " << file_name << ".jpg\n";
503-
}
504-
// Some private image data would go here.
506+
void Save(const std::string& file_name) const {
507+
std::cout << "Saving " << file_name << ".jpg\n";
508+
}
509+
// Some private image data would go here.
505510
};
506511

507512
using Image = std::variant<PngImage, JpegImage>;
508513

509514
void SaveImage(const Image& image, const std::string& file_name) {
510-
std::visit([&](const auto& img) { img.Save(file_name); }, image);
515+
std::visit([&](const auto& img) { img.Save(file_name); }, image);
511516
}
512517

513518
int main() {
514-
const std::vector<Image> images = {PngImage{}, JpegImage{}};
515-
for (const auto& image : images) SaveImage(image, "output");
519+
const std::vector<Image> images = {PngImage{}, JpegImage{}};
520+
for (const auto& image : images) SaveImage(image, "output");
516521
}
517522
```
518523
524+
<!-- TODO: add part about overloaded -->
525+
519526
## **Summary**
520527
521528
Overall, `std::variant` is extremely important for modern C++. If we embrace value semantics and 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`.

0 commit comments

Comments
 (0)