You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: lectures/variant.md
+54-47Lines changed: 54 additions & 47 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -8,11 +8,11 @@
8
8
9
9
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.
10
10
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?
12
12
13
13
And, as you might have already guessed, there’s a modern, elegant solution for exactly that, and its name is `std::variant`!
14
14
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!
16
16
17
17
<!-- Intro -->
18
18
@@ -25,27 +25,27 @@ Think of various image classes that all have a `Save` method and a function `Sav
// A bunch of images that could be put here at runtime.
116
+
// A bunch of image pointers that can be put here at runtime.
116
117
std::vector<std::unique_ptr<Saveable>> images;
117
118
images.push_back(std::make_unique<PngImage>());
118
119
images.push_back(std::make_unique<JpegImage>());
119
120
for (const auto& image : images) SaveImage(*image, "output");
120
121
}
121
122
```
122
123
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!
124
125
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.
126
129
<!-- Watch a video on the heap for a more in-depth look into this topic. -->
127
130
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:
129
132
130
133
```cpp
131
134
#include <iostream>
@@ -165,7 +168,7 @@ However, it is hard not to notice that there is a bit more syntax present here.
165
168
166
169
## Basics of what `std::variant` is
167
170
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.
169
172
170
173
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:
171
174
@@ -190,7 +193,7 @@ Do note though, that once we put one type into variant, `get`ting another type i
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`.
194
197
195
198
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.
196
199
@@ -235,9 +238,7 @@ And while we used an explicit function object here, we could as well use a lambd
235
238
#include <variant>
236
239
237
240
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; };
241
242
242
243
std::variant<int, std::string> value{};
243
244
std::visit(Print, value);
@@ -248,20 +249,24 @@ int main() {
248
249
}
249
250
```
250
251
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:
252
255
253
256
> 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.
254
257
> 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.
255
258
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)! 🎉
257
262
258
263
### All paths must be covered
259
264
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.
261
266
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.
263
268
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:
265
270
266
271
`foo.hpp`
267
272
<!--
@@ -284,7 +289,7 @@ struct Foo {
284
289
285
290
```
286
291
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:
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.
315
320
316
321
<details>
317
322
<summary>Approximate error message</summary>
@@ -399,11 +404,11 @@ Compiler returned: 1
399
404
400
405
</details>
401
406
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.
403
408
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.
405
410
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!
407
412
408
413
<!--
409
414
`CPP_SETUP_START`
@@ -418,8 +423,8 @@ struct Printer {
418
423
void operator()(int value) const {
419
424
std::cout << "Integer: " << value << '\n';
420
425
}
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';
423
428
}
424
429
};
425
430
@@ -436,12 +441,12 @@ $PLACEHOLDER
436
441
437
442
intmain() {
438
443
Foo foo{};
439
-
foo.value = 42.42;
444
+
foo.value = "Hello, variant!";
440
445
foo.Print();
441
446
}
442
447
```
443
448
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.
445
450
446
451
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.
447
452
@@ -491,31 +496,33 @@ At the same time, the use of `std::visit` with a tiny lambda allows us to call `
for (const auto& image : images) SaveImage(image, "output");
516
521
}
517
522
```
518
523
524
+
<!-- TODO: add part about overloaded -->
525
+
519
526
## **Summary**
520
527
521
528
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