Was ist das Muster für eine sichere Schnittstelle in C ++

22

Hinweis: Das Folgende ist C ++ 03-Code, wir erwarten jedoch einen Wechsel zu C ++ 11 in den nächsten zwei Jahren, daher müssen wir dies berücksichtigen.

Ich schreibe eine Anleitung (unter anderem für Anfänger), wie man eine abstrakte Oberfläche in C ++ schreibt. Ich habe beide Artikel von Sutter zu diesem Thema gelesen, im Internet nach Beispielen und Antworten gesucht und einige Tests durchgeführt.

Dieser Code darf NICHT kompiliert werden!

void foo(SomeInterface & a, SomeInterface & b)
{
   SomeInterface c ;               // must not be default-constructible
   SomeInterface d(a);             // must not be copy-constructible
   a = b ;                         // must not be assignable
}

Alle oben genannten Verhaltensweisen finden die Ursache für ihr Problem in der Aufteilung : Die abstrakte Schnittstelle (oder Nicht-Blatt-Klasse in der Hierarchie) sollte weder konstruierbar noch kopierbar / zuweisbar sein, AUCH wenn die abgeleitete Klasse es kann.

0. Lösung: die Grundschnittstelle

class VirtuallyDestructible
{
   public :
      virtual ~VirtuallyDestructible() {}
} ;

Diese Lösung ist einfach und etwas naiv: Sie erfüllt nicht alle unsere Einschränkungen: Sie kann standardmäßig erstellt, kopiert und kopiert zugewiesen werden (ich bin mir nicht einmal sicher, ob Konstruktoren und Zuweisungen verschoben werden müssen, aber ich habe noch 2 Jahre Zeit, um sie abzubilden es raus).

  1. Wir können den Destruktor nicht als rein virtuell deklarieren, da wir ihn inline halten müssen und einige unserer Compiler keine reinen virtuellen Methoden mit inline leerem Body verarbeiten können.
  2. Ja, der einzige Punkt dieser Klasse ist, die Implementierer praktisch zerstörbar zu machen, was ein seltener Fall ist.
  3. Selbst wenn wir eine zusätzliche virtuelle reine Methode hätten (was in den meisten Fällen der Fall ist), wäre diese Klasse immer noch kopierzuweisbar.

Also nein ...

1. Lösung: boost :: noncopyable

class VirtuallyDestructible : boost::noncopyable
{
   public :
      virtual ~VirtuallyDestructible() {}
} ;

Diese Lösung ist die beste, weil sie einfach, klar und C ++ (keine Makros) ist.

Das Problem ist, dass es für diese spezifische Schnittstelle immer noch nicht funktioniert, da VirtuallyConstructible immer noch standardmäßig erstellt werden kann .

  1. Wir können den Destruktor nicht als rein virtuell deklarieren, da wir ihn inline halten müssen und einige unserer Compiler ihn nicht verarbeiten können.
  2. Ja, der einzige Punkt dieser Klasse ist, die Implementierer praktisch zerstörbar zu machen, was ein seltener Fall ist.

Ein weiteres Problem besteht darin, dass Klassen, die die nicht kopierbare Schnittstelle implementieren, dann den Kopierkonstruktor und den Zuweisungsoperator explizit deklarieren / definieren müssen, wenn sie diese Methoden benötigen (und in unserem Code haben wir Wertklassen, auf die unser Client weiterhin über zugreifen kann Schnittstellen).

Dies verstößt gegen die Nullregel, nach der wir vorgehen möchten: Wenn die Standardimplementierung in Ordnung ist, sollten wir sie verwenden können.

2. Lösung: Schützen Sie sie!

class MyInterface
{
   public :
      virtual ~MyInterface() {}

   protected :
      // With C++11, these methods would be "= default"
      MyInterface() {}
      MyInterface(const MyInterface & ) {}
      MyInterface & operator = (const MyInterface & ) { return *this ; }
} ;

Dieses Muster folgt den technischen Einschränkungen, die wir hatten (zumindest im Benutzercode): MyInterface kann nicht standardmäßig erstellt, nicht kopiert und nicht kopiert werden.

