DEV Community

Félix-Olivier Dumas
Félix-Olivier Dumas

Posted on • Edited on • Originally published at Medium

Static Interface Projection Pattern (SIPP) : contrôle statique de l’exposition des méthodes en C++

Introduction et contexte

De nos jours, le C++ est l'un des langages les plus influents du monde entier. Depuis les 40 dernières années, celui-ci a su faire ses preuves autant dans l'aérospatiale que dans votre sous-sol à 2 h du matin.

Le plus dingue est que nous avons tous tendance à oublier l'origine de ce pilier de la programmation moderne. En effet, Stroustrup avait initialement baptisé son nouveau projet « C avec des classes », car il ajoutait des fonctionnalités orientées objet au C sans modifier le langage sous-jacent. Il est donc évident que le polymorphisme joue un rôle central dans la philosophie de ce langage.

À ce jour, il existe deux grandes catégories lorsqu'il est question du polymorphisme en C++ : le polymorphisme statique et le polymorphisme dynamique. Bien que ceux-ci présentent une même philosophie commune, la réalité est qu'ils sont extrêmement différents l'un de l'autre, tant dans leur implémentation que dans leur utilisation.

Dans l'article d'aujourd'hui, nous aborderons principalement le polymorphisme statique. Non pas que le polymorphisme virtuel ne soit pas intéressant, mais il faut comprendre qu'ils diffèrent tellement l'un de l'autre qu'ils nécessiteraient, à eux deux, un article complet à part entière. Je vous invite donc à vous installer et à profiter de ce qui suit !

Comprendre le polymorphisme

En premier lieu, il est important d'expliquer ce qu'est le polymorphisme en C++. Si vous êtes ici en train de lire cet article, je me doute bien que vous comprenez déjà ce qu'est le concept plus générique de polymorphisme, mais je vais tout de même prendre un petit moment pour brièvement expliquer ce que c'est.

En informatique et en théorie des types, le polymorphisme [...] est le concept consistant à fournir une interface unique à des entités pouvant avoir différents types. Par exemple, des opérations telles que la multiplication peuvent ainsi être étendues des scalaires aux vecteurs ou aux matrices, l'addition des scalaires aux fonctions ou aux chaînes de caractères, etc.

Selon le langage informatique employé, le polymorphisme peut être réalisé par différents moyens, inhérents au langage ou par emploi de patrons de conception.

— Source : Wikipédia, Polymorphisme (informatique)

L'explication tirée de la citation ci-dessus est particulièrement efficace, car elle aborde l'un des piliers du concept, qui est celui des interfaces. Une interface peut être vue comme un contrat, une obligation ou une contrainte morphologique. Par exemple, si un chat prétend être un félin, il a le contrat de présenter, d'une façon ou d'une autre, les comportements d'un félin. Ainsi, si l'on voit un chat, nous pouvons prétendre qu'il sait chasser, car les félins chassent.

Mettons un peu les félins de côté et passons à une approche plus concrète, centrée sur le code. D'ailleurs, je dois préciser qu'à partir de maintenant jusqu'à la fin de cet article, je présenterai des exemples en C++. Ok, “Talk is cheap, show me the code.” :

#include <iostream>

struct Felin {
    virtual ~Felin() = default;
    virtual void hunt() = 0;
};

struct Chat : Felin {
    void hunt() override {
        // chasse une souris, un insecte, etc.
        std::cout << "Chat chasse\n";
    }
};
Enter fullscreen mode Exit fullscreen mode

Ici, vous pouvez apercevoir que la struct Felin présente le contrat de la méthode « hunt ». Ensuite, vous pouvez remarquer que la struct « Chat » hérite de « Felin », ce qui l'oblige automatiquement à respecter le contrat et à, conséquemment, avoir sa propre implémentation de la méthode contractuelle. Ainsi, comme nous l'avions brièvement abordé précédemment, pour qu'un objet puisse être interprété comme un « Felin », il doit en hériter et en respecter son contrat.

