web-dev-qa-db-fra.com

Pourquoi la base pour tous les objets est-elle découragée en C ++

Stroustrup dit "N'inventez pas immédiatement une base unique pour toutes vos classes (une classe Object). En règle générale, vous pouvez faire mieux sans elle pour la plupart/la plupart des classes." (Quatrième édition du langage de programmation C++, section 1.3.4)

Pourquoi une classe de base pour tout est-elle généralement une mauvaise idée, et quand est-il sensé d'en créer une?

77
Matthew James Briggs

Parce que qu'aurait cet objet pour la fonctionnalité? Dans Java tout ce que la classe Base a est un toString, un hashCode & égalité et une variable moniteur + condition.

  • ToString n'est utile que pour le débogage.

  • hashCode n'est utile que si vous souhaitez le stocker dans une collection basée sur le hachage (la préférence en C++ est de passer une fonction de hachage au conteneur en tant que paramètre de modèle ou d'éviter std::unordered_* tout à fait et utilisez plutôt std::vector et listes non ordonnées simples).

  • l'égalité sans objet de base peut être aidée au moment de la compilation, si elles n'ont pas le même type, elles ne peuvent pas être égales. En C++, il s'agit d'une erreur de compilation.

  • la variable moniteur et condition est mieux incluse explicitement au cas par cas.

Cependant, lorsqu'il y a plus à faire, il y a un cas d'utilisation.

Par exemple, dans QT, il existe la classe racine QObject qui constitue la base de l'affinité des threads, de la hiérarchie de propriété parent-enfant et du mécanisme des créneaux de signal. Cela force également l'utilisation par le pointeur pour les QObjects, cependant de nombreuses classes dans Qt n'héritent pas de QObject car elles n'ont pas besoin du signal-slot (en particulier les types de valeur d'une certaine description).

74
ratchet freak

Parce qu'il n'y a pas de fonctions partagées par tous les objets. Il n'y a rien à mettre dans cette interface qui aurait du sens pour toutes les classes.

100
DeadMG

Chaque fois que vous construisez de hautes hiérarchies d'héritage d'objets, vous avez tendance à rencontrer le problème de la Fragile Base Class (Wikipedia.) .

Le fait d'avoir de nombreuses petites hiérarchies d'héritage séparées (distinctes, isolées) réduit les chances de rencontrer ce problème.

L'intégration de tous vos objets dans une seule hiérarchie d'héritage gigantesque garantit pratiquement que vous allez rencontrer ce problème.

25
Mike Nakis

Parce que:

  1. Vous ne devriez pas payer pour ce que vous n'utilisez pas.
  2. Ces fonctions ont moins de sens dans un système de type basé sur des valeurs que dans un système de type basé sur des références.

L'implémentation de n'importe laquelle sorte de fonction virtual introduit une table virtuelle, qui nécessite une surcharge d'espace par objet qui n'est ni nécessaire ni souhaitée dans de nombreuses situations (la plupart?).

Implémenter toString de manière non virtuelle serait assez inutile, car la seule chose qu'il pourrait renvoyer est l'adresse de l'objet, qui est très peu conviviale pour l'utilisateur, et à laquelle l'appelant a déjà accès, contrairement à Java.
De même, un equals ou hashCode non virtuel ne peut utiliser que des adresses pour comparer des objets, ce qui est encore une fois assez inutile et souvent même carrément faux - contrairement à Java, les objets sont copiés fréquemment en C++, et donc distinguer "l'identité" d'un objet n'est même pas toujours significatif ou utile. (par exemple, un int devrait vraiment pas avoir une identité autre que sa valeur ... deux entiers de la même valeur devraient être égaux.)

24
user541686

Avoir un objet racine limite ce que vous pouvez faire et ce que le compilateur peut faire, sans trop de bénéfices.

Une classe racine commune permet de créer des conteneurs de n'importe quoi et d'extraire ce qu'ils sont avec un dynamic_cast, Mais si vous avez besoin de conteneurs de quoi que ce soit, quelque chose de semblable à boost::any Peut le faire sans une classe racine commune. Et boost::any Prend également en charge les primitives - il peut même prendre en charge la petite optimisation du tampon et les laisser presque "sans boîte" dans le langage Java Java.

C++ prend en charge et prospère sur les types de valeur. Les littéraux et les types de valeurs écrites du programmeur. Les conteneurs C++ stockent, trient, hachent, consomment et produisent efficacement des types de valeur.

L'héritage, en particulier le type d'héritage monolithique Java impliquent, nécessitent des types de "pointeur" ou de "référence" basés sur la librairie. Votre handle/pointeur/référence aux données contient un pointeur vers le interface de la classe, et polymorphe pourrait représenter autre chose.

Bien que cela soit utile dans certaines situations, une fois que vous vous êtes marié au modèle avec une "classe de base commune", vous avez verrouillé l'intégralité de votre base de code dans le coût et les bagages de ce modèle, même lorsqu'il n'est pas utile.

Presque toujours, vous en savez plus sur un type que "c'est un objet" sur le site appelant ou dans le code qui l'utilise.

Si la fonction est simple, l'écriture de la fonction en tant que modèle vous donne un polymorphisme basé sur le temps de compilation de type canard où les informations sur le site appelant ne sont pas jetées. Si la fonction est plus complexe, un effacement de type peut être effectué par lequel les opérations uniformes sur le type que vous souhaitez effectuer (par exemple, la sérialisation et la désérialisation) peuvent être construites et stockées (au moment de la compilation) pour être consommées (au moment de l'exécution) par le code dans une unité de traduction différente.

