Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Type Erasure with Merged Concepts #11790

Open
guevara opened this issue Sep 12, 2024 · 0 comments
Open

Type Erasure with Merged Concepts #11790

guevara opened this issue Sep 12, 2024 · 0 comments

Comments

@guevara
Copy link
Owner

guevara commented Sep 12, 2024

Type Erasure with Merged Concepts



https://ift.tt/OYR4muK



Andreas Herrmann


Two days ago, I watched a very in­ter­esting talk by Zach Laine: Prag­matic Type Era­sure: Solving OOP Prob­lems with an El­e­gant De­sign Pat­tern. The ques­tion that Zach Laine ad­dresses is how to pro­vide poly­mor­phic in­ter­faces while, at the same time, ad­hering to value se­man­tics. I do also rec­om­mend the fol­lowing talk by Sean Par­ent, which gives a great in­tro­duc­tion into the con­cept and the ben­e­fits of type-era­sure, and value se­man­tics: In­her­i­tance Is The Base Class of Evil

This post is mo­ti­vated by a ques­tion that came up on Reddit. Namely, how can we merge mul­tiple type-erased in­ter­faces into one single in­ter­face. A sim­ilar ques­tion is also asked in the end of the first talk: How to apply type era­sure to types with over­lap­ping in­ter­faces? The speak­er’s an­swer is to simply re­peat the common parts. I think there has to be a better way. So, in this post I am going to ex­plore how to merge type-erased in­ter­faces. But first, let’s quickly re­vise type-era­sure.

(Note, the C++ code ex­am­ples are sim­pli­fied in favour of read­abil­ity. A link to working code is pro­vided at the end of each sec­tion.)

Type Era­sure

Sup­pose we want to write a func­tion which greets a person named Tom in some way. I.e. could print “Hi Tom”, “Hello Tom”, “Good day Tom”, … you get the idea. The func­tion should ac­cept an ar­gu­ment that spec­i­fies how to greet a per­son. We will call this ar­gu­ment a Greeter. Here is a simple im­ple­men­ta­tion of our func­tion:

void greet_tom(const Greeter &g) {
    g.greet("Tom");
}

A user of this func­tion may now wish to greet Tom in Eng­lish and in French. So, he im­ple­ments two Greeters:

struct English {
    void greet(const std::string &name) const {
        std::cout << "Good day " << name << ". How are you?\n";
    }
};

struct French {
void greet(const std::string &name) const {
std::cout << "Bonjour " << name << ". Comment ca va?\n";
}
};

Now, how can the user pass his Greeters to our func­tion? Clas­si­cally, we could ei­ther de­fine an ab­stract base class and let our user de­rive from it, or we could make greet_tom a func­tion tem­plate in Greeter. Both methods have their down-sides, which are de­scribed in the above men­tioned talks.

With type-era­sure, we will hide the tem­plates, and the in­her­i­tance under the cov­ers. We will de­fine a Greeter class that can be ini­tial­ized with any­thing that pro­vides the ex­pected Greeter in­ter­face. Fol­lowing Sean Par­ent’s pat­tern an im­ple­men­ta­tion could look as fol­lows:

class Greeter {
  public:
    // Constructor: We can stuff anything into a Greeter costume.
    template <class T>
    Greeter(T data) : self_(std::make_shared<Model<T>>(data)) {}
<span>// External interface: Just forward the call to the wrapped object.</span>
<span>void</span> <span>greet</span><span>(</span><span>const</span> <span>std</span><span>::</span><span>string</span> <span>&amp;</span><span>name</span><span>)</span> <span>const</span> <span>{</span>
    <span>self_</span><span>-&gt;</span><span>greet</span><span>(</span><span>name</span><span>);</span>
<span>}</span>

private:
// The abstract base class is hidden under the covers...
struct Concept {
virtual ~Concept() = default;
virtual void greet(const std::string &) const = 0;
};
// ... and so are the templates.
template <class T>
class Model : public Concept {
public:
Model(T data) : data_(data) {}
virtual void greet(const std::string &name) const override {
// Forward call to user type.
// Requires that T can greet.
data_.greet(name);
}

  <span>private</span><span>:</span>
    <span>// The user defined Greeter will be stored here. (by value!)</span>
    <span>T</span> <span>data_</span><span>;</span>
<span>};</span>

<span>// Polymorphic types require dynamic storage.</span>
<span>// Here we store our pointer to the Model that holds the users Greeter.</span>
<span>std</span><span>::</span><span>shared_ptr</span><span>&lt;</span><span>const</span> <span>Concept</span><span>&gt;</span> <span>self_</span><span>;</span>

};

Note that we are using a shared-pointer to const to refer to the im­ple­men­ta­tion. The de­tails are ex­plained in Sean Par­ent’s talk. We get copy-on-write and value-se­man­tics out of it for free (Mag­ic!). I chose it here, be­cause it elim­i­nates all the boiler-plate for copy­/­move con­struc­tion/as­sign­ment.

A working ex­ample of the code is avail­able here.