Außerdem werden der Implementierung von Klassen keine künstlichen Einschränkungen auferlegt. Sie können dann entweder die Nullregel befolgen oder einige Konstruktoren / Operatoren in C ++ 11/14 problemlos als "= default" deklarieren.

Das ist ziemlich ausführlich, und eine Alternative wäre die Verwendung eines Makros, so etwas wie:

class MyInterface
{
   public :
      virtual ~MyInterface() {}

   protected :
      DECLARE_AS_NON_SLICEABLE(MyInterface) ;
} ;

Der protected muss außerhalb des Makros bleiben (da er keinen Gültigkeitsbereich hat).

Richtig "namespaced" (dh mit dem Namen Ihres Unternehmens oder Produkts vorangestellt) sollte das Makro harmlos sein.

Und der Vorteil ist, dass der Code in einer Quelle berücksichtigt wird, anstatt in alle Schnittstellen kopiert zu werden. Sollte der move-Konstruktor und die move-Zuweisung in Zukunft explizit auf die gleiche Weise deaktiviert werden, wäre dies eine sehr leichte Änderung im Code.

Fazit

  • Bin ich paranoid, wenn der Code vor dem Durchschneiden von Schnittstellen geschützt werden soll? (Ich glaube nicht, aber man weiß es nie ...)
  • Was ist die beste Lösung unter den oben genannten?
  • Gibt es eine andere, bessere Lösung?

Bitte denken Sie daran, dass dies ein Muster ist, das (unter anderem) als Richtlinie für Neulinge dient. Daher ist eine Lösung wie "Jeder Fall sollte implementiert werden" keine praktikable Lösung.

Kopfgeld und Ergebnisse

Ich habe das Kopfgeld an coredump vergeben, weil ich Zeit für die Beantwortung der Fragen und die Relevanz der Antworten aufgewendet habe.

Meine Lösung des Problems wird wahrscheinlich in etwa so aussehen:

class MyInterface
{
   DECLARE_CLASS_AS_INTERFACE(MyInterface) ;

   public :
      // the virtual methods
} ;

... mit folgendem Makro:

#define DECLARE_CLASS_AS_INTERFACE(ClassName)                                \
   public :                                                                  \
      virtual ~ClassName() {}                                                \
   protected :                                                               \
      ClassName() {}                                                         \
      ClassName(const ClassName & ) {}                                       \
      ClassName & operator = (const ClassName & ) { return *this ; }         \
   private :

Dies ist eine praktikable Lösung für mein Problem aus den folgenden Gründen:

  • Diese Klasse kann nicht instanziiert werden (die Konstruktoren sind geschützt)
  • Diese Klasse kann praktisch zerstört werden
  • Diese Klasse kann vererbt werden, ohne dass der Vererbung von Klassen übermäßige Einschränkungen auferlegt werden (z. B. kann die vererbende Klasse standardmäßig kopiert werden).
  • Die Verwendung des Makros bedeutet, dass die "Deklaration" der Schnittstelle leicht erkennbar (und durchsuchbar) ist und der Code an einer Stelle berücksichtigt wird, was die Änderung erleichtert (ein geeigneter vorangestellter Name entfernt unerwünschte Namenskonflikte).

Beachten Sie, dass die anderen Antworten wertvolle Erkenntnisse lieferten. Vielen Dank an alle, die es probiert haben.

Beachten Sie, dass ich denke, dass ich dieser Frage noch ein Kopfgeld hinzufügen kann, und ich schätze aufschlussreiche Antworten so sehr, dass ich ein Kopfgeld eröffnen würde, um es dieser Antwort zuzuweisen.

