Skip to content

No pointers no problems? Dynamic polymorphism with std:… — Transcript

Explore dynamic polymorphism in C++ using std::variant for value semantics and runtime flexibility without pointers.

Key Takeaways

  • std::variant enables dynamic polymorphism while preserving value semantics.
  • Using std::variant avoids the need for inheritance and pointer-based polymorphism.
  • std::visit provides a clean way to operate on variant-held values without explicit type checks.
  • std::monostate helps handle default construction for variants with non-default-constructible types.
  • This approach can improve performance by avoiding heap allocations and pointer indirections.

Summary

  • Traditional runtime polymorphism in C++ relies on virtual functions and pointer/reference semantics.
  • Modern C++ favors value semantics for efficiency and clarity, raising questions about runtime flexibility.
  • Static polymorphism with templates works at compile time but lacks runtime type flexibility.
  • Dynamic polymorphism with interfaces requires inheritance and pointer semantics, which can limit design and performance.
  • std::variant, introduced in C++17, offers a type-safe union enabling value semantics with runtime polymorphism.
  • Variants store one value of a predefined set of types, sharing memory sized for the largest type plus an index.
  • std::monostate can be used as a default empty state for variants that require default construction.
  • Values in a variant can be accessed safely using std::get or checked with std::holds_alternative to avoid exceptions.
  • std::visit applies a visitor function to the contained value, enabling type-safe operations without knowing the type at compile time.
  • Using std::variant and std::visit allows dynamic polymorphism with value semantics, improved performance, and flexibility.

Full Transcript — Download SRT & Markdown

