web-dev-qa-db-fra.com

Sous-classe / hériter des conteneurs standard?

J'ai souvent lu ces déclarations sur Stack Overflow. Personnellement, je ne trouve aucun problème avec cela, sauf si je l'utilise de manière polymorphe; c'est-à-dire où je dois utiliser le destructeur virtual.

Si je veux étendre/ajouter la fonctionnalité d'un conteneur standard, quelle est la meilleure façon d'en hériter? Envelopper ces conteneurs dans une classe personnalisée nécessite beaucoup plus d'efforts et est toujours impur.

49
iammilind

Il y a plusieurs raisons pour lesquelles c'est une mauvaise idée.

Tout d'abord, c'est une mauvaise idée car les conteneurs standard n'ont pas de destructeurs virtuels . Vous ne devez jamais utiliser quelque chose de polymorphe qui n'a pas de destructeurs virtuels, car vous ne pouvez pas garantir le nettoyage dans votre classe dérivée.

Règles de base pour les dtors virtuels

Deuxièmement, c'est vraiment une mauvaise conception. Et il y a en fait plusieurs raisons pour lesquelles c'est une mauvaise conception. Tout d'abord, vous devez toujours étendre la fonctionnalité des conteneurs standard par le biais d'algorithmes qui fonctionnent de manière générique. C'est une simple raison de complexité - si vous devez écrire un algorithme pour chaque conteneur auquel il s'applique et que vous avez M conteneurs et N algorithmes, c'est-à-dire M x N méthodes que vous devez écrire. Si vous écrivez vos algorithmes de manière générique, vous n'avez que N algorithmes. Vous obtenez donc beaucoup plus de réutilisation.

C'est aussi une mauvaise conception car vous cassez une bonne encapsulation en héritant du conteneur. Une bonne règle de base est la suivante: si vous pouvez effectuer ce dont vous avez besoin en utilisant l'interface publique d'un type, rendez ce nouveau comportement externe au type. Cela améliore l'encapsulation. S'il s'agit d'un nouveau comportement que vous souhaitez implémenter, faites-en une fonction de portée d'espace de noms (comme les algorithmes). Si vous avez un nouvel invariant à imposer, utilisez le confinement dans une classe.

ne description classique de l'encapsulation

Enfin, en général, vous ne devriez jamais penser à l'héritage comme un moyen d'étendre le comportement d'une classe. Ceci est l'un des gros et mauvais mensonges des premiers OOP théorie qui sont survenus en raison d'une réflexion peu claire sur la réutilisation, et il continue d'être enseigné et promu à ce jour même s'il existe une théorie claire expliquant pourquoi il est mauvais. Lorsque vous utilisez l'héritage pour étendre le comportement, vous liez ce comportement étendu à votre contrat d'interface d'une manière qui lie les mains des utilisateurs à l'avenir Par exemple, supposons que vous ayez une classe de type Socket qui communique en utilisant le protocole TCP et vous étendez son comportement en dérivant une classe SSLSocket de Socket et en implémentant le comportement du protocole de pile SSL supérieur au-dessus de Socket. Supposons maintenant que vous ayez une nouvelle exigence d'avoir le même protocole de communication, mais sur une ligne USB ou sur la téléphonie. Vous devrez couper et coller tout ce travail dans une nouvelle classe qui dérive d'un Classe USB, ou classe de téléphonie. Et maintenant, si vous trouvez un bug, vous devez le corriger aux trois endroits, ce qui ne se produira pas toujours, ce qui signifie un bug s prendra plus de temps et ne sera pas toujours réparé ...

Ceci est général pour toute hiérarchie d'héritage A-> B-> C -> ... Lorsque vous souhaitez utiliser les comportements que vous avez étendus dans des classes dérivées, comme B, C, .. sur des objets n'appartenant pas à la classe de base A, vous devez repenser ou vous dupliquez l'implémentation. Cela conduit à des conceptions très monolithiques qui sont très difficiles à changer à l'avenir (pensez au MFC de Microsoft, ou à leur .NET, ou - eh bien, ils font beaucoup cette erreur). Au lieu de cela, vous devriez presque toujours penser à l'extension par la composition autant que possible. L'héritage doit être utilisé lorsque vous pensez au "principe ouvert/fermé". Vous devriez avoir des classes de base abstraites et un runtime de polymorphisme dynamique via une classe héritée, chacune aura des implémentations complètes. Les hiérarchies ne devraient pas être profondes - presque toujours deux niveaux. N'utilisez plus de deux que lorsque vous avez différentes catégories dynamiques qui vont à une variété de fonctions qui ont besoin de cette distinction pour la sécurité des types. Dans ces cas, utilisez des bases abstraites jusqu'aux classes feuilles, qui ont l'implémentation.

82
ex0du5

Peut-être que beaucoup de gens ici n'aimeront pas cette réponse, mais il est temps de raconter une hérésie et oui ... de dire aussi que "le roi est nu!"

Toutes les motivations contre la dérivation sont faibles. La dérivation n'est pas différente de la composition. C'est juste une façon de "mettre les choses ensemble". La composition rassemble les choses en leur donnant des noms, l'héritage le fait sans donner de noms explicites.

Si vous avez besoin d'un vecteur ayant la même interface et la même implémentation de std :: vect plus quelque chose de plus, vous pouvez:

utiliser la composition et réécrire tous les prototypes de fonction d'objet incorporés mettant en œuvre la fonction qui les délègue (et s'ils sont 10000 ... oui: soyez prêt à réécrire tous ces 10000) ou ...