Supposons que vous ayez une bibliothèque où vous voulez que tout soit sérialisable. Une approche consiste à avoir une classe de base:

struct serialization_friendly {
  virtual void write_to( my_buffer* ) const = 0;
  virtual void read_from( my_buffer const* ) = 0;
  virtual ~serialization_friendly() {}
};

Maintenant, chaque bit de code que vous écrivez peut être serialization_friendly.

void serialize( my_buffer* b, serialization_friendly const* x ) {
  if (x) x->write_to(b);
}

Sauf pas un std::vector, Vous devez donc maintenant écrire chaque conteneur. Et pas les entiers que vous avez obtenus de cette bibliothèque bignum. Et ce n'est pas le type que vous avez écrit que vous ne pensiez pas avoir besoin de sérialisation. Et pas un Tuple, ni un int ou un double, ni un std::ptrdiff_t.

Nous adoptons une autre approche:

void write_to( my_buffer* b, int x ) {
  b->write_integer(x);
}    
template<class T,
  class=std::enable_if_t< void_t<
    std::declval<T const*>()->write_to( std::declval<my_buffer*>()
  > >
>
void write_to( my_buffer* b, T const* x ) {
  if (x) x->write_to(b);
}
template<class T>
void serialize( my_buffer* b, T const& t ) {
  write_to( b, t );
}

qui consiste, eh bien, à ne rien faire, en apparence. Sauf que maintenant, nous pouvons étendre write_to En remplaçant write_to Comme une fonction libre dans l'espace de noms d'un type ou une méthode dans le type.

On peut même écrire un peu de code d'effacement de type:

namespace details {
  struct can_serialize_pimpl {
    virtual void write_to( my_buffer* ) const = 0;
    virtual void read_from( my_buffer const* ) = 0;
    virtual ~can_serialize_pimpl() {}
  };
}
struct can_serialize {
  void write_to( my_buffer* b ) const { pImpl->write_to(b); }
  void read_from( my_buffer const* b ) { pImpl->read_from(b); }
  std::unique_ptr<details::can_serialize_pimpl> pImpl;
  template<class T> can_serialize(T&&);
};
namespace details { 
  template<class T>
  struct can_serialize : can_serialize_pimpl {
    std::decay_t<T>* t;
    void write_to( my_buffer*b ) const final override {
      serialize( b, std::forward<T>(*t) );
    }
    void read_from( my_buffer const* ) final override {
      deserialize( b, std::forward<T>(*t) );
    }
    can_serialize(T&& in):t(&in) {}
  };
}
template<class T> can_serialize::can_serialize<T>(T&&t):pImpl(
  std::make_unique<details::can_serialize<T>>( std::forward<T>(t) );
) {}

et maintenant nous pouvons prendre un type arbitraire et le placer automatiquement dans une interface can_serialize qui vous permet d'appeler serialize à un stade ultérieur via une interface virtuelle.

Donc:

void writer_thingy( can_serialize s );

est une fonction qui prend tout ce qui peut sérialiser, au lieu de

void writer_thingy( serialization_friendly const* s );

et le premier, contrairement au second, il peut gérer int, std::vector<std::vector<Bob>> automatiquement.

Cela n'a pas pris beaucoup de temps pour l'écrire, surtout parce que ce genre de chose est quelque chose que vous ne voulez que rarement faire, mais nous avons gagné la capacité de traiter tout comme sérialisable sans nécessiter de type de base.

De plus, nous pouvons maintenant rendre std::vector<T> Sérialisable en tant que citoyen de première classe en remplaçant simplement write_to( my_buffer*, std::vector<T> const& ) - avec cette surcharge, il peut être passé à un can_serialize Et le la sérialisation de std::vector est stockée dans une table virtuelle et accessible par .write_to.

En bref, C++ est suffisamment puissant pour que vous puissiez implémenter les avantages d'une seule classe de base à la volée lorsque cela est nécessaire, sans avoir à payer le prix d'une hiérarchie d'héritage forcé lorsqu'il n'est pas nécessaire. Et les moments où la base unique (falsifiée ou non) est requise sont assez rares.

Lorsque les types sont réellement leur identité et que vous savez ce qu'ils sont, les opportunités d'optimisation abondent. Les données sont stockées localement et de manière contiguë (ce qui est très important pour la convivialité du cache sur les processeurs modernes), les compilateurs peuvent facilement comprendre ce qu'une opération donnée fait (au lieu d'avoir un pointeur de méthode virtuelle opaque sur lequel elle doit sauter, conduisant à un code inconnu sur le de l'autre côté) qui permet de réorganiser les instructions de manière optimale, et moins de chevilles rondes sont martelées dans des trous ronds.

16
Yakk

Il y a beaucoup de bonnes réponses ci-dessus, et le fait clair que tout ce que vous feriez avec une classe de base de tous les objets peut être mieux fait d'autres manières comme le montre la réponse de @ ratchetfreak et les commentaires à ce sujet est très important, mais il y a une autre raison, qui est d'éviter de créer diamants d'héritage lorsque l'héritage multiple est utilisé. Si vous aviez une fonctionnalité dans une classe de base universelle, dès que vous avez commencé à utiliser l'héritage multiple, vous devez commencer à spécifier la variante de celle-ci à laquelle vous souhaitez accéder, car elle pourrait être surchargée différemment dans les différents chemins de la chaîne d'héritage. Et la base ne peut pas être virtuelle, car cela serait très inefficace (nécessitant que tous les objets aient une table virtuelle à un coût potentiellement énorme en utilisation de mémoire et en localité). Cela deviendrait très rapidement un cauchemar logistique.

8
Jules

En fait, les premiers compilateurs et bibliothèques C++ de Microsofts (je connais Visual C++, 16 bits) avaient une telle classe nommée CObject.

Cependant, vous devez savoir qu'à cette époque, les "modèles" n'étaient pas pris en charge par ce simple compilateur C++, donc des classes comme std::vector<class T> n'était pas possible. Au lieu de cela, une implémentation "vectorielle" ne pouvait gérer qu'un seul type de classe, il y avait donc une classe comparable à std::vector<CObject> aujourd'hui. Parce que CObject était la classe de base de presque toutes les classes (malheureusement pas de CString - l'équivalent de string dans les compilateurs modernes), vous pouvez utiliser cette classe pour stocker presque toutes sortes de objets.

