Jaki jest wzorzec bezpiecznego interfejsu w C ++

22

Uwaga: poniżej znajduje się kod C ++ 03, ale spodziewamy się przejścia na C ++ 11 w ciągu najbliższych dwóch lat, więc musimy o tym pamiętać.

Piszę wytyczne (dla początkujących, między innymi) na temat pisania abstrakcyjnego interfejsu w C ++. Przeczytałem oba artykuły Suttera na ten temat, przeszukałem w Internecie przykłady i odpowiedzi oraz wykonałem kilka testów.

Ten kod NIE może się kompilować!

void foo(SomeInterface & a, SomeInterface & b)
{
   SomeInterface c ;               // must not be default-constructible
   SomeInterface d(a);             // must not be copy-constructible
   a = b ;                         // must not be assignable
}

Wszystkie powyższe zachowania znajdują źródło problemu w krojeniu : Interfejs abstrakcyjny (lub klasa niebędąca liściem w hierarchii) nie powinna być konstruowalna ani kopiowalna / przypisywalna, NAWET jeśli klasa pochodna może być.

Rozwiązanie 0: podstawowy interfejs

class VirtuallyDestructible
{
   public :
      virtual ~VirtuallyDestructible() {}
} ;

To rozwiązanie jest proste i nieco naiwne: nie spełnia wszystkich naszych ograniczeń: może być konstruowane domyślnie, konstruowane i przypisywane do kopii (nie jestem nawet pewien co do konstruktorów ruchów i przypisania, ale mam jeszcze 2 lata na wymyślenie to out).

  1. Nie możemy zadeklarować, że destruktor jest wirtualny, ponieważ musimy go utrzymywać w linii, a niektóre z naszych kompilatorów nie będą trawiły czystych metod wirtualnych z wbudowanym pustym ciałem.
  2. Tak, jedynym celem tej klasy jest uczynienie implementatorów praktycznie możliwym do zniszczenia, co jest rzadkim przypadkiem.
  3. Nawet gdybyśmy mieli dodatkową wirtualną czystą metodę (która stanowi większość przypadków), ta klasa nadal byłaby przypisywalna do kopiowania.

Więc nie...

1. rozwiązanie: boost :: noncopyable

class VirtuallyDestructible : boost::noncopyable
{
   public :
      virtual ~VirtuallyDestructible() {}
} ;

To rozwiązanie jest najlepsze, ponieważ jest proste, jasne i C ++ (bez makr)

Problem polega na tym, że nadal nie działa dla tego konkretnego interfejsu, ponieważ program VirtuallyConstructible może być nadal domyślnie konstruowany .

  1. Nie możemy zadeklarować, że destruktor jest wirtualny, ponieważ musimy utrzymywać go w linii, a niektóre z naszych kompilatorów go nie strawią.
  2. Tak, jedynym celem tej klasy jest uczynienie implementatorów praktycznie możliwym do zniszczenia, co jest rzadkim przypadkiem.

Innym problemem jest to, że klasy implementujące interfejs, który nie może być kopiowany, muszą jawnie zadeklarować / zdefiniować konstruktor kopiowania i operator przypisania, jeśli potrzebują tych metod (aw naszym kodzie mamy klasy wartości, do których nasz klient może uzyskać dostęp za pośrednictwem interfejsy).

Jest to sprzeczne z regułą zerową, do której chcemy iść: jeśli domyślna implementacja jest poprawna, powinniśmy być w stanie jej użyć.

Drugie rozwiązanie: zapewnij im ochronę!

class MyInterface
{
   public :
      virtual ~MyInterface() {}

   protected :
      // With C++11, these methods would be "= default"
      MyInterface() {}
      MyInterface(const MyInterface & ) {}
      MyInterface & operator = (const MyInterface & ) { return *this ; }
} ;

Ten wzorzec jest zgodny z ograniczeniami technicznymi, które mieliśmy (przynajmniej w kodzie użytkownika): MyInterface nie może być konstruowany domyślnie, nie może być konstruowany jako kopia i nie może być przypisany do kopii.

Ponadto nie nakłada żadnych sztucznych ograniczeń na implementację klas , które mogą swobodnie przestrzegać Reguły Zero, a nawet zadeklarować kilka konstruktorów / operatorów jako „= default” w C ++ 11/14 bez problemu.

Teraz jest to dość szczegółowe, a alternatywą byłoby użycie makra, coś takiego:

class MyInterface
{
   public :
      virtual ~MyInterface() {}

   protected :
      DECLARE_AS_NON_SLICEABLE(MyInterface) ;
} ;