Enfin, pour les plus observateurs d'entre vous, vous aurez probablement remarqué que l'exemple ci-dessus utilise une syntaxe un peu atypique avec les mots-clés « virtual » et « override ». Ce n'est pas particulièrement étonnant, car il présente une structure utilisant le polymorphisme virtuel. Comme je l'ai dit précédemment, nous n'aborderons pas vraiment cette catégorie de polymorphisme. Nombreuses sont les ressources à votre disposition ; je ne peux que vous inviter à vous y rendre afin de mieux comprendre son fonctionnement si vous êtes curieux.

Le polymorphisme statique en C++

Le polymorphisme statique est un mécanisme de programmation dans lequel la sélection de la bonne implémentation est effectuée à la compilation, en fonction des types connus à ce moment-là. Autrement dit, une même interface peut être utilisée avec différents types, mais le choix de la version exécutée est résolu avant l’exécution du programme, par le compilateur.

Il est important de comprendre que le polymorphisme statique n'est pas qu'une seule technique, mais bien plusieurs, arborant les mêmes contraintes fondamentales. Pour illustrer cela concrètement, examinons l’une des utilisations les plus courantes du CRTP :

#include <iostream>
#include <utility>

struct Base {
    template <typename Self>
    void interface(this Self&& self) {
        std::forward<Self>(self).implementation();
    }
};

struct Derived : Base {
    void implementation() {
        std::cout << "Implementation from Derived\n";
    }
};
Enter fullscreen mode Exit fullscreen mode

Analyse de l’exemple CRTP

Comme vous pouvez le voir, l'exemple ci-dessus est drastiquement plus dense en complexité que celui que nous avions précédemment abordé plus haut. Ce n'est pas anodin, car il faut répliquer, entièrement à la compilation, le comportement que le compilateur effectuerait normalement avec le polymorphisme virtuel.

D'ailleurs, ici, l'on utilise les dernières fonctionnalités du standard C++23 afin de considérablement simplifier l'exécution de ce pattern. L'utilisation du paramètre explicite « this » permet de récupérer l'objet depuis lequel la fonction est appelée. Concernant l'exemple, la méthode interface sera normalement toujours appelée depuis l'objet dérivé « Derived », ce qui lui permet d'appeler la méthode implementation directement sur cet objet.

template <typename Self>
void interface(this Self&& self) { // self = dérivé
    std::forward<Self>(self).implementation(); // appel de la méthode implémentation
}
Enter fullscreen mode Exit fullscreen mode

Avantages du polymorphisme statique

Maintenant, pourquoi utiliser le polymorphisme statique ? Pour la simple et bonne raison que celui-ci apporte des gains non négligeables en performance en comparaison avec le polymorphisme virtuel. De plus, celui-ci permet une très grande flexibilité sur le design, car il est possible de modifier le comportement de la délégation polymorphique, ce qui n'est normalement pas possible avec l'approche virtuelle.

#include <utility>

struct Base {
    template <typename Self>
    void interface(this Self&& self) {
        std::forward<Self>(self).implementationA();
        std::forward<Self>(self).implementationB();
    }
};
Enter fullscreen mode Exit fullscreen mode

Ici, nous appelons deux méthodes à la suite depuis la méthode interface de la base, ce qui est normalement impossible avec le polymorphisme virtuel. Pour vous donner d'autres exemples, il serait possible de chronométrer le temps d'exécution de la méthode du dérivé en démarrant un chronomètre avant l'appel de la méthode et en l'arrêtant à la fin.

Limites et désavantages

Bien qu'il semble parfait à première vue, le polymorphisme statique a de nombreux problèmes évidents qui l'empêchent de totalement remplacer son rival virtuel. Ainsi, j'ai sélectionné les principaux problèmes qui accompagnent son utilisation.

Complexité et lisibilité du code

En premier lieu, il y a bien évidemment la charge en complexité et en lisibilité de son implémentation. C'est bien connu, rien n'est gratuit et vous payez décidément ces gains en complexité. D'ailleurs, c'est pour cette raison que cette approche est généralement utilisée dans des environnements où les performances sont critiques et où la lisibilité est presque secondaire.

Problèmes de slicing et de stockage

