Concepts et polymorphisme

Introduction

Comme le précédent, cet article va s’appuyer sur un problème concret pour illustrer le polymorphisme en utilisant la notion de concept. Il s’appuiera très fortement sur boost.type_erasure pour essayer de garder le plus possible une vision d’ensemble. L’approche est très proche, pour ne pas dire identique, à ce qu’expose Sean Parent ici, je vous invite à lire cet article et plus spécialement si vous voulez un aperçu du fonctionnement précis.

Le problème choisit est le suivant : on se place dans un contexte graphique où l’on manipule et dessine différentes formes. L’objectif est la réalisation d’une classe  permettant de manipuler et dessiner plusieurs formes ensemble. A nouveau le problème est surtout un prétexte, aucune alternative n’a été étudiée et comparée pour la réalisation de cet article.

Contexte

Commençons par du code :

#include<iostream>

//On veut construire Engine
template<
    //Quels parametres
> class Engine
{
    /*Quel type*/ container;

public:
    void draw(/*Quel type*/ render) const
    {
        for(auto& shape : container)
            shape.draw(render);
    }

    void move(/*Quel type*/ vector)
    {
        for(auto& shape : container)
            shape.move(vector);
    }

    void push_back(/*Quel type*/ shape)
    { container.push_back(/*Comment passer shape*/); }
};

//Une classe d'exemple Circle
struct Circle
{
    template<class Vector>
    void move(Vector&&)
    { std::cout << "circle moved" << std::endl; }

    template<class Render>
    void draw(Render& ) const
    { std::cout << "cricle drew" << std::endl;  }
};

//Une classe d'exemple Sprite
struct Sprite
{
    template<class Vector>
    void move(Vector&&)
    { std::cout << "sprite moved" << std::endl; }

    template<class Render>
    void draw(Render& ) const
    { std::cout << "sprite drew" << std::endl;  }
};

//Une classe Render pour tester
struct Render {};

//Deux classes Vector pour tester
struct VectorA {};
struct VectorB {};

//On instancie un Render pour tester
Render render;

Engine</*Quels arguments*/> engine;

//On remplit un peu notre engine
engine.push_back(Circle());
engine.push_back(Sprite());

//On deplace les éléments de notre engine
engine.move(VectorA());
engine.move(VectorB());

//On affiche les éléments de notre engine
engine.draw(render);

Les classes d’exemples Circle et Sprite sont complètes, les détails techniques n’étant pas le sujet de cet article, les paramètres des fonctions qui sont en pratiques nécessaires ne sont pas utilisées mais conservées dans le code pour avoir une description des formes à base de concepts réalistes.

En effet, que ce soit Circle ou Sprite, tout deux respectent deux concepts, qu’on peut exprimer ainsi :

  • Le concept de Drawable, se traduit par l’existence d’une fonction membre draw prenant en paramètre un objet respectant le concept de Render, qu’on ne définit pas ici.
  • Le concept de Movable, se traduit par l’existence d’une fonction membre move prenant en paramètre un objet respectant le concept de Vector, qu’on ne définit pas ici non plus.

Le code présente une ébauche pour la classe Engine, pour l’instant notons simplement les 5 dernières lignes qui illustrent l’objectif : manipuler un objet engine auquel on peut ajouter divers formes et qui permet de les déplacer et afficher ensemble.

Type-Erasure

On veut manipuler plusieurs objets de différents types ensemble, on retrouve donc le besoin d’un type-erasure comme dans l’article précédent. A nouveau l’héritage publique appliqué directement aux classes Circle et Sprite n’est pas possible, les services ne portent bien que sur un seul objet (avec un type-erasure), mais ces services sont polymorphiques car les paramètres sont définies en termes de concepts (pas de fonctions virtuelles et template en C++).

On va aborder le problème sous un autre angle, un type-erasure est une perte d’information, voyons donc comment les différentes solutions techniques évoquées gèrent cette perte :

  • L’héritage publique : on définit une classe de base et un ensemble de services via des fonctions virtuelles. Ensuite chaque classe héritée vient ajouter ses propres informations qui seront ignorées lorsque l’on manipulera une collection hétérogène d’objets.
  • boost.variant : on possède divers classes et l’on veut ajouter des services faisant interagir plusieurs objets de ces divers classes. C’est à dire que l’on ignore toute l’information apportée directement par les divers classes et que l’on ajoute la notre.
  • boost.type_erasure : on verra son utilisation dans la suite, mais l’approche globale permet de voir pourquoi le choisir. On possède divers classes et l’on veut manipuler à travers des services commun une collection hétérogène d’objets. C’est à dire que l’on indique explicitement l’information que l’on veut garder.

Ici on va se placer dans le troisième cas, et avant de voir exactement comment dans la partie suivante, on va terminer sur la syntaxe qui permet d’exprimer les informations que l’on veut garder :

#include<boost/type_erasure/member.hpp>

BOOST_TYPE_ERASURE_MEMBER(
    (drawable), draw)

BOOST_TYPE_ERASURE_MEMBER(
    (movable),  move)

Ces deux éléments de syntaxe sont des macros qui vont déclarer des classes templates, respectivement drawable et movable, qui permettront d’indiquer à notre type-erasure l’information qu’il doit conserver, plus exactement l’information « avoir un service draw, respectivement, move acceptant tels paramètres ».

Utilisation

