Jest jedna rzecz w C ++, która od dłuższego czasu sprawia, że czuję się niekomfortowo, ponieważ szczerze mówiąc, nie wiem, jak to zrobić, chociaż brzmi to prosto:
Jak poprawnie wdrożyć metodę fabryczną w C ++?
Cel: umożliwienie klientowi utworzenia instancji jakiegoś obiektu przy użyciu metod fabrycznych zamiast konstruktorów obiektu, bez niedopuszczalnych konsekwencji i obniżenia wydajności.
Przez „wzorzec metod fabrycznych” rozumiem zarówno statyczne metody fabryczne wewnątrz obiektu lub metody zdefiniowane w innej klasie, jak i funkcje globalne. Po prostu ogólnie „koncepcja przekierowania normalnego sposobu tworzenia instancji klasy X w inne miejsce niż konstruktor”.
Pozwól mi przejrzeć kilka możliwych odpowiedzi, o których myślałem.
0) Nie twórz fabryk, konstruktorów.
Brzmi nieźle (i rzeczywiście często najlepsze rozwiązanie), ale nie jest to ogólne lekarstwo. Przede wszystkim zdarzają się przypadki, gdy konstrukcja obiektu jest wystarczająco złożonym zadaniem, aby uzasadnić jego ekstrakcję do innej klasy. Ale nawet odkładając ten fakt na bok, nawet w przypadku prostych obiektów wykorzystujących tylko konstruktory często tego nie robi.
Najprostszym przykładem, jaki znam, jest klasa 2-D Vector. Tak proste, ale trudne. Chcę być w stanie zbudować go zarówno ze współrzędnych kartezjańskich, jak i biegunowych. Oczywiście nie mogę:
struct Vec2 {
Vec2(float x, float y);
Vec2(float angle, float magnitude); // not a valid overload!
// ...
};
Mój naturalny sposób myślenia to:
struct Vec2 {
static Vec2 fromLinear(float x, float y);
static Vec2 fromPolar(float angle, float magnitude);
// ...
};
Co zamiast konstruktorów prowadzi mnie do użycia statycznych metod fabrycznych ... co w istocie oznacza, że w jakiś sposób implementuję wzorzec fabryczny („klasa staje się własną fabryką”). Wygląda to ładnie (i pasowałoby do tego konkretnego przypadku), ale w niektórych przypadkach zawodzi, co opiszę w punkcie 2. Czytaj dalej.
inny przypadek: próba przeciążenia dwoma nieprzezroczystymi typami plików niektórych API (takich jak GUID niepowiązanych domen lub GUID i pole bitowe), typy semantycznie zupełnie inne (więc - teoretycznie - prawidłowe przeciążenia), ale które w rzeczywistości okazują się to samo - jak niepodpisane inty lub puste wskaźniki.
1) The Java Way
Java ma to proste, ponieważ mamy tylko obiekty dynamicznie alokowane. Tworzenie fabryki jest tak proste, jak:
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);
}
}
W C ++ oznacza to:
class FooFactory {
public:
Foo* createFooInSomeWay() {
return new Foo(some, args);
}
};
Fajne? Rzeczywiście często. Ale to zmusza użytkownika do korzystania tylko z alokacji dynamicznej. Alokacja statyczna sprawia, że C ++ jest skomplikowany, ale także często czyni go potężnym. Ponadto uważam, że istnieją pewne cele (słowo kluczowe: osadzone), które nie pozwalają na dynamiczną alokację. A to nie oznacza, że użytkownicy tych platform lubią pisać czyste OOP.
Zresztą poza filozofią: w ogólnym przypadku nie chcę zmuszać użytkowników fabryki do ograniczania się do dynamicznej alokacji.
2) Zwrot według wartości
OK, więc wiemy, że 1) jest fajny, gdy chcemy dynamicznej alokacji. Dlaczego nie dodamy do tego statycznego przydziału?
class FooFactory {
public:
Foo* createFooInSomeWay() {
return new Foo(some, args);
}
Foo createFooInSomeWay() {
return Foo(some, args);
}
};
Co? Nie możemy przeciążać według typu zwrotu? Oczywiście, że nie możemy. Zmieńmy więc nazwy metod, aby to odzwierciedlić. I tak, napisałem powyższy przykład niepoprawnego kodu, aby podkreślić, jak bardzo nie lubię potrzeby zmiany nazwy metody, na przykład dlatego, że nie możemy teraz poprawnie zaimplementować projektu fabryki niezależnego od języka, ponieważ musimy zmienić nazwy - i każdy użytkownik tego kodu będzie musiał pamiętać różnicę implementacji od specyfikacji.
class FooFactory {
public:
Foo* createDynamicFooInSomeWay() {
return new Foo(some, args);
}
Foo createFooObjectInSomeWay() {
return Foo(some, args);
}
};
OK ... mamy to. To brzydkie, ponieważ musimy zmienić nazwę metody. Jest niedoskonały, ponieważ musimy dwukrotnie napisać ten sam kod. Ale po zakończeniu działa. Dobrze?
Zwykle. Ale czasami tak nie jest. Tworząc Foo, faktycznie polegamy na kompilacji, która przeprowadzi dla nas optymalizację wartości zwracanej, ponieważ standard C ++ jest wystarczająco życzliwy, aby dostawcy kompilatora nie określili, kiedy obiekt zostanie utworzony w miejscu, a kiedy zostanie skopiowany podczas zwracania obiekt tymczasowy według wartości w C ++. Więc jeśli kopiowanie Foo jest kosztowne, takie podejście jest ryzykowne.
A co, jeśli Foo nie będzie w ogóle możliwe do skopiowania? Cóż, doh. ( Zauważ, że w C ++ 17 z gwarantowanym usunięciem kopii brak możliwości kopiowania nie stanowi już problemu dla powyższego kodu )
Wniosek: Utworzenie fabryki poprzez zwrócenie obiektu jest rzeczywiście rozwiązaniem dla niektórych przypadków (takich jak wcześniej wspomniany wektor 2D), ale nadal nie jest ogólnym zamiennikiem dla konstruktorów.
3) Konstrukcja dwufazowa
Inną rzeczą, którą prawdopodobnie wymyśliłby, jest oddzielenie kwestii alokacji obiektów i jej inicjalizacji. Zwykle powoduje to taki kod:
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());
// ...
}
Można pomyśleć, że działa jak urok. Jedyna cena, za którą płacimy w naszym kodzie ...
Ponieważ napisałem to wszystko i zostawiłem to jako ostatnie, muszę też tego nie lubić. :) Dlaczego?
Przede wszystkim ... szczerze nie podoba mi się koncepcja konstrukcji dwufazowej i czuję się winny, kiedy z niej korzystam. Jeśli projektuję moje obiekty z założeniem, że „jeśli istnieje, to jest w poprawnym stanie”, czuję, że mój kod jest bezpieczniejszy i mniej podatny na błędy. Lubię to w ten sposób.
Konieczność porzucenia tej konwencji ORAZ zmiana projektu mojego obiektu tylko w celu wytworzenia fabryki jest ... no cóż, niewygodna.
Wiem, że powyższe nie przekonuje wielu ludzi, więc pozwólcie, że podam więcej solidnych argumentów. Korzystając z konstrukcji dwufazowej, nie można:
- inicjowanie
const
lub referencyjne zmienne składowe, - przekazywać argumenty do konstruktorów klas bazowych i konstruktorów obiektów członkowskich.
I prawdopodobnie może być jeszcze kilka wad, o których nie mogę teraz myśleć, i nawet nie czuję się szczególnie zobowiązany, ponieważ powyższe punkty pocisków mnie już przekonały.
Tak więc: nawet w pobliżu dobrego ogólnego rozwiązania dla wdrożenia fabryki.
Wnioski:
Chcemy mieć sposób tworzenia obiektów, który:
- pozwalają na jednolite tworzenie instancji bez względu na przydział,
- nadaj różne, znaczące nazwy metodom budowy (nie polegając na przeciążeniu argumentami),
- nie wprowadzać znaczącego trafienia wydajnościowego, a najlepiej znacznego uderzenia kodu, szczególnie po stronie klienta,
- być ogólne, jak w: możliwe do wprowadzenia dla dowolnej klasy.
Wierzę, że udowodniłem, że sposoby, o których wspomniałem, nie spełniają tych wymagań.
Jakieś wskazówki? Proszę o rozwiązanie, nie chcę myśleć, że ten język nie pozwoli mi poprawnie wdrożyć tak trywialnej koncepcji.
delete
. Tego rodzaju metody są całkowicie w porządku, o ile jest „udokumentowane” (kod źródłowy to dokumentacja ;-)), że wywołujący przejmuje na własność wskaźnik (czytaj: jest odpowiedzialny za usunięcie go, gdy jest to właściwe).
unique_ptr<T>
zamiast T*
.