Oh, et ce n'est pas tout, car les objets l'utilisant ne peuvent pas être stockés comme ceux utilisant le polymorphisme virtuel le sont. Par exemple, vous ne pouvez pas stocker des objets Chat dans un « std::vector » par valeur, car cela entraîne du slicing : seule la partie correspondant à la classe de base est conservée lors de la copie, et la partie dérivée est perdue. C'est d'ailleurs ce qu'on appelle le « slicing », mais je vais éviter d'entrer dans les détails pour des raisons de public cible. Cependant, il existe une solution, mais elle introduit une énorme charge en complexité et en lisibilité, à vous d'en juger :

#include <iostream>
#include <variant>
#include <vector>

struct Shape {
    template <typename Self>
    void draw(this Self&& self) {
        self.draw_impl();
    }
};

struct Circle {
    void draw_impl() {
        std::cout << "Circle\n";
    }
};

struct Square {
    void draw_impl() {
        std::cout << "Square\n";
    }
};

using AnyShape = std::variant<Circle, Square>;

int main() {
    std::vector<AnyShape> shapes = { Circle{}, Square{}, Circle{} };

    for (auto& s : shapes) {
        std::visit([](auto& shape) {
            shape.draw();
        }, s);
    }
}
Enter fullscreen mode Exit fullscreen mode

Exposition des méthodes d’implémentation

Et puis enfin, l'un des problèmes qui me fatigue le plus, c'est que les méthodes d'implémentation sont toujours visibles et accessibles depuis l'objet dérivé. Cela expose les méthodes inutilement, remplit inutilement l’API publique de l’objet et c’est globalement « sale ». En voici un petit exemple afin que vous compreniez :

#include <iostream>

struct Circle {
    void draw_impl() {
        std::cout << "Circle\n";
    }
};

int main() {
    Circle c{};
    c.draw_impl(); // visible et accessible
}
Enter fullscreen mode Exit fullscreen mode

Conclusion intermédiaire

Pour terminer cette partie, vous avez sans doute remarqué que j’évite de trop entrer dans les détails concernant le fonctionnement de tous ces concepts. C’est principalement pour la raison que là n’est pas le sujet de cet article. Cette section sert davantage de contexte que d’analyse technique. En revanche, j’ai déjà écrit un article gigantesque et très technique sur le sujet du polymorphisme statique et du CRTP. J’y présente d’ailleurs une approche un peu expérimentale appelée « exotic CRTP », qui pourrait intéresser les plus curieux parmi vous.

SIPP : une approche expérimentale du polymorphisme statique

Présentation du pattern

Maintenant que vous comprenez certains des principaux désavantages qu’apporte le polymorphisme statique, je peux vous partager ce sur quoi j’ai longuement expérimenté lors des derniers jours. Voici le résultat de mes expérimentations en la matière.

#include <iostream>

struct Implementation {
    void foo() {
        std::cout << "foo\n";
    }

    void bar() {
        std::cout << "bar\n";
    }
};

template<typename Impl>
struct Interface : private Impl {
    using Impl::foo;
    using Impl::bar;
};
Enter fullscreen mode Exit fullscreen mode

Avant tout, je dois dire que j'ai longuement recherché sur internet afin de trouver des implémentations similaires à ce que j'ai expérimenté, mais malheureusement, je n'ai rien trouvé de concret. Par contre, je suis certain de ne pas être le premier à découvrir cela, car ça semble beaucoup trop évident.

C’est pour toutes ces raisons que j’ai décidé de l’appeler : Static Interface Projection Pattern, ou SIPP.

Comprendre le fonctionnement du SIPP

Dans un premier temps, ça peut sembler un peu incompréhensible, mais nous allons tranquillement aborder chaque aspect de ce code afin de mieux l’appréhender.

Commençons par observer le code. Vous pouvez remarquer que le modèle typique de « Derived » qui hérite de « Base » semble inversé. En effet, l’interface fait ici office de vue sur une implémentation. Pour être plus précis, elle filtre les méthodes qui seront accessibles depuis l’extérieur. Donc si l’on regarde l’exemple ci-dessus, les seules méthodes appelables depuis l’objet « Interface » sont « foo » et « bar » :

#include <iostream>

struct Implementation {
    void foo() {}
    void bar() {}
};

template<typename Impl>
struct InterfaceA : private Impl {
    using Impl::foo;
};

template<typename Impl>
struct InterfaceB : private Impl {
    using Impl::bar;
};