Avant toute chose, il faut bien être conscient que la technique mise en oeuvre repose techniquement sur un héritage publique, et que par conséquent on ne pourra dépasser la barrière des « fonctions virtuelles et template ». La technique utilisée ne permet que de déplacer le problème à un endroit plus propice. En effet si il est complexe de choisir un sous ensemble de services lors de la définition des classes, pour utiliser un héritage publique et les rendre virtuelles, il est plus aisée de le faire lorsque l’on décide de créer un objet engine. Ainsi ce sous ensemble pourra être spécifié par les arguments template de la classe Engine :

  • Le type de Render qu’on va utiliser est connu lors de la création de l’engine, on le spécifiera donc par un seul argument template.
  • L’ensemble des types de Vector qu’on va utiliser est déterminé à la création de l’engine, on les spécifiera donc par une séquence de type en utilisant boost.mpl.

On obtient donc :

#include<boost/mpl/vector.hpp>

template<
    class Render,
    class Vectors
> class Engine

//Et à l'utilisation
Engine<
    Render,
    mpl::vector<VectorA,VectorB>
> engine;

Passons à l’utilisation, on va commencer par uniquement introduire l’information propre à draw et le paramètre template permettant de changer le type de conteneur utilisé par notre classe Engine :

#include<utility>
#include<boost/container/vector.hpp>

#include<boost/type_erasure/any.hpp>
#include<boost/type_erasure/builtin.hpp>

#include<boost/mpl/apply.hpp>
#include<boost/mpl/placeholders.hpp>

namespace container = boost::container;
namespace mpl       = boost::mpl;

using namespace mpl::placeholders;

using mpl::apply;
using boost::type_erasure::_self;

template<
    class Render,
    class Vectors,
    class Container
        =container::vector<_>
> class Engine
{
    using Concepts = mpl::vector<
        boost::type_erasure::copy_constructible<>,
        drawable<void(Render&), const _self>
    >,

    using Shape = boost::type_erasure::any<
        Concepts
    >;

    typename
        apply<Container,Shape>::type
        container;
    //apply permet de déterminer le conteneur
    //avec Container=std::vector<_> on obtient
    //std::vector<Shape>

public:
    //On sait à cet endroit le type de Render qu'on veut utiliser
    void draw(Render& render) const
    {
        for(auto& shape : container)
            shape.draw(render);
    }

    //Aucun raison de perdre l'information sur le type de forme
    //avant l'ajout qui se fait dans le corps de la fonction;
    //on peut donc utiliser un paramètre template
    template<class T>
    void push_back(T&& shape)
    { container.push_back(std::forward<T>(shape)); }
};

On ne va pas s’attarder sur les parties commentées dans le code pour nous attarder sur l’utilisation de boost::any, on voit le paramètre template Concepts qui est une séquence de type mpl, chaque type représente une part de l’information que l’on veut garder, ici deux éléments :

  • copy_constructible : l’information est « l’objet doit être constructible par copie ».
  • drawable : l’information est « l’objet doit posséder le service (constant) draw avec le paramètre Render&amp;« , ceci est indiqué grâce à la classe template drawable déclarée auparavant par l’utilisation de la macro et l’argument template const _self.

Le dernier élément à ajouter et la prise en compte des informations du type « l’objet doit posséder le service move avec le paramètre const Vector&amp; » avec les différents Vector de la séquence de type Vectors.

Pour réaliser cela l’on va introduire une méta-fonction qui à partir d’un type Vector détermine une information particulière, ensuite en utilisant boost.mpl et la séquence trasnform_view, on va préparer de la séquence de type Vectors la séquence de type représentant l’ensemble des informations qu’on ajoutera grâce à joint_view à la séquence qu’on a déjà, ceci donne :

#include<boost/mpl/joint_view.hpp>
#include<boost/mpl/transform_view.hpp>

//La méta-fonction
template<class Vector>
struct Movable
{ using type = movable<void(const Vector&)>; };

class Engine
{
    using Concepts = mpl::joint_view< //Permet de joinde les deux séquence
        //L'ancienne séquence
        mpl::vector<
            boost::type_erasure::copy_constructible<>,
            drawable<void(Render&), const _self>
        >,
        //La nouvelle séquence
        mpl::transform_view<
            Vectors,Movable<_>
        >
    >;

public:
    //On ne connait pas exactement le type de Vector
    template<class Vector>
    void move(Vector&& vector)
    {
        for(auto& shape : container)
            shape.move(vector);
    }
};

Conclusion

Avant de conclure, remarquons que d’un point de vue conceptuel, regrouper le traitement de déplacement et de l’affichage au sein d’une même classe n’est pas nécessairement judicieux, cependant ce n’est pas le sujet de l’article d’où le choix délibéré de le faire.

Pour terminer, il est important de noter que le cas que j’ai pris utilise volontairement des fonctions membres template dans les classes d’exemples pour exclure fortement l’idée d’utiliser un héritage publique directement sur ces classes, mais qu’on peut très bien appliquer le raisonnement sur des choses plus simple. Il faut aussi ne pas oublier que boost.type_erasure propose beaucoup plus de chose que ce que j’ai fait ici.

A mon avis, la démarche qui est faite ici est plus flexible que celle introduisant un héritage publique. En effet dans cette démarche la seule réflexion préalable concerne la définition de concepts généraux qui n’ont qu’un faible impact au niveau du code. Par contre l’utilisation de l’héritage publique à la place de cette démarche impose la définition d’interfaces qui ont un impact très fort au niveau du code. Ces impacts se traduisant directement par des difficultés d’évolution du code.

Laisser un commentaire