Type Erasure

This is my short guide to type erasure, a technique which enables to abstract the underlying class of a function, and use them interchangeably. In this regard, it is similar to (dynamic) polymorphism, but enables to have value objects and not pointers.

Other resources

Problem statement

The example focuses on implementing some Function objects (implemented as classes, for simplicity) that can be then rendered using a generic Drawer function (e.g. that might render to pyplot, or gnuplot).

Dynamic polymorphism

If we think of dynamic polymorphism, to achieve the desired behavior, first we must define our interfaces:

struct Function {
    virtual double operator()(const double& x) const = 0;
    virtual std::string describe() const = 0;
};

struct Drawer {
    using DrawRange = std::pair<double, double>;
    virtual void draw(const Function* fun, DrawRange range = {0.0, 1.0}) = 0;
};

To simplify, Function is a functor interface, and each concrete type must implement an operator() that takes a double and returns a double, and a describe function that yields some sort of description of the function itself.

The Drawer interface, instead, simply requires that the concrete type to have a draw method which accept a Function*, and the range in which to draw the function; note that here we must use a pointer to the interface, otherwise dynamic polymorphism wouldn’t work.

Then, we implement concrete types for the function interface; here we create a wrapper around std::sqrt and a parametric parabola function \(ax^2 + bx + c\):

struct SqrtFunction : Function {
    double operator()(const double& x ) const override { return std::sqrt(x); }
    std::string describe() const override { return "square_root"; }
};

struct ParametricParabola: Function {
    ParametricParabola(double aa, double bb, double cc): a(aa), b(bb), c(cc) {}
    double operator()(const double& x ) const override { return a*x*x + b*x + c; }
    std::string describe() const override { return "parametric_parabola"; }
    double a{1.0};
    double b{-1.0};
    double c{0.0};
};

We also implement a useless drawer, for sake of demonstration

struct VoidDrawer: Drawer{
    void draw(const Function* fun, DrawRange range = {0.0, 1.0}) override {
        fmt::println("Drawing function {}", fun->describe());
        fmt::println(" > Value at {}: {}", range.first, (*fun)(range.first));
        fmt::println(" > Value at {}: {}", range.second, (*fun)(range.second));
    }
};

Once we defined the interfaces and implemented some concrete types, we can start creating some user-defined functions; here we create some type aliases, and proceed implementing a function that draws all functions in a given collection:

using FunctionPtr = std::unique_ptr<Function>;
using FunctionCollection = std::vector<FunctionPtr>;

void draw_functions(const FunctionCollection& funcs, Drawer* drawer){
    for (const auto& f: funcs) drawer->draw(f.get());
}

Finally we can create our main application that creates a polymorphic vector of functions, and use the VoidDrawer to display them:

int main() {
    FunctionCollection funcs;
    funcs.push_back(std::make_unique<SqrtFunction>());
    funcs.push_back(std::make_unique<ParametricParabola>(2.0, 0.0, 1.0));

    VoidDrawer drawer;
    draw_functions(funcs, &drawer);
}

Static polymorphism

We implement the same problem now using static polymorphism, i.e. by using templates.

By using templates, we can omit the common interface, as the constraint will be enforced at compile time. In this way, we can simply define the concrete type of the function with no inheritance:

struct SqrtFunction {
    double operator()(const double& x ) const { return std::sqrt(x); }
    std::string describe() const { return "square_root"; }
};

struct ParametricParabola {
    ParametricParabola(double aa, double bb, double cc): a(aa), b(bb), c(cc) {}
    double operator()(const double& x ) const { return a*x*x + b*x + c; }
    std::string describe() const { return "parametric_parabola"; }
    double a{1.0};
    double b{-1.0};
    double c{0.0};
};

The drawer is a little bit more complicated, as the function call draw must be templated for any Function interface. Here, if we pass a Function object that doesn’t comply with the interface (having operator()(double&) -> double and describe() -> string) will result in a compilation error:

using DrawRange = std::pair<double, double>;

struct VoidDrawer {
    template <typename Function>
    void draw(const Function& fun, DrawRange range = {0.0, 1.0}) {
        fmt::println("Drawing function {}", fun.describe());
        fmt::println(" > Value at {}: {}", range.first, fun(range.first));
        fmt::println(" > Value at {}: {}", range.second, fun(range.second));
    }
};

