Ist es in Ordnung, die Implementierung von STL-Containern zu erben, anstatt sie zu delegieren?
Lesezeit: 9 Minuten
Emil Cormier
Ich habe eine Klasse, die std::vector anpasst, um einen Container mit domänenspezifischen Objekten zu modellieren. Ich möchte den größten Teil der std::vector-API dem Benutzer zur Verfügung stellen, damit er vertraute Methoden (Größe, Löschen, usw.) und Standardalgorithmen für den Container verwenden kann. Dies scheint ein wiederkehrendes Muster für mich in meinen Designs 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, bei der Wiederverwendung einer Klasse für die Implementierung die Komposition der Vererbung vorzuziehen – 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 wirklich so schlimm, die Implementierung in solchen Fällen zu erben? Was sind die Risiken? Gibt es eine sicherere Möglichkeit, dies ohne so viel Tipparbeit zu implementieren? Bin ich ein Ketzer für die Verwendung der Implementierungsvererbung? 🙂
Bearbeiten:
Wie wäre es, klarzustellen, 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 dieses Zeug die ganze Zeit zu machen.
Bearbeiten 2:
Einer der Vorschläge war, kostenlose Funktionen zu verwenden. Ich zeige es hier als Pseudo-Code:
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.
}
Oh gut! Eine weitere Chance, meinen Blog voranzutreiben punchlet.wordpress.com – Schreiben Sie im Grunde freie Funktionen und vergessen Sie den “mehr OO”-Wrapper-Ansatz. Es ist nicht mehr OO – wenn es so wäre, würde es Vererbung verwenden, was Sie in diesem Fall wahrscheinlich nicht sollten. Erinnere dich an OO != Klasse.
– anon
9. Januar 2010 um 21:40 Uhr
@Neil: Aber, aber… globale Funktionen sind böse!!! Alles ist ein Objekt! 😉
– Emil Cormier
9. Januar 2010 um 21:48 Uhr
Sie sind nicht global, wenn Sie sie in einen Namensraum stellen.
– anon
9. Januar 2010 um 21:49 Uhr
Wenn Sie wirklich die gesamte Schnittstelle von vector verfügbar machen möchten, ist es in C++ wahrscheinlich besser, die Komposition zu verwenden und einen Verweis auf den Vektor über einen Getter verfügbar zu machen (mit konstanten und nicht konstanten Versionen). In Java würdest du einfach erben, aber dann kommt in Java etwas numpty nicht mit, ignoriere deine Dokumentation, lösche dein Objekt durch den falschen Zeiger (oder erbe es erneut und vermassele es) und beschwere dich dann. Für ein begrenztes Publikum vielleicht, aber wenn Benutzer vielleicht Freaks für dynamische Polymorphie sind oder kürzlich Ex-Java-Programmierer sind, entwerfen Sie eine Schnittstelle, bei der Sie ziemlich sicher sein können, dass sie missverstanden werden.
– Steve Jessop
9. Januar 2010 um 23:06 Uhr
Sie können sich nicht davor schützen, dass die Dokumentation vollständig ignoriert wird. Ich wäre nicht überrascht, wenn ich herausfinden würde, dass ein solcher Missbrauch in Java genauso viele Probleme verursacht wie in C++.
– Roger Pate
11. April 2010 um 2:08 Uhr
Das Risiko wird durch einen Zeiger auf die Basisklasse aufgehoben (löschen, löschen[], und möglicherweise andere Freigabemethoden). Da diese Klassen (deque, Karte, Schnuretc.) keine virtuellen Dtors haben, ist es unmöglich, sie mit nur einem Zeiger auf diese Klassen richtig zu bereinigen:
struct BadExample : vector<int> {};
int main() {
vector<int>* p = new BadExample();
delete p; // this is Undefined Behavior
return 0;
}
Das gesagt, wenn Sie bereit sind sicherzustellen, dass Sie dies niemals versehentlich tun, gibt es kaum einen großen Nachteil, sie zu erben – aber in manchen 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 (Schnur bestimmtes). In einigen Fällen ist jedoch eine Vererbung vorgesehen, wie z. B. Containeradapter Stapel ein geschütztes Mitglied haben C (der zugrunde liegende Container, den sie anpassen), und es ist fast nur von einer abgeleiteten Klasseninstanz aus darauf zugegriffen werden.
Anstatt entweder Erbschaft oder Zusammensetzung, Denken Sie darüber nach, freie Funktionen zu schreiben die entweder ein Iteratorpaar oder eine Containerreferenz nehmen und damit arbeiten. Praktisch ganz ist ein Beispiel dafür; und make_haufen, pop_haufenund push_haufeninsbesondere , sind ein Beispiel für die Verwendung freier Funktionen anstelle eines domänenspezifischen Containers.
Verwenden Sie also die Containerklassen für Ihre Datentypen und rufen Sie trotzdem die freien Funktionen für Ihre domänenspezifische Logik auf. Aber Sie können immer noch eine gewisse Modularität erreichen, indem Sie eine Typedef verwenden, die es Ihnen ermöglicht, sie sowohl zu vereinfachen als auch einen einzigen Punkt bereitzustellen, 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 value_type und allocator sich ändern können, ohne späteren Code mit typedef zu beeinflussen, und sogar der Container kann sich von a ändern deque zu einem Vektor.
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 „im Sinne von implementiert“, und da sie privat ist, können Sie keinen Zeiger auf die Basisklasse halten
Ich kann nicht umhin, das zu erwähnen private Vererbung ist immer noch Vererbung und damit eine stärkere Beziehung als Komposition. Insbesondere bedeutet dies, dass eine Änderung der Implementierung Ihrer Klasse zwangsläufig die Binärkompatibilität beeinträchtigen wird.
– Matthias M.
10. Januar 2010 um 14:13 Uhr
Sowohl private Vererbung als auch private Datenmitglieder unterbrechen die Binärkompatibilität, wenn sie sich ändern, und abgesehen von Freunden (die wenige sein sollten) ist es normalerweise nicht schwer, zwischen ihnen zu wechseln – was verwendet wird, hängt oft von Implementierungsdetails ab. Siehe auch das “Base-from-Member-Idiom”.
@MatthieuM. Das Brechen von ABI ist für die meisten Anwendungen überhaupt kein Problem. Sogar einige Bibliotheken leben ohne Pimpl für eine bessere Leistung.
– mip
24. November 2014 um 10:00 Uhr
Wie alle bereits gesagt haben, haben STL-Container keine virtuellen Destruktoren, so dass das Erben von ihnen bestenfalls unsicher ist. Ich habe die generische Programmierung mit Templates immer als einen anderen OO-Stil betrachtet – einen ohne Vererbung. Die Algorithmen definieren die Schnittstelle, die sie benötigen. Es ist so nah Duck-Typisierung wie Sie es in einer statischen Sprache bekommen können.
Jedenfalls habe ich etwas zur Diskussion 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.
Diese Klassen stellen dieselbe Schnittstelle wie ein STL-Container bereit. Mir gefiel der Effekt der Trennung der modifizierenden und nicht modifizierenden Operationen in unterschiedliche Basisklassen. Dies hat einen wirklich schönen Effekt auf die const-Korrektheit. 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 Notwendigkeit gestoßen.
Hübsch! Das könnte ich einfach verwenden. Aber andere haben über die Idee nachgedacht, Container anzupassen, also werde ich sie vielleicht nicht verwenden. 🙂
– Emil Cormier
9. Januar 2010 um 23:08 Uhr
Allerdings kann eine aufwändige Template-Programmierung zu ebenso schlechtem Spaghetti-Code, riesigen Bibliotheken, schlechter Isolierung der Funktionalität und unverständlichen Kompilierzeitfehlern führen.
– Erik Aronesty
6. Februar 2020 um 22:32 Uhr
In diesem Fall ist Vererbung eine schlechte Idee: Die STL-Container haben keine virtuellen Destruktoren, sodass Sie möglicherweise auf Speicherlecks stoßen (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 sie in globalen Methoden oder einer einfachen Klasse mit einem Container-Member-Zeiger/einer Referenz deklarieren. Dies erlaubt Ihnen natürlich nicht, Methoden zu verstecken: Wenn Sie wirklich danach suchen, gibt es keine andere Möglichkeit, als die gesamte Implementierung neu zu deklarieren.
Abgesehen von virtuellen Dtors sollte die Entscheidung, zu erben oder zu enthalten, eine Designentscheidung sein, die auf der Klasse basiert, die Sie erstellen. Sie sollten die Containerfunktionalität niemals erben, nur weil es einfacher ist, als einen Container zu enthalten und ein paar Hinzufügungs- und Entfernungsfunktionen hinzuzufügen, die wie simple Wrapper erscheinen wenn nicht Sie können definitiv sagen, dass die Klasse, die Sie erstellen, eine Art Container ist. Beispielsweise enthält eine Klassenzimmerklasse häufig Schülerobjekte, aber ein Klassenzimmer ist für die meisten Zwecke keine Art Liste von Schülern, daher sollten Sie nicht von der Liste erben.
Martin York
Es ist einfacher zu tun:
typedef std::vector<MyObject> MyContainer;
Charles Eli Käse
Die Weiterleitungsmethoden werden sowieso inliniert. Auf diese Weise werden Sie keine bessere Leistung erzielen. Tatsächlich werden Sie wahrscheinlich eine schlechtere Leistung erzielen.
9931700cookie-checkIst es in Ordnung, die Implementierung von STL-Containern zu erben, anstatt sie zu delegieren?yes
Oh gut! Eine weitere Chance, meinen Blog voranzutreiben punchlet.wordpress.com – Schreiben Sie im Grunde freie Funktionen und vergessen Sie den “mehr OO”-Wrapper-Ansatz. Es ist nicht mehr OO – wenn es so wäre, würde es Vererbung verwenden, was Sie in diesem Fall wahrscheinlich nicht sollten. Erinnere dich an OO != Klasse.
– anon
9. Januar 2010 um 21:40 Uhr
@Neil: Aber, aber… globale Funktionen sind böse!!! Alles ist ein Objekt! 😉
– Emil Cormier
9. Januar 2010 um 21:48 Uhr
Sie sind nicht global, wenn Sie sie in einen Namensraum stellen.
– anon
9. Januar 2010 um 21:49 Uhr
Wenn Sie wirklich die gesamte Schnittstelle von vector verfügbar machen möchten, ist es in C++ wahrscheinlich besser, die Komposition zu verwenden und einen Verweis auf den Vektor über einen Getter verfügbar zu machen (mit konstanten und nicht konstanten Versionen). In Java würdest du einfach erben, aber dann kommt in Java etwas numpty nicht mit, ignoriere deine Dokumentation, lösche dein Objekt durch den falschen Zeiger (oder erbe es erneut und vermassele es) und beschwere dich dann. Für ein begrenztes Publikum vielleicht, aber wenn Benutzer vielleicht Freaks für dynamische Polymorphie sind oder kürzlich Ex-Java-Programmierer sind, entwerfen Sie eine Schnittstelle, bei der Sie ziemlich sicher sein können, dass sie missverstanden werden.
– Steve Jessop
9. Januar 2010 um 23:06 Uhr
Sie können sich nicht davor schützen, dass die Dokumentation vollständig ignoriert wird. Ich wäre nicht überrascht, wenn ich herausfinden würde, dass ein solcher Missbrauch in Java genauso viele Probleme verursacht wie in C++.
– Roger Pate
11. April 2010 um 2:08 Uhr