Generic owning type erasure
- Class hierarchies
- A type-erased widget class
- Difference between the two approaches
- Users of the widget do not need to deal with pointers
- The type-erased class has (mostly) value semantic
- Less coupling between functions
- Classes that can be used as
widget1
, do not need to havewidget1
as a dependency - No explicit casts are required
- Every new instance requires an allocation
- Might cause more copies than necessary
- Boilerplate code
- It is possible to use a fundamental type as
widget1
- Support for
dynamic_cast
- Default methods
- Avoid unnecessary copies if everything is immutable
- Conclusion
This is a follow-up of Owning type erasure for simple types, type erased view types, and custom virtual tables (they should all be available under the keyword type erasure)
Until now, all methods presented tried to avoid allocating memory (in that sense, they were lightweight).
In the case of custom virtual tables, the main role of the type-erased class was just a wrapper around some function pointers.
For type erased view types, the type-erased class mainly consists of a custom virtual table and a pointer to the actual object.
Owning type erasure for simple types, contrary to the previous approaches, owns the object, but only up to a certain size determined at compile time. The implementation was also restricted to "simple" types, in order to exploit std::memcpy
to manage the lifetime of objects for us. The presented class can be extended to use placement new for creating objects in place in the buffer, you must also remember to call the destructor manually.
Even if works™, the implementation has a lot of gotchas and is hard to write correctly.
In this note, the described technique is considered heavyweight, as it allocates memory, but it is more robust, as in we do not need to bypass the type system at all (there are no explicit casts)
Class hierarchies
The "easy" way (and for many "classic", or even "only") to provide type erasure is to make a class hierarchy.
Supposing you want to model a widget
, you might end with something like
struct monitor;
struct widget0{
// some virtual methods with a default implementation
virtual bool enabled();
virtual void enable(bool b);
virtual ~widget0() = default;
// some pure virtual methods
virtual int set_height(int) = 0;
virtual int get_height() = 0;
virtual int set_widtht(int) = 0;
virtual int get_width() = 0;
virtual void draw(monitor*) = 0;
};
struct window: widget0 {
// ...
};
struct button: widget0 {
// ...
};
struct menu: widget0 {
// ...
};
struct text: widget0 {
// ...
};
and the resulting code will need to pass around widget0*
.
One of the drawbacks of this method is that it is not possible to use an int
for representing a number (assuming that it makes sense), or a class from another library that could work as a widget.
One needs to define a wrapper class that inherits from widget0
, and reimplement all required methods.
A possible way to define an owning type-erased class is to hide the class hierarchy as an implementation detail.
A type-erased widget class
#include <cassert>
#include <memory>
struct monitor;
class widget1 {
public:
template <typename T>
explicit widget1(T x) : self(std::make_unique<impl<T>>(std::move(x))){
}
widget1(const widget1& o) : self(o.self->clone()){
}
widget1& operator=(const widget1& o){
if(this != &o){
self =o.self->clone();
}
return *this;
}
// begin interface to support
void draw(monitor* m) {
assert(m != nullptr);
self->draw_internal(m);
}
// end interface to support
private:
struct interface {
interface() noexcept = default;
virtual ~interface() = default;
virtual std::unique_ptr<interface> clone() const = 0;
// begin interface to support
virtual void draw_internal(monitor* m) = 0;
// end interface to support
};
template <typename T>
struct impl final: widget1::interface {
impl(T x) : data(std::move(x)) { }
std::unique_ptr<interface> clone() const override
{
return std::make_unique<impl>(*this);
}
T data;
// begin interface to support
void draw_internal(monitor* m) override
{
data.draw(m);
}
// end interface to support
};
std::unique_ptr<interface> self;
};
For brevity, the widget1
class only supports the draw
method.
Contrary to the previous implementation, widget1
itself has no virtual
functions.
The implementation forwards all parameters to the self
member variable, which contains one instance pointing to widget1::interface
.
Note 📝 | One advantage of this technique is that validations can be done once in the non-virtual method. In fact, virtual methods should probably not be public. |
How can this class be used?
here is a minimal example:
#include <cassert>
#include <cstdio>
#include <memory>
#include <string>
#include <vector>
struct monitor;
class widget {
public:
template <typename T>
explicit widget(T x) : self(std::make_unique<impl<T>>(std::move(x))){
}
widget(const widget& o) : self(o.self->clone()){
}
widget& operator=(const widget& o){
if(this != &o){
self = o.self->clone();
}
return *this;
}
// begin interface to support
void draw(monitor* m) {
assert(m != nullptr);
self->draw_internal(m);
}
// end interface to support
private:
struct interface {
interface() noexcept = default;
virtual ~interface() = default;
virtual std::unique_ptr<interface> clone() const = 0;
// begin interface to support
virtual void draw_internal(monitor* m) = 0;
// end interface to support
};
template <typename T>
struct impl final: widget::interface {
impl(T x) : data(std::move(x)) { }
std::unique_ptr<interface> clone() const override
{
return std::make_unique<impl>(*this);
}
T data;
// begin interface to support
void draw_internal(monitor* m) override
{
data.draw(m);
}
// end interface to support
};
std::unique_ptr<interface> self;
};
struct monitor{
int indent = 0;
void draw_pixels(const char* text){
printf("%*s %s\n", indent*2, "", text);
}
};
// some classes that previously were subclasses
struct button{
void draw(monitor* m, int i = 0) const {
m->draw_pixels("button");
}
};
struct menu{
void draw(monitor* m){
m->draw_pixels("menu");
}
};
struct buttons{
std::vector<button> v;
void draw(monitor* m){
m->draw_pixels("buttons");
++m->indent;
for(auto& e : v){
e.draw(m);
}
--m->indent;
}
};
struct pane{
std::vector<widget> v;
void draw(monitor* m){
m->draw_pixels("pane");
++m->indent;
for(auto& e : v){
e.draw(m);
}
--m->indent;
}
};
int main(){
monitor m;
{
auto w = widget(button());
std::puts("= draw button");
w.draw(&m);
std::puts("= draw menu");
w = widget(menu());
w.draw(&m);
}
{
std::puts("= draw buttons");
buttons vb{{button(), button()}};
vb.draw(&m);
}
{
std::puts("= draw empty pane");
pane p;
p.draw(&m);
p.v.emplace_back(button());
p.v.emplace_back(menu());
std::puts("= draw non-empty pane");
p.draw(&m);
std::puts("= draw non-empty pane (2)");
p.v.emplace_back(p);
p.draw(&m);
}
}
The output of this program should look similar too:
= draw button
button
= draw menu
menu
= draw buttons
buttons
button
button
= draw empty pane
pane
= draw non-empty pane
pane
button
menu
= draw non-empty pane (2)
pane
button
menu
pane
button
menu
As mentioned earlier, a pointer to a class hierarchy is an implementation detail.
In fact, in main
there are no pointers, except for &m
.
The second interesting part of this example is the line p.v.emplace_back(p);
, which produces the following text when printed out
pane
button
menu
pane
button
menu
As the type is polymorphic and mostly has value semantic, it is not an issue for std::vector
to be able to hold a widget1
that is type-erasing another vector of widgets.
Before analyzing the differences between this type-erased class and a class hierarchy, let’s address the following issue.
It is still not possible to type-erase an int
, as it does not have any member method, or std::string
, as it does not have the member methods required by the widget interface, and by both types, we cannot extend them.
Difference between the two approaches
Users of the widget do not need to deal with pointers
In the case of a widget0
, the end-user often must use pointers, for example, if a structure needs to hold a widget, it might look like
struct class_holding_widget{ std::unique_ptr<widget0> ptr; };
On the contrary, with widget1
, the equivalent code would look like
struct class_holding_widget{ widget1 w; };
The type-erased class has (mostly) value semantic
As already mentioned, widget1
has mostly value semantic, while widget0
does not, although it might provide a clone
or copy
method.
Why only mostly?
Because if the type that is type-erased does shallow copies (like a pointer or reference type), then also the copy-constructor of widget1
will do shallow copies for some instances.
Note that it is possible, for example with a static_assert, to avoid creating an instance from a pointer, but it is generally not possible to detect if a class has a shallow or deep copy-constructor.
Note that being able to copy and move widget1
is not a requirement. It is perfectly fine to delete the copy constructor.
Less coupling between functions
In the case of widget0
, functions are tightly coupled, which has some minor disadvantages.
Consider
#include <memory>
#include <cstdio>
struct widget0{
virtual bool enabled(){std::puts("widget0::enabled"); return true;}
virtual ~widget0() = default;
};
struct window: widget0 {
bool enabled(int i = 0) {std::puts("window::enabled"); return true;}
};
struct button: widget0 {
bool enabled() const {std::puts("button::enabled"); return true;}
};
int main(){
auto ptr1 =std::unique_ptr<widget0>(std::make_unique<window>());
ptr1->enabled(); // calls widget0::enabled, and not window::enabled
auto ptr2 =std::unique_ptr<widget0>(std::make_unique<button>());
ptr2->enabled(); // calls widget0::enabled, and not button::enabled
}
Both in the case of window
and button
, the function signature differs, and thus enabled
has not been overridden.
With virtual
it is possible to avoid this issue, but what if we wanted to have a subclass with the method marked as const
?
With the widget1
, this is not an issue, as long as the call data.enabled();
is valid. It can be const
-qualified, it can have additional defaulted parameters, it can override
another function, or even take a parameter that is convertible to another one.
Such flexibility might not be always desired, and if problematic, it is possible to destructure a function, and use static_assert
to enforce some properties.
In the case of class hierarchies, at least if the member function were defined as pure (thus with = 0
), then we would get a compilation error (even without writing override
) instead of the undesired behavior at runtime.
Also, note that contrary to a class hierarchy, with some metaprogramming, it is possible to reuse even more code.
For example, we can support classes that use a different function name, or a free function instead of a member function, with the techniques shown here:
struct interface {
interface() noexcept = default;
virtual ~interface() = default;
virtual std::unique_ptr<interface> clone() const = 0;
// begin interface to support
virtual void draw_internal(monitor* m) = 0;
virtual bool enabled_internal(monitor* m) = 0;
// end interface to support
};
template <typename T>
struct impl final: widget1::interface {
impl(T x) : data(std::move(x)) { }
std::unique_ptr<interface> clone() const override
{
return std::make_unique<impl>(*this);
}
T data;
// begin interface to support
void draw_internal(monitor* m) override
{
if constexpr(requires{ &T::draw; }){
data.draw(m);
} else {
// if no member function, assume there is a free function
draw(data, m);
}
}
bool enabled_internal(monitor* m) override
{
if constexpr(requires{ &T::enabled; }){
return data.enabled();
} else if constexpr(requires{ enabled(std::declval<T>()); }){
return enabled(data)
} else { // if no function, it is always enabled
return true;
}
}
// end interface to support
};
Classes that can be used as widget1
, do not need to have widget1
as a dependency
While it might not make a difference in some projects, it might be extremely relevant for others.
Fewer dependencies between components mean that there are more chances to parallelize work during compilation, thus better compile times.
It means that classes of different libraries can be used as a widget1
, without them having an underlying common library.
One side-effect is that it helps to avoid circular dependencies too.
No explicit casts are required
Contrary to most other techniques shown for type-erasure, no method uses any explicit cast.
Both widget0
and widget1
do not require explicit casts.
Every new instance requires an allocation
The constructor of widget1
always allocates, as it calls std::make_unique<impl<T>>
:
template <typename T>
explicit widget1(T x) : self(std::make_unique<impl<T>>(std::move(x))){
}
While with widget0
it is common, but not required to use the heap too, the current implementation of widget1
makes it a hard requirement.
A possible workaround would be to to implement a "small object optimization", like std::any
already does.
At compile time, the constructor of widget1
needs to test for size and alignment of T
, and if adequate, create a new instance in a buffer instead of calling std::make_unique<impl<T>>
.
While this approach will lead to a more performant implementation, it will also greatly increase the complexity of the widget1
class.
Also, this approach does not "fix" the issue entirely. The user of widget1
is not able to decide that certain objects should not be allocated. Some users might even prefer to have a compile error instead of an unexpected allocation.
Another approach would be to define ref_for_widget1
, a non-owning type-erasing class, whose sole existence is for an additional constructor of widget1
, that would avoid the allocation:
explicit widget1(ref_for_widget1 x);
Internally, widgets would have either an unique_ptr<widget1::interface>
, or a ref_for_widget1
.
While it is not nice, as an instance of widget1
with a ref_for_widget1
would not have value semantic, it was also already true before, as it is not possible to verify that some classes do not make mutable shallow copies.
With ref_for_widget1
, this misfeature would at least be easier to acknowledge when creating such objects.
Might cause more copies than necessary
It might not be a fair critique, after all, this is an owning type-erased class. But since I’m comparing it to a class hierarchy, I was asked if one should be concerned.
The constructor of widget1
invokes the move or copy constructor of objects, and the copy-constructor of widget1
also makes copies.
With a public virtual class hierarchy, since objects normally do not have value semantics, there are little to no copies.
Some copies can be avoided by defining an efficient move constructor, and at that point, the author of the widget class needs to decide what happens if someone tries to use a "moved-from" widget.
The main possibilities are
-
throw
an exception (or some other form of recoverable error) -
a non-recoverable error like
std::exit
,std::abort
,std::quick_exit
,std::terminate
, … -
define some "default" behavior for every method
-
let it be UB (in this example it would be in practice to dereference
nullptr
, I recommend to add anassert
)
Also ref_for_widget1
helps to avoid costly copies, but then, the widget class would not really be an owning class, would it?
Note that the same considerations hold for std::string
versus const char*
(and std::string_view
), and yet, in many cases std::string
is, if mutable and independent strings are required, less error-prone and more performant than const char*
.
A possible approach for avoiding copies is if all operations are const
: shallow copies. More on that in another section.
Boilerplate code
This is the biggest disadvantage of the proposed type-erased class.
We need to define the same function multiple times: for widget1
, widget1::interface
, and widget1::impl<T>
.
It might not seem problematic for a class that declares two functions, but what if we have 30 or 40? qwidget declares a lot of functions, imagine yourself typing the same function three times. It is extremely error-prone and verbose for no good reason.
Note 📝 | Also for a non-owning type-erased class we need to define every function more than once. |
I am not aware of an "elegant" solution for the boilerplate code, I do not see how a macro can help; probably an external code generator could avoid some duplication.
At least such duplication is necessary only inside the widget
class, and not outside of it.
Support for dynamic_cast
dynamic_cast
will not work on widget1
, as there is no class hierarchy.
If such an operation is needed, then one needs to provide an accessor to the underlying data, similarly to this accessor method
Default methods
In the case of a class hierarchy, a subclass can reuse the implementation of a parent class without writing a single line of code, or deciding to override
the function:
#include <memory>
#include <cstdio>
struct widget0{
virtual bool enabled(){std::puts("widget0::enabled"); return true;}
virtual ~widget0() = default;
};
struct window: widget0 {
// the implementation of widget0::enabled already does the Right Thing(TM)
};
struct button: widget0 {
// button is special, it needs to override the behavior of enabled
bool enabled() override {std::puts("button::enabled"); return true;}
};
There is no equivalent mechanism for widget1
backed directly in the language, but there are several techniques to achieve something similar.
Member functions
template <typename D>
struct default_enabled {
friend D;
bool enabled(){std::puts("widget1::enabled"); return true;}
void enable(bool b) { /* ... */ }
};
// window is normal, it can reuse a common implementation
struct window: default_enabled<window>{
// other methods
};
// window2 is normal, it can reuse a subset of a common implementation
struct window2: private default_enabled<window>{
// other methods
using default_enabled<window>::enabled;
void enable(bool b) { /* ... */ }
};
// button is special, it needs its own enabled
struct button {
bool enabled() override {std::puts("button::enabled"); return true;}
void enable(bool b) { /* ... */ }
};
Unfortunately, this method changes the layout of a class.
Free functions
With free functions, it is possible to write a catch-all function
template <class T>
bool enabled(T&){
std::puts("widget1::enabled"); return true;
}
// window is normal, it can reuse a common implementation
struct window{
};
// button is special, it needs its own enabled
struct button {
};
bool enabled(button&){
std::puts("button::enabled"); return true;
}
If a catch-all function is too generic (maybe because the function name is too generic), it is still possible to restrict it to fewer types.
A custom namespace to use as a customization point (which also works with friend functions, but is problematic for hidden friends), concepts, or define a common type that other types can convert to it (it’s type-erasure all the way down…), for example:
struct widget1;
struct window;
struct common{
common(const widget1&){}
common(const window&){}
// eventually other types
};
bool enabled(common){
std::puts("widget1::enabled"); return true;
}
// window is normal, it can reuse a common implementation
struct window{
};
// button is special, it needs its own enabled
struct button {
};
bool enabled(button&){
std::puts("button::enabled"); return true;
}
Avoid unnecessary copies if everything is immutable
If all operations supported by widget
do not change the data, then there is little need to create copies.
In this case, replacing unique_ptr
with shared_ptr
and removing the copy-constructor and clone
method is all that is needed:
#include <cassert>
#include <iostream>
#include <memory>
#include <string>
#include <vector>
struct monitor;
class widget2 {
public:
template <typename T>
explicit widget2(T x) : self(std::make_shared<impl<T>>(std::move(x))){
}
// begin interface to support
friend void draw(const widget2& w, monitor* m) {
assert(m != nullptr);
w.self->draw_internal(m);
}
// end interface to support
private:
struct interface {
interface() noexcept = default;
virtual ~interface() = default;
// begin interface to support
virtual void draw_internal(monitor* m) const = 0;
// end interface to support
};
template <typename T>
struct impl final: widget2::interface {
impl(T x) : data(std::move(x)) { }
T data;
// begin interface to support
void draw_internal(monitor* m) const override
{
draw(data, m);
}
// end interface to support
};
std::shared_ptr<interface> self;
};
struct monitor{
int indent = 0;
void draw_pixels(std::string_view text){
std::cout << std::string(indent*2, ' ') << text << "\n";
}
};
// some classes that previously were subclasses
struct button{
};
void draw(const button&, monitor* m) {
m->draw_pixels("button");
}
struct menu{
};
void draw(const menu&, monitor* m){
m->draw_pixels("menu");
}
struct buttons{
std::vector<button> v;
};
void draw(const buttons& bt, monitor* m){
m->draw_pixels("buttons");
++m->indent;
for(auto& e : bt.v){
draw(e, m);
}
--m->indent;
}
struct pane{
std::vector<widget2> v;
};
void draw(const pane& p, monitor* m){
m->draw_pixels("pane");
++m->indent;
for(auto& e : p.v){
draw(e, m);
}
--m->indent;
}
void draw(int i, monitor* m){
m->draw_pixels("int (" + std::to_string(i) + ")");
}
int main(){
monitor m;
{
auto w = widget2(button());
std::cout << "= draw button\n";
draw(w, &m);
std::cout << "= draw menu\n";
w = widget2(menu());
draw(w, &m);
std::cout << "= draw int\n";
w = widget2(-1);
draw(w, &m);
}
{
std::cout << "= draw buttons\n";
buttons vb{{button(), button()}};
draw(vb, &m);
}
{
std::cout << "= draw empty pane\n";
pane p;
draw(p, &m);
p.v.push_back(widget2(button()));
p.v.push_back(widget2(menu()));
std::cout << "= draw non-empty pane\n";
draw(p, &m);
std::cout << "= draw non-empty pane (2)\n";
p.v.push_back(widget2(p));
draw(p, &m);
}
}
The output still looks like
= draw button
button
= draw menu
menu
= draw int
int (-1)
= draw buttons
buttons
button
button
= draw empty pane
pane
= draw non-empty pane
pane
button
menu
= draw non-empty pane (2)
pane
button
menu
pane
button
menu
Note that since widget2
does create a copy during construction, it does if some types have mutating methods, like std::string::clear
.
The constructor of widget2
creates a copy internally that is normally not reachable, thus it is generally sufficient to look at the interface of widget2
to decide if a shallow copy is fine or not.
There are ways to access the internal copy, but not by accident. For example, a user-defined class could reach the internal copy by storing a reference in a global variable, which is another reason why global mutable variables should be used with great care.
Similar to the issue with value semantic and shallow copies, it is not possible to detect if the type-erased types are really immutable, or have shallow constness.
This issue is not unique to widget2
or type-erased types, but it is important to realize what are the limitations of certain design decisions, what the consequences are, and how to react.
For example, if a type-erased type would not be immutable, would it mean to remove shallow copies? It could do more damage than good.
On the other hand, it would be preferable to make all types deep-const.
Conclusion
Without some "small buffer optimizations", there might be allocation when there were none before. With a "small buffer optimization" it can be possible to greatly improve performance compared to handling pointers manually, just like std::string is generally more performant than most string-like classes in the wild.
Not using virtual inheritance as the API of a class might seem like a novel method, but it is not.
Not using public virtual functions makes it easier to establish invariants and value semantics makes it easier to reason about the state of a program.
If your classes are not copyable, or they do not have the equivalent of a clone
method, then an equivalent implementation would be using shallow clones with a mutable interface: generally something it is desirable to avoid.
Contrary to some other techniques, there are no casts (without a "small buffer optimization"), thus the implementation is robust, and most if not all possible implementation errors are caught at compile time.
One minor disadvantage of this technique is that there is no definitive answer on how to support move semantics, and the major disadvantage is the amount of boilerplate code.
Granted, through some metaprogramming techniques it is possible to reduce the amount of code for the classes implementing the API of the widget, but for the widget class itself, there no way is in C++ to avoid writing the same function signature multiple times.
Do you want to share your opinion? Or is there an error, some parts that are not clear enough?
You can contact me anytime.