Considérer:
#include<iostream>
using namespace std;
class Base
{
public:
virtual void show() { cout<<" In Base \n"; }
};
class Derived: public Base
{
public:
void show() { cout<<"In Derived \n"; }
};
int main(void)
{
Base *bp = new Derived;
bp->show(); // RUN-TIME POLYMORPHISM
return 0;
}
Pourquoi ce code provoque-t-il un polymorphisme d'exécution et pourquoi ne peut-il pas être résolu au moment de la compilation?
Parce que dans le cas général, il est impossible au moment de la compilation de déterminer de quel type il s'agit au moment de l'exécution. Votre exemple peut être résolu au moment de la compilation (voir la réponse de @Quentin), mais des cas peuvent être construits qui ne le peuvent pas, tels que:
Base *bp;
if (Rand() % 10 < 5)
bp = new Derived;
else
bp = new Base;
bp->show(); // only known at run time
EDIT: Merci à @nwp, voici un bien meilleur cas. Quelque chose comme:
Base *bp;
char c;
std::cin >> c;
if (c == 'd')
bp = new Derived;
else
bp = new Base;
bp->show(); // only known at run time
En outre, par corollaire de preuve de Turing , il peut être démontré que dans le cas général il est mathématiquement impossible pour un compilateur C++ de savoir à quoi pointe un pointeur de classe de base au moment de l'exécution.
Supposons que nous ayons une fonction de type compilateur C++:
bool bp_points_to_base(const string& program_file);
Cela prend comme entrée program_file
: Le nom de tout fichier texte de code source C++ où un pointeur bp
( comme dans l'OP) appelle sa fonction membre virtual
show()
. Et peut déterminer dans le cas général (au point de séquence A
où la fonction membre virtual
show()
est d'abord invoqué via bp
): que le pointeur bp
pointe vers une instance de Base
ou non.
Considérez le fragment suivant du programme C++ "q.cpp":
Base *bp;
if (bp_points_to_base("q.cpp")) // invokes bp_points_to_base on itself
bp = new Derived;
else
bp = new Base;
bp->show(); // sequence point A
Maintenant, si bp_points_to_base
Détermine que dans "q.cpp": bp
pointe vers une instance de Base
à A
alors "q.cpp" pointe bp
vers autre chose sur A
. Et s'il détermine que dans "q.cpp": bp
ne pointe pas vers une instance de Base
à A
, alors "q.cpp" pointe bp
à une instance de Base
à A
. C'est une contradiction. Notre hypothèse initiale est donc incorrecte. Donc bp_points_to_base
Ne peut pas être écrit pour le cas général .
Les compilateurs dévirtualisent régulièrement ces appels, lorsque le type statique de l'objet est connu. Coller votre code tel quel dans Explorateur du compilateur produit l'assembly suivant:
main: # @main
pushq %rax
movl std::cout, %edi
movl $.L.str, %esi
movl $12, %edx
callq std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long)
xorl %eax, %eax
popq %rdx
retq
pushq %rax
movl std::__ioinit, %edi
callq std::ios_base::Init::Init()
movl std::ios_base::Init::~Init(), %edi
movl std::__ioinit, %esi
movl $__dso_handle, %edx
popq %rax
jmp __cxa_atexit # TAILCALL
.L.str:
.asciz "In Derived \n"
Même si vous ne pouvez pas lire Assembly, vous pouvez voir que seul "In Derived \n"
est présent dans l'exécutable. Non seulement la répartition dynamique a été optimisée, tout comme la classe de base entière.
Pourquoi ce code provoque-t-il le polymorphisme d'exécution et pourquoi ne peut-il pas être résolu au moment de la compilation?
Qu'est-ce qui vous fait penser que c'est le cas?
Vous faites une hypothèse commune: ce n'est pas parce que le langage identifie ce cas que l'utilisation du polymorphisme d'exécution ne signifie pas qu'un l'implémentation est réservée à la répartition au moment de l'exécution. La norme C++ a une règle dite "comme si": les effets observables des règles de la norme C++ sont décrits en ce qui concerne une machine abstraite, et les mises en œuvre sont libres d'obtenir lesdits effets observables comme ils le souhaitent.
En fait, dévirtualisation est le mot général utilisé pour parler des optimisations du compilateur visant à résoudre les appels aux méthodes virtuelles au moment de la compilation.
Le but n'est pas tant de réduire la surcharge des appels virtuels presque imperceptibles (si la prédiction de branchement fonctionne bien), il s'agit de supprimer une boîte noire. Les meilleurs gains, en termes d'optimisations, reposent sur l'incrustation des appels: cela ouvre une propagation constante et beaucoup d'optimisation, et l'incrustation ne peut que être atteint lorsque le corps de la fonction appelée est connu au moment de la compilation (car il s'agissait de supprimer l'appel et de le remplacer par le corps de la fonction).
Quelques opportunités de dévirtualisation:
final
ou à une méthode virtual
d'une classe final
est dévirtualisé de manière trivialevirtual
d'une classe définie dans un espace de noms anonyme peut être dévirtualisé si cette classe est une feuille dans la hiérarchievirtual
via une classe de base peut être dévirtualisé si le type dynamique de l'objet peut être établi au moment de la compilation (ce qui est le cas de votre exemple, la construction étant dans la même fonction)Pour l'état de l'art, cependant, vous voudrez lire le blog de Honza Hubička. Honza est un développeur gcc et l'année dernière il a travaillé sur la dévirtualisation spéculative : le but est de calculer les probabilités du type dynamique soit A, B ou C puis dévirtualiser spéculativement les appels un peu comme transformer:
Base& b = ...;
b.call();
dans:
Base& b = ...;
if (b.vptr == &VTableOfA) { static_cast<A&>(b).call(); }
else if (b.vptr == &VTableOfB) { static_cast<B&>(b).call(); }
else if (b.vptr == &VTableOfC) { static_cast<C&>(b).call(); }
else { b.call(); } // virtual call as last resort
Honza a fait un post en 5 parties:
Il existe de nombreuses raisons pour lesquelles les compilateurs ne peuvent généralement pas remplacer la décision d'exécution par des appels statiques, principalement parce qu'elle implique des informations non disponibles au moment de la compilation, par ex. configuration ou entrée utilisateur. Hormis cela, je voudrais souligner deux autres raisons pour lesquelles cela n'est pas possible en général.
Tout d'abord, le modèle de compilation C++ est basé sur des unités de compilation distinctes. Lorsqu'une unité est compilée, le compilateur ne sait que ce qui est défini dans le ou les fichiers source en cours de compilation. Considérons une unité de compilation avec une classe de base et une fonction prise comme référence à la classe de base:
struct Base {
virtual void polymorphic() = 0;
};
void foo(Base& b) {b.polymorphic();}
Lorsqu'il est compilé séparément, le compilateur n'a aucune connaissance des types qui implémentent Base
et ne peut donc pas supprimer la répartition dynamique. Ce n'est pas quelque chose que nous voulons parce que nous voulons pouvoir étendre le programme avec de nouvelles fonctionnalités en implémentant l'interface. Il peut être possible de le faire au moment de la liaison , mais uniquement en supposant que le programme est entièrement terminé. Les bibliothèques dynamiques peuvent briser cette hypothèse, et comme on peut le voir ci-dessous, il y aura toujours des cas où ce n'est pas possible du tout.
Une raison plus fondamentale vient de la théorie de la calculabilité. Même avec des informations complètes, il n'est pas possible de définir un algorithme qui calcule si une certaine ligne d'un programme sera atteinte ou non. Si vous pouviez, vous pourriez résoudre le problème de l'arrêt: pour un programme P
, je crée un nouveau programme P'
en ajoutant une ligne supplémentaire à la fin de P
. L'algorithme serait désormais en mesure de décider si cette ligne est atteinte, ce qui résout le problème d'arrêt.
L'impossibilité de décider en général signifie que les compilateurs ne peuvent pas décider quelle valeur est attribuée aux variables en général, par ex.
bool someFunction( /* arbitrary parameters */ ) {
// ...
}
// ...
Base* b = nullptr;
if (someFunction( ... ))
b = new Derived1();
else
b = new Derived2();
b->polymorphicFunction();
Même lorsque tous les paramètres sont connus au moment de la compilation, il n'est pas possible de prouver en général quel chemin sera utilisé dans le programme et quel type statique b
aura. Les approximations peuvent et sont faites en optimisant les compilateurs, mais il y a toujours des cas où cela ne fonctionne pas.
Cela dit, les compilateurs C++ s'efforcent très fort de supprimer la répartition dynamique car cela ouvre de nombreuses autres possibilités d'optimisation, principalement de pouvoir incorporer et propager des connaissances à travers le code. Si vous êtes intéressant, vous pouvez trouver une série intéressante d'articles de blog pour le GCC implémentation de la dévirtualisation.
Cela pourrait facilement être résolu au moment de la compilation si l'optimiseur choisissait de le faire.
La norme spécifie le même comportement que si le polymorphisme d'exécution s'était produit. Il ne précise pas ce qui peut être réalisé par le polymorphisme d'exécution réel.
Fondamentalement, le compilateur devrait être en mesure de comprendre que cela ne devrait pas entraîner de polymorphisme d'exécution dans votre cas très simple. Il y a très probablement des compilateurs qui font cela, mais c'est surtout une conjecture.
La problématique est le cas général lorsque vous construisez en fait un complexe, et en dehors des cas avec des dépendances de bibliothèque, ou la complexité de l'analyse de plusieurs unités de compilation post-compilation, ce qui nécessiterait de conserver plusieurs versions du même code, ce qui exploserait - génération AST, le vrai problème se résume à décidabilité et le problème d'arrêt.
Ce dernier ne permet pas de résoudre le problème si un appel peut être dévirtualisé dans le cas général.
Le problème arrêt consiste à décider si un programme donné une entrée va s'arrêter ( nous disons que la paire entrée-programme s'arrête ). Il est connu qu'il n'y a pas d'algorithme général, par ex. n compilateur, qui résout toutes les paires d'entrée de programme possibles.
Pour que le compilateur décide pour n'importe quel programme si un appel doit être fait virtuel ou non, il doit pouvoir décider que pour tous les programmes possibles- paires d'entrée.
Pour ce faire, le compilateur devrait avoir un algorithme A qui décide que le programme P1 donné et le programme P2 où P2 effectue un appel virtuel, puis programme P3 {while ({P1, I}! = {P2, I})} s'arrête pour toute entrée I.
Ainsi, le compilateur pour être en mesure de comprendre toute dévirtualisation possible devrait pouvoir décider que pour n'importe quelle paire (P3, I) sur tous les P3 et I possibles; ce qui est indécidable pour tous car A n'existe pas. Cependant, il peut être décidé pour des cas spécifiques qui peuvent être examinés.
C'est pourquoi dans votre cas, l'appel peut être dévirtualisé, mais pas dans tous les cas.