Mul­tiple Con­cepts

The problem arises when we want to merge two ex­isting in­ter­faces. For ex­am­ple, sup­pose there is a second con­cept: A door-opener, short Opener. I.e. a thing that opens doors. In some places of our code an Opener will be suf­fi­cient, in some other places we only need a Greeter, but in some places we need to first open the door for someone and then greet them:

void open_door_and_greet_john(const OpenerAndGreeter &g) {
    g.open();
    g.greet("John");
}

How do we create OpenerAndGreeter? Well, we can just create a whole new class for it and copy-paste the Opener, and Greeter parts into it. Like so:

class OpenerAndGreeter {
  public:
    template <class T>
    OpenerAndGreeter(T data) : self_(std::make_shared<Model<T>>(data)) {}
<span>void</span> <span>open</span><span>()</span> <span>const</span> <span>{</span> <span>self_</span><span>-&gt;</span><span>open</span><span>();</span> <span>}</span>
<span>void</span> <span>greet</span><span>(</span><span>const</span> <span>std</span><span>::</span><span>string</span> <span>&amp;</span><span>name</span><span>)</span> <span>const</span> <span>{</span> <span>self_</span><span>-&gt;</span><span>greet</span><span>(</span><span>name</span><span>);</span> <span>}</span>

private:
struct Concept {
virtual ~Concept() = default;
virtual void open() const = 0;
virtual void greet(const std::string &) const = 0;
};
template <class T>
class Model : public Concept {
public:
Model(T data) : data_(data) {}
virtual void open() const override { data_.open(); }
virtual void greet(const std::string &name) const override {
data_.greet(name);
}

  <span>private</span><span>:</span>
    <span>T</span> <span>data_</span><span>;</span>
<span>};</span>

<span>std</span><span>::</span><span>shared_ptr</span><span>&lt;</span><span>const</span> <span>Concept</span><span>&gt;</span> <span>self_</span><span>;</span>

};

But this is not ideal. It would be much better if we could take an ex­isting Greeter con­cept, and an ex­isting Opener con­cept, and just merge the two to­gether.

A working ex­ample of the code is avail­able here.

Dis­secting Type-Era­sure

Be­fore we get there we need to un­der­stand what our type-era­sure class ac­tu­ally does. So let’s take the Greeter apart.

First, it de­fines an ab­stract base class Con­cept. This is very spe­cific to the Greeter. But, it has nothing to do with type-era­sure. So, we pull it out.

// Defines the concept of a Greeter.
struct Concept {
    virtual ~Concept() = default;
    virtual void greet(const std::string &name) const = 0;
};

Sec­ond, there is the model of that con­cept. This ac­tu­ally does two things: It holds an ar­bi­trary value, and it passes the con­cept’s in­ter­face through to that value. So, let’s sep­a­rate them.

// Holds a value of arbitrary type.
template <class T>
class Holder {
  public:
    Holder(T obj) : data_(std::move(obj)) {}
    virtual ~Holder() = default;
    const T &get() const { return data_; }

private:
T data_;
};

// Passes the Concept's interface through to the held value.
template <class Holder>
struct Model : public Holder, public Concept {
using Holder::Holder; // pull in holder's constructor
virtual void greet(const std::string &name) const override {
this->Holder::get().greet(name);
}
};

Next, Greeter is also a con­tainer that refers to a con­cept, and ini­tial­izes it with a model. This is very spe­cific to type-era­sure, but has nothing to do with greeting peo­ple.

template <class Concept, template <class> class Model>
class Container {
  public:
    template <class T>
    Container(T obj)
        : self_(std::make_shared<Model<Holder<T>>>(std::move(obj))) {};
<span>const</span> <span>Concept</span> <span>&amp;</span><span>get</span><span>()</span> <span>const</span> <span>{</span> <span>return</span> <span>*</span><span>self_</span><span>.</span><span>get</span><span>();</span> <span>}</span>

private:
std::shared_ptr<const Concept> self_;
};

And after all this hacking and slashing there is only one bit left. Namely, the ex­ternal in­ter­face that passes calls through to the con­tainer.

template <class Container>
struct ExternalInterface : public Container {
    using Container::Container;  // pull in container's constructor
    void greet(const std::string &name) const {
        this->Container::get().greet(name);
    }
};

Great! We started out with a per­fectly well func­tioning class and took it apart into tiny pieces. Now we need to re­assemble them and make sure that it still works. But don’t for­get, the goal of this ex­er­cise is to make con­cepts merge­able — au­to­mat­i­cally. Hence, we need an au­to­mated way to as­semble all the pieces that we cre­ated. So, it’s time for some tem­plate magic.

Au­to­mated Type-Era­sure

Above pieces fall into two cat­e­gories: One, there are pieces that de­fine the Greeter’s in­ter­face, and two, there are pieces which de­fine how to hold and call ob­jects of ar­bi­trary types. On the holding and calling side we find Holder, and Container, which are im­ple­men­ta­tion de­tails of our type-era­sure con­tainer; whereas Concept, Model, and ExternalInterface are de­tails of a Greeter. To keep things in order we will col­lect the Greeter parts in a super type that we call GreeterSpec.

