Kann eine Pimpl-Variante ohne Leistungseinbußen implementiert werden?

9

Eines der Probleme von pimpl ist die Leistungsbeeinträchtigung bei der Verwendung (zusätzliche Speicherzuweisung, nicht zusammenhängende Datenelemente, zusätzliche Indirektionen usw.). Ich möchte eine Variation der Pimpl-Sprache vorschlagen, mit der diese Leistungseinbußen vermieden werden, wenn nicht alle Vorteile von Pimpl genutzt werden. Die Idee ist, alle privaten Datenelemente in der Klasse selbst zu belassen und nur die privaten Methoden in die pimpl-Klasse zu verschieben. Der Vorteil gegenüber Pimpl besteht darin, dass der Speicher zusammenhängend bleibt (keine zusätzliche Indirektion). Die Vorteile gegenüber der Nichtverwendung von Pimpl sind:

  1. Es verbirgt die privaten Funktionen.
  2. Sie können es so strukturieren, dass alle diese Funktionen eine interne Verknüpfung haben und der Compiler es aggressiver optimieren kann.

Meine Idee ist es also, den Pickel von der Klasse selbst erben zu lassen (klingt ein bisschen verrückt, ich weiß, aber ertrage es mit mir). Es würde ungefähr so ​​aussehen:

In der Ah-Datei:

class A
{
    A();
    void DoSomething();
protected:  //All private stuff have to be protected now
    int mData1;
    int mData2;
//Not even a mention of a PImpl in the header file :)
};

In der A.cpp-Datei:

#define PCALL (static_cast<PImpl*>(this))

namespace //anonymous - guarantees internal linkage
{
struct PImpl : public A
{
    static_assert(sizeof(PImpl) == sizeof(A), 
                  "Adding data members to PImpl - not allowed!");
    void DoSomething1();
    void DoSomething2();
    //No data members, just functions!
};

void PImpl::DoSomething1()
{
    mData1 = bar(mData2); //No Problem: PImpl sees A's members as it's own
    DoSomething2();
}

void PImpl::DoSomething2()
{
    mData2 = baz();
}

}
A::A(){}

void A::DoSomething()
{
    mData2 = foo();
    PCALL->DoSomething1(); //No additional indirection, everything can be completely inlined
}

Soweit ich sehe, gibt es absolut keine Leistungseinbußen bei der Verwendung von vs no pimpl und einige mögliche Leistungssteigerungen und eine sauberere Header-Dateischnittstelle. Ein Nachteil gegenüber Standard-Pimpl ist, dass Sie die Datenelemente nicht ausblenden können, sodass Änderungen an diesen Datenelementen immer noch eine Neukompilierung von allem auslösen, was von der Header-Datei abhängt. Aber so wie ich es sehe, ist es entweder der Vorteil oder der Leistungsvorteil, wenn die Mitglieder zusammenhängend im Speicher sind (oder machen Sie diesen Hack- "Warum Versuch Nr. 3 bedauerlich ist"). Eine weitere Einschränkung ist, dass wenn A eine Vorlagenklasse ist, die Syntax ärgerlich wird (Sie wissen, Sie können mData1 nicht direkt verwenden, Sie müssen dies tun-> mData1, und Sie müssen den Typnamen und möglicherweise Vorlagenschlüsselwörter für abhängige Typen verwenden und Schablonentypen usw.). Eine weitere Einschränkung ist, dass Sie in der ursprünglichen Klasse nicht mehr private, sondern nur geschützte Mitglieder verwenden können, sodass Sie den Zugriff von keiner ererbenden Klasse, nicht nur von der Zuhälterin, einschränken können. Ich habe es versucht, konnte dieses Problem aber nicht umgehen. Ich habe beispielsweise versucht, den Pimpl zu einer Friend-Template-Klasse zu machen, in der Hoffnung, die Friend-Deklaration so breit zu gestalten, dass ich die eigentliche Pimpl-Klasse in einem anonymen Namespace definieren kann, aber das funktioniert einfach nicht. Wenn jemand eine Idee hat, wie er die Datenelemente privat halten und dennoch einer in einem anonymen Namensraum definierten ererbenden Pimpl-Klasse den Zugriff auf diese erlauben kann, würde ich es wirklich gerne sehen! Das würde meine Hauptreservierung davon abhalten, dies zu verwenden.