héritez-en et ajoutez juste ce dont vous avez besoin (et ... réécrivez simplement les constructeurs, jusqu'à ce que les avocats en C++ décident de les laisser héritables également: je me souviens encore il y a 10 ans d'une discussion passionnée sur "pourquoi les ctors ne peuvent pas s'appeler" et pourquoi est une "mauvaise mauvaise mauvaise chose" ... jusqu'à ce que C++ 11 le permette et tout à coup tous ces fanatiques se taisent!) et laisse le nouveau destructeur non virtuel comme il était l'original.

Tout comme pour chaque classe qui a certains méthode virtuelle et certains pas, vous savez que vous ne pouvez pas prétendre invoquer la méthode non virtuelle de dérivée en adressant la base, la même chose s'applique pour supprimer. Il n'y a aucune raison de simplement supprimer pour prétendre à un soin particulier particulier. Un programmeur qui sait que ce qui n'est pas virtuel ne peut pas être adressé à la base sait également ne pas utiliser delete sur votre base après avoir alloué votre dérivé.

Tous les "éviter ceci" "ne font pas cela" sonnent toujours comme la "moralisation" de quelque chose qui est nativement agnostique. Toutes les fonctionnalités d'une langue existent pour résoudre un problème. Le fait qu'une manière donnée de résoudre le problème soit bonne ou mauvaise dépend du contexte, et non de la fonctionnalité elle-même. Si ce que vous faites doit servir de nombreux conteneurs, l'héritage n'est probablement pas le chemin (vous devez le refaire pour tous). Si c'est pour un cas spécifique ... l'héritage est un moyen de composer. Oubliez OOP purismes: C++ n'est pas un "OOP pur" et le conteneur ne l'est pas OOP du tout).

26
Emilio Garavaglia

Vous devez vous abstenir de dériver publiquement de contianers standard. Vous pouvez choisir entre héritage privé et composition et il me semble que toutes les directives générales indiquent que la composition est meilleure ici puisque vous ne remplacez aucune fonction. Ne dérivez pas publiquement des conteneurs STL - il n'y en a vraiment pas besoin.

Soit dit en passant, si vous souhaitez ajouter un tas d'algorithmes au conteneur, envisagez de les ajouter en tant que fonctions autonomes prenant une plage d'itérateurs.

7
Armen Tsirunyan

L'héritage public est un problème pour toutes les raisons que d'autres ont mentionnées, à savoir que votre conteneur peut être transféré vers la classe de base qui n'a pas de destructeur virtuel ou d'opérateur d'affectation virtuel, ce qui peut conduire à problèmes de découpage .

L'héritage privé, d'autre part, est moins un problème. Prenons l'exemple suivant:

#include <vector>
#include <iostream>

// private inheritance, nobody else knows about the inheritance, so nobody is upcasting my
// container to a std::vector
template <class T> class MyVector : private std::vector<T>
{
private:
    // in case I changed to boost or something later, I don't have to update everything below
    typedef std::vector<T> base_vector;

public:
    typedef typename base_vector::size_type       size_type;
    typedef typename base_vector::iterator        iterator;
    typedef typename base_vector::const_iterator  const_iterator;

    using base_vector::operator[];

    using base_vector::begin;
    using base_vector::clear;
    using base_vector::end;
    using base_vector::erase;
    using base_vector::Push_back;
    using base_vector::reserve;
    using base_vector::resize;
    using base_vector::size;

    // custom extension
    void reverse()
    {
        std::reverse(this->begin(), this->end());
    }
    void print_to_console()
    {
        for (auto it = this->begin(); it != this->end(); ++it)
        {
            std::cout << *it << '\n';
        }
    }
};


int main(int argc, char** argv)
{
    MyVector<int> intArray;
    intArray.resize(10);
    for (int i = 0; i < 10; ++i)
    {
        intArray[i] = i + 1;
    }
    intArray.print_to_console();
    intArray.reverse();
    intArray.print_to_console();

    for (auto it = intArray.begin(); it != intArray.end();)
    {
        it = intArray.erase(it);
    }
    intArray.print_to_console();

    return 0;
}

PRODUCTION:

1
2
3
4
5
6
7
8
9
10
10
9
8
7
6
5
4
3
2
1

Propre et simple, et vous donne la liberté d'étendre les conteneurs standard sans trop d'effort.

Et si vous pensez faire quelque chose de stupide, comme ceci:

std::vector<int>* stdVector = &intArray;

Vous obtenez ceci:

error C2243: 'type cast': conversion from 'MyVector<int> *' to 'std::vector<T,std::allocator<_Ty>> *' exists, but is inaccessible
5
ErsatzStoat

Le problème est que vous, ou quelqu'un d'autre, pourriez accidentellement passer votre classe étendue à une fonction en attendant une référence à la classe de base. Cela supprimera efficacement (et silencieusement!) Les extensions et créera des bogues difficiles à trouver.

Devoir écrire certaines fonctions de transfert semble être un petit prix à payer en comparaison.

4
Bo Persson

La raison la plus courante de vouloir hériter des conteneurs est que vous voulez ajouter une fonction membre à la classe. Puisque stdlib lui-même n'est pas modifiable, l'héritage est considéré comme le substitut. Cela ne fonctionne cependant pas. Il vaut mieux faire une fonction libre qui prend un vecteur comme paramètre:

void f(std::vector<int> &v) { ... }
2
tp1

Parce que vous ne pouvez jamais garantir que vous ne les avez pas utilisés de manière polymorphe. Vous suppliez pour des problèmes. Prendre la peine d'écrire quelques fonctions n'est pas un gros problème, et, même vouloir le faire est au mieux douteux. Qu'est-il arrivé à l'encapsulation?

2
Puppy