Pour permettre la polyformité dans C++, le langage utilise une technique de liaison dynamique. Le cœur de cette méthode repose sur la table des fonctions virtuelles (appelée ci-après « table virtuelle »). Cet article explique comment les tables virtuelles permettent d'assurer la liaison dynamique.
Structure des tables virtuelles par classe
Chaque classe qui contient au moins une fonction virtuelle possède sa propre table virtuelle. Lorsqu'une classe (A) hérite d'une autre classe (B), la classe A hérite également de l'accès aux fonctions de la classe B. Si une classe de base contient des fonctions virtuelles, alors les classes dérivées peuvent également appeler ces fonctions virtuelles. En d'autres termes, une classe héritant d'une classe de base contenant des fonctions virtuelles possède également sa propre table virtuelle.
Voici un exemple de code montrant une classe A avec deux fonctions virtuelles :
class A {
public:
virtual void vfunc1();
virtual void vfunc2();
void func1();
void func2();
private:
int m_data1, m_data2;
};
La table virtuelle de la classe A est illustrée dans la figure 1. Cette table est un tableau de pointeurs vers les fonctions virtuelles, chaque élément correspondant à un pointeur de fonctoin virtuelle. Il est important de noter que les fonctions normales (non virtuelles) ne passent pas par la table virtuelle lors de leur appel, donc leurs pointeurs ne sont pas inclus dans la table.
Les entrées de la table virtuelle sont initialisées pendant la phase de compilation, ce qui signifie que la table peut être construite dès la compilation du code.
Pointeur de la table virtuelle
La table virtuelle appartient à la classe et non à un objet spécifique. Une seule table virtuelle est nécessaire pour une classe donnée, et tous les objets de cette classe partagent la même table virtuelle. Pour identifier la table virtuelle utilisée par un objet, celui-ci contient un pointeur vers la table virtuelle. Le compilateur ajoute automatiquement un champ nommé __vptr à la classe pour pointer vers la table virtuelle. Ainsi, lors de la création d'un objet, ce pointeur est automatiquement configuré pour pointer vers la table virtuelle de la classe.
Figure 2 : Objet et sa table virtuelle
Comme mentionné précédemment, si une classe de base contient des fonctions virtuelles, les classes dérivées héritent également de cette table virtuelle, donc les objets de ces classes dérivées possèdent également un pointeur vers leur table virtuelle respective.
Liaison dynamique (polyformisme dynmaique)
Note : La plupart de cet article est reproduit d'une source originale. L'auteur original désigne la liaison dynamique comme étant un aspect du polymorphisme dynamique en C++, contrairement à la liaison statique qui correspond au polymorphisme statiqeu via les surcharges de fonctions ou les modèles.
1. La liaison statique, également connue sous le nom de liaison précoce (early binding), est déterminée à la compilation du programme, appelée aussi polymorphisme statique. Exemples : surcharge de fonctions, modèles de fonctions. 2. La liaison dynamique, aussi appelée liaison tardive (late binding), se produit lors de l'exécution du programme, selon le type réel de l'objet, pour exécuter la fonction appropriée. Cela est appelé aussi polymorphisme dynamique.
Pour comprendre comment C++ utilise les tables virtuelles et les pointeurs de table virtuelle pour implémenter la liaison dynamique, examinons le code suivant :
class A
{
public:
virtual void vfunc1();
virtual void vfunc2();
void func1();
void func2();
private:
int m_data1, m_data2;
};
class B : public A
{
public:
virtual void vfunc1();
void func1();
private:
int m_data3;
};
class C: public B
{
public:
virtual void vfunc2();
void func2();
private:
int m_data1, m_data4;
};
La classe A est la classe de base, B hérite de A, et C hérite de B. Les modèles d'objets des classes A, B et C sont illustrés dans la figure 3.
Figure 3 : Modèles d'objets des classes A, B et C
Chacune de ces classes possède des fonctions virtuelles, donc le compilateur crée une table virtuelle pour chacune : A vtbl, B vtbl et C vtbl. Chaque objet possède un pointeur de table virtuelle (__vptr) pointant vers la table virtuelle de sa classe.
La classe A contient deux fonctions virtuelles, donc A vtbl contient deux pointeurs vers A::vfunc1() et A::vfunc2().
La classe B hérite de A, elle peut donc appeler les fonctions de A. Étant donné que B redéfinit vfunc1(), B vtbl contient des pointeurs vers B::vfunc1() et A::vfunc2().
La classe C hérite de B, elle peut appeler les fonctions de B. Étant donné que C redéfinit vfunc2(), C vtbl contient des pointeurs vers B::vfunc1() (la version la plus proche dans la chaîne d'héritance) et C::vfunc2().
En supposant la création d’un objet de la classe B, il contient un pointeur __vptr pointant vers la table virtuelle de B :
int main()
{
B bObject;
}
Définissons maintenant un pointeur de type A pointant vers bObject. Bien que ce pointeur soit de type A*, il peut accéder au pointeur __vptr de l'objet car ce dernier fait partie de la classe de base. Ce pointeur __vptr pointe vers la table virtuelle de B, donc le pointeur p peut accéder à B vtbl.
int main()
{
B bObject;
A *p = & bObject;
}
Lorsque nous appelons p->vfunc1(), que se passe-t-il ?
int main()
{
B bObject;
A *p = & bObject;
p->vfunc1();
}
Lors de l’exécution de p->vfunc1(), le compilateur détecte que p est un pointeur et que vfunc1() est une fonction virtuelle. Il suit ensuite les étapes suivantes :
- Utilise le pointeur __vptr de p pour accéder à la table virtuelle de l’objet bObject. Même si p est de type A*, le pointeur __vptr est une partie de la classe de base, donc on peut y accéder via p->__vptr.
- Recherche la fonction appelée dans la table virtuelle. Étant donné que la table virtuelle est créée à la compilation, le compilateur peut localiser la fonction dans la table.
- Appelle la fonction via le pointeur trouvé dans la table virtuelle. Selon la figure 3, le premier élément de B vtbl pointe vers B::vfunc1(), donc p->vfunc1() appelle effectivement B::vfunc1().
Que se passe-t-il si p pointe vers un objet de la classe A ?
int main()
{
A aObject;
A *p = &aObject;
p->vfunc1();
}
Lors de la création de aObject, son pointeur __vptr est configuré pour pointer vers A vtbl. Donc p->__vptr pointe vers A vtbl. Le pointeur de vfunc1 dans A vtbl pointe vers A::vfunc1(), donc p->vfunc1() appelle A::vfunc1().
On peut résumer ces trois étapes par l'expression suivante :
Grâce à ces tables virtuelles, même avec un pointeur de classe de base, il est possible d'appeler correctement la fonction virtuelle de l'objet réel.
Cette méthode d'appel via la table virtuelle est appelée liaison dynamique, et le comportement observé est le polymorphisme dynamique. À l'inverse, les appels de fonctions traditionnels sont des liaisons statiques, déterminées à la compilation.
Quand l'appel d'une fonction est soumis à une liaison dynamique ? Trois conditions doivent être remplies :
- Appel via un pointeur ou référence de la classe dérivée
- Conversion de type (upcast) : conversion d'une classe dérivée en classe de base
- Appel d'une fonction virtuelle
Si ces trois conditions sont remplies, le compilateur transforme l'appel en liaison dynamique, utilisant le mécanisme de la table virtuelle.
Tables virtuelles dans les cas d’héritance simple et multiple
Héritance simple
class Base
{
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
private:
int a;
};
class Derive :public Base
{
public:
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
virtual void func4() { cout << "Derive::func4" << endl; }
private:
int b;
};
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
cout << " Table address>" << vTable << endl;
for (int i = 0; vTable[i] != nullptr; ++i)
{
printf(" Function %d address :0X%x,->", i, vTable[i]);
VFPTR f = vTable[i];
f();
}
cout << endl;
}
int main()
{
Base b;
Derive d;
VFPTR* vTableb = (VFPTR*)(*(int*)&b);
PrintVTable(vTableb);
VFPTR* vTabled = (VFPTR*)(*(int*)&d);
PrintVTable(vTabled);
return 0;
}
Le résultat de l'exécution correspond approximativement à ce qui a été décrit.
Héritance multiple
class Base1
{
public:
virtual void func1() { cout << "Base1::func1" << endl; }
virtual void func2() { cout << "Base1::func2" << endl; }
private:
int b1;
};
class Base2
{
public:
virtual void func1() { cout << "Base2::func1" << endl; }
virtual void func2() { cout << "Base2::func2" << endl; }
private:
int b2;
};
class Derive : public Base1, public Base2
{
public:
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
private:
int d1;
};
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
cout << " Table address>" << vTable << endl;
for (int i = 0; vTable[i] != nullptr; ++i)
{
printf(" Function %d address :0X%x,->", i, vTable[i]);
VFPTR f = vTable[i];
f();
}
cout << endl;
}
int main()
{
Derive d;
VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
PrintVTable(vTableb1);
VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
PrintVTable(vTableb2);
return 0;
}
En observant la figure :
- Dans une héritance multiple, les fonctions virtuelles non redéfinies sont placées dans la table virtuelle de la première classe héritée.
- Le nombre de tables virtuelles correspond au nombre de classes de base ayant des fonctions virtuelles.
Résumé
L'encapsulation, l'héritage et le polymorphisme constituent les trois caractéristiques fondamentales de la programmation orientée objet. Parmi elles, le polymorphisme est essentiel. C++ implémente le polymorphisme grâce aux tables virtuelles et aux liaisons dynamiques entre fonctions virtuelles et objets, formant ainsi la base de la programmation orientée objet en C++.