Ich bin jedoch der Meinung, dass diese Vorbehalte für die Vorteile meines Vorschlags akzeptabel sind.

Ich habe versucht, online nach Hinweisen auf diese "Nur-Funktion-Zuhälter" -Sprache zu suchen, konnte aber nichts finden. Ich bin wirklich interessiert daran, was die Leute darüber denken. Gibt es andere Probleme oder Gründe, warum ich das nicht verwenden sollte?

AKTUALISIEREN:

Ich habe diesen Vorschlag gefunden , der mehr oder weniger versucht, genau das zu erreichen, was ich bin, aber dies durch Ändern des Standards. Ich stimme diesem Vorschlag voll und ganz zu und hoffe, dass er in den Standard aufgenommen wird (ich weiß nichts über diesen Prozess, daher habe ich keine Ahnung, wie wahrscheinlich dies sein wird). Ich hätte es viel lieber, wenn dies über einen eingebauten Sprachmechanismus möglich wäre. Der Vorschlag erklärt auch die Vorteile dessen, was ich viel besser erreichen möchte als ich. Es hat auch nicht das Problem, die Kapselung zu brechen, wie es mein Vorschlag getan hat (privat -> geschützt). Bis dieser Vorschlag es in den Standard schafft (falls dies jemals passiert), denke ich, dass mein Vorschlag es ermöglicht, diese Vorteile mit den von mir aufgeführten Vorbehalten zu erzielen.

UPDATE2:

In einer der Antworten wird LTO als mögliche Alternative erwähnt, um einige der Vorteile zu nutzen (ich vermute, aggressivere Optimierungen). Ich bin mir nicht sicher, was genau in verschiedenen Compiler-Optimierungsdurchläufen vor sich geht, aber ich habe ein wenig Erfahrung mit dem resultierenden Code (ich verwende gcc). Durch einfaches Einfügen der privaten Methoden in die ursprüngliche Klasse werden diese zu einer externen Verknüpfung gezwungen.

Ich könnte mich hier irren, aber die Art und Weise, wie ich das interpretiere, ist, dass der Optimierer zur Kompilierungszeit die Funktion nicht eliminieren kann, selbst wenn alle seine Aufrufinstanzen vollständig in dieser TU eingebettet sind. Aus irgendeinem Grund weigert sich sogar LTO, die Funktionsdefinition loszuwerden, selbst wenn es den Anschein hat, dass alle Aufrufinstanzen in der gesamten verknüpften Binärdatei inline sind. Ich habe einige Referenzen gefunden, die besagen, dass der Linker nicht weiß, ob Sie die Funktion irgendwie immer noch mithilfe von Funktionszeigern aufrufen (obwohl ich nicht verstehe, warum der Linker nicht herausfinden kann, dass die Adresse dieser Methode niemals verwendet wird ).

Dies ist nicht der Fall, wenn Sie meinen Vorschlag verwenden und diese privaten Methoden in einem Pickel in einem anonymen Namespace ablegen. Wenn diese inline werden, werden die Funktionen NICHT in der Objektdatei angezeigt (mit -O3, einschließlich -finline-Funktionen).

So wie ich es verstehe, berücksichtigt der Optimierer bei der Entscheidung, ob eine Funktion eingebunden werden soll oder nicht, ihre Auswirkungen auf die Codegröße. Mit meinem Vorschlag mache ich es für den Optimierer etwas "billiger", diese privaten Methoden zu integrieren.

dcmm88
quelle
Die Verwendung von PCALList undefiniertes Verhalten. Sie können ein Ain a PImplumwandeln und verwenden, es sei denn, das zugrunde liegende Objekt ist tatsächlich vom Typ PImpl. Sofern ich mich nicht irre, erstellen Benutzer nur Objekte vom Typ A.
BeeOnRope

Antworten:

8