00:00
Speaker A
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.
00:13
Speaker A
So a natural question comes up. What do we do if we want that runtime flexibility without giving up the power of value semantics? And, well, as you might have already guessed, there's actually a couple of modern elegant solutions for exactly that. And today
00:28
Speaker A
we'll talk about one of them: standard variant. [Applause] When we talked about static polymorphism, we learned how to use templates to be able to work with objects of different types that all conform to some common interface. Think of various image classes that all have a
00:48
Speaker A
save method and a function save image taking a template or a concept from C++20 on that we assume to have a save method. We can run this code and it outputs what we expect. But this pattern is not very useful if we want to decide
01:02
Speaker A
which format an image has at runtime. All the code with all the concrete types needs to be visible to the compiler while at compile time. Furthermore, it would be awesome to be able to store a bunch of different images into, say, a
01:16
Speaker A
vector, potentially populating this vector at runtime and save them all using their common save method. But our images have different types. So we can't put them into a single vector in a naive way. Aha, I hear you say we seem to be
01:29
Speaker A
talking about textbook dynamic polymorphism and we already know how to deal with that from our previous lectures. We can create an interface class and in our vector store pointers to objects of classes deriving from this interface and process all of them in a
01:43
Speaker A
for loop without regard to their actual type. The output of this code is exactly the same as before, but now the selection of which implementation is used happens at runtime. Is it polymorphism? Yes. Is it dynamic? Yes, again, but we did have to give up
01:59
Speaker A
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 have been a bad design decision. And as I mentioned countless times before, it
02:12
Speaker A
is very hard to predict the future. We're 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
02:24
Speaker A
move the actual objects around. Thus, the use of non-copyable base class. For a refresher on this, please refer to our lecture about inheritance. Another potential issue with having to allocate pointers rather than concrete objects is that it usually means that we have to
02:40
Speaker A
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
02:52
Speaker A
takes some potentially non-negligible time. Watch a video where we talk about memory for a more in-depth look into this topic. This is where standard variant comes to the rescue. It allows us to keep using templates just like we
03:05
Speaker A
originally wanted, but adds a twist. We can store a variant of our types in a vector and use standard visit to call our save image on any type from the ones that we allow in our variant. Note how
03:19
Speaker A
we create the vector in exactly the way that we dreamed about before. We also call the same save image function just like we did when using virtual functions and pointers. However, it is hard not to notice that there is a bit more syntax
03:31
Speaker A
present here. There is a new standard variant class as well as the new standard visit entity being used and we have never seen those before. So let's look into them now. The class standard variant is a so-called type-safe union
03:45
Speaker A
type introduced in C++17. A variable of such a variant type holds one value out of a defined set of types. Intuitively, we can imagine standard variant as a box type in which we can store objects of some selected set of types. The values
04:02
Speaker A
in a variant occupy the same memory, which means that the amount of memory allocated for the whole variant object needs to be enough to store an object of the biggest type plus some memory to store an index of which value is
04:14
Speaker A
actually stored. As our box needs to be big enough to hold the banana type, if we put an apple into it, some space will be left unused. Or in terms of code, if a variable's type is standard variant,
04:25
Speaker A
int, std::string, double, it means that this variable can hold either an int, std::string, or a double value. Do note, though, that a variant is not permitted to hold references, arrays, or the type void. Also, while variant can
04:40
Speaker A
technically be configured with the same type more than once, I rarely see the need to do this as these variants are much harder to work with. But if we look long enough at our box analogy, a logical question pops up. Can the box be
04:54
Speaker A
empty? In other words, can we have an empty standard variant? Those who were paying attention to the example code we've just looked at could notice a comment that by default variants store a value of the first type. So it would
05:07
Speaker A
seem that creating an empty box would be problematic. But if we think more about this, always creating a variable of the first type to populate standard variant might not be desirable or indeed even possible. Imagine that we store types
05:21
Speaker A
that do not have a default constructor in the first place. This means that this code won't compile, throwing an error that tells us that a constructor of a variant is ignored because it does not satisfy the requirement standard is
05:33
Speaker A
default constructible. To mitigate such situations, there is a type standard monostate. This is an empty type that is default constructible and we can use it as the first type in the list of types that we pass into standard variant if we
05:46
Speaker A
want our variant to be in an empty state by default. Now, with all of that out of the way, let us talk about how we can store some value in a standard variant object. If we don't have repeating types
05:57
Speaker A
in our variant, we can set our variant variable's value by simply assigning it a value of any of our selected types. For all the other cases, I'd point you to the in-place method of the variant class, but we won't talk about it here. Once we
06:11
Speaker A
stored some values in a variant object, we usually want to get them out somehow.
06:15
Speaker A
The simplest method for getting a value would be to just get it by using either the type name or an index of that type.
06:22
Speaker A
Again, here we mostly focus on getting the value by type. Do note, though, that once we put one type into the variant, getting another type will throw a standard bad access variant exception.
06:34
Speaker A
If we don't want to use exceptions, we can use standard holds alternative to check if the variant holds the correct type before getting it. But this tiny example might feel quite limited. Think about it. We need to provide the type of
06:46
Speaker A
our variable in order to get its value at the call site, which means that we somehow have to know at compile time which type our standard variant holds to use it, which almost feels like it defeats the purpose of using the variant
06:59
Speaker A
type in the first place. But remember, in our motivating example there was this function standard visit that we could use to magically access the stored object regardless of its type. Here, standard visit applies a function object to the value contained in the variant.
07:15
Speaker A
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. And while we used an explicit function object for illustration here, we could as well use
07:28
Speaker A
a lambda function, of course. 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
07:41
Speaker A
here because standard cout is able to accept any of the types.
07:53
Speaker A
use a neat trick to allow specifying all the different overloads for different types at the call site. By creating a template strct, often called overloaded, that accepts multiple classes, each with an operator round brackets, like a function call, we can instantiate an
08:10
Speaker A
object of this strct by passing in as many function objects or lambdas, as we need. This lets us easily create a local visitor right where we need it, taking full advantage of the flexibility that lambdas offer while having explicit
08:24
Speaker A
overloads for types that demand it. Note how here too we can use auto to catch any types that we don't want to handle explicitly. But if we do decide to handle them explicitly, those implementations are preferred. All in
08:36
Speaker A
all, our overloaded strct can take anything that has a call operator. So, as a tiny exercise, try to use the above overload instruct with the printer function object we created in the previous example. Would that work? Post a link to godball.org with your answer
08:53
Speaker A
in the comments below this video. And while you're there, please take a moment and answer a simple question for me. How useful are my videos to you? If they are useful, please tell me what they helped you achieve in the comments. And maybe
09:05
Speaker A
even consider supporting this channel if they brought you something tangible. If these videos fall short of their goal of being helpful though, please tell me what I can improve. I spent quite some time on these videos and it would be
09:17
Speaker A
such a waste not to keep improving them just because of the lack of feedback.
09:21
Speaker A
Regardless of the choices you made, I thank you sincerely for your time. And now back to standard visit. The fact that standard visit can select the correct function from a variant seems almost magical. But as always, it is
09:33
Speaker A
nothing but a clever implementation. The exact details of how standard 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 standard visit is called.
09:47
Speaker A
Implementations usually generate a table equivalent to a possibly multi-dimensional array of function pointers for every specialization of standard visit which is similar to the implementation of virtual functions. On typical implementations, the time complexity of the invocation of the
10:03
Speaker A
callable can be considered equal to that of access to an element in a possibly multi-dimensional array or execution of a switch statement. That is to say, selecting the right function is usually pretty fast but still takes some tiny
10:17
Speaker A
amount of time at runtime. But the main message I wanted to convey here is that this selection does happen at runtime.
10:25
Speaker A
And that means what? It means that we did implement dynamic polymorphism that still lets us use value semantics and even built-in types and typically works pretty fast. However, the whole conversation about standard visit would be incomplete without talking about one
10:42
Speaker A
important pitfall of standard visit that I see many beginners struggle with and we'll have to do some digging to understand all the reasons behind it.
10:49
Speaker A
You see, we need to ensure that all the types in a variant are covered in the function object we provide into the standard visit. otherwise the code won't compile. This might seem confusing at the beginning. Why do we have to cover
11:01
Speaker A
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. Imagine for a moment that we have a class fu that holds some standard variant member
11:14
Speaker A
variable and a function print. Let's say the declaration of this class lives in a fu.hppp file. We implement its print function in a corresponding fu.cpp file.
11:24
Speaker A
And because we want to print the value stored in the standard variant, we use the standard visit with say bad printer function object passed to it. We call it bad because this particular printer class does not handle all of the types
11:37
Speaker A
that we can store in our variant. 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 to call to invoke bat printer const string
11:49
Speaker A
reference. This happens because the compiler wants us to cover all of the types of our variant in the bat printer class. To understand why the compiler insists on it, let us for a moment assume that it would be allowed to
12:00
Speaker A
compile this code into a library without covering all the variant types in the provided function object. In that case, there would be no way for the standard visit compiled into this libraries binary file to handle the missing standard string type as the binary code
12:13
Speaker A
for this would have never been generated. But there is nothing that forbids someone to write an executable and link it to our library. Right? There is also nothing that forbids them to store a standard string in the variant
12:27
Speaker A
and call print on our fu object. The behavior of this code would be undefined as our libraries binary file would have no idea about how to print a string stored in the variant of the fu class object. To the degree of my
12:40
Speaker A
understanding, this is the reason why the standard requires a function object passed into standard visit to be able to handle all the types that can be stored inside of a given standard variant object. And now we know everything that
12:51
Speaker A
we need to know to return to our original example. It is now clear that declaring image to be a variant allows us to store either a PNG image or a JPEG image in the vector of images. At the
13:03
Speaker A
same time, the use of standard visit with a tiny lambda allows us to call save on any concrete image class object, thus achieving dynamic polymorphism, all while keeping our value semantics intact. So, I hope I could get you
13:15
Speaker A
convinced that standard 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, standard variant along with standard visit offer a pretty
13:32
Speaker A
good way to do that. For whatever reason, these tools are still not considered first class citizens when C++ is taught in schools and universities, which is a bit of a shame. I hope that after this lecture, you'll be able to
13:46
Speaker A
embrace the power that these tools provide and be better informed about the options we have when in need of implementing dynamic polymorphism in modern C++ while maintaining value semantics. And on this note, I want to thank you for watching until the end. It
14:00
Speaker A
really seems to have a big impact on how many people end up seeing this video and well on my motivation for continuing doing this work. And if you feel like you would benefit from a refresher on the topic of dynamic polymorphism as
14:10
Speaker A
viewed through the prism of inheritance, then please watch this video of mine. While if you'd rather refresh your understanding of how we allocate memory in C++, then go ahead and watch this video about stack heap, smart pointers, and overall on memory management in C++
14:26
Speaker A
instead. Thanks a lot and see you in the next one. Bye.
Topics:C++runtime polymorphismstd::variantvalue semanticsdynamic polymorphismtemplatesstd::visitstd::monostatetype-safe unionmodern C++

Answers

Frequently Asked Questions

What is the main benefit of using std::variant over traditional polymorphism?

std::variant allows dynamic polymorphism while maintaining value semantics, avoiding the need for pointers and inheritance, which can improve performance and design flexibility.

How does std::visit help when working with std::variant?

std::visit applies a visitor function to the value stored in a variant, enabling type-safe operations without needing to know the exact type at compile time.

Why would I use std::monostate with std::variant?

std::monostate is used as a default type in a variant to allow default construction when the other types do not have default constructors, enabling the variant to start in an empty or default state.

Get More with the Söz AI App

Transcribe recordings, audio files, and YouTube videos — with AI summaries, speaker detection, and unlimited transcriptions.

Or transcribe another YouTube video here →