[C++] Lets Review my Article on Type Erasure
On my first article about Type Erasure I had a comment:
This example is quite broken. When
character
is constructed,value
will get moved toc_model::m_value
and tocharacter::m_value
. As a result, thedestructor
for value will get called twice [..]
So lets have a simplified class cwt::any
to hold any type:
namespace cwt {
// we have a templated constructor and we can hold
// any type here. the base and derive class is
// private in 'any', nothing special so far
class any {
public:
template<typename T>
any(T&& value) {
m_value = std::make_unique<c_model<T>>(std::move(value));
}
private:
struct c_concept {
virtual ~c_concept() {}
};
template<typename T>
struct c_model : public c_concept
{
c_model(T const& value)
: m_value(value) {}
T m_value;
};
private:
std::unique_ptr<c_concept> m_value;
};
} // namespace cwt
And when we use a simple class foo
:
struct foo {
foo() { std::cout << "foo()\n"; }
~foo() { std::cout << "~foo()\n"; }
};
int main()
{
cwt::any a{foo{}};
}
We get the following output:
foo()
~foo()
~foo()
And my first impression was, well he has a point ... At the time, I replied to his comment that I had provided a lightweight example and referred to Klaus Iglberger's talk from CppCon 2022 on type erasure (which I also did in the article).
One of the first things I did was, to compare it with std::any
and it gave the same results:
int main()
{
std::cout << "cwt::any: \n";
{
cwt::any a{foo{}};
}
std::cout << "now std::any: \n";
{
std::any a{foo{}};
}
}
And it prints actually the same:
cwt::any:
foo()
~foo()
~foo()
now std::any:
foo()
~foo()
~foo()
So lets remove std::any
for now and have a look at what the memory address says and print foo's address:
struct foo {
foo() { std::cout << "foo() with address: " << this << '\n'; }
~foo() { std::cout << "~foo() with address: " << this << '\n'; }
};
int main()
{
cwt::any a{foo{}};
}
Results in:
foo() with address: 0x7ffc66d296de
~foo() with address: 0x7ffc66d296de
~foo() with address: 0x1cadec8
That means the object will be created another time and the first one will be destroyed. So the next step is to implement the copy and move constructors and see what happens:
struct foo {
foo() { std::cout << "foo() with address: " << this << '\n'; }
foo(const foo& other) { std::cout << "foo(const foo& other)" << this << " other's address: " << &other << '\n'; }
foo(foo&& other) { std::cout << "foo(foo&& other)" << this << " other's address: " << &other << '\n'; }
~foo() { std::cout << "~foo() with address: " << this << '\n'; }
};
int main()
{
cwt::any a{foo{}};
}
Results in:
foo() with address: 0x7ffc3118ad97
foo(const foo& other) with address: 0x210eec8 other's address: 0x7ffc3118ad97
~foo() with address: 0x7ffc3118ad97
~foo() with address: 0x210eec8
And this is interesting, because if you do the same with std::any
you'll see that the move constructor is called. I honestly would have expected that ...
Taking a closer look to cwt::any
I noted, that the underlying interface creates a copy. Lets fix that:
// in namespace cwt: class any:
// ...
template<typename T>
struct c_model : public c_concept
{
c_model(T&& value)
: m_value(value) {}
T m_value;
};
// ...
// the constructor to:
c_model(T&& value)
: m_value(std::move(value)) {}
And now we see that foo
's move constructor is called:
foo() with address: 0x7ffe10345537
foo(foo&& other) with address: 0xeb0ec8 other's address: 0x7ffe10345537
~foo() with address: 0x7ffe10345537
~foo() with address: 0xeb0ec8
So far it all seems reasonable and understandable to me. There is an emplace
method in std::any
that constructs the object directly and moves values to a possible constructor. We can add this method as well. I have modified my any
class, you can see the modifications with the comments below:
class any {
public:
// we need a default constructor now (i know the rule of 3/5 but keep it simple for this article)
any() = default;
template<typename T>
any(T&& value) {
m_value = std::make_unique<c_model<T>>(std::move(value));
}
// an emplace method
template<typename T, typename... Args>
void emplace(Args&&... args)
{
// lets reset a possible value before creating a new one
m_value.reset();
m_value = std::make_unique<c_model<T>>(std::forward<Args>(args)...);
}
private:
struct c_concept {
virtual ~c_concept() {}
};
template<typename T>
struct c_model : public c_concept
{
// we probably have default constructors to call
// so lets use a default constructor here
c_model() = default;
c_model(T&& value)
: m_value(std::move(value)) {}
T m_value;
};
private:
std::unique_ptr<c_concept> m_value;
};
And when we use cwt::any
now like the following, we see only one destructor call:
int main()
{
cwt::any a;
a.emplace<foo>();
}
Results in:
foo() with address: 0xe53eb8
~foo() with address: 0xe53eb8
Conclusion
You can find this example here on Compiler Explorer
Going back to the basics and looking at what happens was quite interesting for me. And again, if you are using Type Erasure in production, I highly recommend watching Klaus Iglberger's talk from CppCon on Type Erasure. If I remember correctly, he implements a special swap function for objects.
Ultimately, I don't think cwt::any
is broken, and it seems like reasonable and expected behavior. If I missed something, please leave a comment below :)
I hope it helped and thats it for now.
Best Thomas