Ist es in Ordnung, die Implementierung von STL-Containern zu erben, anstatt sie zu delegieren?

79

Ich habe eine Klasse, die std :: vector anpasst, um einen Container mit domänenspezifischen Objekten zu modellieren. Ich möchte dem Benutzer den größten Teil der std :: vector-API zur Verfügung stellen, damit er vertraute Methoden (Größe, Löschen, At usw.) und Standardalgorithmen für den Container verwenden kann. Dies scheint für mich ein wiederkehrendes Muster in meinen Entwürfen zu sein:

class MyContainer : public std::vector<MyObject>
{
public:
   // Redeclare all container traits: value_type, iterator, etc...

   // Domain-specific constructors
   // (more useful to the user than std::vector ones...)

   // Add a few domain-specific helper methods...

   // Perhaps modify or hide a few methods (domain-related)
};

Ich bin mir der Praxis bewusst, Komposition gegenüber Vererbung zu bevorzugen, wenn eine Klasse für die Implementierung wiederverwendet wird - aber es muss eine Grenze geben! Wenn ich alles an std :: vector delegieren würde, gäbe es (nach meiner Zählung) 32 Weiterleitungsfunktionen!

Meine Fragen sind also ... Ist es in solchen Fällen wirklich so schlimm, die Implementierung zu erben? Was sind die Risiken? Gibt es eine sicherere Möglichkeit, dies zu implementieren, ohne so viel zu tippen? Bin ich ein Ketzer für die Verwendung der Implementierungsvererbung? :) :)

Bearbeiten:

Wie wäre es damit, klar zu machen, dass der Benutzer MyContainer nicht über einen std :: vector <> -Zeiger verwenden sollte:

// non_api_header_file.h
namespace detail
{
   typedef std::vector<MyObject> MyObjectBase;
}

// api_header_file.h
class MyContainer : public detail::MyObjectBase
{
   // ...
};

Die Boost-Bibliotheken scheinen dies die ganze Zeit zu tun.

Bearbeiten 2:

Einer der Vorschläge war die Verwendung freier Funktionen. Ich werde es hier als Pseudocode zeigen:

typedef std::vector<MyObject> MyCollection;
void specialCollectionInitializer(MyCollection& c, arguments...);
result specialCollectionFunction(const MyCollection& c);
etc...

Eine OO-Methode:

typedef std::vector<MyObject> MyCollection;
class MyCollectionWrapper
{
public:
   // Constructor
   MyCollectionWrapper(arguments...) {construct coll_}

   // Access collection directly
   MyCollection& collection() {return coll_;} 
   const MyCollection& collection() const {return coll_;}

   // Special domain-related methods
   result mySpecialMethod(arguments...);

private:
   MyCollection coll_;
   // Other domain-specific member variables used
   // in conjunction with the collection.
}
Emile Cormier
quelle
6
Oh gut! Eine weitere Möglichkeit, meinen Blog auf Punchlet.wordpress.com zu pushen - schreiben Sie im Grunde kostenlose Funktionen und vergessen Sie den Wrapper-Ansatz "more OO". Es ist nicht mehr OO - wenn es so wäre, würde es Vererbung verwenden, was Sie in diesem Fall wahrscheinlich nicht sollten. Denken Sie daran, OO! = Klasse.
1
@Neil: Aber, aber ... globale Funktionen sind böse !!! Alles ist ein Objekt! ;)
Emile Cormier
4
Sie sind nicht global, wenn Sie sie in einen Namespace einfügen.
1
Wenn Sie wirklich die gesamte Schnittstelle des Vektors verfügbar machen möchten, ist es in C ++ wahrscheinlich besser, die Komposition zu verwenden und einen Verweis auf den Vektor über einen Getter (mit const- und non-const-Versionen) verfügbar zu machen. In Java würden Sie nur erben, aber in Java wird eine gewisse Zahl nicht auftauchen, Ihre Dokumentation ignorieren, Ihr Objekt durch den falschen Zeiger löschen (oder erneut erben und es vermasseln) und sich dann beschweren. Für ein begrenztes Publikum vielleicht, aber wenn Benutzer Freaks mit dynamischem Polymorphismus oder kürzlich Ex-Java-Programmierer sind, entwerfen Sie eine Schnittstelle, von der Sie ziemlich sicher sein können, dass sie sie falsch verstehen.
Steve Jessop
1
Sie können sich nicht vor Personen schützen, die die Dokumentation vollständig ignorieren. Es würde mich nicht wundern, wenn ich herausfinde, dass ein solcher Missbrauch in Java genauso viele Probleme verursacht wie in C ++.