paercebal
quelle
5
Können Sie nicht einfach rein virtuelle Funktionen in der Benutzeroberfläche verwenden? virtual void bar() = 0;beispielsweise? Das würde verhindern, dass Ihre Schnittstelle instanziiert wird.
Morwenn
@Morwenn: Wie in der Frage gesagt, würde das 99% der Fälle lösen (ich strebe wenn möglich 100% an). Selbst wenn wir uns dafür entscheiden, die fehlenden 1% zu ignorieren, würde dies auch die Aufteilung der Zuordnung nicht lösen. Nein, das ist keine gute Lösung.
Paercebal
@Morwenn: Ernsthaft? ... :-D ... Ich habe diese Frage zuerst auf StackOverflow geschrieben und dann meine Meinung geändert, bevor ich sie abschickte. Glaubst du, ich sollte es hier löschen und bei SO einreichen?
Paercebal
Wenn ich Recht habe, brauchen Sie nur eine virtual ~VirtuallyDestructible() = 0virtuelle Vererbung von Schnittstellenklassen (nur mit abstrakten Mitgliedern). Sie könnten das VirtuallyDestructible wahrscheinlich weglassen.
Dieter Lücking
5
@paercebal: Wenn der Compiler an rein virtuellen Klassen erstickt, gehört er in den Papierkorb. Eine reale Schnittstelle ist per Definition rein virtuell.
Niemand

Antworten:

13

Die kanonische Möglichkeit, eine Schnittstelle in C ++ zu erstellen, besteht darin, ihr einen reinen virtuellen Destruktor zu geben. Dies stellt sicher, dass

  • Es können keine Instanzen der Interface-Klasse selbst erstellt werden, da Sie in C ++ keine Instanz einer abstrakten Klasse erstellen können. Dies berücksichtigt die nicht konstruierbaren Anforderungen (sowohl Standard als auch Kopie).
  • Das Aufrufen deleteeines Zeigers auf die Schnittstelle macht das Richtige: Es ruft den Destruktor der am häufigsten abgeleiteten Klasse für diese Instanz auf.

Nur ein reiner virtueller Destruktor verhindert nicht die Zuweisung eines Verweises auf die Schnittstelle. Wenn dies ebenfalls fehlschlagen soll, müssen Sie Ihrer Schnittstelle einen geschützten Zuweisungsoperator hinzufügen.

Jeder C ++ - Compiler sollte in der Lage sein, eine Klasse / Schnittstelle wie diese zu verarbeiten (alles in einer Header-Datei):

class MyInterface {
public:
  virtual ~MyInterface() = 0;
protected:
  MyInterface& operator=(const MyInterface&) { return *this; } // or = default for C++14
};

inline MyInterface::~MyInterface() {}

Wenn Sie einen Compiler haben, der dies unterdrückt (was bedeutet, dass es sich um einen Compiler vor C ++ 98 handelt), ist Ihre Option 2 (mit geschützten Konstruktoren) eine gute zweitbeste.

Die Verwendung boost::noncopyableist für diese Aufgabe nicht empfehlenswert, da sie die Meldung sendet, dass alle Klassen in der Hierarchie nicht kopierbar sein sollten, und daher Verwirrung bei erfahreneren Entwicklern stiften kann, die mit Ihren Absichten für eine solche Verwendung nicht vertraut sind.

Bart van Ingen Schenau
quelle
If you need [prevent assignment] to fail as well, then you must add a protected assignment operator to your interface.: Das ist die Wurzel meines Problems. Die Fälle, in denen ich eine Schnittstelle benötige, um die Zuweisung zu unterstützen, müssen in der Tat selten sein. Andererseits sind die Fälle, in denen ich eine Schnittstelle als Referenz übergeben möchte (die Fälle, in denen NULL nicht akzeptabel ist) und daher ein No-Op oder ein Slicing, das kompiliert wird, vermeiden möchten, viel größer.
Paercebal
Da der Zuweisungsoperator niemals aufgerufen werden sollte, warum geben Sie ihm eine Definition? Warum sollte man es nicht schaffen private? Darüber hinaus möchten Sie sich möglicherweise mit dem Standard- und Kopier-ctor befassen.
Deduplizierer
5

Bin ich paranoid ...

  • Bin ich paranoid, wenn der Code vor dem Durchschneiden von Schnittstellen geschützt werden soll? (Ich glaube nicht, aber man weiß es nie ...)

