What if I told you, you can make static polymorphism with virtual functions?
by Baduit
Article::Article
Often virtual functions are shown as an example of runtime polymorphism in C++, in opposition with compile time polymorphism (like template, function overload, CRTP, etc.).
Why? Because the dispatch takes place during runtime instead of compile time!
You heard that right! We’ll see how and why in this article.
Note that I won’t explain how virtual method are implemented by the compiler, because this is not relevant and not even defined by the standard (even if all of the compilers I know use a vtable).
Explanations
Since C++11 there are constexpr functions. These functions can be evaluated at compile time if the arguments are known during the compilation, it can be a constant like 5 or a constexpr variable or the result of a constexpr function. For example this code compiles :
1
2
3
4
5
6
7
8
9
10
// A simple function, we just added constexpr in the prototype
constexpr int foo(int n)
{
// The logic is sooooooo complicated
return n * 2;
}
// A static assert is like a normal assert,
// but it does with with compile time predicate instead of runtime
// (4 * 2) == 8, everything is good
static_assert(foo(4) == 8);
A predicate is a callable that returns a value testable as a boolean
But that code does not :
1
2
3
4
5
6
7
8
9
10
// Same as above, we added constexpr in the prototype
constexpr int bar(int n)
{
// The logic is more complicated here, if that's possible
return n * 3;
}
// 2 * 3 != 7, we have an error
static_assert(bar(2) == 7);
// error: static assertion failed
// | static_assert(bar(2) == 7);
A constexpr function must satisfy some requirements, for example you can’t make I/O in a constexpr function. But with each new standard, the requirements are fewer and fewer. Here’s the requirement that interests us today:
it must not be virtual (until C++20)
This means now we can make virtual functions constexpr, therefore they can be evaluated at compile time like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// The base class
struct Toto
{
// The constexpr virtual function we will override
constexpr virtual int triple(int n) const = 0;
};
// The child class
struct Impl: Toto
{
// Override the method
constexpr virtual int triple(int n) const override
{
// The unexpected logic
return 3 * n;
}
};
// Instanciate the implementation
constexpr auto impl = Impl{};
// We need a reference or a pointer, else the dispatch won't work
constexpr const Toto& impl_ref = impl;
constexpr auto a = impl_ref.triple(3);
static_assert(a == 9);
Compiler explorer link And because everything is done during compile time, we can say it is static polymorphism.
You can try this code with C++17, it won’t compile, but in C++20 it works like a charm!
Enforce it with consteval
With C++20 a new keyword arrives : consteval, the names may seem redundant with other keyword, but it has a utility and it is pretty simple : it is like constexpr but it can only be used during the compilation, meaning that its argument and its results are compile time constant expressions.
Here’s an example that compiles :
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
// Put the consteval at the same place you would put the constexpr specifier
consteval int do_stuff(int n)
{
return n + 3;
}
int main()
{
// It prints 10
std::cout << do_stuff(7) << std::endl;
}
Compiler explorer link And one that does not:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
// Same function
consteval int do_stuff(int n)
{
return n + 3;
}
int main(int argc, char** argv)
{
// argc is not know at compile time, this is not good
std::cout << do_stuff(argc) << std::endl;
}
// main.cpp: In function 'int main(int, char**)':
// main.cpp:12:26: error: 'argc' is not a constant expression
// 12 | std::cout << do_stuff(argc) << std::endl;
Article::~Article
I showed you some things you can do in C++ 20 with virtual functions, it may not seem intuitive, but it is possible.
I don’t have a specific use case in mind where it is useful, but in general, the more you can do during compile time, the better it is, because it can often have better runtime performance (as it has already be performed during the compilation) but more important, the compiler makes more checks, therefore the code is safer.
Also note that it is just a tool like every other feature of the language, and just because you can use it does not mean you should always use it everywhere, you don’t have only a hammer, you have a complete toolbox.