Antworten:

75

Das Risiko besteht in der Freigabe durch einen Zeiger auf die Basisklasse ( Löschen , Löschen [] und möglicherweise andere Freigabemethoden). Da diese Klassen ( deque , map , string usw.) keine virtuellen dtors haben, ist es unmöglich, sie mit nur einem Zeiger auf diese Klassen ordnungsgemäß zu bereinigen:

struct BadExample : vector<int> {};
int main() {
  vector<int>* p = new BadExample();
  delete p; // this is Undefined Behavior
  return 0;
}

Das heißt, wenn Sie bereit sind, sicherzustellen, dass Sie dies niemals versehentlich tun, hat es wenig großen Nachteil, sie zu erben - aber in einigen Fällen ist das ein großes Wenn. Weitere Nachteile sind Konflikte mit Implementierungsspezifikationen und -erweiterungen (von denen einige möglicherweise keine reservierten Bezeichner verwenden) und der Umgang mit aufgeblähten Schnittstellen ( insbesondere Zeichenfolge ). In einigen Fällen ist jedoch eine Vererbung vorgesehen, da Containeradapter wie der Stapel ein geschütztes Element c (den zugrunde liegenden Container, den sie anpassen) haben und auf den fast nur von einer abgeleiteten Klasseninstanz aus zugegriffen werden kann.

Anstelle von Vererbung oder Komposition sollten Sie freie Funktionen schreiben , die entweder ein Iteratorpaar oder eine Containerreferenz verwenden, und diese bearbeiten. Praktisch der gesamte <Algorithmus> ist ein Beispiel dafür. und make_heap , pop_heap und push_heap , insbesondere, ist ein Beispiel von freien Funktionen anstelle einen domänenspezifischen Behälter verwenden.

Verwenden Sie also die Containerklassen für Ihre Datentypen und rufen Sie dennoch die freien Funktionen für Ihre domänenspezifische Logik auf. Mit einem typedef können Sie dennoch eine gewisse Modularität erreichen, wodurch Sie sowohl das Deklarieren vereinfachen als auch einen einzelnen Punkt bereitstellen können, wenn ein Teil von ihnen geändert werden muss:

typedef std::deque<int, MyAllocator> Example;
// ...
Example c (42);
example_algorithm(c);
example_algorithm2(c.begin() + 5, c.end() - 5);
Example::iterator i; // nested types are especially easier

Beachten Sie, dass sich value_type und allocator ändern können, ohne den späteren Code mithilfe von typedef zu beeinflussen, und dass sich sogar der Container von einer Deque zu einem Vektor ändern kann .

Stilltracy
quelle
35

Sie können die private Vererbung und das Schlüsselwort "using" kombinieren, um die meisten der oben genannten Probleme zu umgehen: Die private Vererbung ist "in Bezug auf implementiert", und da sie privat ist, können Sie keinen Zeiger auf die Basisklasse halten

#include <string>
#include <iostream>

class MyString : private std::string
{
public:
    MyString(std::string s) : std::string(s) {}
    using std::string::size;
    std::string fooMe(){ return std::string("Foo: ") + *this; }
};