int main() {
    InterfaceA<Implementation> objA;
    InterfaceB<Implementation> objB;

    objA.foo();
    objB.bar();
}
Enter fullscreen mode Exit fullscreen mode

Inversion du modèle de contrat

Comme vous pouvez le comprendre, l’Implementation (Derived) n’hérite plus d’un contrat : c’est le contrat lui-même qui lui est attribué. De cette façon, la classe Implementation devient totalement indépendante et découplée de toute structure contractuelle. Cela résulte en une sorte d’inversion, où désormais autant le contrat polymorphique que l’implémentation doivent s’accommoder pour que le contrat fonctionne. C’est un peu comme si vous passiez d’une relation où il n’y a qu’une seule personne qui décide à une relation où les deux ont leur mot à dire.

Une relation contractuelle symétrique

De cette façon, nous créons une relation polymorphique contractuelle symétrique. Par exemple, si une classe Interface expose la méthode « foo », la classe implémentation se doit obligatoirement d’avoir cette méthode d’accessible, sinon le contrat ne pourra pas être validé (le compilateur va tout simplement échouer car la méthode foo n’existe pas chez Impl). D’un autre côté, la classe implémentation elle-même peut exposer d’autres méthodes hors du contrat, mais ce ne sont que les méthodes du contrat qui seront accessibles depuis l’extérieur.

Extension au multi-héritage et composition de contrats

Maintenant, il y a le sujet du multi-héritage qui devient un peu ambigu. Ce modèle est principalement pensé pour une relation 1:1 entre interface et implémentation, mais il serait aussi possible de l’étendre au multi-héritage avec un peu de prudence. Ainsi, j’ai préparé un exemple de ce à quoi ça pourrait ressembler :

#include <iostream>

template<typename... Interfaces>
struct MultiInterface : public Interfaces... {};

struct Implementation {
    void foo() { std::cout << "foo\n"; }
    void bar() { std::cout << "bar\n"; }
};

template<typename Impl>
struct InterfaceA : private Impl {
    using Impl::foo;
};

template<typename Impl>
struct InterfaceB : private Impl {
    using Impl::bar;
};

int main() {
    MultiInterface<
        InterfaceA<Implementation>,
        InterfaceB<Implementation>
    > obj;

    obj.foo();
    obj.bar();
}
Enter fullscreen mode Exit fullscreen mode

Comme vous pouvez le remarquer, ça devient très lourd en lisibilité très rapidement. Il s’agirait d’utiliser des alias afin de réduire un peu la pression de ces signatures démoniaques. En ce qui concerne l’implémentation, ce n’est pas si sorcier que ça : nous utilisons un wrapper qui hérite et expose publiquement toutes les méthodes des contrats qui le forment. C’est simple et efficace, mais ce n’est pas tout à fait 100 % sécuritaire, car il y a toujours le risque que deux contrats exposent la même méthode, ce qui résulterait tout simplement en un appel ambigu.

En revanche, et je dois mettre l’emphase là-dessus, ce pattern n’est pas vraiment prévu pour une utilisation où le multi-héritage est primordial.

Applications concrètes du SIPP

Cette drôle de relation nous pousse à comprendre que cette forme de polymorphisme statique ouvre de nombreuses portes au niveau du design d’architectures orientées objet ultra performantes. Pour bien illustrer la chose, voici deux exemples très intéressants qui utilisent ce pattern.

Premier exemple : système de logging

Voici le premier exemple : il s’agit d’une simple implémentation d’un système de logging où le logger peut être vu sous deux états différents selon le contrat choisi :

#include<iostream>

struct ConsoleLogger {
    void write() {
        std::cout << "write to console\n";
    }

    void flush() {
        std::cout << "flush console\n";
    }

    void debug() {
        std::cout << "debug: console\n";
    }
};

template<typename Impl>
struct LoggingInterface : private Impl {
    using Impl::write;
    using Impl::flush;
};

template<typename Impl>
struct DebugLoggingInterface : private Impl {
    using Impl::write;
    using Impl::flush;
    using Impl::debug;
};

