Ich versuche, ein sehr offenes Plugin-Framework in C ++ zu erstellen, und mir scheint, dass ich einen Weg gefunden habe, aber ein quälender Gedanke sagt mir immer wieder, dass etwas sehr, sehr falsch mit dem ist Ich mache es, und es wird entweder nicht funktionieren oder es wird Probleme verursachen.
Der Entwurf, den ich für mein Framework habe, besteht aus einem Kernel, der die init
-Funktion jedes Plugins aufruft. Die init-Funktion dreht sich dann um und verwendet die registerPlugin
und registerFunction
des Kernels, um eine eindeutige ID zu erhalten und anschließend jede Funktion zu registrieren, auf die das Plugin unter Verwendung dieser ID zugreifen möchte.
Die Funktion registerPlugin gibt die eindeutige ID zurück. Die Funktion registerFunction übernimmt diese ID, den Funktionsnamen und einen generischen Funktionszeiger wie folgt:
%Vor%wo plugin_function
ist %Vor% Der Kernel übernimmt dann den Funktionszeiger und fügt ihn in eine Map mit dem function_name
und plugin_id
ein. Alle Plugins, die ihre Funktion registrieren, müssen die Funktion plugin_function
eingeben.
Um die Funktion abzurufen, ruft ein anderes Plugin den Kernel auf
%Vor% Dann muss dieses Plugin den plugin_function
auf seinen ursprünglichen Typ umwandeln, damit es benutzt werden kann. Es weiß (in der Theorie), was der richtige Typ ist, indem es Zugriff auf eine .h
Datei hat, die alle Funktionen des Plugins anzeigt. Plugins werden übrigens als dynamische Bibliotheken implementiert.
Ist das ein cleverer Weg, um verschiedene Plugins miteinander zu verbinden? Oder ist das eine verrückte und wirklich schreckliche Programmiertechnik? Wenn es s ist, zeigen Sie mir bitte in die richtige Richtung, um dies zu erreichen.
EDIT: Wenn eine Klärung erforderlich ist, fragen Sie und es wird zur Verfügung gestellt.
Funktionszeiger sind seltsame Kreaturen. Sie haben nicht unbedingt die gleiche Größe wie Datenzeiger und können daher nicht sicher in void*
und zurück umgewandelt werden. Aber die C ++ (und C) Spezifikationen erlauben jeden Funktionszeiger sicher in einen anderen Funktionszeiger umzuwandeln (obwohl Sie es haben um es später wieder auf den früheren Typ zu übertragen, bevor Sie es aufrufen, wenn Sie ein definiertes Verhalten wünschen). Dies ist vergleichbar mit der Möglichkeit, einen Datenzeiger sicher auf void*
und zurück zu übertragen.
Zeiger auf Methoden sind dort, wo es wirklich haarig wird: ein Methodenzeiger kann größer sein als ein normaler Funktionszeiger, abhängig vom Compiler, ob die Anwendung 32- oder 64-Bit ist, usw. Aber noch interessanter ist, dass selbst auf demselben Compiler / Plattform haben nicht alle Methodenzeiger die gleiche Größe: Methodenzeiger auf virtuelle Funktionen können größer sein als normale Methodenzeiger; Wenn mehrere Vererbungen (z. B. virtuelle Vererbung im Rautenmuster) beteiligt sind, können die Methodenzeiger sogar noch größer sein. Dies variiert auch mit Compiler und Plattform. Dies ist auch der Grund, warum es schwierig ist, Funktionsobjekte zu erstellen (die sowohl beliebige Methoden als auch freie Funktionen umschließen), insbesondere ohne Speicher auf dem Heap zuzuweisen (es ist nur möglich mit Template Zauberei ).
Durch die Verwendung von Funktionszeigern in Ihrer Schnittstelle wird es für die Plugin-Autoren daher unpraktisch, Methodenzeiger an Ihr Framework zurückzugeben, auch wenn sie denselben Compiler verwenden. Dies könnte eine akzeptable Einschränkung sein; mehr dazu später.
Da es keine Garantie dafür gibt, dass Funktionszeiger von Compiler zu Compiler die gleiche Größe haben, beschränken Sie durch das Registrieren von Funktionszeigern die Plugin-Autoren auf Compiler, die Funktionszeiger implementieren, die die gleiche Größe wie Ihr Compiler haben. Dies wäre in der Praxis nicht unbedingt so schlecht, da die Funktionszeigergrößen in den Compilerversionen tendenziell stabil sind (und bei mehreren Compilern sogar gleich sein können).
Die echten Probleme beginnen zu entstehen, wenn Sie die Funktionen aufrufen wollen, auf die die Funktionszeiger zeigen; Sie können die Funktion nicht sicher aufrufen, wenn Sie ihre wahre Signatur nicht kennen (Sie erhalten schlechte Ergebnisse von "funktioniert nicht" bis hin zu Segmentierungsfehlern). Also, die Plugin-Autoren wären weiter darauf beschränkt, nur void
-Funktionen zu registrieren, die keine Parameter annehmen.
Es wird schlimmer: Die Art und Weise, wie ein Funktionsaufruf auf der Assembler-Ebene funktioniert, hängt nicht nur von der Größe des Signatur- und Funktionszeigers ab. Es gibt auch die Aufrufkonvention, die Art, wie Exceptions gehandhabt werden (der Stack muss ordnungsgemäß abgewickelt werden, wenn eine Exception ausgelöst wird) und die tatsächliche Interpretation der Bytes des Funktionszeigers (wenn sie größer ist als ein Datenzeiger, was bedeuten die zusätzlichen Bytes? In welcher Reihenfolge?). An dieser Stelle ist der Autor des Plugins ziemlich darauf beschränkt, denselben Compiler (und dieselbe Version!) Wie Sie zu verwenden, und muss vorsichtig sein, um die Optionen für die Aufrufkonvention und die Ausnahmebehandlung (mit dem MSVC ++ - Compiler, z. B. Ausnahmebehandlung) zu finden wird nur explizit mit der Option /EHsc
aktiviert. Außerdem verwenden Sie nur normale Funktionszeiger mit genau der von Ihnen definierten Signatur.
Alle Beschränkungen soweit können als angemessen angesehen werden, wenn sie ein bisschen einschränkend sind. Aber wir sind noch nicht fertig.
Wenn Sie std::string
(oder fast irgendeinen Teil der STL) einwerfen, werden die Dinge sogar noch schlimmer, denn selbst mit demselben Compiler (und Version) gibt es mehrere verschiedene Flags / Makros, die die STL steuern; Diese Flags können die Größe und Bedeutung der Bytes, die String-Objekte darstellen, beeinflussen. Es ist in der Tat so, als hätte man zwei verschiedene Struct-Deklarationen in separaten Dateien mit jeweils demselben Namen und hofft, dass sie austauschbar sind; offensichtlich funktioniert das nicht. Ein Beispielflag ist _HAS_ITERATOR_DEBUGGING
. Beachten Sie, dass diese Optionen sogar zwischen Debug- und Release-Modus wechseln können! Diese Art von Fehlern manifestiert sich nicht immer sofort / konsistent und kann sehr schwer zu finden sein.
Sie müssen auch bei der dynamischen Speicherverwaltung über mehrere Module hinweg sehr vorsichtig sein, da new
in einem Projekt anders definiert sein kann als new
in einem anderen Projekt (z. B. kann es überlastet sein). Beim Löschen haben Sie möglicherweise einen Zeiger auf eine Schnittstelle mit einem virtuellen Destruktor, was bedeutet, dass vtable
benötigt wird, um delete
korrekt in das Objekt einzufügen, und verschiedene Compiler die vtable
-Stücke unterschiedlich implementieren. Im Allgemeinen möchten Sie, dass das Modul, das ein Objekt zuordnet, dasjenige ist, das die Zuordnung aufhebt; Genauer gesagt möchten Sie, dass der -Code , der die Zuordnung eines Objekts aufhebt, genau unter denselben Bedingungen wie der Code kompiliert wurde, der es zugewiesen hat.Dies ist ein Grund, warum std::shared_ptr
ein "Deleter" -Argument verwenden kann, wenn es konstruiert wird - weil sogar mit dem gleichen Compiler und den Flags (der einzige garantierte sichere Weg, shared_ptr
s zwischen Modulen zu teilen) new
und delete
ist möglicherweise nicht überall gleich, da shared_ptr
zerstört werden kann. Mit dem Deleter steuert der Code, der den gemeinsamen Zeiger erstellt, auch, wie er schließlich zerstört wird. (Ich habe gerade diesen Absatz für einen guten Zweck geworfen; Sie scheinen keine Objekte über Modulgrenzen hinweg zu teilen.)
All dies ist eine Folge davon, dass C ++ keine Standard-Binärschnittstelle hat ( ABI ); Es ist ein Alleskönner, bei dem es sehr einfach ist, sich in den Fuß zu schießen (manchmal ohne es zu merken).
Also, gibt es Hoffnung? Darauf kannst du wetten! Sie können stattdessen eine C-API für Ihre Plugins bereitstellen und Ihre Plugins auch eine C-API verfügbar machen. Das ist ziemlich nett, weil eine C-API mit praktisch jeder Sprache interoperiert werden kann. Sie müssen sich nicht um Ausnahmen sorgen, abgesehen davon, dass sie nicht über den Plugin-Funktionen auftauchen können (das ist das Anliegen der Autoren), und sie ist stabil, egal wie der Compiler / die Optionen sind (vorausgesetzt, Sie übergeben keine STL-Container und dergleichen). Es gibt nur eine Standard-Aufrufkonvention ( cdecl
). Dies ist die Standardeinstellung für Funktionen, die mit extern "C"
deklariert wurden. void*
ist in der Praxis für alle Compiler auf derselben Plattform gleich (z. B. 8 Bytes auf x64).
Sie (und die Plugin-Autoren) können Ihren Code immer noch in C ++ schreiben, solange die gesamte externe Kommunikation zwischen den beiden eine C-API verwendet (dh vorgibt, ein C-Modul für Interop-Zwecke zu sein).
> C-Funktionszeiger sind wahrscheinlich auch in der Praxis zwischen Compilern kompatibel. Wenn Sie jedoch nicht davon abhängig sind, könnte das Plugin eine Funktion name ( const char*
) anstelle der Adresse registrieren. und dann können Sie die Adresse selbst extrahieren, indem Sie zB verwenden LoadLibrary
mit GetProcAddress
für Windows (ähnlich haben Linux und Mac OS X dlopen
und dlsym
). Dies funktioniert, weil name-mangling für Funktionen deaktiviert ist deklariert mit extern "C"
.
Beachten Sie, dass es keinen direkten Weg gibt, die registrierten Funktionen auf einen einzelnen Prototyp-Typ zu beschränken (andernfalls können Sie sie, wie gesagt, nicht richtig aufrufen). Wenn Sie einer Plugin-Funktion einen bestimmten Parameter zuweisen (oder einen Wert zurückerhalten) müssen Sie die verschiedenen Funktionen mit unterschiedlichen Prototypen separat registrieren und aufrufen (obwohl Sie alle Funktionszeiger auf einen gemeinsamen Funktionszeiger reduzieren könnten) intern eingeben und nur in letzter Minute zurückwerfen).
Schließlich können Sie, obwohl Sie Methodenzeiger nicht direkt unterstützen können (die nicht einmal in einer C-API existieren, aber auch mit einer C ++ API variable Größe haben und daher nicht einfach gespeichert werden können), den Plugins erlauben, a anzugeben "user-data" opaque pointer beim Registrieren ihrer Funktion, die bei jedem Aufruf an die Funktion übergeben wird; Dies gibt den Plug-in-Autoren eine einfache Möglichkeit, Funktionsumbringer um Methoden zu schreiben und das Objekt, auf das die Methode angewendet werden soll, im Parameter user-data zu speichern. Der user-data-Parameter kann auch für alles andere verwendet werden, was der Plugin-Autor möchte, wodurch Ihr Plugin-System viel einfacher zu verbinden und zu erweitern ist. Ein anderes Beispiel ist die Anpassung zwischen verschiedenen Funktionsprototypen mithilfe eines Wrappers und zusätzlichen Argumenten, die in den Benutzerdaten gespeichert sind.
Diese Vorschläge führen zu Code in etwa so (für Windows - der Code ist für andere Plattformen sehr ähnlich):
%Vor%Entschuldigung für die Länge dieser Antwort; Das meiste davon lässt sich zusammenfassen mit "der C ++ - Standard erstreckt sich nicht auf die Interoperation; verwenden Sie stattdessen C, da es mindestens de facto Standards hat."
Hinweis: Manchmal ist es am einfachsten, eine normale C ++ API (mit Funktionszeigern oder Schnittstellen oder was immer Ihnen am besten gefällt) zu entwerfen, unter der Annahme, dass die Plugins unter genau den gleichen Umständen kompiliert werden; Dies ist sinnvoll, wenn Sie erwarten, dass alle Plugins von Ihnen selbst entwickelt werden (d. h. die DLLs sind Teil des Projektkerns). Dies könnte auch funktionieren, wenn Ihr Projekt Open-Source ist. In diesem Fall kann jeder selbständig eine zusammenhängende Umgebung wählen, unter der das Projekt und die Plugins kompiliert werden - aber dann ist es schwierig, Plugins außer als Quellcode zu verteilen.
>Update : Wie von ern0 in den Kommentaren hervorgehoben, ist es möglich, die Details der Modulinteroperation (über eine C-API) zu abstrahieren, sodass sowohl das Hauptprojekt als auch die Plugins einfacher arbeiten C ++ API.Was folgt ist ein Überblick über eine solche Implementierung:
%Vor%Dies hat den Vorteil, eine C-API für die Kompatibilität zu verwenden, bietet aber auch eine höhere Abstraktionsebene für in C ++ geschriebene Plugins (und für den Haupt-Projektcode, der C ++ ist). Beachten Sie, dass mehrere Plugins in einer einzelnen DLL definiert werden können. Sie könnten auch einige der Duplizierung von Funktionsnamen durch Verwendung von Makros beseitigen, aber ich entschied mich nicht für dieses einfache Beispiel.
All dies setzt übrigens Plugins voraus, die keine Abhängigkeiten haben - wenn Plugin A Plugin B beeinflusst (oder von ihm benötigt wird), müssen Sie eine sichere Methode für das Injizieren / Konstruieren von Abhängigkeiten nach Bedarf entwickeln, da es keine gibt Möglichkeit zu garantieren, in welcher Reihenfolge die Plugins geladen (oder initialisiert) werden. Ein zweistufiger Prozess würde in diesem Fall gut funktionieren: Laden und Registrieren aller Plugins; Bei der Registrierung jedes Plugins sollten sie alle von ihnen bereitgestellten Dienste registrieren. Erstellen Sie während der Initialisierung angeforderte Dienste nach Bedarf, indem Sie sich die registrierte Diensttabelle ansehen. Dies stellt sicher, dass alle Dienste, die von allen Plugins angeboten werden, registriert sind, bevor versucht wird, sie zu verwenden, unabhängig davon, in welcher Reihenfolge Plugins registriert oder initialisiert werden.
Der Ansatz, den Sie gewählt haben, ist im Allgemeinen vernünftig, aber ich sehe ein paar mögliche Verbesserungen.
Ihr Kernel sollte C-Funktionen mit einer konventionellen Aufrufkonvention (cdecl, oder vielleicht stdcall, wenn Sie unter Windows sind) für die Registrierung von Plugins und Funktionen exportieren. Wenn Sie eine C ++ - Funktion verwenden, erzwingen Sie alle Plugin-Autoren, denselben Compiler und dieselbe Compiler-Version zu verwenden, da viele Dinge wie C ++ - Funktionsnamen Mangling, STL-Implementierung und Aufrufkonventionen compilerspezifisch sind.
Plugins sollten nur C-Funktionen wie den Kernel exportieren.
Von der Definition von getFunction
scheint jedes Plugin einen Namen zu haben, den andere Plugins benutzen können, um seine Funktionen zu erhalten. Dies ist keine sichere Vorgehensweise. Zwei Entwickler können zwei verschiedene Plugins mit demselben Namen erstellen. Wenn also ein Plugin nach einem anderen Plugin fragt, kann es ein anderes Plugin als das erwartete Plugin bekommen. Eine bessere Lösung wäre, wenn Plugins eine öffentliche GUID hätten. Diese GUID kann in der Headerdatei jedes Plugins erscheinen, so dass andere Plugins darauf verweisen können.
Sie haben die Versionierung nicht implementiert. Idealerweise möchten Sie, dass Ihr Kernel versioniert wird, weil Sie ihn in Zukunft immer ändern werden. Wenn sich ein Plugin beim Kernel registriert, übergibt es die Version der Kernel-API, gegen die es kompiliert wurde. Der Kernel kann dann entscheiden, ob das Plugin geladen werden kann. Wenn zum Beispiel Kernel Version 1 eine Registrierungsanforderung für ein Plugin erhält, das Kernel Version 2 benötigt, haben Sie ein Problem. Die beste Möglichkeit, das Problem zu beheben, besteht darin, das Laden des Plugins nicht zuzulassen, da Kernelfunktionen benötigt werden ältere Version. Der umgekehrte Fall ist auch möglich, Kernel V2 lädt möglicherweise Plugins, die für Kernel v1 erstellt wurden, und wenn er es erlaubt, muss er sich möglicherweise an die ältere API anpassen.
Ich bin mir nicht sicher, ob mir die Idee gefällt, dass ein Plugin ein anderes Plugin finden und seine Funktionen direkt aufrufen kann, da dies die Kapselung sprengt. Es scheint mir besser zu sein, wenn Plugins ihre Fähigkeiten dem Kernel gegenüber bekannt machen, so dass andere Plugins Dienste finden können, die sie benötigen, anstatt andere Plugins nach Name oder GUID zu adressieren.
Beachten Sie, dass jedes Plugin, das Speicher zuweist, eine Deallokationsfunktion für diesen Speicher bereitstellen muss. Jedes Plugin könnte eine andere Laufzeitbibliothek verwenden, so dass Speicher, der von einem Plugin zugewiesen wird, anderen Plugins oder dem Kernel unbekannt sein kann. Die Zuordnung und Freigabe in demselben Modul vermeidet Probleme.
C ++ hat keine ABI . Was Sie tun wollen, hat eine Einschränkung: Die Plugins und Ihr Framework müssen kompilieren & amp; Link von demselben Compiler & amp; Linker mit demselben Parameter im selben Betriebssystem. Das ist bedeutungslos, wenn die Errungenschaft in Form von Binärdistributionen interoperabel ist, weil jedes Plugin, das für das Framework entwickelt wurde, viele Versionen vorbereiten muss, die auf unterschiedliche Compiler auf verschiedenen Betriebssystemen abzielen. Also wird der Quellcode praktischer sein als dies und das ist der Weg von GNU (download a src, configure and make)
COM ist eine Auswahl, aber sie ist zu komplex und veraltet. Oder verwaltet C ++ auf. NET-Laufzeit. Aber sie sind nur auf ms os. Wenn Sie eine universelle Lösung wünschen, schlage ich vor, dass Sie in eine andere Sprache wechseln.
Wie schon Jean erwähnt, gibt es keine standardmäßigen C ++ - ABI- und Standard-Namensveränderungskonventionen, und Sie müssen Dinge mit demselben Compiler und Linker kompilieren. Wenn Sie eine Art Plug-in für DLLs benötigen, müssen Sie etwas C-ish verwenden.
Wenn alle mit demselben Compiler und Linker kompiliert werden, sollten Sie auch std :: function in Betracht ziehen.
%Vor%Sie können auch Plug-in-Funktionen haben, die Argumente annehmen. Dies wird die Platzhalter für std :: bind verwenden, aber bald kann man feststellen, dass hinter boost :: bind etwas fehlt. Boost Bind hat schöne Dokumentation und Beispiele.
Es gibt keinen Grund, warum Sie das nicht tun sollten. In C ++ mit dieser Art von Zeiger ist das Beste, da es nur ein einfacher Zeiger ist. Ich kenne keinen populären Compiler, der so hirntot wäre, als würde er keinen Funktionszeiger wie einen normalen Zeiger machen. Es liegt jenseits der Vernunft, dass jemand etwas so Schreckliches tun würde.
Der Vst-Plugin-Standard funktioniert in ähnlicher Weise. Es verwendet nur Funktionszeiger in der DLL und hat keine Möglichkeiten, direkt auf Klassen zuzugreifen. Vst ist ein sehr beliebter Standard und unter Windows benutzen Leute fast jeden Compiler, um Vst-Plugins zu machen, einschließlich Delphi, das Pascal-basiert ist und nichts mit C ++ zu tun hat.
Also würde ich genau das tun, was Sie persönlich vorschlagen. Für die allgemein bekannten Plugins würde ich keinen String-Namen verwenden, sondern einen ganzzahligen Index, der viel schneller nachgeschlagen werden kann.
Die Alternative ist die Verwendung von Schnittstellen, aber ich sehe keinen Grund dafür, wenn Ihr Denken bereits auf Funktionszeigern basiert.
Wenn Sie Interfaces verwenden, ist es nicht so einfach, die Funktionen aus anderen Sprachen aufzurufen. Sie können es aus Delphi tun, aber was ist mit .NET.
Mit Ihrem Vorschlag für einen Funktionszeiger können Sie .NET verwenden, um beispielsweise eines der Plugins zu erstellen. Offensichtlich müssten Sie Mono in Ihrem Programm hosten, um es zu laden, aber nur für hypothetische Zwecke veranschaulicht es die Einfachheit davon.
Außerdem, wenn Sie Schnittstellen verwenden, müssen Sie in die Referenzzählung, was unangenehm ist. Halten Sie Ihre Logik in Funktionszeigern, wie Sie es vorschlagen, und wickeln Sie dann das Steuerelement in einige C ++ - Klassen ein, um die Aufrufe und andere Dinge für Sie auszuführen. Dann können andere Leute die Plugins mit anderen Sprachen wie Delphi Pascal, Free Pascal, C, anderen C ++ - Compilern etc ...
machenAber wie auch immer, unabhängig davon, was Sie tun, bleibt die Ausnahmebehandlung zwischen Compilern ein Problem, so dass Sie über die Fehlerbehandlung nachdenken müssen. Der beste Weg ist, dass die plugins-eigene Methode eigene Plugin-Exceptions abfängt und einen Fehlercode an den Kernel etc. zurückgibt ...
Mit all den ausgezeichneten Antworten oben werde ich nur hinzufügen, dass diese Praxis tatsächlich ziemlich weit verbreitet ist. In meiner Praxis habe ich es sowohl in kommerziellen Projekten als auch in Freeware / Opensource gesehen.
Also - ja, es ist eine gute und bewährte Architektur.
Sie müssen Funktionen nicht manuell registrieren. "Ja wirklich?" Wirklich.
Was Sie verwenden könnten, ist eine Proxy-Implementierung für Ihre Plugin-Schnittstelle, bei der jede Funktion ihr Original von der gemeinsam genutzten Bibliothek bei Bedarf transparent lädt und aufruft. Wer ein Proxy-Objekt dieser Schnittstellendefinition erreicht, kann nur die Funktionen aufrufen. Sie werden bei Bedarf geladen.
Wenn es sich bei den Plugins um Singletons handelt, ist kein manuelles Binden erforderlich (andernfalls muss zuerst die korrekte Instanz ausgewählt werden).
Die Idee für den Entwickler eines neuen Plugins wäre, zunächst die Schnittstelle zu beschreiben, dann einen Generator, der einen Stub für die Implementierung der Shared Library erzeugt, und zusätzlich eine Plugin-Proxy-Klasse mit der gleichen Signatur, aber mit Autoloading auf Anfrage, die dann in der Client-Software verwendet wird. Beide sollten die gleiche Schnittstelle (in C ++ eine reine abstrakte Klasse) erfüllen.
Tags und Links c++ plugins frameworks function-pointers dynamic-library