web-dev-qa-db-fra.com

Pourquoi le polymorphisme ne fonctionne-t-il pas sans pointeurs / références?

J'ai déjà trouvé des questions sur SO avec un titre similaire - mais quand j'ai lu les réponses, elles se concentraient sur différentes parties de la question qui étaient vraiment spécifiques (par exemple STL/conteneurs).

Quelqu'un pourrait-il me montrer pourquoi vous devez utiliser des pointeurs/références pour implémenter le polymorphisme? Je peux comprendre que les pointeurs peuvent aider, mais les références ne font sûrement que la différence entre les valeurs de passage et les références de référence ??

Sûrement aussi longtemps que vous allouez de la mémoire sur le tas - afin que vous puissiez avoir une liaison dynamique, cela aurait suffi - évidemment pas.

54
user997112

En C++, un objet a toujours un type et une taille fixes connus au moment de la compilation et (s'il peut prendre son adresse et le fait) existe toujours à une adresse fixe pendant toute sa durée de vie. Ce sont des fonctionnalités héritées de C qui aident à rendre les deux langages adaptés à la programmation de systèmes de bas niveau. (Cependant, tout cela est soumis à la règle du `` comme si '': un compilateur conforme est libre de faire ce qu'il veut avec le code tant qu'il peut être prouvé qu'il n'a aucun effet détectable sur le comportement d'un programme conforme qui est garanti par la norme.)