Étant donné que les compilateurs modernes prennent en charge les modèles, ce cas d'utilisation d'une "classe de base générique" n'est plus donné.

Vous devez penser au fait que l'utilisation d'une telle classe de base générique coûtera (un peu) de mémoire et d'exécution - par exemple dans l'appel au constructeur. Il existe donc des inconvénients lors de l'utilisation d'une telle classe, mais au moins lors de l'utilisation de compilateurs C++ modernes, il n'y a pratiquement aucun cas d'utilisation pour une telle classe.

5
Martin Rosenau

Je vais suggérer une autre raison qui vient de Java.

Parce que vous ne pouvez pas créer une classe de base pour tout du moins pas sans un tas de plaques de chaudière.

Vous pourrez peut-être vous en tirer pour vos propres classes - mais vous constaterez probablement que vous finissez par dupliquer beaucoup de code. Par exemple. "Je ne peux pas utiliser std::vector Ici car il n'implémente pas IObject - je ferais mieux de créer un nouveau IVectorObject dérivé qui fait la bonne chose ...".

Ce sera le cas chaque fois que vous aurez affaire à des classes de bibliothèques intégrées ou standard ou à des classes d'autres bibliothèques.

Maintenant, s'il était intégré au langage, vous vous retrouveriez avec des choses comme la confusion Integer et int qui est en Java, ou un changement important de la syntaxe du langage. (Attention, je pense que d'autres langues ont fait du bon travail en l'intégrant dans tous les types - Ruby semble être un meilleur exemple.)