Ist das nicht ein Risikomanagement-Problem?

  • Befürchten Sie, dass ein Fehler im Zusammenhang mit dem Schneiden auftreten könnte?
  • Denken Sie, dass es unbemerkt bleiben und nicht behebbare Fehler hervorrufen kann?
  • Inwieweit sind Sie bereit zu gehen, um das Schneiden zu vermeiden?

Beste Lösung

  • Was ist die beste Lösung unter den oben genannten?

Ihre zweite Lösung ("machen Sie sie geschützt") sieht gut aus, aber denken Sie daran, dass ich kein C ++ - Experte bin.
Zumindest scheinen die ungültigen Verwendungen von meinem Compiler (g ++) als fehlerhaft gemeldet worden zu sein.

Benötigen Sie nun Makros? Ich würde "Ja" sagen, denn obwohl Sie nicht sagen, was der Zweck der Richtlinie ist, die Sie schreiben, besteht diese meiner Meinung nach darin, bestimmte Best Practices im Code Ihres Produkts durchzusetzen.

Zu diesem Zweck können Makros helfen, festzustellen, wann die Benutzer das Muster effektiv anwenden: Ein grundlegender Filter für Festschreibungen kann Ihnen mitteilen, ob das Makro verwendet wurde:

  • Wenn es verwendet wird, wird das Muster wahrscheinlich angewendet und, was noch wichtiger ist, korrekt angewendet (überprüfen Sie einfach, ob es ein protectedSchlüsselwort gibt).
  • Wenn es nicht verwendet wird, können Sie herausfinden, warum dies nicht der Fall ist.

Ohne Makros müssen Sie überprüfen, ob das Muster in allen Fällen notwendig und gut implementiert ist.

Bessere Lösung

  • Gibt es eine andere, bessere Lösung?

Das Schneiden in C ++ ist nichts anderes als eine Besonderheit der Sprache. Da Sie eine Richtlinie schreiben (insbesondere für Anfänger), sollten Sie sich auf das Unterrichten konzentrieren und nicht nur "Codierungsregeln" aufzählen. Sie müssen sicherstellen, dass Sie wirklich erklären, wie und warum das Schneiden stattfindet, zusammen mit Beispielen und Übungen (erfinden Sie das Rad nicht neu, lassen Sie sich von Büchern und Tutorials inspirieren).

Der Titel einer Übung könnte beispielsweise lauten: " Wie lautet das Muster für eine sichere Schnittstelle in C ++ ?"

Ihr bester Schritt wäre es also, sicherzustellen, dass Ihre C ++ - Entwickler verstehen, was passiert, wenn das Schneiden stattfindet. Ich bin davon überzeugt, dass sie in diesem Fall nicht so viele Fehler im Code machen, wie Sie befürchten würden, auch ohne dieses bestimmte Muster formal durchzusetzen (aber Sie können es trotzdem durchsetzen, Compiler-Warnungen sind gut).

Über den Compiler

Du sagst :

Ich habe keinen Einfluss auf die Auswahl der Compiler für dieses Produkt.

Oft werden die Leute sagen "Ich habe nicht das Recht, [X] zu tun" , "Ich soll nicht [Y] tun ..." , ... weil sie denken, dass dies nicht möglich ist und nicht, weil sie es tun versucht oder gefragt.

Es ist wahrscheinlich Teil Ihrer Stellenbeschreibung, Ihre Meinung zu technischen Fragen zu äußern. Wenn Sie der Meinung sind, dass der Compiler die perfekte (oder einzigartige) Wahl für Ihre Problemdomäne ist, verwenden Sie ihn. Sie sagten aber auch, dass "reine virtuelle Destruktoren mit Inline-Implementierung nicht der schlechteste Erstickungspunkt sind, den ich je gesehen habe" . Nach meinem Verständnis ist der Compiler so speziell, dass selbst erfahrene C ++ - Entwickler Schwierigkeiten haben, ihn zu verwenden: Ihr älterer / interner Compiler ist jetzt technisch hoch verschuldet, und Sie haben das Recht (die Pflicht?), dieses Problem mit anderen Entwicklern und Managern zu diskutieren .