Die Verkaufsargumente des Pimpl-Musters sind:

  • Gesamtkapselung: In der Header-Datei des Schnittstellenobjekts sind keine (privaten) Datenelemente erwähnt.
  • Stabilität: Bis Sie die öffentliche Schnittstelle (die in C ++ private Mitglieder enthält) beschädigen, müssen Sie niemals Code neu kompilieren, der vom Schnittstellenobjekt abhängt. Dies macht den Pimpl zu einem großartigen Muster für Bibliotheken, die nicht möchten, dass ihre Benutzer bei jeder internen Änderung den gesamten Code neu kompilieren.
  • Polymorphismus und Abhängigkeitsinjektion: Die Implementierung oder das Verhalten des Schnittstellenobjekts kann zur Laufzeit problemlos ausgetauscht werden, ohne dass abhängiger Code neu kompiliert werden muss. Großartig, wenn Sie etwas für einen Unit-Test verspotten müssen.

Zu diesem Zweck besteht der klassische Pimpl aus drei Teilen:

  • Eine Schnittstelle für das Implementierungsobjekt, die öffentlich sein muss und virtuelle Methoden für die Schnittstelle verwendet:

    class IFrobnicateImpl
    {
    public:
        virtual int frobnicate(int) const = 0;
    };

    Diese Schnittstelle muss stabil sein.

  • Ein Schnittstellenobjekt, das die private Implementierung vertritt. Es müssen keine virtuellen Methoden verwendet werden. Das einzige zulässige Mitglied ist ein Zeiger auf die Implementierung:

    class Frobnicate
    {
        std::unique_ptr<IFrobnicateImpl> _impl;
    public:
        explicit Frobnicate(std::unique_ptr<IFrobnicateImpl>&& impl = nullptr);
        int frobnicate(int x) const { return _impl->frobnicate(x); }
    };
    
    ...
    
    Frobnicate::Frobnicate(std::unique_ptr<IFrobnicateImpl>&& impl /* = nullptr */)
    : _impl(std::move(impl))
    {
        if (!_impl)
            _impl = std::make_unique<DefaultImplementation>();
    }

    Die Header-Datei dieser Klasse muss stabil sein.

  • Mindestens eine Implementierung

Der Pimpl verschafft uns dann viel Stabilität für eine Bibliotheksklasse auf Kosten einer Heap-Zuweisung und eines zusätzlichen virtuellen Versands.

Wie misst sich Ihre Lösung?

  • Die Einkapselung entfällt. Da Ihre Mitglieder geschützt sind, kann sich jede Unterklasse mit ihnen anlegen.
  • Es beseitigt die Stabilität der Schnittstelle. Wann immer Sie Ihre Datenelemente ändern - und diese Änderung ist nur ein Refactoring entfernt - müssen Sie den gesamten abhängigen Code neu kompilieren.
  • Die virtuelle Versandschicht entfällt, wodurch ein einfaches Austauschen der Implementierung verhindert wird.

Für jedes Ziel des Pimpl-Musters erfüllen Sie dieses Ziel nicht. Es ist daher nicht sinnvoll, Ihr Muster als Variation des Pimpl zu bezeichnen, es ist viel mehr eine gewöhnliche Klasse. Eigentlich ist es schlimmer als eine gewöhnliche Klasse, weil Ihre Mitgliedsvariablen privat sind. Und wegen dieser Besetzung, die ein eklatanter Punkt der Zerbrechlichkeit ist.

Beachten Sie, dass das Pimpl-Muster nicht immer optimal ist - es gibt einen Kompromiss zwischen Stabilität und Polymorphismus einerseits und Speicherkompaktheit andererseits. Es ist semantisch unmöglich, dass eine Sprache beides hat (ohne JIT-Kompilierung). Wenn Sie also die Speicherkompaktheit mikrooptimieren, ist der Pimpl eindeutig keine geeignete Lösung für Ihren Anwendungsfall. Sie werden wahrscheinlich auch die Hälfte der Standardbibliothek nicht mehr verwenden, da diese schrecklichen Zeichenfolgen- und Vektorklassen dynamische Speicherzuweisungen beinhalten ;-)