At this stage we can write a tem­plate class that as­sem­bles all these pieces to­gether and con­structs a type-era­sure con­tainer for an ar­bi­trary spec. It will take the spec’s ExternalInterface tem­plate, and in­stan­tiate it with a con­tainer for the spec’s con­cept, and model. It will also pull in the base-classes con­struc­tor, so that we can still con­struct it from ob­jects of ar­bi­trary types.

template <class Spec>
struct TypeErasure
    : public Spec::ExternalInterface<Container<Spec::Concept, Spec::Model>> {
    using Base =
        Spec::ExternalInterface<Container<Spec::Concept, Spec::Model>>;
    using Base::Base;
};

using Greeter = TypeErasure<GreeterSpec>;

As the last line demon­strates, the Greeter it­self is nothing but a Type­Era­sure of a cer­tain spec.

Again, a working ex­ample of the code is avail­able here.

Merging Con­cepts

Now, with all that ma­chinery backing us, we can tackle the orig­inal prob­lem: How to merge two con­cepts? We have a tool that cre­ates a type-era­sure class out of an ar­bi­trary spec. And, we as­sume that we al­ready have a GreeterSpec, and an OpenerSpec that de­fine those two con­cepts. What we need is a tool to au­to­mat­i­cally merge two specs into one. Let’s ap­proach this com­po­nent by com­po­nent.

How do we merge Concept classes, i.e. in­ter­faces? In C++ we do this by mul­tiple in­her­i­tance:

struct Concept : public virtual ConceptA, public virtual ConceptB {};

How about the Mod­els? The model is a tem­plate class that takes a holder as a tem­plate pa­ra­meter and then in­herits from said holder, thus be­coming a holder it­self. So, we can take SpecB, and the holder, and merge them into one class. This new class will it­self be a holder. Next, we take SpecA, and that new holder, and merge them to get our final merged Model. There is one nifty de­tail, though: We need to use vir­tual in­her­i­tance for the con­cepts. The reason is that ConceptA, and ConceptB will enter the merged Model through the merged Concept, but also through the models of the two specs.

template <class Holder>
struct Model : public SpecA::Model<SpecB::Model<Holder>>,
               public virtual Concept { /* ... */ };

The ex­ternal in­ter­faces are merged the same way, just without the con­cepts:

template <class Container>
struct ExternalInterface
    : public SpecA::ExternalInterface<SpecB::ExternalInterface<Container>> {
    /* ... */
};

Fi­nally, to con­struct a merged spec we take all the above items and wrap them in a tem­plate class, that takes two specs:

template <class SpecA, class SpecB>
struct MergeSpecs {
    /* ... */
};

With this it is trivial to create a type-era­sure that merges two con­cepts:

using OpenerAndGreeter = TypeErasure<MergeSpecs<OpenerSpec, GreeterSpec>>;

And with just a little bit more of tem­plate magic it is even pos­sible to merge two ex­isting type-era­sure classes. So, with all the above we write the fol­lowing code:

using Opener = TypeErasure<OpenerSpec>;
using Greeter = TypeErasure<GreeterSpec>;
using OpenerAndGreeter = MergeConcepts<Opener, Greeter>;

Done!

And this last code ex­ample is avail­able here.

Con­clu­sion & Out­look

We find that it is in­deed pos­sible to merge two ex­isting type-era­sure classes into one that has a common in­ter­face. And what’s more, we can do it fully au­to­mat­i­cally and in just one line of code. The costly bit is to de­fine the orig­inal type-era­sure classes. For each one we need to de­fine a spec class, and man­u­ally de­fine the in­ter­face. The reason is that C++ does not sup­port in­tro­spec­tion. On the other hand, these specs follow a fairly strict scheme and it should be quite pos­sible to pro­duce them through tool­ing, or pos­sibly even a macro.

An­other pos­sible issue is the in­her­i­tance pat­tern for Model, and ExternalInterface. Due to the chaining of base classes we in­tro­duce user spec­i­fied names into the classes Holder, and Container. The method get could be shad­owed by a user method. The li­brary code does ac­tu­ally con­tain more tem­plate magic to avoid this prob­lem. A tem­plate meta-func­tion peels layers of de­rived classes off until it ar­rives at the ac­tual Holder, or Container class. An ex­ternal getter func­tion is pro­vided for the user, which makes sure to call the cor­rect getter method. An ob­scured name of the in­ternal getter method pro­vides fur­ther pro­tec­tion.

The full code is avail­able here. Please feel in­vited to try it out and give me your feed­back. Also, since this is my first blog post, any crit­i­cism is very wel­come.

Thanks for read­ing!

Un­for­tu­nately, I have not yet fig­ured out how to add com­ments to github pages. For the mo­ment I would like to defer any dis­cus­sion to Reddit. I apol­o­gize for the in­con­ve­nience.







via Andreas Herrmann

September 12, 2024 at 09:01AM
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant