Idiom Pimpl vs czysty interfejs klasy wirtualnej

118

Zastanawiałem się, co skłoniłoby programistę do wybrania albo idiomu Pimpl, albo czystej wirtualnej klasy i dziedziczenia.

Rozumiem, że idiom pimpl ma jedno wyraźne dodatkowe wskazanie dla każdej metody publicznej i narzutu tworzenia obiektów.

Z drugiej strony klasa wirtualna Pure ma niejawne pośrednictwo (vtable) do dziedziczenia implementacji i rozumiem, że nie ma narzutu tworzenia obiektów.
EDYCJA : Ale jeśli tworzysz obiekt z zewnątrz, potrzebujesz fabryki

Co sprawia, że ​​czysta klasa wirtualna jest mniej pożądana niż idiom pimpl?

Arkaitz Jimenez
źródło
3
Świetne pytanie, po prostu chciałem zadać to samo. Zobacz także boost.org/doc/libs/1_41_0/libs/smart_ptr/sp_techniques.html
Frank,

Odpowiedzi:

60

Pisząc klasę w C ++, warto pomyśleć o tym, czy tak będzie

  1. Typ wartości

    Skopiuj według wartości, tożsamość nigdy nie jest ważna. Odpowiednie jest, aby był to klucz w std :: map. Na przykład klasa „ciągowa”, klasa „data” lub klasa „liczby zespolonej”. „Kopiowanie” instancji takiej klasy ma sens.

  2. Typ jednostki

    Tożsamość jest ważna. Zawsze przekazywane przez odwołanie, nigdy przez „wartość”. Często nie ma sensu w ogóle „kopiować” instancji klasy. Jeśli ma to sens, zwykle bardziej odpowiednia jest polimorficzna metoda „klonowania”. Przykłady: klasa Socket, klasa bazy danych, klasa „strategii”, wszystko, co byłoby „zamknięciem” w języku funkcjonalnym.

Zarówno pImpl, jak i czysta abstrakcyjna klasa bazowa są technikami zmniejszającymi zależności czasu kompilacji.

Jednak zawsze używam pImpl tylko do implementowania typów wartości (typ 1) i tylko czasami, gdy naprawdę chcę zminimalizować sprzężenia i zależności w czasie kompilacji. Często nie warto się tym przejmować. Jak słusznie zauważyłeś, jest więcej narzutów składniowych, ponieważ musisz napisać metody przekazywania dla wszystkich metod publicznych. W przypadku klas typu 2 zawsze używam czystej abstrakcyjnej klasy bazowej z powiązanymi metodami fabrycznymi.

Paul Hollingsworth
źródło
6
Zobacz komentarz Paula de Vrieze do tej odpowiedzi . Pimpl i Pure Virtual różnią się znacznie, jeśli jesteś w bibliotece i chcesz zamienić swoje pliki .so / .dll bez przebudowywania klienta. Klienci łączą się z frontendami pimpl po nazwie, więc wystarczy zachować stare podpisy metod. OTOH w czysto abstrakcyjnym przypadku skutecznie łączą się za pomocą indeksu vtable, więc zmiana kolejności metod lub wstawienie w środku zepsuje kompatybilność.
SnakE
1
Możesz dodawać (lub zmieniać kolejność) tylko metod w interfejsie klasy Pimpl, aby zachować porównywalność binarną. Mówiąc logicznie, nadal zmieniłeś interfejs i wydaje się nieco podejrzany. Odpowiedzią jest rozsądna równowaga, która może również pomóc w testowaniu jednostkowym poprzez „wstrzykiwanie zależności”; ale odpowiedź zawsze zależy od wymagań. Twórcy bibliotek zewnętrznych (w odróżnieniu od korzystania z biblioteki we własnej organizacji) mogą zdecydowanie preferować Pimpl.
Spacen Jasset
31

Pointer to implementationpolega zwykle na ukryciu szczegółów implementacji strukturalnej. Interfacesdotyczą tworzenia instancji różnych implementacji. Tak naprawdę służą dwóm różnym celom.

D.Shawley
źródło
13
niekoniecznie, widziałem klasy, które przechowują wiele pimpl w zależności od pożądanej implementacji. Często mówi się, że implik win32 vs linux implikuje coś, co musi być zaimplementowane inaczej na platformę.
Doug T.
14
Ale możesz użyć interfejsu, aby oddzielić szczegóły implementacji i ukryć je
Arkaitz Jimenez
6
Chociaż możesz zaimplementować pimpl za pomocą interfejsu, często nie ma powodu, aby oddzielać szczegóły implementacji. Więc nie ma powodu, aby przejść do polimorfii. Powód, dla pimpl jest, aby zachować szczegóły implementacji od klienta (w C ++, aby utrzymać je w nagłówku). Możesz to zrobić używając abstrakcyjnej bazy / interfejsu, ale generalnie jest to niepotrzebna przesada.
Michael Burr
10
Dlaczego jest to przesada? Chodzi mi o to, czy jest to wolniejsza metoda interfejsu niż pimplowa? Mogą istnieć logiczne powody, ale z praktycznego punktu widzenia powiedziałbym, że łatwiej to zrobić z abstrakcyjnym interfejsem
Arkaitz Jimenez
1
Powiedziałbym, że abstrakcyjna klasa bazowa / interfejs jest "normalnym" sposobem robienia rzeczy i umożliwia łatwiejsze testowanie poprzez kpiny
paulm
28

Idiom pimpl pomaga zredukować zależności i czasy kompilacji, szczególnie w dużych aplikacjach, i minimalizuje ekspozycję nagłówka szczegółów implementacji Twojej klasy do jednej jednostki kompilacji. Użytkownicy twojej klasy nie powinni nawet być świadomi istnienia pryszcza (z wyjątkiem tajemniczego wskaźnika, do którego nie są wtajemniczeni!).

Klasy abstrakcyjne (czyste wirtuale) to coś, o czym Twoi klienci muszą być świadomi: jeśli spróbujesz ich użyć do zredukowania sprzężeń i odwołań cyklicznych, musisz dodać sposób, aby umożliwić im tworzenie obiektów (np. Za pomocą metod lub klas fabrycznych, zastrzyk zależności lub inne mechanizmy).

Pontus Gagge
źródło
17

Szukałem odpowiedzi na to samo pytanie. Po przeczytaniu kilku artykułów i trochę praktyki wolę używać „czystych wirtualnych interfejsów klas” .

  1. Są prostsze (to subiektywna opinia). Idiom Pimpl sprawia, że ​​czuję, że piszę kod „dla kompilatora”, a nie dla „następnego programisty”, który będzie czytał mój kod.
  2. Niektóre struktury testowe mają bezpośrednie wsparcie dla mockowania czystych klas wirtualnych
  3. To prawda, że potrzebujesz fabryki, aby była dostępna z zewnątrz. Ale jeśli chcesz wykorzystać polimorfizm: to także jest „za”, a nie „przeciw”. ... a prosta metoda fabryczna tak naprawdę nie boli

Jedyną wadą ( próbuję to zbadać ) jest to, że idiom pimpl mógłby być szybszy

  1. gdy wywołania proxy są wbudowane, podczas gdy dziedziczenie koniecznie wymaga dodatkowego dostępu do obiektu VTABLE w czasie wykonywania
  2. zużycie pamięci publicznej klasy proxy pimpl jest mniejsze (możesz łatwo przeprowadzić optymalizacje w celu szybszej wymiany i innych podobnych optymalizacji)
Ilias Bartolini
źródło
21
Pamiętaj też, że używając dziedziczenia wprowadzasz zależność od układu vtable. Aby utrzymać ABI, nie możesz już zmieniać funkcji wirtualnych (dodawanie na końcu jest w pewnym sensie bezpieczne, jeśli nie ma klas potomnych, które dodają własne metody wirtualne).
Paul de Vrieze
1
^ Ten komentarz powinien być lepki.
CodeAngry,
10

Nienawidzę pryszczów! Robią klasę brzydko i nieczytelnie. Wszystkie metody są przekierowywane na pryszcz. Nigdy nie widzisz w nagłówkach, jakie funkcjonalności ma ta klasa, więc nie możesz jej refaktoryzować (np. Po prostu zmienić widoczność metody). Klasa wydaje się być „w ciąży”. Myślę, że używanie iterfaces jest lepsze i naprawdę wystarczające, aby ukryć implementację przed klientem. Możesz pozwolić jednej klasie zaimplementować kilka interfejsów, aby były cienkie. Należy preferować interfejsy! Uwaga: nie jest wymagana klasa fabryczna. Istotne jest to, że klienci klasy komunikują się z jej instancjami za pośrednictwem odpowiedniego interfejsu. Ukrywanie prywatnych metod uważam za dziwną paranoję i nie widzę powodu, ponieważ mamy interfejsy.

Masha Ananieva
źródło
1
W niektórych przypadkach nie można używać czystych interfejsów wirtualnych. Na przykład, gdy masz jakiś starszy kod i masz dwa moduły, które musisz rozdzielić bez dotykania ich.
AlexTheo,
Jak zauważył @Paul de Vrieze poniżej, tracisz kompatybilność ABI podczas zmiany metod klasy bazowej, ponieważ masz niejawną zależność od vtable tej klasy. To zależy od przypadku użycia, czy jest to problem.
H. Rittich
„Ukrywanie prywatnych metod uważam za dziwną paranoję” Czy to nie pozwala na ukrycie zależności, a tym samym zminimalizowanie czasu kompilacji, jeśli zależność się zmieni?
pooya13
Nie rozumiem też, w jaki sposób fabryki są łatwiejsze do ponownego uwzględnienia niż pImpl. Nie wychodzisz z „interfejsu” w obu przypadkach i nie zmieniasz implementacji? W Factory musisz zmodyfikować jeden plik .h i jeden .cpp, aw pImpl musisz zmodyfikować jeden .h i dwa .cpp, ale to wszystko i generalnie nie musisz modyfikować pliku cpp interfejsu pImpl.
pooya13
8

Istnieje bardzo poważny problem z bibliotekami współdzielonymi, który idiom pimpl omija z łatwością, czego nie mogą zrobić zwykli wirtualiści: nie można bezpiecznie modyfikować / usuwać członków danych klasy bez zmuszania użytkowników tej klasy do ponownej kompilacji kodu. Może to być dopuszczalne w pewnych okolicznościach, ale nie np. W przypadku bibliotek systemowych.

Aby szczegółowo wyjaśnić problem, rozważ następujący kod w udostępnianej bibliotece / nagłówku:

// header
struct A
{
public:
  A();
  // more public interface, some of which uses the int below
private:
  int a;
};

// library 
A::A()
  : a(0)
{}

Kompilator emituje kod w bibliotece współdzielonej, który oblicza adres liczby całkowitej, która ma być zainicjowana, aby była pewnym przesunięciem (prawdopodobnie w tym przypadku zerem, ponieważ jest to jedyny element członkowski) od wskaźnika do obiektu A, o którym wie, że jest this.

Po stronie użytkownika kodu, a new Anajpierw przydzieli sizeof(A)bajty pamięci, a następnie przekaże wskaźnik do tej pamięci A::A()konstruktorowi as this.

Jeśli w późniejszej wersji biblioteki zdecydujesz się usunąć liczbę całkowitą, powiększyć ją, pomniejszyć lub dodać składowe, wystąpi niezgodność między ilością pamięci przydzielonej przez użytkownika a przesunięciami oczekiwanymi przez kod konstruktora. Prawdopodobnym rezultatem jest awaria, jeśli masz szczęście - jeśli masz mniej szczęścia, oprogramowanie zachowuje się dziwnie.

Dzięki pimpl'owaniu możesz bezpiecznie dodawać i usuwać składowe danych do klasy wewnętrznej, ponieważ alokacja pamięci i wywołanie konstruktora mają miejsce w bibliotece współdzielonej:

// header
struct A
{
public:
  A();
  // more public interface, all of which delegates to the impl
private:
  void * impl;
};

// library 
A::A()
  : impl(new A_impl())
{}

Wszystko, co musisz teraz zrobić, to uwolnić swój publiczny interfejs od członków danych innych niż wskaźnik do obiektu implementacji, a będziesz bezpieczny przed tą klasą błędów.

Edycja: Powinienem dodać, że jedynym powodem, dla którego mówię tutaj o konstruktorze, jest to, że nie chciałem udostępniać więcej kodu - ta sama argumentacja dotyczy wszystkich funkcji, które mają dostęp do członków danych.


źródło
4
Zamiast void *, myślę, że bardziej tradycyjne jest deklarowanie klasy implementującej:class A_impl *impl_;
Frank Krueger
9
Nie rozumiem, nie powinieneś deklarować prywatnych członków w wirtualnej czystej klasie, której zamierzasz używać jako interfejsu, chodzi o to, aby klasa była raczej abstrakcyjna, bez rozmiaru, tylko czyste wirtualne metody, nic nie widzę nie możesz tego zrobić przez biblioteki współdzielone
Arkaitz Jimenez
@Frank Krueger: Masz rację, byłem leniwy. @Arkaitz Jimenez: Drobne nieporozumienie; jeśli masz klasę, która zawiera tylko czyste funkcje wirtualne, nie ma sensu mówić o bibliotekach współdzielonych. Z drugiej strony, jeśli masz do czynienia z bibliotekami współdzielonymi, spreparowanie klas publicznych może być rozważne z powodów przedstawionych powyżej.
10
To jest po prostu niepoprawne. Obie metody pozwalają ci ukryć stan implementacji twoich klas, jeśli uczynisz swoją drugą klasę "czysto abstrakcyjną klasą bazową".
Paul Hollingsworth
10
Pierwsze zdanie w Twoim anser sugeruje, że czyste wirtuale z powiązaną metodą fabryczną w jakiś sposób nie pozwalają ci ukryć wewnętrznego stanu klasy. To nieprawda. Obie techniki pozwalają ukryć wewnętrzny stan klasy. Różnica polega na tym, jak wygląda to dla użytkownika. pImpl pozwala nadal reprezentować klasę z semantyką wartości, ale jednocześnie ukrywa stan wewnętrzny. Metoda fabryki Pure Abstract Base Class + pozwala na reprezentowanie typów encji, a także pozwala ukryć stan wewnętrzny. To ostatnie dokładnie tak działa COM. Rozdział 1 „Essential COM” zawiera świetne wyjaśnienie na ten temat.
Paul Hollingsworth
6

Nie wolno nam zapominać, że dziedziczenie jest silniejszym i bliższym związkiem niż delegacja. Chciałbym również wziąć pod uwagę wszystkie kwestie poruszone w udzielonych odpowiedziach przy podejmowaniu decyzji, jakie idiomy projektowe zastosować w rozwiązaniu konkretnego problemu.

Sam
źródło
3

Chociaż szeroko omówione w innych odpowiedziach, być może mogę nieco bardziej jednoznacznie powiedzieć o jednej korzyści z pimpl w porównaniu z wirtualnymi klasami bazowymi:

Podejście pimpl jest przejrzyste z punktu widzenia użytkownika, co oznacza, że ​​można np. Tworzyć obiekty klasy na stosie i używać ich bezpośrednio w kontenerach. Jeśli spróbujesz ukryć implementację za pomocą abstrakcyjnej wirtualnej klasy bazowej, będziesz musiał zwrócić wspólny wskaźnik do klasy bazowej z fabryki, co komplikuje jej użycie. Rozważ następujący równoważny kod klienta:

// Pimpl
Object pi_obj(10);
std::cout << pi_obj.SomeFun1();

std::vector<Object> objs;
objs.emplace_back(3);
objs.emplace_back(4);
objs.emplace_back(5);
for (auto& o : objs)
    std::cout << o.SomeFun1();

// Abstract Base Class
auto abc_obj = ObjectABC::CreateObject(20);
std::cout << abc_obj->SomeFun1();

std::vector<std::shared_ptr<ObjectABC>> objs2;
objs2.push_back(ObjectABC::CreateObject(13));
objs2.push_back(ObjectABC::CreateObject(14));
objs2.push_back(ObjectABC::CreateObject(15));
for (auto& o : objs2)
    std::cout << o->SomeFun1();
Superfly Jon
źródło
2

W moim rozumieniu te dwie rzeczy służą zupełnie innym celom. Celem idiomu pryszcz jest w zasadzie dać ci uchwyt do implementacji, dzięki czemu możesz robić takie rzeczy, jak szybkie zamiany na sort.

Cel klas wirtualnych jest bardziej podobny do dopuszczenia polimorfizmu, tj. Masz nieznany wskaźnik do obiektu typu pochodnego, a kiedy wywołujesz funkcję x, zawsze otrzymujesz odpowiednią funkcję dla dowolnej klasy, na którą faktycznie wskazuje wskaźnik bazowy.

Naprawdę jabłka i pomarańcze.

Robert S. Barnes
źródło
Zgadzam się z jabłkami / pomarańczami. Wygląda jednak na to, że używasz pImpl do funkcji. Moim celem jest głównie budowanie i ukrywanie informacji.
xtofl
2

Najbardziej irytującym problemem związanym z idiomem pimpl jest to, że niezwykle trudno jest utrzymać i analizować istniejący kod. Więc używając pimpl płacisz czasem i frustracją programisty tylko po to, aby „zredukować zależności i czasy kompilacji oraz zminimalizować wyświetlanie szczegółów implementacji w nagłówku”. Zdecyduj sam, czy naprawdę warto.

Zwłaszcza „czasy kompilacji” to problem, który można rozwiązać za pomocą lepszego sprzętu lub narzędzi takich jak Incredibuild (www.incredibuild.com, również zawarte w programie Visual Studio 2017), nie wpływając w ten sposób na projekt oprogramowania. Projekt oprogramowania powinien być zasadniczo niezależny od sposobu, w jaki jest ono zbudowane.

Trantor
źródło
Płacisz również czasem programisty, gdy czas kompilacji wynosi 20 minut zamiast 2, więc jest to trochę balansu, prawdziwy system modułów bardzo by tu pomógł.
Arkaitz Jimenez
IMHO, sposób budowania oprogramowania nie powinien w ogóle wpływać na projekt wewnętrzny. To jest zupełnie inny problem.
Trantor,
2
Co utrudnia analizę? Kilka wywołań w pliku implementacji przekazujących do klasy Impl nie wydaje się trudne.
mabraham
2
Wyobraź sobie implementacje debugowania, w których używane są zarówno pimpl, jak i interfejsy. Zaczynając od wywołania w kodzie użytkownika A, śledzisz do interfejsu B, przeskakujesz do pimpled klasy C, aby ostatecznie rozpocząć debugowanie klasy implementacji D ... Cztery kroki, aż będziesz mógł przeanalizować, co naprawdę się dzieje. A jeśli całość jest zaimplementowana w bibliotece DLL, prawdopodobnie znajdziesz interfejs C gdzieś pomiędzy….
Trantor
Dlaczego miałbyś używać interfejsu z pImpl, skoro pImpl może również wykonywać zadanie interfejsu? (tzn. może pomóc ci osiągnąć odwrócenie zależności)
pooya13