int main() {
    LoggingContract<ConsoleLogger> console_logger;

    console_logger.write();
    console_logger.flush();

    DebugLoggingContract<ConsoleLogger> debug_console_logger;

    debug_console_logger.debug(); // nouvelle méthode exposée
    debug_console_logger.write();
    debug_console_logger.flush();
}
Enter fullscreen mode Exit fullscreen mode

Dans cet exemple, vous pouvez voir que la première vue, celle de LoggingContract, n’expose que les méthodes « write » et « flush ». Similairement, la seconde vue « DebugLoggingContract » expose les mêmes méthodes que la première, mais cette fois-ci avec l’exposition de la méthode « debug ». De cette façon, il est possible de représenter un objet de deux façons totalement différentes sans même changer son implémentation. La classe « ConsoleLogger » n’a qu’à implémenter toutes les méthodes qu’elle souhaite, et ce sont les vues/contracts qui s’occupent de l’exposition. Enfin, cela permet de garder une API propre et extrêmement extensible, vu la simplicité des contracts : il ne suffit que d’ajouter un « using Impl::ma_methode; » afin qu’elle soit maintenant exposée.

Deuxième exemple : réinterprétation de contrat et cast

Ce second exemple est un peu plus technique et pourrait être un peu plus controversé que celui d’auparavant. Il ne semble pas être le plus intéressant, mais il requiert des manipulations un peu « hacky » qui ne sont pas destinées à tous. Je ne vais pas plus tarder et je vous le montre :

#include <iostream>
#include <type_traits>

struct ConsoleLogger {
    void write() {
        std::cout << "write\n";
    }

    void flush() {
        std::cout << "flush\n";
    }

    void debug() {
        std::cout << "debug\n";
    }
};

template<typename Impl>
struct LoggingContract : private Impl {
    using Impl::write;
    using Impl::flush;
};

template<typename Impl>
struct DebugLoggingContract : private Impl {
    using Impl::write;
    using Impl::flush;
    using Impl::debug;
};

int main() {
    LoggingContract<ConsoleLogger> console_logger;

    console_logger.write();
    console_logger.flush();

    auto& debug_console_logger =
        reinterpret_cast<DebugLoggingContract<ConsoleLogger>&>(console_logger);

    debug_console_logger.debug();

    static_assert(std::is_layout_compatible_v<
        LoggingContract<ConsoleLogger>,
        DebugLoggingContract<ConsoleLogger>
    >);
}
Enter fullscreen mode Exit fullscreen mode

Comme vous pouvez l’apercevoir, j’utilise un « reinterpret_cast » afin de transformer ma vue initiale utilisant « LoggingContract » en une autre vue « DebugLoggingContract ». À première vue, ça peut sembler un peu ésotérique, et ça l’est, mais il y a tout de même une certaine logique derrière cette manipulation.

Pour commencer, la question à se poser serait probablement : pourquoi est-ce que ça compile en premier lieu ? La raison est bien plus simple que vous l’imaginez, car en réalité, « LoggingContract » et « DebugLoggingContract » sont exactement la même chose en mémoire. D’ailleurs, j’ai testé à l’aide de l’utilitaire « std::is_layout_compatible_v » de la librairie standard afin d’en être certain, et le résultat fut « true ». En revanche, qui dit fonctionnel ne dit pas automatiquement sécuritaire. En effet, je vais éviter d’entrer trop dans les détails de comment l’ABI et le layout mémoire des classes fonctionnent sur la majorité des compilateurs, mais ce qu’il faut retenir, c’est qu’en mémoire, la vue (qui ne contient rien) qui hérite est exactement la même chose que sa base. Ça fonctionne très bien en pratique, mais ce n’est qu’une convention au sein de l’ABI et ce n’est pas quelque chose que le standard garantit.

En revanche, cette approche permet de transformer l'objet sans effectuer aucune opération à l'exécution, ce qui la rend quasi sans coûts. Cela permet principalement d'éviter d'avoir à effectuer une copie ou une assignation, ce qui est clairement non négligeable dans des projets où les performances sont critiques. C'est un coût à payer et c'est à vous de voir s'il convient à votre situation.