Versuchen Sie, die Kosten für die Beibehaltung des Compilers im Vergleich zu den Kosten für die Verwendung eines anderen Compilers zu bewerten:

  1. Was bringt Ihnen der aktuelle Compiler, was kein anderer kann?
  2. Ist Ihr Produktcode mit einem anderen Compiler leicht zu kompilieren? Warum nicht ?

Ich kenne Ihre Situation nicht, und tatsächlich haben Sie wahrscheinlich triftige Gründe, an einen bestimmten Compiler gebunden zu sein.
Aber in dem Fall, dass dies nur eine reine Trägheit ist, wird sich die Situation niemals ändern, wenn Sie oder Ihre Mitarbeiter keine Probleme mit der Produktivität oder der technischen Verschuldung melden.

Core-Dump
quelle
Am I paranoid...: "Machen Sie die Verwendung Ihrer Schnittstellen einfach und fehlerhaft." Ich habe dieses spezielle Prinzip probiert, als jemand meldete, dass eine meiner statischen Methoden versehentlich falsch angewendet wurde. Der erzeugte Fehler schien nichts damit zu tun zu haben, und es dauerte mehrere Stunden, bis ein Ingenieur die Quelle gefunden hatte. Dieser "Schnittstellenfehler" entspricht dem Zuweisen einer Schnittstellenreferenz zu einer anderen. Also, ja, ich möchte diese Art von Fehler vermeiden. Außerdem besteht die Philosophie in C ++ darin, zur Kompilierungszeit so viel wie möglich abzufangen, und die Sprache gibt uns diese Kraft, also gehen wir damit um.
Paercebal
Best solution: Genau. . . Better solution: Das ist eine tolle Antwort. Ich werde daran arbeiten ... Nun zum Thema Pure virtual classes: Was ist das? Eine abstrakte C ++ Schnittstelle? (Klasse ohne Status und nur rein virtuelle Methoden?). Wie hat mich diese "rein virtuelle Klasse" vor dem Aufschneiden geschützt? (Bei rein virtuellen Methoden wird die Instanziierung nicht kompiliert, aber die Zuweisung von Kopien und die Zuweisung von Verschiebungen wird ebenfalls IIRC).
Paercebal
About the compiler: Wir sind uns einig, aber unsere Compiler liegen außerhalb meines Verantwortungsbereichs (nicht, dass es mich von pingeligen Kommentaren abhält ... :-p ...). Ich werde die Details nicht weitergeben (ich wünschte, ich könnte es), aber es hängt mit internen Gründen (wie Testsuiten) und externen Gründen (z. B. Verlinkung des Clients mit unseren Bibliotheken) zusammen. Letztendlich ist das Ändern der Compilerversion (oder sogar das Patchen) KEINE triviale Operation. Geschweige denn einen kaputten Compiler durch einen neuen gcc ersetzen.
Paercebal
@paercebal danke für deine Kommentare; über reine virtuelle Klassen haben Sie recht, es löst nicht alle Ihre Einschränkungen (ich werde diesen Teil entfernen). Ich verstehe den Teil "Schnittstellenfehler" und wie nützlich es ist, Fehler beim Kompilieren abzufangen: Sie haben jedoch gefragt, ob Sie paranoid sind, und ich denke, der vernünftige Ansatz besteht darin, Ihr Bedürfnis nach statischen Überprüfungen mit der Wahrscheinlichkeit des Auftretens des Fehlers in Einklang zu bringen. Viel Glück mit der Compiler-Sache :)
Coredump
1
Ich bin kein Fan der Makros, vor allem, weil die Richtlinien (auch) auf Junior-Männer abzielen. Zu oft habe ich Leute gesehen, denen solche „praktischen“ Werkzeuge gegeben wurden, um sie blind anzuwenden, und die nie verstanden haben, was tatsächlich vor sich ging. Sie glauben, dass das, was das Makro tut, die komplizierteste Sache sein muss, weil ihr Chef dachte, es wäre zu schwierig für sie, sich selbst zu tun. Und da das Makro nur in Ihrem Unternehmen vorhanden ist, können sie nicht einmal im Internet danach suchen, wohingegen sie nach einer dokumentierten Richtlinie, welche Mitgliederfunktionen zu deklarieren sind und warum, suchen können.
5gon12eder
2