Note: to make the interface requirement more explicit, we could have used C++20 concepts, but that would have make the code more complex.

Now, following the dynamic polymorphism examplew, we need to implement a draw_function like the following:

template <typename Drawer>
void draw_functions(const FunctionCollection& funcs, Drawer& drawer){
    for (const auto& f: funcs) drawer.draw(f);
}

When using templates, such definition does not work; indeed, we cannot clearly define a FunctionCollection type as in the other case: since there’s no common base class, we cannot rely on pointers to base class to store heterogeneous types.

To tackle this issue, we must use variants, i.e. a type-safe union, to store the 2 concrete types of function into a single object. Doing so, we can create a collection simply as a std::vector of values (and no more of pointers).

Now we could be tempted to do the following:

using AllFunctions = std::variant<SqrtFunction, ParametricParabola>;
using FunctionCollection = std::vector<AllFunctions>;

template <typename Drawer>
void draw_functions(const FunctionCollection& funcs, Drawer& drawer){
    for (const auto& f: funcs) drawer.draw(f);
}

This is still incorrect (such code won’t compile), since f in the for-loop is a variant object, not the concrete function type we want. To call the proper draw function of the drawer based on the actual function stored in the variant, we must use a visitor:

using AllFunctions = std::variant<SqrtFunction, ParametricParabola>;
using FunctionCollection = std::vector<AllFunctions>;

template <typename Drawer>
void draw_functions(const FunctionCollection& funcs, Drawer& drawer){
    for (const auto& f: funcs) 
        std::visit([&drawer](const auto& fun) { drawer.draw(fun); }, f);
}

Finally, we can define the main function; in this case we don’t rely anymore on pointers, but rather on value objects.

int main() {
    FunctionCollection funcs;
    funcs.emplace_back(SqrtFunction());
    funcs.emplace_back(ParametricParabola(2.0, 0.0, 1.0));

    VoidDrawer drawer;
    draw_functions(funcs, drawer);
}

Bonus: concept version

In C++20, we might use concepts to better define templated interfaces.

Indeed, in the templated version of VoidDrawer, any object that has a describe() is technically valid (as long as it is formattable by the fmt library used in the example). For instance, of SqrtFunction::describe returned a double, the code would have compiled just fine.

In this version we create the function_concept concept that ensures that, given a type T and a const double& value value:

  • obj(value) is a valid expression;

  • the outcome of the expression { obj(value) } is convertible to a double;

  • obj.describe() is a valid expression;

  • the outcome { obj.describe() } can be converted to a std::string.

template <typename T>
concept function_concept = requires(const T& obj, const double& value){
    { obj(value) } -> std::convertible_to<double>;
    { obj.describe() } -> std::convertible_to<std::string>;
}; 

struct VoidDrawer {
    template <function_concept Function>
    void draw(const Function& fun, DrawRange range = {0.0, 1.0}) {
        fmt::println("Drawing function {}", fun.describe());
        fmt::println(" > Value at {}: {}", range.first, fun(range.first));
        fmt::println(" > Value at {}: {}", range.second, fun(range.second));
    }
};

Value based polymorphism - Type erasure

As the name suggests, type erasure is a technique that aims at hiding the underlying type we want to use.

Let us see how to create a type-erased function; first, we need to specify the interface for a Function, i.e.:

struct FunctionConcept {
    virtual double operator()(const double& v) const = 0;
    virtual std::string describe() const = 0;
};

This is exactly the interface we had for the dynamic polymorphism implementation (except we changed the name of such purely virtual interface).

Now, we define a concrete function object as a templated class FunctionModel which owns the actual function implementation:

template <typename CallableFunction>
struct FunctionModel : FunctionConcept {
    explicit FunctionModel(CallableFunction fun): _f(std::move(fun)) {};

    double operator()(const double& v) const override { return _f(v); }
    std::string describe() const override { return _f.describe(); }

    CallableFunction _f;
};

Here we observe that:

  • FunctionModel inherits from FunctionConcept, thus it includes some polymorphic behavior;

  • FunctionModel is a templated class, and requires an actual CallableFunction to become a concrete type. We observe here that the arbitrary CallableFunction might not inherit from FunctionConcept; it’s the wrapper provided by the FunctionModel that will encapsulate the polymorphic behavior. Of course, if CallableFunction doesn’t conform with the interface, a compilation error will be thrown.