int main()
{
    MyString s("Hi");
    std::cout << "MyString.size(): " << s.size() << std::endl;
    std::cout << "MyString.fooMe(): " << s.fooMe() << std::endl;
}
Ben
quelle
2
Ich kann nicht anders, als zu erwähnen, dass privateVererbung immer noch Vererbung ist und somit eine stärkere Beziehung als Komposition. Dies bedeutet insbesondere, dass eine Änderung der Implementierung Ihrer Klasse zwangsläufig die Binärkompatibilität beeinträchtigt.
Matthieu M.
8
Private Vererbung und private Datenelemente beeinträchtigen beide die Binärkompatibilität, wenn sie sich ändern, und mit Ausnahme von Freunden (die nur wenige sein sollten) ist es normalerweise nicht schwierig, zwischen ihnen zu wechseln - was verwendet wird, wird häufig durch Implementierungsdetails bestimmt. Siehe auch die "Basis-von-Mitglied-Sprache".
Für die Neugierigen - Base-from-Member Idiom: en.wikibooks.org/wiki/More_C%2B%2B_Idioms/Base-from-Member
Emile Cormier
1
@MatthieuM. Das Brechen von ABI ist für die meisten Anwendungen überhaupt kein Problem. Sogar einige Bibliotheken leben ohne Pimpl, um eine bessere Leistung zu erzielen.
Doc
15

Wie bereits erwähnt, verfügen STL-Container nicht über virtuelle Destruktoren, sodass das Erben von ihnen bestenfalls unsicher ist. Ich habe generische Programmierung mit Vorlagen immer als einen anderen OO-Stil betrachtet - einen ohne Vererbung. Die Algorithmen definieren die Schnittstelle, die sie benötigen. Es ist so nah wie möglich an Duck Typing in einer statischen Sprache.

Jedenfalls habe ich der Diskussion etwas hinzuzufügen. Die Art und Weise, wie ich zuvor meine eigenen Vorlagenspezialisierungen erstellt habe, besteht darin, Klassen wie die folgenden zu definieren, die als Basisklassen verwendet werden sollen.

template <typename Container>
class readonly_container_facade {
public:
    typedef typename Container::size_type size_type;
    typedef typename Container::const_iterator const_iterator;

    virtual ~readonly_container_facade() {}
    inline bool empty() const { return container.empty(); }
    inline const_iterator begin() const { return container.begin(); }
    inline const_iterator end() const { return container.end(); }
    inline size_type size() const { return container.size(); }
protected: // hide to force inherited usage only
    readonly_container_facade() {}
protected: // hide assignment by default
    readonly_container_facade(readonly_container_facade const& other):
        : container(other.container) {}
    readonly_container_facade& operator=(readonly_container_facade& other) {
        container = other.container;
        return *this;
    }
protected:
    Container container;
};

template <typename Container>
class writable_container_facade: public readable_container_facade<Container> {
public:
    typedef typename Container::iterator iterator;
    writable_container_facade(writable_container_facade& other)
        readonly_container_facade(other) {}
    virtual ~writable_container_facade() {}
    inline iterator begin() { return container.begin(); }
    inline iterator end() { return container.end(); }
    writable_container_facade& operator=(writable_container_facade& other) {
        readable_container_facade<Container>::operator=(other);
        return *this;
    }
};

Diese Klassen stellen dieselbe Schnittstelle wie ein STL-Container bereit. Mir hat der Effekt gefallen, die modifizierenden und nicht modifizierenden Operationen in verschiedene Basisklassen zu unterteilen. Dies hat einen sehr schönen Effekt auf die Konstantenkorrektheit. Der einzige Nachteil ist, dass Sie die Schnittstelle erweitern müssen, wenn Sie diese mit assoziativen Containern verwenden möchten. Ich bin jedoch nicht auf die Not gestoßen.

D. Shawley
quelle
Nett! Ich könnte das einfach benutzen. Aber andere haben über die Idee nachgedacht, Container anzupassen, also werde ich sie vielleicht nicht verwenden. :)
Emile Cormier
Trotzdem kann eine umfangreiche Vorlagenprogrammierung zu ebenso schlechtem Spaghetti-Code, massiven Bibliotheken, einer schlechten Isolation der Funktionalität und unverständlichen Fehlern bei der Kompilierung führen.
Erik Aronesty
5