Das Slicing-Problem ist eines, aber sicherlich nicht das einzige, das auftritt, wenn Sie Ihren Benutzern eine polymorphe Laufzeitschnittstelle zur Verfügung stellen. Denken Sie an Nullzeiger, Speicherverwaltung und gemeinsame Daten. Keines dieser Probleme lässt sich in jedem Fall leicht lösen (intelligente Zeiger sind großartig, aber selbst sie sind keine Wunderwaffe). Tatsächlich scheint es in Ihrem Beitrag nicht so zu sein , als wollten Sie das Problem des Aufteilens lösen , sondern umgehen es, indem Sie Benutzern nicht erlauben, Kopien zu erstellen. Alles, was Sie tun müssen, um eine Lösung für das Slicing-Problem anzubieten, ist das Hinzufügen einer Funktion für virtuelle Klonmitglieder. Ich denke, das tiefere Problem beim Anzeigen einer polymorphen Laufzeitschnittstelle besteht darin, dass Sie Benutzer dazu zwingen, sich mit Referenzsemantik zu befassen, die schwerer zu erklären ist als Wertsemantik.

Der beste Weg, wie ich weiß, um diese Probleme in C ++ zu vermeiden, ist die Typlöschung . Dies ist eine Technik, bei der Sie eine polymorphe Laufzeitschnittstelle hinter einer normalen Klassenschnittstelle verbergen. Diese normale Klassenschnittstelle hat dann eine Wertesemantik und kümmert sich um alles polymorphe "Durcheinander" hinter den Bildschirmen. std::functionist ein Paradebeispiel für das Löschen von Typen.

Die folgenden Präsentationen von Sean Parent erläutern, warum es schlecht ist, die Vererbung Ihren Benutzern zugänglich zu machen und wie das Löschen von Typen Abhilfe schaffen kann:

Vererbung ist die Basisklasse des Bösen (kurze Version)

Value Semantics und Concepts-based Polymorphism (lange Version; einfacher zu folgen, aber der Klang ist nicht großartig)

D Dr.
quelle
0

Du bist nicht paranoid. Meine erste berufliche Aufgabe als C ++ - Programmierer führte zum Absturz. Ich kenne andere. Dafür gibt es nicht viele gute Lösungen.

In Anbetracht Ihrer Compiler-Einschränkungen ist Option 2 die beste. Anstatt ein Makro zu erstellen, das Ihre neuen Programmierer als seltsam und mysteriös ansehen werden, würde ich ein Skript oder Tool zur automatischen Codegenerierung vorschlagen. Wenn Ihre neuen Mitarbeiter eine IDE verwenden, sollten Sie in der Lage sein, ein "New MYCOMPANY Interface" -Tool zu erstellen, das nach dem Namen der Schnittstelle fragt und die gewünschte Struktur erstellt.

Wenn Ihre Programmierer die Befehlszeile verwenden, verwenden Sie die für Sie verfügbare Skriptsprache, um das Skript NewMyCompanyInterface zum Generieren des Codes zu erstellen.

Ich habe diesen Ansatz in der Vergangenheit für gängige Codemuster (Schnittstellen, Zustandsautomaten usw.) verwendet. Das Schöne daran ist, dass neue Programmierer die Ausgabe leicht lesen und verstehen können und den benötigten Code reproduzieren können, wenn sie etwas benötigen, das nicht generiert werden kann.

Makros und andere Methoden der Metaprogrammierung verschleiern in der Regel das Geschehen, und die neuen Programmierer erfahren nicht, was "hinter den Kulissen" geschieht. Wenn sie das Muster brechen müssen, sind sie genauso verloren wie zuvor.

Ben
quelle