Chroniony musi pozostać poza makrem (ponieważ nie ma zasięgu).

Poprawnie „z przestrzenią nazw” (tzn. Z nazwą firmy lub produktu) makro powinno być nieszkodliwe.

Zaletą jest to, że kod jest uwzględniany w jednym źródle, zamiast kopiowania wklejonego we wszystkich interfejsach. Jeśli konstruktor ruchów i przypisanie ruchów zostaną wyraźnie wyłączone w ten sam sposób w przyszłości, byłaby to bardzo niewielka zmiana w kodzie.

Wniosek

  • Czy mam paranoję, aby chronić kod przed krojeniem interfejsów? (Wierzę, że nie jestem, ale nigdy nie wiadomo ...)
  • Jakie jest najlepsze rozwiązanie spośród powyższych?
  • Czy istnieje inne, lepsze rozwiązanie?

Pamiętaj, że jest to wzorzec, który posłuży jako wskazówka dla początkujących (między innymi), więc rozwiązanie takie jak: „Każdy przypadek powinien mieć swoją implementację” nie jest realnym rozwiązaniem.

Nagroda i wyniki

Nagrodę przyznałem coredump ze względu na czas poświęcony na udzielenie odpowiedzi na pytania oraz trafność odpowiedzi.

Moje rozwiązanie problemu będzie prawdopodobnie dotyczyło czegoś takiego:

class MyInterface
{
   DECLARE_CLASS_AS_INTERFACE(MyInterface) ;

   public :
      // the virtual methods
} ;

... z następującym makrem:

#define DECLARE_CLASS_AS_INTERFACE(ClassName)                                \
   public :                                                                  \
      virtual ~ClassName() {}                                                \
   protected :                                                               \
      ClassName() {}                                                         \
      ClassName(const ClassName & ) {}                                       \
      ClassName & operator = (const ClassName & ) { return *this ; }         \
   private :

To realne rozwiązanie mojego problemu z następujących powodów:

  • Nie można utworzyć instancji tej klasy (konstruktory są chronione)
  • Tę klasę można praktycznie zniszczyć
  • Ta klasa może być dziedziczona bez nakładania nadmiernych ograniczeń na klasy dziedziczące (np. Klasa dziedzicząca może być domyślnie kopiowalna)
  • Użycie makra oznacza, że ​​interfejs „deklaracja” jest łatwo rozpoznawalny (i możliwy do przeszukiwania), a jego kod jest faktoryzowany w jednym miejscu, co ułatwia modyfikację (odpowiednio prefiksowana nazwa usunie niepożądane konflikty nazw)

Pamiętaj, że inne odpowiedzi dały cenny wgląd. Dziękuję wszystkim, którzy spróbowali.

Zauważ, że myślę, że wciąż mogę nałożyć kolejną nagrodę na to pytanie i cenię sobie oświecające odpowiedzi na tyle, że jeśli je zobaczę, otworzę nagrodę tylko po to, aby przypisać ją do tej odpowiedzi.

paercebal
źródło
5
Czy nie można po prostu używać czystych funkcji wirtualnych w interfejsie? virtual void bar() = 0;na przykład? Zapobiegnie to inicjalizacji Twojego interfejsu.
Morwenn
@Morwenn: Jak wspomniano w pytaniu, rozwiązałoby to 99% spraw (dążę do 100%, jeśli to możliwe). Nawet jeśli zdecydujemy się zignorować brakujący 1%, nie rozwiąże to również podziału na przypisania. Więc nie, to nie jest dobre rozwiązanie.
paercebal
@Morwenn: Poważnie? ... :-D ... Najpierw napisałem to pytanie na StackOverflow, a potem zmieniłem zdanie tuż przed jego przesłaniem. Czy uważasz, że powinienem go tutaj usunąć i przesłać do SO?
paercebal
Jeśli mam rację, wszystko czego potrzebujesz to virtual ~VirtuallyDestructible() = 0wirtualne dziedziczenie klas interfejsów (tylko z elementami abstrakcyjnymi). Prawdopodobnie możesz pominąć ten Wirtualnie Zniszczalny.
Dieter Lücking
5
@paercebal: Jeśli kompilator dusi się w klasach czysto wirtualnych, należy do kosza. Prawdziwy interfejs jest z definicji czysto wirtualny.
Nikt

Odpowiedzi:

13

Kanonicznym sposobem stworzenia interfejsu w C ++ jest nadanie mu czystego wirtualnego destruktora. To zapewnia

  • Nie można utworzyć żadnych instancji samej klasy interfejsu, ponieważ C ++ nie pozwala na tworzenie instancji klasy abstrakcyjnej. Zajmuje się to wymaganiami niemożliwymi do zbudowania (zarówno domyślnymi, jak i kopiowanymi).
  • Wywołanie deletewskaźnika do interfejsu działa właściwie: wywołuje destruktor klasy najbardziej pochodnej dla tego wystąpienia.