amon
quelle
Über Ihre letzte Anmerkung zur Verwendung von Zeichenfolgen und Vektoren - es ist sehr nah an der Wahrheit :). Ich verstehe, dass Zuhälter Vorteile haben, die mein Vorschlag einfach nicht bietet. Es hat jedoch Vorteile, die in dem Link, den ich im UPDATE hinzugefügt habe, beredter dargestellt werden. In Anbetracht der Tatsache, dass die Leistung in meinem Anwendungsfall eine sehr große Rolle spielt, wie würden Sie meinen Vorschlag damit vergleichen, Pimpl überhaupt nicht zu verwenden? Weil es für meinen Anwendungsfall nicht mein Vorschlag gegen Pimpl ist, sondern mein Vorschlag gegen No-Pimpl, da Pimpl Leistungskosten hat, die mir nicht entstehen sollen.
dcmm88
1
@ dcmm88 Wenn Leistung Ihr oberstes Ziel ist, dann sind Dinge wie Codequalität und Kapselung offensichtlich ein faires Spiel. Das einzige, was Ihre Lösung gegenüber normalen Klassen verbessert, ist das Vermeiden einer Neukompilierung, wenn sich private Methodensignaturen ändern. In meinem Buch ist das nicht besonders viel, aber wenn dies eine grundlegende Klasse in einer großen Codebasis ist, könnte sich die seltsame Implementierung lohnen. In diesem praktischen XKCD-Diagramm können Sie ermitteln, wie viel Entwicklungszeit Sie in die Verkürzung der Kompilierungszeiten investieren können . Abhängig von Ihrem Gehalt ist ein Upgrade Ihres Computers möglicherweise günstiger.
Amon
1
Außerdem können die privaten Funktionen intern verknüpft werden, was eine aggressivere Optimierung ermöglicht.
dcmm88
1
Es scheint, dass Sie die öffentliche Schnittstelle der pimpl-Klasse in der Header-Datei der ursprünglichen Klasse verfügbar machen. AFAUI die ganze Idee ist, so viel wie möglich zu verstecken. Normalerweise enthält die Header-Datei, wie ich die Implementierung von pimpl sehe, nur eine Vorwärtsdeklaration der pimpl-Klasse und die cpp enthält die vollständige Definition der pimpl-Klasse. Ich denke, die Austauschfähigkeit kommt mit einem anderen Designmuster - dem Brückenmuster. Ich bin mir also nicht sicher, ob es fair ist zu sagen, dass mein Vorschlag es nicht liefert, wenn pimpl es normalerweise auch nicht liefert.
dcmm88
Entschuldigung für den letzten Kommentar. Sie haben tatsächlich eine ziemlich große Antwort, und der Punkt ist am Ende, also habe ich ihn verpasst
BЈовић
3

Für mich überwiegen die Vorteile nicht die Nachteile.

Vorteile :

Dies kann die Kompilierung beschleunigen, da eine Neuerstellung gespeichert wird, wenn nur die Signaturen der privaten Methode geändert wurden. Eine Neuerstellung ist jedoch erforderlich, wenn sich öffentliche oder geschützte Methodensignaturen oder private Datenelemente geändert haben, und es ist selten, dass ich private Methodensignaturen ändern muss, ohne eine dieser anderen Optionen zu berühren.

Es kann aggressivere Compiler-Optimierungen zulassen, aber LTO sollte viele der gleichen Optimierungen zulassen (zumindest denke ich, dass dies möglich ist - ich bin kein Guru für Compiler-Optimierung), sowie einige weitere und kann standardmäßig und automatisch erstellt werden.

Nachteile:

Sie haben einige Nachteile erwähnt: die Unfähigkeit, private Vorlagen zu verwenden, und die Komplexität mit Vorlagen. Für mich ist der größte Nachteil jedoch, dass es einfach umständlich ist: ein unkonventioneller Programmierstil mit nicht ganz standardmäßigen Pimpl-Stil-Sprüngen zwischen Schnittstelle und Implementierung, der zukünftigen Betreuern oder neuen Teammitgliedern unbekannt sein wird und möglicherweise auch nicht von Werkzeugen schlecht unterstützt werden (siehe z. B. diesen GDB-Fehler ).