Une fonction virtual en C++ est définie (plus ou moins, pas besoin d'avocat de langage extrême) comme une exécution basée sur le type d'exécution d'un objet; lorsqu'il est appelé directement sur un objet, ce sera toujours le type de compilation de l'objet, donc il n'y a pas de polymorphisme lorsqu'une fonction virtual est appelée de cette façon.

Notez que cela ne doit pas nécessairement être le cas: les types d'objet avec des fonctions virtual sont généralement implémentés en C++ avec un pointeur par objet vers une table de fonctions virtual qui est unique à chaque type. Si tel est le cas, un compilateur pour une variante hypothétique de C++ pourrait implémenter l'affectation sur des objets (tels que Base b; b = Derived()) en copiant le contenu de l'objet et le pointeur de table virtual avec lui, ce qui fonctionnerait facilement si Base et Derived avaient la même taille. Dans le cas où les deux n'étaient pas de la même taille, le compilateur pourrait même insérer du code qui suspend le programme pendant une durée arbitraire afin de réorganiser la mémoire dans le programme et de mettre à jour toutes les références possibles à cette mémoire d'une manière qui pourrait être s'est avéré n'avoir aucun effet détectable sur la sémantique du programme, mettant fin au programme si aucun tel réarrangement ne pouvait être trouvé: cela serait cependant très inefficace et ne pourrait être garanti de s'arrêter un jour, ce qui n'est évidemment pas souhaitable pour un opérateur d'affectation. avoir.

Ainsi, au lieu de ce qui précède, le polymorphisme en C++ est accompli en permettant aux références et pointeurs vers des objets de référencer et de pointer vers des objets de leurs types de temps de compilation déclarés et de leurs sous-types. Lorsqu'une fonction virtual est appelée via une référence ou un pointeur et que le compilateur ne peut pas prouver que l'objet référencé ou pointé est d'un type d'exécution avec une implémentation connue spécifique de cette fonction virtual , le compilateur insère du code qui recherche la fonction virtual correcte pour appeler un run-time. Cela ne devait pas non plus être le cas: les références et les pointeurs auraient pu être définis comme étant non polymorphes (en les interdisant de référencer ou de pointer vers des sous-types de leurs types déclarés) et de forcer le programmeur à trouver d'autres moyens de mettre en œuvre le polymorphisme . Ce dernier est clairement possible car il est fait tout le temps en C, mais à ce stade, il n'y a pas beaucoup de raisons d'avoir un nouveau langage.

En somme, la sémantique de C++ est conçue de manière à permettre l'abstraction et l'encapsulation de haut niveau du polymorphisme orienté objet tout en conservant des fonctionnalités (comme un accès de bas niveau et une gestion explicite de la mémoire) qui lui permettent de convenir à développement de bas niveau. Vous pourriez facilement concevoir un langage qui avait une autre sémantique, mais ce ne serait pas du C++ et aurait des avantages et des inconvénients différents.

45
Stephen Lin

"Sûrement tant que vous allouez de la mémoire sur le tas" - où la mémoire est allouée n'a rien à voir avec cela. Tout tourne autour de la sémantique. Prenez, par exemple:

Derived d;
Base* b = &d;

d est sur la pile (mémoire automatique), mais le polymorphisme fonctionnera toujours sur b.

Si vous n'avez pas de pointeur de classe de base ou de référence à une classe dérivée, le polymorphisme ne fonctionne pas car vous n'avez plus de classe dérivée. Prendre

Base c = Derived();

L'objet c n'est pas un Derived, mais un Base, à cause de découpage. Donc, techniquement, le polymorphisme fonctionne toujours, c'est juste que vous n'avez plus d'objet Derived dont vous pouvez parler.

Maintenant, prenez

Base* c = new Derived();

c pointe simplement vers un endroit en mémoire, et peu vous importe si c'est en fait un Base ou un Derived, mais l'appel à un virtual sera résolue dynamiquement.

50
Luchian Grigore

J'ai trouvé très utile de comprendre qu'un constructeur de copie est invoqué lors de l'attribution comme ceci:

class Base { };    
class Derived : public Base { };

Derived x; /* Derived type object created */ 
Base y = x; /* Copy is made (using Base's copy constructor), so y really is of type Base. Copy can cause "slicing" btw. */ 

Étant donné que y est un objet réel de la classe Base, plutôt que l'original, les fonctions appelées sur ce sont des fonctions de Base.

12
Elliot

Considérez les petites architectures endiennes: les valeurs sont d'abord stockées en octets de poids faible. Ainsi, pour tout entier non signé donné, les valeurs 0 à 255 sont stockées dans le premier octet de la valeur. L'accès aux 8 bits bas de n'importe quelle valeur nécessite simplement un pointeur vers son adresse.

Nous pourrions donc implémenter uint8 En tant que classe. Nous savons qu'une instance de uint8 Est ... un octet. Si nous en dérivons et produisons uint16, uint32, Etc., l'interface reste la même à des fins d'abstraction , mais le changement le plus important est la taille des instances concrètes de l'objet.

Bien sûr, si nous avons implémenté uint8 Et char, les tailles peuvent être les mêmes, de même que sint8.

Cependant, operator= De uint8 Et uint16 Vont déplacer différentes quantités de données.

Pour créer une fonction polymorphe, nous devons soit pouvoir:

a/recevoir l'argument par valeur en copiant les données dans un nouvel emplacement de taille et de disposition correctes, b/prendre un pointeur vers l'emplacement de l'objet, c/prendre une référence à l'instance d'objet,

Nous pouvons utiliser des modèles pour réaliser un, donc le polymorphisme peut fonctionner sans pointeurs et références, mais si nous ne comptons pas les modèles, alors considérons ce qui se passe si nous implémenter uint128 et le passer à une fonction attendant uint8? Réponse: 8 bits sont copiés au lieu de 128.

Et si nous faisions accepter notre fonction polymorphe uint128 Et que nous lui transmettions un uint8. Si notre uint8 Que nous copions était malheureusement localisé, notre fonction tenterait de copier 128 octets dont 127 en dehors de notre mémoire accessible -> planter.

Considérer ce qui suit:

class A { int x; };
A fn(A a)
{
    return a;
}

class B : public A {
    uint64_t a, b, c;
    B(int x_, uint64_t a_, uint64_t b_, uint64_t c_)
    : A(x_), a(a_), b(b_), c(c_) {}
};

B b1 { 10, 1, 2, 3 };
B b2 = fn(b1);
// b2.x == 10, but a, b and c?

Au moment de la compilation de fn, il n'y avait aucune connaissance de B. Cependant, B est dérivé de A donc le polymorphisme devrait permettre d'appeler fn avec un B. Cependant, l'objet qu'il renvoie doit être un A comprenant un seul int.

Si nous passons une instance de B à cette fonction, ce que nous obtenons devrait être juste un { int x; } Sans a, b, c.

C'est du "tranchage".

Même avec des pointeurs et des références, nous n'évitons pas cela gratuitement. Considérer:

std::vector<A*> vec;

Les éléments de ce vecteur pourraient être des pointeurs vers A ou quelque chose dérivé de A. Le langage résout généralement cela en utilisant la "vtable", un petit ajout à l'instance de l'objet qui identifie le type et fournit des pointeurs de fonction pour les fonctions virtuelles. Vous pouvez le considérer comme quelque chose comme:

template<class T>
struct PolymorphicObject {
    T::vtable* __vtptr;
    T __instance;
};

Plutôt que chaque objet ayant sa propre vtable distincte, les classes en ont et les instances d'objet pointent simplement vers la vtable pertinente.

Le problème n'est plus le découpage mais la correction de type:

struct A { virtual const char* fn() { return "A"; } };
struct B : public A { virtual const char* fn() { return "B"; } };

#include <iostream>
#include <cstring>

int main()
{
    A* a = new A();
    B* b = new B();
    memcpy(a, b, sizeof(A));
    std::cout << "sizeof A = " << sizeof(A)
        << " a->fn(): " << a->fn() << '\n';
}          

http://ideone.com/G62Cn

sizeof A = 4 a->fn(): B

Ce que nous aurions dû faire, c'est utiliser a->operator=(b)

http://ideone.com/Vym3Lp

mais encore une fois, cela copie un A vers un A et donc le découpage se produirait:

struct A { int i; A(int i_) : i(i_) {} virtual const char* fn() { return "A"; } };
struct B : public A {
    int j;
    B(int i_) : A(i_), j(i_ + 10) {}
    virtual const char* fn() { return "B"; }
};

#include <iostream>
#include <cstring>

int main()
{
    A* a = new A(1);
    B* b = new B(2);
    *a = *b; // aka a->operator=(static_cast<A*>(*b));
    std::cout << "sizeof A = " << sizeof(A)
        << ", a->i = " << a->i << ", a->fn(): " << a->fn() << '\n';
}       

http://ideone.com/DHGwun

(i est copié, mais le j de B est perdu)

La conclusion ici est que des pointeurs/références sont nécessaires car l'instance d'origine contient l'appartenance avec laquelle la copie peut interagir.

Mais aussi, ce polymorphisme n'est pas parfaitement résolu en C++ et il faut être conscient de leur obligation de fournir/bloquer des actions qui pourraient produire un découpage.

4
kfsone

Vous avez besoin de pointeurs ou de références car pour le type de polymorphisme qui vous intéresse (*), vous avez besoin que le type dynamique soit différent du type statique, c'est-à-dire que le vrai type de l'objet soit différent du type déclaré. En C++, cela se produit uniquement avec des pointeurs ou des références.


(*) La généricité, le type de polymorphisme fourni par les modèles, n'a pas besoin de pointeurs ni de références.

1
AProgrammer

Lorsqu'un objet est passé par valeur, il est généralement placé sur la pile. Mettre quelque chose sur la pile nécessite de connaître sa taille. Lorsque vous utilisez le polymorphisme, vous savez que l'objet entrant implémente un ensemble particulier de fonctionnalités, mais vous n'avez généralement aucune idée de la taille de l'objet (et vous ne devriez pas nécessairement, cela fait partie de l'avantage). Ainsi, vous ne pouvez pas le mettre sur la pile. Cependant, vous connaissez toujours la taille d'un pointeur.

Maintenant, tout ne se passe pas sur la pile, et il y a d'autres circonstances atténuantes. Dans le cas des méthodes virtuelles, le pointeur vers l'objet est également un pointeur vers les tables de l'objet, qui indiquent où se trouvent les méthodes. Cela permet au compilateur de rechercher et d'appeler les fonctions, quel que soit l'objet avec lequel il travaille.

Une autre cause est que très souvent l'objet est implémenté en dehors de la bibliothèque appelante et alloué avec un gestionnaire de mémoire complètement différent (et éventuellement incompatible). Il peut également y avoir des membres qui ne peuvent pas être copiés, ou qui causeraient des problèmes s'ils étaient copiés avec un autre gestionnaire. Il pourrait y avoir des effets secondaires à la copie et toutes sortes d'autres complications.

Le résultat est que le pointeur est le seul bit d'informations sur l'objet que vous comprenez vraiment correctement et fournit suffisamment d'informations pour déterminer où se trouvent les autres bits dont vous avez besoin.

0
ssube