samo posiadanie czystego wirtualnego destruktora nie zapobiega przypisywaniu odwołania do interfejsu. Jeśli chcesz, aby to również się nie powiodło, musisz dodać do interfejsu operatora chronionego przypisania.

Każdy kompilator C ++ powinien być w stanie obsłużyć taką klasę / interfejs (wszystko w jednym pliku nagłówkowym):

class MyInterface {
public:
  virtual ~MyInterface() = 0;
protected:
  MyInterface& operator=(const MyInterface&) { return *this; } // or = default for C++14
};

inline MyInterface::~MyInterface() {}

Jeśli masz kompilator, który dusi się na tym (co oznacza, że ​​musi to być wersja wcześniejsza niż C ++ 98), to twoja opcja 2 (posiadająca chronione konstruktory) jest dobra na drugim miejscu.

Używanie boost::noncopyablenie jest wskazane dla tego zadania, ponieważ wysyła komunikat, że wszystkie klasy w hierarchii nie mogą być kopiowane, a zatem może powodować zamieszanie dla bardziej doświadczonych programistów, którzy nie byliby zaznajomieni z twoimi zamiarami korzystania z tego w ten sposób.

Bart van Ingen Schenau
źródło
If you need [prevent assignment] to fail as well, then you must add a protected assignment operator to your interface.: To jest podstawa mojego problemu. Przypadki, w których potrzebuję interfejsu do obsługi zadań, muszą być naprawdę rzadkie. Z drugiej strony przypadki, w których chcę przekazać interfejs przez odwołanie (przypadki, w których NULL jest niedopuszczalny), a tym samym chcą uniknąć braku operacji lub krojenia, które kompilują, są znacznie większe.
paercebal
Ponieważ operator przypisania nigdy nie powinien być nazywany, dlaczego podajesz mu definicję? Nawiasem mówiąc, dlaczego nie zrobić private? Ponadto możesz zająć się domyślnym i kopiowaniem ctor.
Deduplicator
5

Czy jestem paranoikiem ...

  • Czy mam paranoję, aby chronić kod przed krojeniem interfejsów? (Wierzę, że nie jestem, ale nigdy nie wiadomo ...)

Czy to nie jest problem zarządzania ryzykiem?

  • obawiasz się, że prawdopodobnie zostanie wprowadzony błąd związany z krojeniem?
  • myślisz, że może to pozostać niezauważone i spowodować nieusuwalne błędy?
  • w jakim stopniu jesteś gotowy, aby uniknąć krojenia?

Najlepsze rozwiązanie

  • Jakie jest najlepsze rozwiązanie spośród powyższych?

Twoje drugie rozwiązanie („zapewnij im ochronę”) wygląda dobrze, ale pamiętaj, że nie jestem ekspertem od C ++.
Przynajmniej nieprawidłowe użycie wydaje się być poprawnie zgłaszane jako błędne przez mój kompilator (g ++).

Teraz potrzebujesz makr? Powiedziałbym „tak”, ponieważ nawet jeśli nie powiesz, jaki jest cel wytycznej, którą piszesz, myślę, że ma to na celu egzekwowanie określonego zestawu najlepszych praktyk w kodzie produktu.

W tym celu makra mogą pomóc wykryć, kiedy ludzie skutecznie zastosują wzorzec: podstawowy filtr zatwierdzeń może powiedzieć, czy makro zostało użyte:

  • jeśli zostanie użyty, wówczas wzór prawdopodobnie zostanie zastosowany, a co ważniejsze, poprawnie zastosowany (wystarczy sprawdzić, czy istnieje protectedsłowo kluczowe),
  • jeśli nie zostanie użyty, możesz spróbować dowiedzieć się, dlaczego tak się nie stało.

Bez makr musisz sprawdzić, czy wzorzec jest konieczny i dobrze wdrożony we wszystkich przypadkach.

Lepsze rozwiązanie

  • Czy istnieje inne, lepsze rozwiązanie?

Cięcie w C ++ jest niczym więcej niż osobliwością tego języka. Ponieważ piszesz wytyczne (szczególnie dla początkujących), powinieneś skupić się na nauczaniu, a nie tylko na wyliczaniu „reguł kodowania”. Musisz upewnić się, że naprawdę wyjaśnisz, w jaki sposób i dlaczego występuje krojenie, wraz z przykładami i ćwiczeniami (nie wymyślaj ponownie koła, czerp inspirację z książek i samouczków).