Outre tous ces malheureux détails, ce cast permet principalement de réinterpréter la vue d’un objet sans en changer sa nature. En fait, le « debug_console_logger » issu du « reinterpret_cast » pointe sur l’exact même objet que la vue « LoggingContract » originale pointait. Tout cela signifie que nous pouvons réinterpréter le contrat polymorphique d’un objet à la compilation, et je trouve ça extrêmement intéressant.

Troisième exemple : conversion sécuritaire entre vues

Ce troisième et dernier exemple est destiné à adresser les problèmes de sécurité du dernier exemple. En effet, je n'ai pas voulu placer cet exemple avant l'autre, car il traite de techniques un peu différentes que le pattern de base. Je vous montre :

#include <iostream>
#include <type_traits>

struct ConsoleLogger {
    ConsoleLogger() = default;

    ConsoleLogger& operator=(const ConsoleLogger& other) {
        return *this;
    }

    void write() {
        std::cout << "write\n";
    }

    void flush() {
        std::cout << "flush\n";
    }

    void debug() {
        std::cout << "debug\n";
    }
};

template<typename Impl>
struct LoggingContract : private Impl {
    explicit LoggingContract(Impl& m) : Impl(m) {} // afin d'assigner la base à la création

    using Impl::Impl;

    using Impl::write;
    using Impl::flush;
};

template<typename Impl>
struct DebugLoggingContract : private Impl {
    explicit DebugLoggingContract(Impl& m) : Impl(m) {} 

    using Impl::Impl;

    using Impl::write;
    using Impl::flush;
    using Impl::debug;
};

int main() {
    ConsoleLogger console_logger_impl;

    LoggingContract<ConsoleLogger> console_logger{ console_logger_impl }; // il supporte la construction à l'aide du type de l'implémentation

    console_logger.write();
    console_logger.flush();

    DebugLoggingContract<ConsoleLogger> debug_console_logger;

    debug_console_logger = console_logger_impl; // il supporte aussi l'assignation du type de l'implémentation

    debug_console_logger.debug();
}

Enter fullscreen mode Exit fullscreen mode

Comme vous pouvez l'apercevoir, cette version a, elle aussi, comme principal but de permettre l'assignation d'un objet à différentes vues. Elle le fait en utilisant un constructeur de délégation se situant dans la vue et qui lui permet de recevoir un objet du type 'Impl' afin de l'assigner en tant que base. De plus, elle expose les méthodes spécifiques de l'implémentation avec 'using Impl::Impl', qui, dans notre exemple ici, permet d'exposer l'opérateur d'assignation.

Il faut savoir que le mécanisme utilisé ici est totalement sécuritaire et prévu par le langage. Cependant, bien qu'il semble être une meilleure option que le dernier exemple, il présente tout de même un très gros problème : le coût en performance. Effectivement, le passage de paramètres par constructeur et l'utilisation de l'opérateur d'assignation ajoutent un coût supplémentaire lors de l'exécution du programme. De ce fait, ce n'est pas la fin du monde, mais dans le contexte d'un système où ce patron est massivement utilisé, ça pourrait commencer à coûter très cher. Enfin, il apporte aussi un certain coût en complexité et en lisibilité, ce qui n'est pas négligeable, car les éléments ajoutés dans cette version seront présents dans chacune des vues et des implémentations du système.

Conclusion finale

Pour conclure, je souhaiterais adresser quelques mots aux personnes qui se sont rendues jusqu’ici dans l’article. Déjà, merci d’avoir lu mon travail, j’en suis extrêmement reconnaissant.

Ensuite, je dois vraiment mettre au clair que je ne prétends pas avoir inventé un pattern. Je ne fais que mettre en lumière une variation très intéressante de choses qui existent déjà. D’ailleurs, j’avais aussi comme objectif de donner un nom à cette technique, et je ne sais pas vraiment comment la communauté C++ réagit à ce genre de choses, mais je le fais avec de bonnes motivations.

Je vois de l’avenir dans ce pattern et je crois vraiment qu’il y a quelque chose de vraiment plus grand à en tirer. Bon, c’est ainsi que se termine mon second article à vie. Il fut beaucoup moins technique que mon premier, et c’était intentionnel. Vulgariser est un art, et il est la clé de l’apprentissage.

Ressources et lecture complémentaire

Top comments (0)