In diesem Fall ist das Erben eine schlechte Idee: Die STL-Container verfügen nicht über virtuelle Destruktoren, sodass möglicherweise Speicherlecks auftreten (außerdem ist dies ein Hinweis darauf, dass STL-Container überhaupt nicht vererbt werden sollen).

Wenn Sie nur einige Funktionen hinzufügen müssen, können Sie diese in globalen Methoden oder in einer Lightweight-Klasse mit einem Containerelementzeiger / einer Referenz deklarieren. Natürlich können Sie Methoden nicht ausblenden: Wenn Sie wirklich danach suchen, gibt es keine andere Option, als die gesamte Implementierung neu zu deklarieren.

stijn
quelle
Sie können Methoden weiterhin ausblenden, indem Sie sie nicht im Header und stattdessen nur in der Implementierung deklarieren, indem Sie sie zu nicht öffentlichen statischen Methoden in einer Dummy-Klasse machen (von der aus Sie eine Freundschaft schließen können). Dies funktioniert für Vorlagen, die nur Header enthalten müssen ) oder indem Sie sie in ein "Detail" oder einen ähnlich benannten Namespace einfügen. (Alle drei funktionieren genauso gut wie herkömmliche private Methoden.)
Ich verstehe nicht, wie Sie eine Methode des 'Vektors' verbergen können, indem Sie sie nicht in Ihrem Header deklarieren. Es ist bereits in Vektor deklariert.
Jherico
Jherico: Sprichst du mit mir oder stijn? Wie auch immer, ich denke, Sie haben einen von uns missverstanden.
@roger Ich bin zweiter Jherico und glaube nicht, dass ich dich verstehe: Sprichst du davon, Methoden vor std :: vector oder vor etwas anderem zu verstecken? Wie wird eine Methode in einem anderen Namespace ausgeblendet? Solange in einem Header angegeben, auf den jemand Zugriff hat, ist er nicht wirklich so versteckt, wie das private Schlüsselwort es verbirgt?
stijn
stijn: Darauf habe ich beim privaten Zugriff hingewiesen, es ist auch nicht wirklich verborgen, da jeder, der Zugriff auf den Header hat, die Quelle lesen oder -Dprivate=publicin der Compiler-Befehlszeile verwenden kann. Zugriffsspezifizierer wie private sind meistens Dokumentationen, die zufällig erzwungen werden.
4

Abgesehen von virtuellen Dtoren sollte die Entscheidung zum Erben im Vergleich zum Enthalten eine Entwurfsentscheidung sein, die auf der von Ihnen erstellten Klasse basiert. Sie sollten niemals Container-Funktionen erben, nur weil dies einfacher ist als das Enthalten eines Containers und das Hinzufügen einiger Funktionen zum Hinzufügen und Entfernen, die wie vereinfachte Wrapper erscheinen, es sei denn, Sie können definitiv sagen, dass die von Ihnen erstellte Klasse eine Art Container ist. Beispielsweise enthält eine Klassenklasse häufig Schülerobjekte, aber ein Klassenzimmer ist für die meisten Zwecke keine Art Liste von Schülern, sodass Sie nicht von der Liste erben sollten.

Jherico
quelle
1

Es ist einfacher zu tun:

typedef std::vector<MyObject> MyContainer;
Martin York
quelle
3
Ich verstehe, aber ich möchte Folgendes tun: typedef (std :: vector <MuObject> + mods) MyContainer mit Bedacht.
Emile Cormier
1

Die Weiterleitungsmethoden werden ohnehin weggeschnitten. Auf diese Weise erhalten Sie keine bessere Leistung. In der Tat werden Sie wahrscheinlich schlechtere Leistung erhalten.

Charles Eli Käse
quelle