Na przykład tytuł ćwiczenia może brzmieć „ Jaki jest wzorzec bezpiecznego interfejsu w C ++ ?”

Najlepszym rozwiązaniem byłoby upewnienie się, że programiści C ++ rozumieją, co się dzieje, gdy nastąpi krojenie. Jestem przekonany, że jeśli to zrobią, nie popełnią tak wielu błędów w kodzie, jak byś się obawiał, nawet bez formalnego egzekwowania tego konkretnego wzorca (ale nadal możesz go wymusić, ostrzeżenia kompilatora są dobre).

O kompilatorze

Mówisz :

Nie mam wpływu na wybór kompilatorów dla tego produktu,

Często ludzie mówią: „Nie mam prawa robić [X]” , „Nie powinienem robić [Y] ...” ,… ponieważ myślą , że to nie jest możliwe, a nie dlatego, że próbowałem lub pytałem.

Prawdopodobnie częścią opisu stanowiska jest wyrażenie opinii na temat problemów technicznych; jeśli naprawdę uważasz, że kompilator jest idealnym (lub unikalnym) wyborem dla Twojej problematycznej domeny, skorzystaj z niego. Ale powiedziałeś również, że „wirtualne niszczyciele z implementacją wbudowaną nie są najgorszym dławieniem, jakie widziałem” ; z mojego zrozumienia, kompilator jest tak wyjątkowy, że nawet doświadczeni programiści C ++ mają trudności z używaniem go: twój starszy / wewnętrzny kompilator jest teraz technicznym długiem i masz prawo (obowiązek?) omówić ten problem z innymi programistami i menedżerami .

Spróbuj oszacować koszt utrzymania kompilatora w porównaniu z kosztem użycia innego:

  1. Co oferuje obecny kompilator, czego nikt inny nie może?
  2. Czy kod produktu można łatwo skompilować przy użyciu innego kompilatora? Dlaczego nie ?

Nie znam twojej sytuacji i prawdopodobnie masz ważne powody, by być powiązanym z konkretnym kompilatorem.
Ale w przypadku, gdy jest to zwykła bezwładność, sytuacja nigdy się nie zmieni, jeśli ty lub twoi współpracownicy nie zgłosicie problemów z wydajnością lub zadłużeniem technicznym.

rdzeń rdzeniowy
źródło
Am I paranoid...: „Uczyń swoje interfejsy łatwymi w użyciu i trudnymi w użyciu”. Spróbowałem tej szczególnej zasady, gdy ktoś zgłosił, że jedna z moich metod statycznych została przez pomyłkę zastosowana nieprawidłowo. Wytworzony błąd wydawał się niezwiązany i znalezienie źródła zajęło inżynierowi wiele godzin. Ten „błąd interfejsu” jest równoznaczny z przypisaniem odwołania interfejsu do innego. Tak, chcę uniknąć tego rodzaju błędu. Ponadto w C ++ filozofia polega na tym, aby złapać jak najwięcej w czasie kompilacji, a język daje nam tę moc, więc idziemy z nią.
paercebal
Best solution: Zgadzam się. . . Better solution: To niesamowita odpowiedź. Popracuję nad tym ... Teraz o Pure virtual classes: Co to jest? Abstrakcyjny interfejs C ++? (klasa bez stanu i tylko czyste metody wirtualne?). Jak ta „czysta klasa wirtualna” uchroniła mnie przed krojeniem? (metody czysto wirtualne sprawią, że tworzenie instancji nie będzie się kompilować, ale przypisanie kopii zostanie wykonane, a przypisanie przeniesienia również IIRC).
paercebal
About the compiler: Zgadzamy się, ale nasze kompilatory są poza moim zakresem odpowiedzialności (nie dlatego, że powstrzymuje mnie od złośliwych komentarzy ... :-p ...). Nie ujawnię szczegółów (chciałbym, żebym mógł), ale są one powiązane z przyczynami wewnętrznymi (jak pakiety testowe) i zewnętrznymi (np. Połączenie klienta z naszymi bibliotekami). Ostatecznie zmiana wersji kompilatora (lub nawet łatanie go) NIE jest trywialną operacją. Nie mówiąc już o zastąpieniu jednego zepsutego kompilatora najnowszym gcc.
paercebal
@paercebal dzięki za komentarze; o czysto wirtualnych klasach, masz rację, to nie rozwiązuje wszystkich twoich ograniczeń (usunę tę część). Rozumiem część „błąd interfejsu” i to, jak przydatne jest wychwytywanie błędów w czasie kompilacji: ale zapytałeś, czy jesteś paranoikiem, i myślę, że racjonalnym podejściem jest zrównoważenie potrzeby kontroli statycznych z prawdopodobieństwem popełnienia błędu. Powodzenia w kompilatorze :)
rdzeń
1
Nie jestem fanem makr, zwłaszcza że wytyczne są skierowane (również) do młodszych mężczyzn. Zbyt często widziałem ludzi, którzy otrzymywali tak „przydatne” narzędzia, aby ślepo je stosować i nigdy nie rozumieli, co się właściwie dzieje. Uznają, że to, co robi makro, musi być najbardziej skomplikowaną rzeczą, ponieważ ich szef uważał, że byłoby im zbyt trudno zrobić to samo. A ponieważ makro istnieje tylko w Twojej firmie, nie mogą nawet wyszukiwać go w Internecie, podczas gdy w przypadku udokumentowanych wskazówek, jakie funkcje członka należy zadeklarować i dlaczego, mogą.
5gon12eder 10.1015
2

