Es gibt eine Sache in C++, die mir schon seit langem Unbehagen bereitet, weil ich ehrlich gesagt nicht weiß, wie es geht, auch wenn es einfach klingt:
Wie implementiere ich die Factory-Methode in C++ richtig?
Ziel: dem Client die Möglichkeit zu geben, ein Objekt mit Hilfe von Factory-Methoden anstelle der Konstruktoren des Objekts zu instanziieren, ohne inakzeptable Konsequenzen und Leistungseinbußen.
Mit "Fabrikmethodenmuster" meine ich sowohl statische Fabrikmethoden innerhalb eines Objekts als auch Methoden, die in einer anderen Klasse definiert sind, oder globale Funktionen. Ganz allgemein "das Konzept, den normalen Weg der Instanziierung der Klasse X an eine andere Stelle als den Konstruktor umzuleiten".
Lassen Sie mich kurz einige mögliche Antworten aufzählen, an die ich gedacht habe.
0) Machen Sie keine Fabriken, sondern Konstrukteure.
Das hört sich gut an (und ist auch oft die beste Lösung), ist aber kein Allheilmittel. Zunächst einmal gibt es Fälle, in denen die Konstruktion von Objekten eine Aufgabe ist, die komplex genug ist, um ihre Auslagerung in eine andere Klasse zu rechtfertigen. Aber selbst wenn man diese Tatsache beiseite lässt, reicht es selbst für einfache Objekte oft nicht aus, nur Konstruktoren zu verwenden.
Das einfachste Beispiel, das ich kenne, ist eine 2-D-Vektorklasse. So einfach und doch so knifflig. Ich möchte in der Lage sein, sie sowohl aus kartesischen als auch aus polaren Koordinaten zu konstruieren. Offensichtlich kann ich das nicht:
struct Vec2 {
Vec2(float x, float y);
Vec2(float angle, float magnitude); // not a valid overload!
// ...
};
Meine natürliche Denkweise ist dann:
struct Vec2 {
static Vec2 fromLinear(float x, float y);
static Vec2 fromPolar(float angle, float magnitude);
// ...
};
Das führt mich anstelle von Konstruktoren zur Verwendung von statischen Fabrikmethoden... was im Wesentlichen bedeutet, dass ich das Fabrikmuster in irgendeiner Weise implementiere ("die Klasse wird zu ihrer eigenen Fabrik"). Das sieht nett aus (und würde in diesem speziellen Fall auch passen), versagt aber in einigen Fällen, die ich in Punkt 2 beschreiben werde. Lesen Sie bitte weiter.
ein anderer Fall: der Versuch, mit zwei undurchsichtigen Typendefinitionen einer API (z. B. GUIDs von nicht verwandten Domänen oder eine GUID und ein Bitfeld) zu überladen, Typen, die semantisch völlig unterschiedlich sind (also - theoretisch - gültige Überladungen), die sich aber tatsächlich als dasselbe herausstellen - wie unsigned ints oder void pointers.
1) Der Java-Weg
Java hat es einfach, da wir nur dynamisch zugewiesene Objekte haben. Die Erstellung einer Fabrik ist so trivial wie:
class FooFactory {
public Foo createFooInSomeWay() {
// can be a static method as well,
// if we don't need the factory to provide its own object semantics
// and just serve as a group of methods
return new Foo(some, args);
}
}
In C++ heißt das übersetzt:
class FooFactory {
public:
Foo* createFooInSomeWay() {
return new Foo(some, args);
}
};
Cool? Oft, in der Tat. Aber dann ist der Benutzer gezwungen, nur dynamische Zuweisungen zu verwenden. Die statische Zuweisung ist es, die C++ komplex macht, aber sie ist auch das, was es oft leistungsfähig macht. Ich glaube auch, dass es einige Ziele (Stichwort: embedded) gibt, die keine dynamische Zuweisung zulassen. Und das bedeutet nicht, dass die Benutzer dieser Plattformen gerne sauberes OOP schreiben.
Wie auch immer, Philosophie beiseite: Im allgemeinen Fall möchte ich die Benutzer der Fabrik nicht zwingen, sich auf eine dynamische Zuweisung zu beschränken.
2) Rückgabe nach Wert
OK, wir wissen also, dass 1) in Ordnung ist, wenn wir eine dynamische Zuweisung wünschen. Warum fügen wir nicht noch eine statische Zuweisung hinzu?
class FooFactory {
public:
Foo* createFooInSomeWay() {
return new Foo(some, args);
}
Foo createFooInSomeWay() {
return Foo(some, args);
}
};
Was? Wir können nicht durch den Rückgabetyp überladen? Oh, natürlich nicht. Ändern wir also die Methodennamen, um das zu berücksichtigen. Und ja, ich habe das ungültige Codebeispiel oben nur geschrieben, um zu betonen, wie sehr ich die Notwendigkeit, den Methodennamen zu ändern, ablehne, zum Beispiel, weil wir jetzt kein sprachunabhängiges Fabrikdesign richtig implementieren können, da wir die Namen ändern müssen - und jeder Benutzer dieses Codes wird sich diesen Unterschied zwischen der Implementierung und der Spezifikation merken müssen.
class FooFactory {
public:
Foo* createDynamicFooInSomeWay() {
return new Foo(some, args);
}
Foo createFooObjectInSomeWay() {
return Foo(some, args);
}
};
OK... da haben wir es. Es ist hässlich, da wir den Namen der Methode ändern müssen. Es ist unvollkommen, da wir denselben Code zweimal schreiben müssen. Aber einmal gemacht, funktioniert es. Oder?
Nun, normalerweise. Aber manchmal auch nicht. Bei der Erstellung von Foo sind wir darauf angewiesen, dass der Compiler die Optimierung des Rückgabewerts für uns übernimmt, denn der C++-Standard ist so wohlwollend, dass die Compiler-Hersteller nicht angeben, wann das Objekt an Ort und Stelle erstellt und wann es kopiert wird, wenn ein temporäres Objekt nach Wert in C++ zurückgegeben wird. Wenn Foo also teuer zu kopieren ist, ist dieser Ansatz riskant.
Und was, wenn Foo gar nicht kopierbar ist? Tja, puh. ( Beachten Sie, dass in C++17 mit garantierter Copy Elision das Nicht-Kopierbar-Sein kein Problem mehr für den obigen Code ist )
Schlussfolgerung: Die Erstellung einer Fabrik durch Rückgabe eines Objekts ist in der Tat eine Lösung für einige Fälle (wie z. B. den bereits erwähnten 2-D-Vektor), aber noch kein allgemeiner Ersatz für Konstruktoren.
3) Zweiphasige Konstruktion
Eine andere Sache, auf die jemand wahrscheinlich kommen würde, ist die Trennung von Objektzuweisung und Initialisierung. Dies führt normalerweise zu einem Code wie diesem:
class Foo {
public:
Foo() {
// empty or almost empty
}
// ...
};
class FooFactory {
public:
void createFooInSomeWay(Foo& foo, some, args);
};
void clientCode() {
Foo staticFoo;
auto_ptr<Foo> dynamicFoo = new Foo();
FooFactory factory;
factory.createFooInSomeWay(&staticFoo);
factory.createFooInSomeWay(&dynamicFoo.get());
// ...
}
Man könnte meinen, es funktioniert wie ein Zauber. Der einzige Preis, den wir für unseren Code zahlen...
Da ich das alles geschrieben habe und dies als letztes, muss es mir auch nicht gefallen :) Und warum?
Zunächst einmal... Ich mag das Konzept des zweiphasigen Aufbaus wirklich nicht und habe ein schlechtes Gewissen, wenn ich es verwende. Wenn ich meine Objekte mit der Behauptung entwerfe, dass "wenn es existiert, ist es in einem gültigen Zustand", habe ich das Gefühl, dass mein Code sicherer und weniger fehleranfällig ist. Das ist gut so.
Diese Konvention fallen zu lassen UND das Design meines Objekts zu ändern, nur um eine Fabrik daraus zu machen, ist nun ja, unhandlich.
Ich weiß, dass die obigen Ausführungen viele Leute nicht überzeugen werden, also möchte ich einige solidere Argumente anführen. Mit der Zwei-Phasen-Konstruktion können Sie nicht:
- initialisieren
const
oder Referenzmitgliedsvariablen, - Argumente an Basisklassenkonstruktoren und Konstruktoren von Mitgliedsobjekten übergeben.
Und wahrscheinlich gibt es noch weitere Nachteile, die mir im Moment nicht einfallen, und ich fühle mich auch nicht besonders verpflichtet, da mich die oben genannten Punkte bereits überzeugen.
Also: nicht einmal annähernd eine gute allgemeine Lösung für die Implementierung einer Fabrik.
Schlussfolgerungen:
Wir wollen eine Möglichkeit der Objektinstanziierung haben, die das ermöglicht:
- ermöglichen eine einheitliche Instanziierung unabhängig von der Zuweisung,
- den Konstruktionsmethoden unterschiedliche, aussagekräftige Namen geben (und sich somit nicht auf das Überladen von By-Argumenten verlassen),
- keine signifikanten Leistungseinbußen und vorzugsweise keine signifikante Codeaufblähung mit sich bringen, insbesondere auf der Client-Seite,
- allgemein sein, d.h. für jede Klasse eingeführt werden können.
Ich glaube, ich habe bewiesen, dass die von mir genannten Möglichkeiten diese Anforderungen nicht erfüllen.
Haben Sie einen Tipp? Bitte bieten Sie mir eine Lösung, ich will nicht denken, dass diese Sprache wird nicht zulassen, dass ich richtig zu implementieren, wie ein triviales Konzept.