De C# à C++ : Pointer to implementation, un design-pattern très utile en C++
Une des grandes différences entre les langages C++ et C# concerne les déclarations associées aux code que l’on écrit. En effet, en C#, lorsque l’on écrit la définition d’une classe, sa déclaration est automatiquement extraite à la compilation, et est inclue dans les métadonnées de l’assembly générée. En C++, les binaires générés ne contiennent pas de métadonnées, et il est donc nécessaire d’exposer les déclarations de nos classes à tout code voulant les utiliser à part. C’est la fonction première des fichiers .h : déclarer ce qui est présent dans le résultat d’une compilation C++.
Voyons un exemple de déclaration de classe d’un client twitter :
#pragma once #include "tweet.h" // déclaration d'un tweet : OK car exposé en public #include <ppltasks.h> // déclaration des tasks ppl : OK car les opérations asynchrones sont exposées par des tasks ppl #include <vector> // déclaration du conteneur std::vector : ok car la liste des tweets sera exposé dans un vector #include "http_client.h" // client http : :( détail d'implémentation non publique #include "json.h" // librairie json casablanca : :( détail d'implémentation non publique class TwitterClient { private: web::http::client::http_client _httpClient; Tweet buildTweetFromJson(const web::json::value& json); public: TwitterClient(void); ~TwitterClient(void); concurrency::task<std::vector<Tweet>> searchTweetsAsync(const std::wstring& subject); };
Ce fichier .h pose un certain nombre de problèmes. En effet, pour pouvoir autoriser un fichier .cpp à utiliser une classe, le compilateur doit connaitre la liste des fonctions exposées au publique par cette classe, mais aussi la taille en mémoire d’une instance de cette classe. Pour cela, le compilateur doit connaître la liste de toutes les variables membres de la classe Y COMPRIS LES VARIABLES MEMBRES PRIVEES ! Ce problème peut déjà faire hurler les puristes de l’encapsulation, mais ca reste encore acceptable. Mais il y’a largement pire : pour connaître la taille à réserver pour une variable membre, le compilateur doit avoir accès à la déclaration du type de cette variable membre. Ce qui oblige à faire un #include du header correspondant à chaque type de variable membre (ainsi qu’à ceux des types des paramètres des méthodes privées d’ailleurs).
Cela a des impacts très importants sur plusieurs points :
- Plus le nombre de headers inclus (directement ou indirectement) par un fichier de code, plus on a de risques potentiels de collisions de noms
- Plus un programme grandit, plus le graphe de dépendances des fichiers .h grandit, et cela peut produire des lenteurs à la compilation, ainsi que de potentielles références cycliques difficiles à résoudre
- Plus les dépendances sont grandes entre le code consommant une classe et les détails d’implémentation de cette classe, plus on obtient des temps de compilation horriblement longs à la moindre modification d’un champ ou d’une fonction privée.
Ces 2 derniers points sont particulièrement critiques pour les projets de grande ampleur. Notamment au niveau des temps de compilation. Si l’on prend l’exemple de notre client Twitter qui pour le moment repose sur le Rest SDK de Microsoft, et que l’on souhaite changer la stack http sur laquelle il est implémenté, comme cela demandera une modification du .h (modification des champs privées pour déclarer une variable membre différente, et inclusion d’un header différent par exemple), tous les autres fichiers .cpp du système incluant directement ou indirectement TwitterClient.h devront être recompilés. Sur des projets de grande taille, cela peut poser de gros problèmes et ralentir énormément les développements.
Pour contourner ce problème, on a bien sûr des solutions communes avec celles que l’on connait en C# (classe abstraites, interfaces, etc.), mais il en existe une que je trouve particulièrement élégante : il s’agit de l’utilisation du pattern “pointer to implementation”
Pointer to Implementation
Le principe du Pointer to Implementation repose sur un principe très simple : sur une architecture processeur particulière, un pointeur occupe toujours la même taille en mémoire quelque soit le type vers lequel il pointe. Le compilateur tient compte de cela, et nous autorise donc d’écrire ce genre de code :
#pragma once #include "tweet.h" // déclaration d'un tweet : OK car exposé en public #include <ppltasks.h> // déclaration des tasks ppl : OK car les opérations asynchrones sont exposées par des tasks ppl #include <vector> // déclaration du conteneur std::vector : ok car la liste des tweets sera exposé dans un vector class TwitterClient { private: class Impl; // forward déclaration only Impl* _pimpl; public: TwitterClient(void); ~TwitterClient(void); concurrency::task<std::vector<Tweet>> searchTweetsAsync(const std::wstring& subject); };
Ou même, pour éviter d’avoir à manipuler un pointeur nu (voir mon précédent post sur les smart pointers) :
#pragma once #include "tweet.h" // déclaration d'un tweet : OK car exposé en public #include <ppltasks.h> // déclaration des tasks ppl : OK car les opérations asynchrones sont exposées par des tasks ppl #include <vector> // déclaration du conteneur std::vector : ok car la liste des tweets sera exposé dans un vector #include <memory> // ok, tout code C++ moderne se doit d'utiliser les smart pointers de toute manière class TwitterClient { private: class Impl; // forward déclaration only std::unique_ptr<Impl> _pimpl; public: TwitterClient(void); ~TwitterClient(void); concurrency::task<std::vector<Tweet>> searchTweetsAsync(const std::wstring& subject); };
Notez que le .h n’a du coup plus besoin de forcer l’inclusion d’autres headers ne concernant que ses détails d’implémentation (“http_client.h”, “json.h”).
Côté cpp, on écrira alors:
#include "stdafx.h" #include "TwitterClient.h" #include "http_client.h" #include "json.h" using namespace web::json; using namespace web::http::client; class TwitterClient::Impl{ private: http_client _httpClient; Tweet buildTweetFromJson(const value& json){ // implémentation } public: concurrency::task<std::vector<Tweet>> searchTweetsAsync( const std::wstring& subject ) const { // implémentation } }; TwitterClient::TwitterClient(void) : _pimpl(new Impl()) { } TwitterClient::~TwitterClient(void) { } concurrency::task<std::vector<Tweet>> TwitterClient::searchTweetsAsync( const std::wstring& subject ) { return _pimpl->searchTweetsAsync(subject); }
On voit maintenant que les champs privées n’apparaissent plus dans le .h, mais seulement dans le fichier .cpp. Un changement d’implémentation (ajout de méthode ou champ privé dans la classe TwitterClient::Impl par exemple) ne demandera donc plus de recompiler les cpp dépendant de TwitterClient, mais simplement de les relinker (ce qui est beaucoup plus rapide). De plus, comme les méthodes “relaie vers _pimpl” comme TwitterClient::searchTweetsAsync sont situées dans la même “Compile unit” que leur implémentation véritable, il y’a de très très grande chances (en fait c’est quasi garanti) que celles-ci soient inlinées. (et donc que le coup d’appel de méthode soit le même que si il n’y avait pas de “pimpl”).
Pointer to Shared Implementation
Une variante de ce pattern est le pattern “Pointer to Shared Implementation”. Il s’agit d’exploiter le même mécanisme, mais cette fois-ci en rajoutant une mécanique particulière quand une instance de notre classe est copiée. En effet avec l’implémentation ci-dessus, chaque instance de TwitterClient est possesseur unique du pointeur vers son implémentation (d’ailleurs, pour compléter mon exemple de code, il faudrait que je surcharge le constructeur par copie de TwitterClient pour instancier une copie de l’implémentation). Lorsque l’on copie un objet créé avec le pattern “Pointer to Shared Implementation”, chaque copie pointe vers la même instance de l’implémentation. Cela permet de partager un état entre plusieurs instances. Pour illustrer cela, on peut prendre le concept des CancellationToken de ppl (c++) ou tpl (C#): ces CancellationToken sont toujours instanciés statiquement (en C# CancellationToken est une structure) et recopiés à chaque objet ayant besoin de répondre aux requêtes d’une même CancellationTokenSource. Pour que chaque instance vivante de CancellationToken soit correctement marquées comme annulées lorsque CancellationTokenSource::Cancel() est appelé, celle-ci partagent un pointeur vers la même instance d’implémentation.
Ainsi, pour écrire une version (simplifié) d’un cancellation token, on pourrait faire comme cela:
cancellation_token.h :
#pragma once #include<memory> class CancellationToken { private: class Impl; std::shared_ptr<Impl> _pimpl; public: CancellationToken(void); CancellationToken(const CancellationToken& copied); CancellationToken& operator = (const CancellationToken& other); ~CancellationToken(void); void Cancel(); bool isCancelled() const; };
(notez l’utilisation du shared_ptr pour matérialiser le “partage” d’implémentation)
cancellation_token.cpp
#include "stdafx.h" #include "cancellation_token.h" class CancellationToken::Impl{ private: bool _isCancelled; public: Impl() : _isCancelled(false){} void Cancel(){ _isCancelled = true; } bool isCancelled() const{ return _isCancelled; } }; CancellationToken::CancellationToken( void ) : _pimpl(new Impl()) { } CancellationToken::CancellationToken( const CancellationToken& copied ) : _pimpl(copied._pimpl) { } CancellationToken& CancellationToken::operator=( const CancellationToken& other ) { this->_pimpl = other._pimpl; return *this; } CancellationToken::~CancellationToken( void ) { } void CancellationToken::Cancel() { _pimpl->Cancel(); } bool CancellationToken::isCancelled() const { return _pimpl->isCancelled(); }
Notez que pour le constructeur par copie et l’opérateur d’assignement, on copie simplement le shared_ptr : la copie pointe donc vers la même instance, et la copie du shared_ptr permet de tracker le nombre de références actives (et de ne provoquer le delete de CancellationToken::Impl que lorsque plus aucun CollectionToken ne pointe dessus).
Update 1 : Pointer To Implementation et localité de la mémoire
Pointer to Implementation est un pattern intéressant, mais comme tout pattern, il a aussi ses inconvénients. Le principal étant le besoin d’instancier dynamiquement l’implémentation. Comme tout pattern, son utilisation doit donc être faite avec mesure. Par exemple, on évitera de l’utiliser dans des classes qui seront utilisés dans des tableaux ou vectors, car cette instanciation dynamique fait que les données de chaque instance vont se retrouver dispersées en mémoire même si tous les objets de classe “publiques” sont instanciés ensemble dans un tableau. On évitera aussi, pour des raisons de fragmentation mémoire d’utiliser ce pattern pour des objets à durée de vie très courte.
Commentaires