Problem krojenia jest jeden, ale z pewnością nie jedyny, wprowadzony, gdy udostępniasz użytkownikom interfejs polimorficzny w czasie wykonywania. Pomyśl o zerowych wskaźnikach, zarządzaniu pamięcią, udostępnianych danych. Żadnego z nich nie da się łatwo rozwiązać we wszystkich przypadkach (inteligentne wskaźniki są świetne, ale nawet nie są srebrną kulą). W rzeczywistości z twojego postu nie wydaje się, że próbujesz rozwiązać problem krojenia, ale raczej omijasz go, nie pozwalając użytkownikom na tworzenie kopii. Wszystko, co musisz zrobić, aby zaoferować rozwiązanie problemu krojenia, to dodanie funkcji wirtualnego członka klonu. Myślę, że głębszym problemem związanym z ujawnianiem interfejsu polimorficznego w czasie wykonywania jest to, że zmuszasz użytkowników do radzenia sobie z semantyką odniesienia, której trudniej jest uzasadnić niż semantykę wartości.

Najlepszym sposobem, jaki znam, aby uniknąć tych problemów w C ++, jest usunięcie typu . Jest to technika, w której ukrywa się interfejs polimorficzny w czasie wykonywania, za normalnym interfejsem klasy. Ten normalny interfejs klasy ma następnie semantykę wartości i zajmuje się całym polimorficznym „bałaganem” za ekranami. std::functionjest doskonałym przykładem usuwania typu.

Aby uzyskać doskonałe wyjaśnienie, dlaczego ujawnianie dziedziczenia użytkownikom jest złe, oraz w jaki sposób wymazywanie typu może pomóc w rozwiązaniu problemu z wyświetlaniem tych prezentacji przez Sean Parent:

Inheritance Is The Base Class of Evil (wersja skrócona)

Wartość Semantyka i polimorfizm oparty na koncepcjach (długa wersja; łatwiejsza do naśladowania, ale dźwięk nie jest świetny)

D Drmmr
źródło
0

Nie jesteś paranoikiem. Moje pierwsze profesjonalne zadanie jako programisty C ++ spowodowało krojenie i zawieszanie się. Znam innych. Nie ma na to wiele dobrych rozwiązań.

Biorąc pod uwagę ograniczenia kompilatora, najlepsza jest opcja 2. Zamiast tworzyć makro, które nowi programiści będą postrzegać jako dziwne i tajemnicze, sugerowałbym skrypt lub narzędzie do automatycznego generowania kodu. Jeśli twoi nowi pracownicy będą używać IDE, powinieneś być w stanie stworzyć narzędzie „Nowy interfejs MYCOMPANY”, które poprosi o nazwę interfejsu i utworzy poszukiwaną strukturę.

Jeśli programiści używają wiersza polecenia, użyj dowolnego dostępnego języka skryptowego, aby utworzyć skrypt NewMyCompanyInterface w celu wygenerowania kodu.

W przeszłości korzystałem z tego podejścia do typowych wzorców kodu (interfejsy, maszyny stanów itp.). Zaletą jest to, że nowi programiści mogą odczytać dane wyjściowe i łatwo je zrozumieć, odtwarzając potrzebny kod, gdy potrzebują czegoś, czego nie można wygenerować.

Makra i inne metody meta-programowania mają tendencję do zaciemniania tego, co się dzieje, a nowi programiści nie dowiadują się, co dzieje się „za zasłoną”. Kiedy muszą przełamać wzór, są tak samo zagubieni jak wcześniej.

Ben
źródło