Hier gelten die Standardbedenken hinsichtlich der Optimierung: Haben Sie gemessen, dass die Optimierungen Ihre Leistung erheblich verbessern? Wäre Ihre Leistung dadurch besser oder würden Sie sich die Zeit nehmen, um dies aufrechtzuerhalten und in die Profilerstellung von Hotspots, die Verbesserung von Algorithmen usw. zu investieren? Persönlich würde ich lieber einen klaren und unkomplizierten Programmierstil wählen, unter der Annahme, dass dadurch Zeit für gezielte Optimierungen frei wird. Aber das ist meine Perspektive für die Arten von Code, an denen ich arbeite - für Ihre Problemdomäne können die Kompromisse unterschiedlich sein.

Randnotiz: Zulassen von privat mit nur Methode pimpl

Sie haben gefragt, wie Sie private Mitglieder mit Ihrem Pimpl-Vorschlag nur für Methoden zulassen können. Leider halte ich Pimpl nur für Methoden für eine Art Hack, aber wenn Sie entschieden haben, dass die Vorteile die Nachteile überwiegen, können Sie den Hack genauso gut annehmen.

Ah:

#ifndef A_impl
#define A_impl private
#endif

class A
{
public:
    A();
    void DoSomething();
A_impl:
    int mData1;
    int mData2;
};

A.cpp:

#define A_impl public
#include "A.h"
Josh Kelley
quelle
Es ist mir ein bisschen peinlich zu sagen, aber ich mag Ihren privaten Vorschlag #define A_impl :). Ich bin auch kein Optimierungsguru, aber ich habe einige Erfahrungen mit LTO und das Fummeln daran hat mich dazu gebracht, dies zu erfinden. Ich werde meine Frage mit den Informationen aktualisieren, die ich über die Verwendung habe, und warum sie immer noch nicht den vollen Nutzen bietet, den mein pimpl nur für Methoden bietet.
dcmm88
@ dcmm88 - Danke für die Infos zu LTO. Ich habe nicht viel Erfahrung damit.
Josh Kelley
1

Sie können std::aligned_storageden Speicher für Ihren Pimpl in der Schnittstellenklasse deklarieren.

class A
{
std::aligned_storage< 128 > _storage;
public:
  A();
};

In der Implementierung können Sie Ihre Pimpl-Klasse direkt unter folgender Adresse erstellen _storage:

class Pimpl
{
  int _some_data;
};

A::A()
{
  ::new(&_storage) Pimpl();
  // accessing Pimpl: *(Pimpl*)(_storage);
}

A::~A()
{
  ((Pimpl*)(_storage))->~Pimpl(); // calls destructor for inline pimpl
}
Fabio
quelle
Ich habe vergessen zu erwähnen, dass Sie ab und zu die Speichergröße erhöhen müssen, um eine Neukompilierung zu erzwingen. Oft können Sie viele Änderungen vornehmen, die keine Neukompilierungen auslösen - wenn Sie dem Speicher etwas Spielraum geben.
Fabio
0

Nein, Sie können es nicht ohne Leistungseinbußen implementieren. PIMPL ist von Natur aus eine Leistungsbeeinträchtigung, da Sie eine Laufzeit-Indirektion anwenden.

Dies hängt natürlich genau davon ab, was Sie indirekt wollten. Einige Informationen werden vom Verbraucher einfach nicht verwendet - wie genau das, was Sie in Ihre 64 4-Byte-ausgerichteten Bytes einfügen möchten. Andere Informationen sind jedoch wie die Tatsache, dass Sie 64 4-Byte-ausgerichtete Bytes für Ihr Objekt benötigen.

Generische PIMPLs ohne Leistungseinbußen existieren nicht und werden niemals existieren. Es sind dieselben Informationen, die Sie Ihrem Benutzer verweigern, die er zur Optimierung verwenden möchte. Wenn Sie es ihnen geben, wird Ihre IMPL nicht abstrahiert. Wenn Sie es ihnen verweigern, können sie nicht optimieren. Sie können es nicht in beide Richtungen haben.