Notez également que si votre classe de base n'est pas polymorphe au moment de l'exécution (c'est-à-dire en utilisant des fonctions virtuelles), vous pourriez obtenir le même avantage en utilisant un trait comme le framework.

par exemple. au lieu de .toString(), vous pourriez avoir les éléments suivants: (REMARQUE: je sais que vous pouvez le faire plus en utilisant les bibliothèques existantes, etc., ce n'est qu'un exemple illustratif.)

template<typename T>
struct ToStringTrait;

template<typename T> 
std::string toString(const T & t) {
  return ToStringTrait<T>::toString(t);
}

template<>
struct ToStringTrait<int> {
  std::string toString(int v) {
    return itoa(v);
  }
}

template<typename T>
struct ToStringTrait<std::vector<T>> {
  std::string toString(const std::vector<T> &v) {
    std::stringstream ss;
    ss<<"{";
    for(int i=0; i<v.size(); ++i) {
      ss<<toString(v[i]);
    }
    ss<<"}";
    return ss.str();
  }
}
5
Michael Anderson

On peut dire que "vide" remplit beaucoup de rôles d'une classe de base universelle. Vous pouvez convertir n'importe quel pointeur en void*. Vous pouvez ensuite comparer ces pointeurs. Vous pouvez static_cast retour à la classe d'origine.

Cependant, ce que vous ne pouvez pas faire avec void ce que vous pouvez faire avec Object est d'utiliser RTTI pour déterminer quel type d'objet vous avez vraiment. C'est finalement dû au fait que tous les objets en C++ n'ont pas RTTI, et en effet il est possible d'avoir des objets de largeur nulle.

3
pjc50

Java adopte la philosophie de conception qui le comportement indéfini ne devrait pas exister. Code tel que:

Cat felix = GetCat();
Woofer Rover = (Woofer)felix;
Rover.woof();

testera si felix contient un sous-type de Cat qui implémente l'interface Woofer; si c'est le cas, il effectuera le transtypage et invoquera woof() et s'il ne le fait pas, il lèvera une exception. Le comportement du code est entièrement défini, que felix implémente Woofer ou non.

C++ part du principe que si un programme ne doit pas tenter une opération, peu importe ce que le code généré ferait si cette opération était tentée, et l'ordinateur ne devrait pas perdre de temps à essayer de contraindre le comportement dans les cas où cela "devrait" ne se pose jamais. En C++, en ajoutant les opérateurs d'indirection appropriés pour convertir un *Cat En un *Woofer, Le code donnerait un comportement défini lorsque le transtypage est légitime, mais un comportement indéfini quand il l'est) pas.

Le fait d'avoir un type de base commun pour les choses permet de valider les transtypages parmi les dérivés de ce type de base, et également d'effectuer des opérations de transtypage, mais la validation des transtypages est plus coûteuse que de simplement supposer qu'ils sont légitimes et d'espérer qu'il ne se passe rien de mal. La philosophie C++ serait qu'une telle validation nécessite "de payer pour quelque chose dont vous n'avez [généralement] pas besoin".

Un autre problème qui concerne C++, mais ne serait pas un problème pour un nouveau langage, est que si plusieurs programmeurs créent chacun une base commune, en dérivent leurs propres classes et écrivent du code pour travailler avec les choses de cette classe de base commune, un tel code ne pourra pas travailler avec des objets développés par des programmeurs qui ont utilisé une classe de base différente. Si un nouveau langage requiert que tous les objets de tas aient un format d'en-tête commun et n'a jamais autorisé les objets de tas qui ne l'ont pas fait, alors une méthode qui nécessite une référence à un objet de tas avec un tel en-tête acceptera une référence à n'importe quel objet de tas n'importe qui pourrait jamais créer.

Personnellement, je pense qu'avoir un moyen commun de demander à un objet "êtes-vous convertible en type X" est une fonctionnalité très importante dans un langage/framework, mais si une telle fonctionnalité n'est pas intégrée dans une langue depuis le début, il est difficile de ajoutez-le plus tard. Personnellement, je pense qu'une telle classe de base devrait être ajoutée à une bibliothèque standard à la première occasion, avec une forte recommandation que tous les objets qui seront utilisés de manière polymorphe héritent de cette base. Le fait que les programmeurs implémentent chacun leurs propres "types de base" rendrait plus difficile le passage d'objets entre des codes de personnes différentes, mais le fait d'avoir un type de base commun dont de nombreux programmeurs ont hérité faciliterait la tâche.

ADDENDUM

En utilisant des modèles, il est possible de définir un "support d'objet arbitraire" et de lui demander le type de l'objet qu'il contient; le paquet Boost contient une chose appelée any. Ainsi, même si le C++ n'a pas de type standard "référence vérifiable par type à n'importe quoi", il est possible d'en créer un. Cela ne résout pas le problème susmentionné de ne pas avoir quelque chose dans la norme de langage, c'est-à-dire l'incompatibilité entre les implémentations de différents programmeurs, mais cela explique comment C++ s'en sort sans avoir un type de base dont tout est dérivé: en permettant de créer quelque chose qui agit comme tel.

2
supercat

Symbian C++ avait en fait une classe de base universelle, CBase, pour tous les objets qui se comportaient d'une manière particulière (principalement s'ils allouaient un tas). Il a fourni un destructeur virtuel, mis à zéro la mémoire de la classe sur la construction et caché le constructeur de la copie.

La raison derrière cela était qu'il s'agissait d'un langage pour les systèmes embarqués et que les compilateurs et spécifications C++ étaient vraiment vraiment de la merde il y a 10 ans.

Pas toutes les classes héritées de cela, seulement certaines.

1
James