At this point, we have all the ingredients to create a type-erased Function object:

struct Function {

    template <typename CallableFunction>
    Function(CallableFunction f) : 
        _fun(std::make_unique<FunctionModel<CallableFunction>>(std::move(f))) {}

    double operator()(const double& v) const { return (*_fun)(v); }
    std::string describe() const { return _fun->describe(); }

private:
    struct FunctionConcept {
        virtual double operator()(const double& v) const = 0;
        virtual std::string describe() const = 0;
    };

    template <typename CallableFunction>
    struct FunctionModel : FunctionConcept {
        explicit FunctionModel(CallableFunction fun): _f(std::move(fun)) {};

        double operator()(const double& v) const override { return _f(v); }
        std::string describe() const override { return _f.describe(); }

        CallableFunction _f;
    };

    std::unique_ptr<FunctionConcept> _fun;
};

Here, FunctionConcept and FunctionModel are private members of Function, and thus cannot be accessed outside its definition; doing so, we prevent concrete types to inherit from Function::FunctionConcept.

The Function object stores the implementation in a (unique) pointer to FunctionConcept, in order to effectively enable the polymorphic behavior. Such pointer is instantiated using a templated constructor that assigns _fun a pointer to FunctionModel<CallableFunction>, i.e., it creates a concrete FunctionModel type based on the provided input.

To have a proper value-based semantic of the interface, Function must re-export the interfaced functions, but without the need of the virtual overload.

Program overview

Based on this explanation of type erasure, we achieve a different program.

We can start off defining the inheritance-free concrete types for the function (as in the static polymorphism example):

struct SqrtFunction {
    double operator()(const double& x ) const { return std::sqrt(x); }
    std::string describe() const { return "square_root"; }
};

struct ParametricParabola {
    ParametricParabola(double aa, double bb, double cc): a(aa), b(bb), c(cc) {}
    double operator()(const double& x ) const { return a*x*x + b*x + c; }
    std::string describe() const { return "parametric_parabola"; }
    double a{1.0};
    double b{-1.0};
    double c{0.0};
};

Using type-erasure, we can define the interface after the definition of the concrete types (tthat wasn’t possible using standard dynamic polymorphism), as required by the drawing facility; in particular, also the Drawer interface can be type-erased:

struct Function {
   // ...
};
using DrawRange = std::pair<double, double>;

struct Drawer {
    
    template <typename CallableDrawer>
    Drawer(CallableDrawer drawer) : 
        _d(std::make_unique<DrawerModel<CallableDrawer>>(std::move(drawer))) {}

    void draw(const Function& fun, DrawRange range = {0.0, 1.0}) { _d->draw(fun, range); }

private:
    struct DrawerConcept {
        virtual void draw(const Function& fun, DrawRange range) = 0;
    };

    template <typename CallableDrawer>
    struct DrawerModel : DrawerConcept {
        explicit DrawerModel(CallableDrawer drawer): _drawer(std::move(drawer)) {}

        void draw(const Function& fun, DrawRange range) override { return _drawer.draw(fun, range); }

        CallableDrawer _drawer;
    };

    std::unique_ptr<DrawerConcept> _d;
};

Based on these interfaces, we can define the template-less draw_functions that accept values as inputs, as well as a drawer:

using FunctionCollection = std::vector<Function>;

void draw_functions(const FunctionCollection& funcs, Drawer drawer){
    for (const auto& f: funcs) drawer.draw(f);
}

struct VoidDrawer {
    void draw(const Function& fun, DrawRange range = {0.0, 1.0}) {
        fmt::println("Drawing function {}", fun.describe());
        fmt::println(" > Value at {}: {}", range.first, fun(range.first));
        fmt::println(" > Value at {}: {}", range.second, fun(range.second));
    }
};

Finally, the main application consists in a std::vector of Function value objects, and not pointer, and a call to draw_functions with the provided VoidDrawer:

int main() {
    FunctionCollection funcs;
    funcs.emplace_back(SqrtFunction());
    funcs.emplace_back(ParametricParabola(2.0, 0.0, 1.0));

    draw_functions(funcs, VoidDrawer());
}