DeadMG
quelle
Ich habe einen möglichen "partiellen Zuhälter" -Vorschlag gemacht, von dem ich behaupte, dass er keine Leistungseinbußen und mögliche Leistungssteigerungen aufweist. Sie können es ablehnen, wenn ich es als pimpl bezeichne, aber ich verstecke einige Details der Implementierung (die privaten Methoden). Ich könnte es als verstecktes Implementieren einer privaten Methodenimplementierung bezeichnen, entschied mich jedoch dafür, es als eine Variation von pimpl oder pimpl nur für Methoden zu bezeichnen.
dcmm88
0

Bei allem Respekt und nicht mit der Absicht, diese Aufregung zu töten, sehe ich keinen praktischen Nutzen, den dies aus Sicht der Kompilierungszeit bringt. Viele der Vorteile von pimplswerden sich aus dem Ausblenden benutzerdefinierter Typdetails ergeben. Zum Beispiel:

struct Foo
{
    Bar data;
};

... in einem solchen Fall ergeben sich die höchsten Kosten für die Kompilierung aus der Tatsache, dass Foowir zur Definition die Größen- / Ausrichtungsanforderungen von kennen müssen Bar(was bedeutet, dass wir die Definition von rekursiv benötigen müssen Bar).

Wenn Sie die Datenelemente nicht ausblenden, geht einer der wichtigsten Vorteile aus Sicht der Kompilierungszeit verloren. Außerdem gibt es dort möglicherweise gefährlich aussehenden Code, aber der Header wird nicht leichter und die Quelldatei wird mit mehr Weiterleitungsfunktionen schwerer, sodass die Kompilierungszeiten insgesamt wahrscheinlich eher erhöht als verkürzt werden.

Leichtere Header sind der Schlüssel

Um die Erstellungszeiten zu verkürzen, möchten Sie eine Technik zeigen können, die zu einem dramatisch leichteren Header führt (normalerweise, indem Sie #includeeinige andere Header nicht mehr rekursiv ausführen können, weil Sie Details ausgeblendet haben, für die bestimmte struct/classDefinitionen nicht mehr erforderlich sind ). Hier pimplskann Original einen signifikanten Effekt haben, indem es kaskadierende Ketten von Header-Einschlüssen abbricht und weitaus unabhängigere Header ergibt, bei denen alle privaten Details verborgen sind.

Sicherere Wege

Wenn Sie sowieso so etwas tun möchten, ist es auch viel einfacher, eine friendin Ihrer Quelldatei definierte Klasse zu verwenden, als eine, die dieselbe Klasse erbt, die Sie nicht mit Zeiger-Casting-Tricks zum Aufrufen von Methoden instanziieren ein nicht instanziiertes Objekt oder verwenden Sie einfach freistehende Funktionen mit interner Verknüpfung innerhalb der Quelldatei, die die entsprechenden Parameter erhalten, um die erforderliche Arbeit zu erledigen (beides könnte es Ihnen zumindest ermöglichen, einige private Methoden aus dem Header auszublenden, um sehr geringfügige Einsparungen zu erzielen in Kompilierungszeiten und ein wenig Spielraum, um eine kaskadierende Neukompilierung zu vermeiden).

Feste Allokator

Wenn Sie die billigste Art von Zuhälter wollen, besteht der Haupttrick darin, einen festen Allokator zu verwenden. Insbesondere beim Aggregieren von Zuhältern in großen Mengen ist der Verlust der räumlichen Lokalität und die zusätzlichen obligatorischen Seitenfehler beim erstmaligen Zugriff auf die Zuhälter der größte Killer. Durch die Vorbelegung von Speicherpools, die Speicher für zugewiesene Pimpls bündeln und den Speicher an den Pool zurückgeben, anstatt ihn bei der Freigabe freizugeben, werden die Kosten für eine Schiffsladung von Pimpl-Instanzen drastisch gesenkt. Unter Leistungsgesichtspunkten ist es zwar immer noch nicht kostenlos, aber viel billiger und weitaus cache- / seitenfreundlicher.


quelle