[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 to c_model::m_value and to character::m_value. As a result, the destructor 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

Previous
Previous

[C++] Start Using Cucucmber Part 2. This Time With CWT-Cucumber

Next
Next

Crafting a Cucumber Interpreter in C/C++