Tables de méthodes virtuelles
La puissance à l'état pur
Je deblaie tout de suite avec une exemple de header C++ :
class VtDynamicArray
{
public:
virtual long ResetDynamicArray (long _nElementSize) = 0;
virtual long InsertArrayElements (long _nIndex, long _nCount, void** _ppFirstAddress) = 0;
virtual long RemoveArrayElements (long _nIndex, long _nCount) = 0;
virtual long GetDynamicArrayLongs (long* _pEltSize, long* _pEltCount) = 0;
virtual long GetArrayElementAddress ( long _nIndex, void** _ppAddress) = 0;
virtual long DeleteThis () = 0;
};
long CreateDynamicArray (VtDynamicArray** _ppObject);On peut voir ci-dessus le plus typique des cas : un contrat défini dans une classe abstraite et une fonction d'instanciation de l'une de ses implémentations (il peut y en avoir d'autres). En C++ on appelle un peu abusivement "classe abstraite" toute classe ayant au moins une virtuelle pure, y compris les classes qui ont aussi des membres concrets, alors qu'un contrat est plus restrictif que cela, c'est une classe, disons, "purement abstraite". Dans le source de l'implémentation on a biensûr une classe concrète dérivée de VtDynamicArray, mais celle-ci ne doit pas être connue par le programme client, ce dernier se contente simplement de l'en-tête tel qu'il est ci-dessus.
Le composant instancié par CreateDynamicArray est sensé gérer un tableau dynamique d'éléments de même taille accessibles directement par adresse. Les valeurs de retours des fonctions sont toutes des codes d'erreur ou zéro en cas de succès. La méthode DeleteThis sert à demander à l'objet de s'autodétruire lorsqu'il n'est plus utilisé. Voici comment on appelle les fonctions :
VtDynamicArray* l_pObject = 0;
// création de l'objet en obtenant un pointeur sur le contrat
CreateDynamicArray (&l_pObject);
if (l_pObject)
{
long l_nError = 0;
long* l_pLong = 0;
l_pObject->ResetDynamicArray (sizeof (long));
// insertion de 2000 éléments à zéro
l_nError = l_pObject->InsertArrayElements (0, 2000, (void**) &l_pLong);
if (! l_nError)
*(l_pLong + 1000) = 123; // autres utilisations de l'objet
l_pObject->RemoveArrayElements (100, 20);
// ... etc jusqu'à plus besoin
l_pObject->DeleteThis (); // destruction de l'objet
}Là où les choses divergent radicalement d'une programmation objet "classique", c'est lorsqu'on écrit une seconde implémentation, pouvant être très différente de la première, mais qui n'empêche nullement la cohabitation des deux variantes. L'en-tête donné plus haut peut très bien contenir à la fin ceci :
// ...
long CreateDynamicArray (VtDynamicArray** _ppObject);
long CreateFastDynamicArray (VtDynamicArray** _ppObject);Une seconde implémentation accessible avec la deuxième fonction d'instanciation peut, par exemple, optimiser les petits tableaux en se servant de buffers alloués globalement au préalable. Un programme client peut utiliser l'une, l'autre ou les deux implémentations en même temps, mais seules les lignes de code qui instancient sont concernées par ces choix, tout le reste du code étant indépendant de ces considérations.
Les tables virtuelles, appelées aussi VTables, ne sont pas équivalentes à des tableaux d'adresses de fonctions globales. Dans l'exemple, le contrat comporte 6 fonctions, donc la VTable est bien un tableau de 6 adresses de fonctions, mais ces fonctions sont des méthodes de classe, c'est-à-dire que dans l'implémentation de chacune d'entre elles on dispose du this de l'instance de la classe concrète (et donc de tout ce qui est accessible avec), en dépit du fait que le source client qui tient un VtDynamicArray* ne connaît pas cette classe.
Dans le dessin ci-dessus, on voit comment les programmes clients accèdent à des adresses de VTable à travers les barrières d'abstraction, sans accéder aux instances des classes concrètes. La VTable dans le dessin contient, en principe, les adresses des codes exécutables des méthodes, cependant le compilateur peut y placer d'autres adresses de codes générés qui mènent indirectement aux codes des méthodes, donc il ne faut pas essayer de manipuler le contenu binaire d'une VTable, de même qu'il ne faut pas préjuger de la façon dont le code généré par le compilateur retrouve l'adresse du this de l'instance.
COM
Dans l'exemple ci-dessus la "vie" d'une instance se passe entre l'appel à la fonction d'instanciation et l'appel à sa méthode DeleteThis, ce qui oblige une utilisation "linéaire" maîtrisant ces deux moments de l'exécution. Lorsqu'on a besoin d'une instance à une échelle plus large, où un pointeur VtDynamicArray* voyage en tant qu'argument de fonction on ne sait plus très bien à quel endroit détruire l'objet. Pour résoudre cette difficulté, on peut munir l'instance d'un compteur de références. A chaque fois qu'on a besoin de l'instance on incrémente son compteur, puis quand on n'en veut plus on décrémente le compteur. La dernière décrémentation provoquera l'autodestruction de l'objet, sans que l'on soit obligé dans le source de connaître précisément cette dernière décrémentation.
La norme COM (Component Object Model) de Windows apporte une solution standard à ce problème de compteur de références, avec le contrat IUnknown dont les méthodes AddRef et Release permettent aux programmes clients d'agir sur un compteur abstrait. Dans le même temps, ce standard prend en compte les objets qui implémentent plusieurs contrats et définit un moyen d'obtenir, à partir d'une VTable, les VTables des autres contrats, avec la méthode QueryInterface. L'innovation majeure ici est l'identification des contrats par une donnée binaire de 16 octets, appelée GUID (globally unique identifier) dont l'interprétation est unique dans le monde. En effet, la fonction système CreateGUID génère un exemplaire en utilisant le numéro de série d'un composant matériel (carte réseau) et l'état de l'horloge, ce qui fait qu'on ne peut pas générer des GUIDs identiques sur deux ordinateurs différents dans le monde, ni sur le même ordinateur à des moments différents (à moins de le faire exprès, ce qui n'a aucun intérêt). De cette façon, un contrat COM ne risque jamais d'être confondu avec un autre et, par ailleurs, on peut détourner l'utilisation des GUIDs à d'autres fins que de "faire du COM". Voici un exemple de GUID, au format "registry", c'est-à-dire une chaîne entre accolades de 32 caractères hexadécimaux (représentant les 16 octets) avec quelques tirets :
{48790A65-ABAC-4FC1-9D96-5E559156B5A0}
généré avec l'utilitaire guidgen.exe de Visual Studio 6. Disons que c'est l'identificateur d'un nouveau contrat COM qu'on appelera IDynamicArray et cet identificateur sera codé "en dur" dans un source C++ ainsi :
#include <windows.h>
// {48790A65-ABAC-4fc1-9D96-5E559156B5A0}
extern "C" const GUID IID_IDynamicArray = { 0x48790a65, 0xabac, 0x4fc1, { 0x9d, 0x96, 0x5e, 0x55, 0x91, 0x56, 0xb5, 0xa0 } };On définit l'interface (le contrat) IDynamicArray dans un header ainsi :
#include <windows.h>
extern "C" const GUID IID_IDynamicArray;
class IDynamicArray : public IUnknown
{
public:
virtual HRESULT PASCAL ResetDynamicArray (long _nElementSize) = 0;
virtual HRESULT PASCAL InsertArrayElements (long _nIndex, long _nCount, void** _ppFirstAddress) = 0;
virtual HRESULT PASCAL RemoveArrayElements (long _nIndex, long _nCount) = 0;
virtual HRESULT PASCAL GetDynamicArrayLongs (long* _pEltSize, long* _pEltCount) = 0;
virtual HRESULT PASCAL GetArrayElementAddress ( long _nIndex, void** _ppAddress) = 0;
};
// répond avec succès si en _iid reçoit IID_IUnknown ou IID_IDynamicArray
HRESULT CreateDynamicArray (REFGUID _iid, void** _ppObject);Dans cet exemple de sources je m'inspire du code généré par le compilateur MIDL de Visual Studio 6 à partir d'un fichier en IDL (interface definition language) Microsoft. Toutefois, ici je n'utilise absolument pas IDL, tout ceci est du C++ qui réutilise le type structure GUID, le type REFGUID (référence GUID), le type HRESULT (en fait un long), la classe purement abstraite IUnknown, la variable globale IID_IUnknown et la convention d'appel PASCAL pour les méthodes, tout cela étant du Windows SDK de base.
Une implémentation de ce contrat, accessible par CreateDynamicArray, ressemblerait en partie à ceci (aux tests d'erreurs près) :
#include <windows.h>
#include "DynamicArray.h" // le header partagé avec les clients
class ImplDynamicArray : public IDynamicArray
{
public:
virtual HRESULT PASCAL QueryInterface (REFGUID _iid, void** _ppObject);
virtual ULONG PASCAL AddRef ();
virtual ULONG PASCAL Release ();
virtual HRESULT PASCAL ResetDynamicArray (long _nElementSize);
virtual HRESULT PASCAL InsertArrayElements (long _nIndex, long _nCount, void** _ppFirstAddress);
virtual HRESULT PASCAL RemoveArrayElements (long _nIndex, long _nCount);
virtual HRESULT PASCAL GetDynamicArrayLongs (long* _pEltSize, long* _pEltCount);
virtual HRESULT PASCAL GetArrayElementAddress ( long _nIndex, void** _ppAddress);
private:
long m_nRefCount;
// ... reste omis ici
};
HRESULT ImplDynamicArray::QueryInterface (REFGUID _iid, void** _ppObject)
{
if ((_iid == IID_IUnknown) | | (_iid == IID_IDynamicArray))
{
*_ppObject = (void*) (IDynamicArray*) this;
AddRef ()
return S_OK;
}
else
return E_NOINTERFACE;
}
ULONG ImplDynamicArray::AddRef ()
{
return (++ m_nRefCount);
}
ULONG ImplDynamicArray::Release ()
{
if (-- m_nRefCount)
return m_nRefCount;
else
delete this; // autodestruction
return 0;
}
// ... reste de l'implémentation omis
//
HRESULT CreateDynamicArray (REFGUID _iid, void** _ppObject);
{
ImplDynamicArray* l_pObject = new ImplDynamicArray;
HRESULT l_nRes = l_pObject->QueryInterface (_iid, _ppObject);
if (l_nRes != S_OK)
delete l_pObject;
return l_nRes;
}Encore une fois, ce qu'on entend généralement par COM c'est beaucoup plus que ce que je fais ici. Tout ce dont je parle dans ce document est du C++ et je réutilise simplement le principe de base de COM, et non pas les API système, la registry et tout le reste de la technique...
En passant, le langage Java propose une solution élégante de remplacement des trois méthodes de IUnknown avec le mot-clé implements qui permet de dériver explicitement des contrats et pas autre chose (en C++ on est limité par la discipline du codeur). L'équivalent du QueryInterface est réalisé avec un simple casting du type de contrat, qui génére une exception si le contrat demandé n'est pas implémenté, et le compteur de références est géré automatiquement, bien entendu. Mais je ne pense pas qu'il y ait un moyen intégré d'identification universelle des contrats.
Dérivations
Ce que j'appelerais volontiers un composant est un "objet" (plutôt une sorte de classe d'objets) pouvant être instancié à l'aide d'une fonction globale d'instanciation, communiquant avec son "extérieur" exclusivement par l'intermédiaire d'interfaces de type COM, et qui s'autodétruit lorsque sont compteur de références "tombe" à zéro. Un composant peut implémenter plusieurs contrats. Un même contrat peut être implémenté par plusieurs composants.
Un composant qui implémente plusieurs contrats est réalisé en C++ à l'aide de l'héritage multiple. Supposons que l'on a besoin d'un composant qui implémente IDynamicArray défini plus haut et un autre contrat IQuelqueChose dérivé aussi de IUnknown, dont la définition exacte n'est pas importante ici. L'implémentation d'un tel composant se fait dans une classe concrète telle que ci-dessous :
#include <windows.h>
#include <Contrats.h> // le header définissant les contrats
class ImplComponent : public IDynamicArray, public IQuelqueChose
{
public:
virtual HRESULT PASCAL QueryInterface (REFGUID _iid, void** _ppObject);
virtual ULONG PASCAL AddRef ();
virtual ULONG PASCAL Release ();
virtual HRESULT PASCAL ResetDynamicArray (long _nElementSize);
// ... etc
};
HRESULT ImplComponent::QueryInterface (REFGUID _iid, void** _ppObject)
{
if ((_iid == IID_IUnknown) | | (_iid == IID_IDynamicArray))
*_ppObject = (void*) (IDynamicArray*) this;
else if (_iid == IID_IQuelqueChose)
*_ppObject = (void*) (IQuelqueChose*) this;
else
return E_NOINTERFACE;
return S_OK;
}On est dans le cas particulier d'un héritage multiple où les classes de base ont elles-même IUnknown comme classe de base commune. De ce fait, le casting du composant en IUnknown* est ambigu lors de la compilation, parce que les VTables doivent être des tableaux d'adresses contigües. Une instance de la classe C++ ImplComponent est un bloc de mémoire contenant les adresses des VTables et les membres concrets. Je crois que le contenu de l'instance commence par l'adresse de la VTable de IDynamicArray, suivie de l'adresse de l'autre VTable, puis des membres concrets. L'opérateur sizeof sur la classe nous donne donc la taille totale de tout cela, c'est-à-dire la taille des adresses des VTables ajoutées à la taille de la partie concrète. Il y a ainsi deux "sous-VTables" IUnknown, en quelque sorte :
Ce stockage suggère qu'il y a deux implémentations de IUnknown, pourtant il n'en est rien. Le compilateur dispose d'une certaine marge de manoeuvre dans le code généré, mais il est contraint de faire en sorte que toutes les "cellules" d'une VTable (donc y compris celles des méthodes héritées) soient à des adresses consécutives en mémoire. Il y a bien deux "emplacements d'adresses" pour la méthode QueryInterface, par exemple, mais soit les valeurs des adresses sont identiques, soit elles mènent à des codes exécutables qui appellent finalement la seule méthode correspondante. Ainsi, pour répondre à IID_IUnknown, dans la méthode QueryInterface on est obligé de fournir l'une des VTables qui dérivent de IUnknown (et toujours la même à chaque demande IUnknown).
Il y a une différence fondamentale entre l'approche orientée objet et l'approche orientée contrats, et dans un sens, ces approches sont même opposées. Dans l'orienté objet on définit une nouvelle classe qui hérite d'autres classes concrètes, donc hérite de choses déjà faites. Dans l'approche orientée contrats, l'héritage est un handicap, puisqu'un contrat hériterait de choses à faire pour toutes les implémentations, immédiates et futures. Dans les contrats standard des ActiveX on rencontre pourtant des interfaces dérivées, jusqu'à deux ou trois fois en chaîne, je crois, mais ce sont simplement de petites erreurs de conception, à mon avis. L'héritage de IUnknown pour définir les contrats COM devrait être la seule exception ou l'une des rarissimes exceptions à la règle, règle qui consiste donc à ne jamais dériver un contrat mais plutôt à définir de nouveaux contrats dérivés de IUnknown, au fur et à mesure des besoins, et les implémenter dans des composants en usant de l'héritage multiple, comme dans l'exemple ci-dessus. Cela n'empêche pas, bien évidemment, d'implémenter des composants à l'aide d'autres composants, d'avoir des pointeurs contrats comme paramètres des méthodes et, donc, de bâtir de gros composants structurés, manipulables en "surfant" de contrat en contrat.
Les contrats en callback
Imaginons un composant permettant la gestion d'un tableau dynamique d'éléments de même taille, avec la possibilité que ces éléments ne soient pas linéaires. Il serait intéressant alors que le programme client utilisant les capacités dynamiques de ce tableau fournisse les fonctions permettant d'initialiser et de détruire les éléments (dont le composant ne connaît pas la structure). On peut faire en sorte que le composant appelle en "callback" les fonctions fournies par le client, du moment que le prototypage est défini côté composant (serveur).
Dans l'exemple ci-dessous, j'ai choisi de définir un contrat COM pour la manipulation du tableau et un contrat COM à fournir optionnellement par le client :
#include <windows.h>
extern "C" const GUID IID_IDynamicArrayElement;
extern "C" const GUID IID_INewDynamicArray;
class IDynamicArrayElement : public IUnknown
{
public:
virtual HRESULT PASCAL InitElement (void* _pElement) = 0;
virtual HRESULT PASCAL ClearElement (void* _pElement) = 0;
};
class INewDynamicArray : public IUnknown
{
public:
virtual HRESULT PASCAL ResetNewDynamicArray (long _nElementSize, IDynamicArrayElement* _pCallback, bool _nKeepRef) = 0;
virtual HRESULT PASCAL GetElementCallback (IDynamicArrayElement** _ppCallback) = 0;
virtual HRESULT PASCAL InsertArrayElements (long _nIndex, long _nCount, void** _ppFirstAddress) = 0;
virtual HRESULT PASCAL RemoveArrayElements (long _nIndex, long _nCount) = 0;
virtual HRESULT PASCAL GetDynamicArrayLongs (long* _pEltSize, long* _pEltCount) = 0;
virtual HRESULT PASCAL GetArrayElementAddress ( long _nIndex, void** _ppAddress) = 0;
};
long CreateNewDynamicArray (REFGUID _iid, void** _ppObject);Le contrat IDynamicArrayElement n'est pas implémenté par le composant, mais par un autre composant fourni par le programme client. Si le client souhaite un tableau d'élements simples, il passe un pointeur nul dans la fonction ResetNewDynamicArray et utilise le composant d'une manière plus basique. Si les éléments sont des structures avec des pointeurs vers d'autres blocs de mémoire, le client peut fournir la callback de façon à ce que l'implémentation du composant appelle InitElement à chaque insertion et ClearElement à chaque suppression, ainsi que lors de son autodestruction (s'il reste des éléments). La présence du paramètre _nKeepRef dans la méthode ResetNewDynamicArray nécessite quelques explications détaillées...
Un problème courant se pose quand le programme client est un composant plus gros, avec son propre compteur de référence, qui peut transmettre un INewDynamicArray* (avec un AddRef dessus dans le foulée) à un autre composant. Cet imbroglio permis par les références des contrats COM fait qu'on peut se retrouver dans des situations inédites, avec un composant qui peut survivre au composant qui l'a créé. Ainsi, le composant gérant le tableau dynamique pourrait continuer d'exister pendant que son créateur, qui implémente éventuellement la callback, est détruit. Pour éviter un plantage dans cette situation, le composant serveur devrait appeler un AddRef sur la callback pour être sûr qu'elle reste disponible pendant la durée de vie du tableau.
On pourrait en déduire qu'il suffirait d'incrémenter le compteur de références de la callback systématiquement. Or, si on fait cela, un autre problème peut surgir, parce que le composant client "tient" en général une référence sur le tableau dynamique qu'il crée et on risque une situation de deadlock où chacun attendra le Release de l'autre pour s'autodétruire.
Finalement, le programme client est le seul à savoir ce qui se passe, puisqu'il connaît le composant tableau dynamique et sait ce qu'il en fera, alors que ce dernier ne sait pas qui sont ses clients. C'est donc à l'appelant de décider si le serveur doit incrémenter ou non le compteur de références de la callback, d'où le paramètre booléen _nKeepRef.
Au lieu de définir une interface COM pour la callback, on pourrait tout aussi bien définir deux prototypes de fonctions globales, ou alors une interface "de base" qui ne dérive pas de IUnknown. C'est parfaitement jouable, biensûr, cela dit, en poursuivant dans la même logique, on pourrait se passer aussi de tout contrat ou classe abstraite, voire de toute classe. Le contrat IDynamicArrayElement n'est pas une fin en lui-même, il est seulement le moyen le plus prometteur en terme d'extensions fonctionnelles et les contraintes qu'implique sa définition peuvent payer à moyen terme.
Les évolutions imprévues
Lorsque le programme client ne fournit pas de callback, le composant est utilisé dans le cas particulier plus simple correspondant à l'exemple du contrat IDynamicArray plus dépouillé. Le nouveau contrat peut donc être considéré comme une extension fontionnelle de l'ancien. On pourrait se dire alors qu'il aurait suffit de définir d'emblée le nouveau contrat au lieu de se "tromper" avec l'ancien. Certes, mais cela ne s'est pas passé comme ça, c'est tout. De nombreuses utilisations de l'ancien contrat peuvent être déjà opérationnelles dans tous les recoins d'un logiciel, avant de s'apercevoir qu'il y avait mieux à faire et que pour développer certaines extensions on est obligé de reconcevoir cette partie-là.
Tout cela est normal. Il faut renoncer à l'idée de prévoir à l'avance tous les cas de figure et, plutôt, se contenter d'anticiper de son mieux. La qualité de la programmation ne dépend pas tant de l'anticipation que de ses possibilités de marche-arrière, de reconceptions partielles et de modifications inattendues.
Supposons donc qu'un "vieux" composant gérant un tableau dynamique à travers le contrat IDynamicArray soit déjà instancié à de nombreux endroits dans les sources et que l'on désire le remplacer par le nouveau composant implémentant INewDynamicArray. Lorsque la modification n'est pas de trop grande envergure, comme dans cette exemple simple, on peut se permettre de modifier les sources à l'aveuglette et de corriger à la compilation finale les quelques fautes de frappes. Mais dans certains cas, une telle modification est un chantier délicat et dangereux, voire démotivant pour les équipes. Voyons alors comment toucher les dividendes des efforts fournis pour définir et implémenter des contrats purement abstraits...
L'ancien contrat est un cas particulier du nouveau, donc le nouveau composant peut aisément implémenter les deux contrats (dans la pratique, ce n'est pas toujours aussi facile, mais personnellement je n'ai pas encore rencontré de cas insurmontable). Voici le début de la classe concrète dans l'implémentation :
#include "NewComponent.h"
class ImplNewDynamicArray : public INewDynamicArray, public IDynamicArray
{
public:
virtual HRESULT PASCAL QueryInterface (REFGUID _iid, void** _ppObject);
virtual ULONG PASCAL AddRef ();
virtual ULONG PASCAL Release ();
virtual HRESULT PASCAL ResetNewDynamicArray (long _nElementSize, IDynamicArrayElement* _pCallback, bool _nAddRef);
virtual HRESULT PASCAL GetElementCallback (IDynamicArrayElement** _ppCallback);
virtual HRESULT PASCAL InsertArrayElements (long _nIndex, long _nCount, void** _ppFirstAddress);
virtual HRESULT PASCAL RemoveArrayElements (long _nIndex, long _nCount);
virtual HRESULT PASCAL GetDynamicArrayLongs (long* _pEltSize, long* _pEltCount);
virtual HRESULT PASCAL GetArrayElementAddress ( long _nIndex, void** _ppAddress);
virtual HRESULT PASCAL ResetDynamicArray (long _nElementSize);
private:
long m_nRefCount;
// ... reste de la déclaration
};Il n'y a guère que la méthode ResetDynamicArray qui s'ajoute et, en plus, on l'implémente très facilement... Les autres méthodes virtuelles figureront dans les deux VTables, mais auront des implémentations communes aux deux contrats. Il faut juste ne pas oublier l'héritage multiple et la prise en compte des contrats dans QueryInterface.
A ce stade, les deux composants, nouveau et ancien, peuvent cohabiter dans le projet sans se gêner l'un l'autre. On peut commencer à remplacer les appels à l'ancienne fonction d'instanciation (CreateDynamicArray) par des appels à la nouvelle fonction (CreateNewDynamicArray), tout en continuant de demander l'ancien contrat IDynamicArray. Ce remplacement peut se faire occurrence par occurrence, tout en disposant à chaque pas d'une version stable du produit. Au moment où l'ancien composant n'est plus instancié nulle part, on peut le supprimer du projet. La seconde phase, plus délicate, consiste à utiliser le nouveau contrat INewDynamicArray à la place de l'ancien, en commençant par les endroits où il y en a le plus besoin. A chaque pas, on dispose toujours d'une version stable (et même livrable) et certains remplacements peuvent être reportés à plus tard. Ce n'est que lorsque toutes les allusions à l'ancien contrat ont disparu que l'on peut enfin supprimer sa déclaration, ainsi que sa dernière implémentation dans le nouveau composant.
Cela ne fait que quelques années que je pratique pleinement l'approche orientée contrats, mais j'ai déjà eu l'occasion, à plusieurs reprises, de mettre en oeuvre cette technique de migration en douceur, à diverses échelles.