std::polymorphic_value + Duck Typing = Type Erasure
I recently had an insight about type erasure that I wanted to share.
Type erasure is a combination of two techniques working together to achieve both polymorphism and value semantics:
std::polymorphic_value
, a proposed standard library type, and duck typing.
Let’s take the example I’ve used back in my visitor pattern post again: We want to model the AST of some markup language, like Markdown. It contains text, emphasis, code blocks, and so on. We parse the input, create the AST, and then need to convert it to HTML.
A natural approach of modelling it is with a class hierarchy:
We have a node
base class and derived classes like document
, paragraph
, text
, emphasis
etc.
Some classes are containers of child nodes, like document
, some are not, like text
.
class node
{
public:
virtual ~node() = default;
virtual std::string render_html() const = 0;
};
class text final : public node
{
public:
std::string render_html() const override
{
return sanitize_html(content_);
}
private:
std::string content_;
};
class document final : public node
{
public:
std::string render_html() const override
{
std::string result = "<head>…</head>\n<body>\n";
for (auto& child : children_)
result += child->render_html();
result += "</body>\n";
return result;
}
private:
std::vector<std::unique_ptr<node>> children_;
};
…
I’ve followed my advice of making non-base classes
final
. This prevents various slicing issues.
This works well enough, and is similar to what I’ve done in standardese.
However, there are two things I don’t like.
Problem: Lack of Value Semantics
Scott Meyers once said that you should “do as the ints do” – write classes that behave like int
s.
And this makes a lot of sense, as the language makes it very convenient to work with int
:
You can just create them on the stack, pass them around, create a copy that is a completely separate entity, classes containing int
s can follow the rule of zero, etc.
int do_something(int a, int b)
{
int tmp = a + b;
int copy = tmp;
++tmp;
// copy is unaffected
return tmp + copy;
}
Most standard library classes follow this advice, for example std::string
.
As such, all the same principles apply to it as well:
std::string do_something(std::string a, std::string b b)
{
std::string tmp = a + b;
std::string copy = tmp;
tmp += "world";
// copy is unaffected
return tmp + copy;
}
This ability – to write classes that behave like built-in types – is one of the most important features of C++.
However, our class hierarchy does not behave like this!
We can’t create a variable holding some type derived from node
on the stack, we need to put it on the heap, requiring memory management.
We can’t just pass them around (slicing), we need to pass references or (smart) pointers around.
We can’t just copy them to get a separate entity, at most we can do reference counting.
Classes containing arbitrary derived types of node
, like our document
, can’t follow the rule of zero, because of the additional burden of lifetime management for the pointer or reference to the actual object.
For example, we would need to write our own copy constructor of document
.
This makes them a bit awkward to use.
It would be better, if they behaved just like int
s do.
Solution: Value Semantic Wrapper
We can – of course – solve this problem by an extra layer of indirection.
Instead of manually passing node
s around, we create a node_value
that stores a heap-allocated node
, but wraps it and provides value semantics.
At the most basic level, it just contains a std::unique_ptr
again:
class node_value
{
public:
template <typename T>
requires std::is_base_of_v<node, T>
node_value(T obj)
: ptr_(std::make_unique<T>(std::move(obj))
{}
node* operator->() const
{
return ptr_.get();
}
node& operator*() const
{
return *ptr_;
}
private:
std::unique_ptr<node> ptr_;
};
We have a constructor that takes any object derived from node
(constrained by a requires
) and puts it on the heap.
Then we provide the pointer like access that gives us a node.
So far, this is not different from a plain std::unique_ptr
, so what gives?
The trick is, we can now write a copy constructor if we add a clone()
function to our class hierarchy:
class node
{
public:
virtual std::unique_ptr<node> clone() const = 0;
};
class text final : public node
{
public:
std::unique_ptr<node> clone() const override
{
return std::make_unique<text>(content_);
}
private:
std::string content_;
};
class document final : public node
{
public:
std::unique_ptr<node> clone() const override
{
std::vector<std::unique_ptr<node>> children;
for (auto& c : children_)
children.push_back(c->clone());
return std::make_unique<document>(std::move(children));
}
private:
std::vector<std::unique_ptr<node>> children_;
};
…
This clone()
function is basically a virtual
copy constructor.
Then we can implement copy for node_value
:
class node_value
{
public:
node_value(const node_value& other)
: ptr_(other->clone())
{}
node_value& operator=(const node_value& other)
{
ptr_ = other->clone();
return *this;
}
private:
std::unique_ptr<node> ptr_;
};
And now, while node
still does not behave like int
s, node_value
does:
we can freely create it on the stack, copy it around, and so on.
We have wrapped a type that does not provide value semantics into one that does - but at the cost of boilerplate.
Luckily, there is a proposal for basically a generic node_value
: std::polymorphic_value
.
A std::polymorphic_value<node>
behaves exactly like our node_value
.
std::polymorphic_value<node> n = …;
auto html = n->render_html();
std::polymorphic_value<node> copy = n;
…
It is even able to perform correct copies without the need for a clone()
member function!
You can find a reference implementation here: github.com/jbcoe/polymorphic_value.
Problem: No Implicit Extensibility
The second problem with our node
class hierarchy is a common one for all class hierarchy:
You need to be aware of the base class in order to take part in it.
What if some third party library just happens to provide a class with a render_html()
function?
We can’t use it, because it doesn’t derived from node
.
Granted, this is probably unlikely in our example, but bear with me.
Solution: Duck Typing
We can solve it by providing a wrapper, that takes an arbitrary object that happens to provide a render_html()
function, but inherits from node
:
template <typename T>
class node_like final : public node
{
public:
node_like(T obj)
: obj_(std::move(obj))
{}
// We can provide cloning by simply using T's copy constructor,
// if it is still required.
std::unique_ptr<node> clone() const override
{
return std::make_unique<node_like<T>>(obj_);
}
std::string render_html() const override
{
return obj_.render_html();
}
private:
T obj_;
};
That way, an arbitrary type can be part of the node
hierarchy.
Note that we’re able to implement
clone()
by forwarding toT
’s copy constructor. The same trick is also done bypolymorphic_value
: when storing aT
, we remember the copy constructor and invoke it on copy.
Combination: Type Erasure
What happens when we combine node_value
and node_like
?
Well, given node_like
, text
, document
, and so on don’t really need to inherit from node
anymore - they just need to be wrapped in node_like
.
And because we only store node
s in a node_value
, we can let it do all of the wrapping:
class node_value
{
public:
template <typename T>
node_value(T obj)
: ptr_(std::make_unique<node_like<T>>(std::move(obj)))
{}
// dereference and copy as before
private:
std::unique_ptr<node> ptr_;
};
At this point, our node_value
can just handle any type that happens to provide a render_html()
function.
Now, do we really need to keep the node
base class or node_like
public?
Functions that work with arbitrary node
s can just take node_value
, and node_like
is a mere wrapper required by node_value
.
So we can go one step further and make the two classes implementation details of node_value
.
That also frees up the name node
, so we can rename node_value
to simply node
.
Instead of providing dereference, we just manually implement the interface node
originally has - because that’s what we can do with node
anyway!
class node // formerly node value
{
class base // formerly node
{
public:
virtual ~base() = default;
virtual std::unique_ptr<base> clone() const = 0;
virtual std::string render_html() const = 0;
};
template <typename T>
class wrapper final : public base // formely node_like
{
public:
wrapper(T obj)
: obj_(std::move(obj))
{}
std::unique_ptr<base> clone() const override
{
return std::make_unique<wrapper<T>>(obj_);
}
std::string render_html() const override
{
return obj_.render_html();
}
private:
T obj_;
};
public:
template <typename T>
node(T obj)
: ptr_(std::make_unique<wrapper<T>>(std::move(obj)))
{}
node(const node& other)
: ptr_(other.ptr_->clone())
{}
node& operator=(const node& other)
{
ptr_ = other.ptr_->clone();
return *this;
}
std::string render_html() const
{
return ptr_->render_html();
}
private:
std::unique_ptr<base> ptr_;
};
Now our text
and document
classes are just regular classes with a render_html()
function:
class text
{
public:
std::string render_html() const
{
return sanitize_html(content_);
}
private:
std::string content_;
};
class document
{
public:
std::string render_html() const
{
std::string result = "<head>…</head>\n<body>\n";
for (auto& child : children_)
result += child.render_html();
result += "</body>\n";
return result;
}
private:
std::vector<node> children_;
};
No need to inherit from anything, no need to store other node
s in a pointer, copy works out of the box and so on.
By combining a value semantics wrapper and duck typing, we no longer have a class hierarchy requiring the awkward use of smart pointers, but instead simple types with value semantics. In addition, it works with every type we throw at it, as long as it has the required function. This makes extension very easy.
This technique is type erasure – it combines polymorphic behavior, value semantics, and duck-typing.
std::function
uses type erasure; the required interface is the operator()
(and copy constructor).
std::any
also provides type erasure; it only requires copy constructors and a destructor.
And even std::polymorphic_value
does type erasure to provide copies.
The only downside to type erasure the boilerplate: We need to create a base class with the required virtual functions, a templated wrapper that just forwards, and then a public interface forwarding to the base class – this is annoying. However, if the type is used often enough, it can be worth it. There are also libraries that use meta programming techniques to eliminate a lot of the boilerplate. And metaclasses can even eliminate it completely.
And even if you don’t use type erasure, consider using something like std::polymorphic_value
instead:
it gives you a lot of the benefits with no boilerplate whatsoever.