Dlaczego „czysty polimorfizm” jest lepszy od stosowania RTTI?

106

Prawie każdy zasób C ++, który widziałem, który omawia tego rodzaju rzeczy, mówi mi, że powinienem preferować podejście polimorficzne od używania RTTI (identyfikacja typu w czasie wykonywania). Ogólnie rzecz biorąc, traktuję tego rodzaju rady poważnie i spróbuję zrozumieć uzasadnienie - w końcu C ++ to potężna bestia i trudna do zrozumienia w całej jej głębi. Jednak w przypadku tego konkretnego pytania rysuję puste miejsce i chciałbym zobaczyć, jakie porady może zaoferować internet. Najpierw podsumuję to, czego się do tej pory nauczyłem, wymieniając częste powody, dla których RTTI jest „uważane za szkodliwe”:

Niektóre kompilatory tego nie używają / RTTI nie zawsze jest włączone

Naprawdę nie kupuję tego argumentu. To tak, jakby powiedzieć, że nie powinienem używać funkcji C ++ 14, ponieważ istnieją kompilatory, które go nie obsługują. A jednak nikt nie zniechęciłby mnie do korzystania z funkcji C ++ 14. Większość projektów będzie miała wpływ na kompilator, którego używają, i sposób jego konfiguracji. Nawet cytując stronę podręcznika gcc:

-fno-rtti

Wyłącz generowanie informacji o każdej klasie z funkcjami wirtualnymi do użycia przez funkcje identyfikacji typu w czasie wykonywania C ++ (dynamic_cast i typeid). Jeśli nie używasz tych części języka, możesz zaoszczędzić trochę miejsca, używając tej flagi. Zauważ, że obsługa wyjątków używa tych samych informacji, ale G ++ generuje je w razie potrzeby. Operator dynamic_cast może być nadal używany do rzutów, które nie wymagają informacji o typie w czasie wykonywania, tj. Rzutowania do „void *” lub do jednoznacznych klas bazowych.

To mówi mi, że jeśli nie używam RTTI, mogę go wyłączyć. To tak, jakby powiedzieć, że jeśli nie używasz Boost, nie musisz się z nim łączyć. Nie muszę planować przypadku, w którym ktoś się kompiluje -fno-rtti. Ponadto w tym przypadku kompilator zawiedzie głośno i wyraźnie.

To kosztuje dodatkową pamięć / może działać wolno

Ilekroć mam ochotę użyć RTTI, oznacza to, że muszę uzyskać dostęp do informacji o typie lub cechach mojej klasy. Jeśli zaimplementuję rozwiązanie, które nie korzysta z RTTI, zwykle oznacza to, że będę musiał dodać kilka pól do moich klas, aby przechowywać te informacje, więc argument pamięci jest trochę pusty (podam przykład tego poniżej).

Rzeczywiście, dynamic_cast może być powolny. Zwykle jednak istnieją sposoby, aby uniknąć konieczności używania go w sytuacjach krytycznych dla prędkości. I nie do końca widzę alternatywę. To TAK odpowiedź sugeruje użycie wyliczenia zdefiniowanego w klasie bazowej do przechowywania typu. Działa to tylko wtedy, gdy znasz wszystkie klasy pochodne a-priori. To dość duże „jeśli”!

Z tej odpowiedzi wynika również, że koszt RTTI również nie jest jasny. Różni ludzie mierzą różne rzeczy.

Eleganckie, polimorficzne projekty sprawią, że RTTI będzie niepotrzebne

Tego rodzaju rady traktuję poważnie. W tym przypadku po prostu nie mogę wymyślić dobrych rozwiązań innych niż RTTI, które obejmowałyby mój przypadek użycia RTTI. Podam przykład:

Powiedzmy, że piszę bibliotekę do obsługi wykresów jakiegoś rodzaju obiektów. Chcę zezwolić użytkownikom na generowanie własnych typów podczas korzystania z mojej biblioteki (więc metoda wyliczeniowa nie jest dostępna). Mam klasę bazową dla mojego węzła:

class node_base
{
  public:
    node_base();
    virtual ~node_base();

    std::vector< std::shared_ptr<node_base> > get_adjacent_nodes();
};

Teraz moje węzły mogą być różnych typów. Co powiesz na te:

class red_node : virtual public node_base
{
  public:
    red_node();
    virtual ~red_node();

    void get_redness();
};

class yellow_node : virtual public node_base
{
  public:
    yellow_node();
    virtual ~yellow_node();

    void set_yellowness(int);
};

Do diabła, czemu nie jeden z tych:

class orange_node : public red_node, public yellow_node
{
  public:
    orange_node();
    virtual ~orange_node();

    void poke();
    void poke_adjacent_oranges();
};

Ostatnia funkcja jest interesująca. Oto sposób na zapisanie tego:

void orange_node::poke_adjacent_oranges()
{
    auto adj_nodes = get_adjacent_nodes();
    foreach(auto node, adj_nodes) {
        // In this case, typeid() and static_cast might be faster
        std::shared_ptr<orange_node> o_node = dynamic_cast<orange_node>(node);
        if (o_node) {
             o_node->poke();
        }
    }
}

To wszystko wydaje się jasne i czyste. Nie muszę definiować atrybutów ani metod, w których ich nie potrzebuję, podstawowa klasa węzłów może pozostać szczupła i wredna. Bez RTTI, gdzie mam zacząć? Może mogę dodać atrybut node_type do klasy bazowej:

class node_base
{
  public:
    node_base();
    virtual ~node_base();

    std::vector< std::shared_ptr<node_base> > get_adjacent_nodes();

  private:
    std::string my_type;
};

Czy std :: string to dobry pomysł na typ? Może nie, ale z czego jeszcze mogę skorzystać? Wymyśl numer i masz nadzieję, że nikt inny go jeszcze nie używa? Co więcej, w przypadku mojego orange_node, co jeśli chcę użyć metod z red_node i yellow_node? Czy musiałbym przechowywać wiele typów na węzeł? Wydaje się to skomplikowane.

Wniosek

Te przykłady nie wydają się zbyt skomplikowane ani niezwykłe (pracuję nad czymś podobnym w mojej codziennej pracy, w której węzły reprezentują rzeczywisty sprzęt, który jest kontrolowany przez oprogramowanie i które robią bardzo różne rzeczy w zależności od tego, czym są). Jednak nie znałbym czystego sposobu na zrobienie tego za pomocą szablonów lub innych metod. Zwróć uwagę, że staram się zrozumieć problem, a nie bronić swojego przykładu. Moje czytanie stron, takich jak odpowiedź SO, do której linkowałem powyżej i ta strona na Wikibooks wydaje się sugerować, że nadużywam RTTI, ale chciałbym się dowiedzieć, dlaczego.

Wracając do mojego pierwotnego pytania: dlaczego „czysty polimorfizm” jest lepszy od stosowania RTTI?

mbr0wn
źródło
9
To, czego „brakuje” (jako funkcji języka) przy rozwiązywaniu problemu z pomarańczami, to wysyłanie wielokrotne („multimethods”). Dlatego szukam sposobów na naśladowanie, które mogą być alternatywą. Dlatego zwykle używany jest wzorzec odwiedzających.
Daniel Jour
1
Użycie ciągu znaków jako typu nie jest zbyt pomocne. Użycie wskaźnika do instancji jakiejś klasy „typu” przyspieszyłoby to. Ale wtedy w zasadzie robisz ręcznie to, co robiłby RTTI.
Daniel Jour
4
@MargaretBloom Nie, nie będzie, RTTI oznacza informacje o typie środowiska uruchomieniowego, podczas gdy CRTP jest tylko dla szablonów - typów statycznych, więc.
edmz
2
@ mbr0wn: wszystkie procesy inżynierskie podlegają pewnym regułom; programowanie nie jest wyjątkiem. Zasady można podzielić na dwa segmenty: zasady miękkie (POWINIEN) i reguły twarde (MUSI). (Jest też, że tak powiem, zasobnik porad / opcji (MOŻLIWY).) Przeczytaj, jak standard C / C ++ (lub jakikolwiek inny standard ang.) Je definiuje. Wydaje mi się, że twój problem wynika z tego, że pomyliłeś się „nie używaj RTTI” jako twardej reguły („NIE MUSISZ używać RTTI”). W rzeczywistości jest to miękka reguła („NIE POWINIENEŚ używać RTTI”), co oznacza, że ​​powinieneś jej unikać, kiedy tylko jest to możliwe - i po prostu używać, gdy nie możesz tego uniknąć
3
Zauważam, że wiele odpowiedzi nie zwraca uwagi na pomysł, który sugeruje twój przykład, node_basejest częścią biblioteki, a użytkownicy będą tworzyć własne typy węzłów. Wtedy nie mogą zmodyfikować, node_baseaby zezwolić na inne rozwiązanie, więc może wtedy RTTI stanie się ich najlepszą opcją. Z drugiej strony istnieją inne sposoby zaprojektowania takiej biblioteki, tak aby nowe typy węzłów mogły być w niej znacznie bardziej eleganckie, bez konieczności używania RTTI (i innych sposobów projektowania nowych typów węzłów).
Matthew Walton,

Odpowiedzi:

69

Interfejs opisuje, co trzeba wiedzieć, aby wejść w interakcję w danej sytuacji w kodzie. Po przedłużeniu interfejs typu „całej hierarchii”, Twój interface „pole powierzchni” staje się ogromna, co sprawia, że rozumowanie o tym trudniej .

Na przykład „szturchanie sąsiednich pomarańczy” oznacza, że ​​ja, jako osoba trzecia, nie mogę naśladować bycia pomarańczą! Prywatnie zadeklarowałeś pomarańczowy typ, a następnie użyj RTTI, aby kod zachowywał się wyjątkowo podczas interakcji z tym typem. Jeśli chcę „być pomarańczowy”, to musi być w swoim prywatnym ogrodzie.

Teraz każdy, kto łączy się z „orangi”, łączy się z całym swoim pomarańczowym typem, a pośrednio z całym prywatnym ogrodem, zamiast z określonym interfejsem.

Chociaż na pierwszy rzut oka wygląda to na świetny sposób na rozszerzenie ograniczonego interfejsu bez konieczności zmiany wszystkich klientów (dodawania am_I_orange), zamiast tego zwykle powoduje to ossyzację bazy kodu i uniemożliwia dalsze rozszerzenie. Specjalna oranizm staje się nieodłącznym elementem funkcjonowania systemu i uniemożliwia stworzenie „mandarynkowego” zamiennika pomarańczy, który jest zaimplementowany w inny sposób i być może usuwa zależność lub elegancko rozwiązuje jakiś inny problem.

Oznacza to, że Twój interfejs musi być wystarczający, aby rozwiązać problem. Z tego punktu widzenia, dlaczego trzeba tylko szturchać pomarańcze, a jeśli tak, to dlaczego w interfejsie niedostępna była oryginalność? Jeśli potrzebujesz rozmytego zestawu tagów, które można dodawać ad hoc, możesz dodać to do swojego typu:

class node_base {
  public:
    bool has_tag(tag_name);

Zapewnia to podobne ogromne rozszerzenie interfejsu z wąsko określonego do szerokiego opartego na tagach. Z wyjątkiem tego, że zamiast robić to przez RTTI i szczegóły implementacji (aka, „Jak zaimplementujesz? W przypadku pomarańczowego typu? Ok, zdasz.”), Robi to z czymś, co łatwo emuluje za pomocą zupełnie innej implementacji.

Można to nawet rozszerzyć na metody dynamiczne, jeśli tego potrzebujesz. - Czy popierasz bycie Foo'd z argumentami Baz, Tom i Alice? Ok, wzywam cię. W dużym sensie jest to mniej inwazyjne niż rzutowanie dynamiczne, aby dowiedzieć się, że drugi obiekt jest typem, który znasz.

Teraz obiekty mandarynki mogą mieć pomarańczowy znacznik i grać razem z nimi, podczas gdy ich implementacja jest oddzielona.

Wciąż może prowadzić do ogromnego bałaganu, ale jest to przynajmniej bałagan wiadomości i danych, a nie hierarchie wdrożeniowe.

Abstrakcja to gra polegająca na oddzielaniu i ukrywaniu nieistotności. Dzięki temu kod łatwiej jest rozumować lokalnie. RTTI wierci dziurę prosto przez abstrakcję do szczegółów implementacji. Może to ułatwić rozwiązanie problemu, ale wiąże się to z bardzo łatwym kosztem związania się z jedną konkretną implementacją.

Yakk - Adam Nevraumont
źródło
14
+1 za ostatni akapit; nie tylko dlatego, że się z tobą zgadzam, ale dlatego, że jest to młotek w gwóźdź.
7
W jaki sposób można uzyskać określoną funkcjonalność, skoro wiadomo, że obiekt jest oznaczony jako obsługujący tę funkcjonalność? Albo wymaga to rzutowania, albo istnieje klasa Boga z każdą możliwą funkcją składową. Pierwsza możliwość to rzutowanie niesprawdzone, w którym to przypadku tagowanie jest tylko własnym, bardzo zawodnym dynamicznym schematem sprawdzania typu, lub jest ono sprawdzane dynamic_cast(RTTI), w którym to przypadku tagi są zbędne. Druga możliwość, klasa Boga, jest odrażająca. Podsumowując, ta odpowiedź zawiera wiele słów, które moim zdaniem brzmią dobrze dla programistów Java, ale rzeczywista treść jest bez znaczenia.
Pozdrawiam i hth. - Alf
2
@Falco: To jest (jeden z wariantów) pierwsza możliwość, o której wspomniałem, niezaznaczone casting na podstawie tagu. Tutaj tagowanie jest własnym, bardzo kruchym i bardzo omylnym schematem dynamicznego sprawdzania typu. Każde małe niewłaściwe zachowanie w kodzie klienta, aw C ++ jest wyłączone w UB-land. Nie dostaniesz wyjątków, jak możesz uzyskać w Javie, ale niezdefiniowane zachowanie, takie jak awarie i / lub nieprawidłowe wyniki. Oprócz tego, że jest wyjątkowo zawodny i niebezpieczny, jest również wyjątkowo nieefektywny w porównaniu z bardziej rozsądnym kodem C ++. IOW., To bardzo niedobre; niezwykle.
Pozdrawiam i hth. - Alf
1
Uhm. :) Typy argumentów?
Pozdrawiam i hth. - Alf
2
@JojOatXGME: Ponieważ „polimorfizm” oznacza możliwość pracy z różnymi typami. Jeśli musisz sprawdzić, czy jest to określony typ, poza już istniejącym sprawdzeniem typu, którego użyłeś do uzyskania wskaźnika / odniesienia na początek, to szukasz polimorfizmu. Nie pracujesz z różnymi typami; pracujesz z określonym typem. Tak, istnieją „(duże) projekty w Javie”, które to robią. Ale to jest Java ; język dopuszcza tylko dynamiczny polimorfizm. C ++ ma również statyczny polimorfizm. Poza tym to, że robi to ktoś „duży”, nie czyni tego dobrym pomysłem.
Nicol Bolas
31

Większość moralnych perswazji przeciwko tej czy innej funkcji ma swoje źródło w spostrzeżeniu, że istnieje wiele błędnych zastosowań tej cechy.

Tam, gdzie moraliści zawodzą, to zakładają, że WSZYSTKIE zwyczaje są błędne, podczas gdy w rzeczywistości cechy istnieją z jakiegoś powodu.

Mają coś, co zwykłem nazywać „kompleksem hydraulików”: uważają, że wszystkie krany działają nieprawidłowo, ponieważ wszystkie krany, które mają naprawić, są. W rzeczywistości większość kranów działa dobrze: po prostu nie wzywasz do nich hydraulika!

Szaloną rzeczą, która może się zdarzyć, jest sytuacja, gdy programiści, aby uniknąć korzystania z danej funkcji, piszą dużo standardowego kodu, a w rzeczywistości prywatnie ponownie wdrażają dokładnie tę funkcję. (Czy zdarzyło Ci się spotkać klasy, które nie używają RTTI ani wywołań wirtualnych, ale mają wartość umożliwiającą śledzenie, który to rzeczywisty typ pochodny? To nic więcej niż przemyślenie RTTI w przebraniu).

Istnieje ogólny sposób myślenia o polimorfizmu: IF(selection) CALL(something) WITH(parameters). (Przepraszam, ale programowanie, pomijając abstrakcję, polega na tym)

Zastosowanie polimorfizmu w czasie projektowania (koncepcje) w czasie kompilacji (na podstawie dedukcji szablonu), w czasie wykonywania (dziedziczenie i oparte na funkcjach wirtualnych) lub na danych (RTTI i przełączanie) zależy od tego, ile decyzji jest znanych na każdym z etapów produkcji i jak zmienne są w każdym kontekście.

Chodzi o to, że:

im więcej możesz przewidzieć, tym większa szansa na wyłapanie błędów i uniknięcie błędów wpływających na użytkownika końcowego.

Jeśli wszystko jest stałe (łącznie z danymi), możesz zrobić wszystko za pomocą metaprogramowania szablonu. Po kompilacji na zaktualizowanych stałych cały program sprowadza się do instrukcji return, która wypluwa wynik .

Jeśli istnieje wiele przypadków, które są znane w czasie kompilacji , ale nie znasz faktycznych danych, na których muszą działać, rozwiązaniem może być polimorfizm w czasie kompilacji (głównie CRTP lub podobny).

Jeśli wybór przypadków zależy od danych (nie znanych wartości czasu kompilacji), a przełączanie jest jednowymiarowe (to, co należy zrobić, można zredukować tylko do jednej wartości), wówczas wysyłanie oparte na funkcjach wirtualnych (lub ogólnie „tabele wskaźników funkcji ") jest potrzebne.

Jeśli przełączanie jest wielowymiarowe, ponieważ w języku C ++ nie ma natywnych wysyłek wielu uruchomień , musisz:

  • Zredukuj do jednego wymiaru przez Goedelization : tam właśnie są wirtualne bazy i wielokrotne dziedziczenie, z diamentami i ułożonymi równoległobokami , ale wymaga to poznania liczby możliwych kombinacji i ich stosunkowo niewielkich rozmiarów.
  • Łańcuch wymiarów jeden w drugi (jak we wzorze złożonego gościa, ale wymaga to od wszystkich klas świadomości swojego rodzeństwa, dlatego nie może „wyskalować” z miejsca, w którym został poczęty)
  • Wysyłaj połączenia na podstawie wielu wartości. Właśnie do tego służy RTTI.

Jeśli nie tylko przełączanie, ale nawet działania nie są znane w czasie kompilacji, wymagane jest tworzenie skryptów i parsowanie : same dane muszą opisywać akcję, która ma zostać na nich wykonana.

Teraz, ponieważ każdy z przypadków, które wyliczyłem, można postrzegać jako szczególny przypadek tego, co następuje, możesz rozwiązać każdy problem, nadużywając rozwiązania najniższego, również w przypadku problemów, na które można sobie pozwolić z najwyższym.

Właśnie tego moralizacja stara się unikać. Ale to nie znaczy, że problemy żyjące w najniższych domenach nie istnieją!

Uderzanie RTTI tylko po to, aby go uderzyć, jest jak walenie w niego gototylko po to, by go uderzyć. Rzeczy dla papug, nie dla programistów.

Emilio Garavaglia
źródło
Dobre przedstawienie poziomów, na których ma zastosowanie każde podejście. Nie słyszałem jednak o „Goedelization” - czy jest też znane pod inną nazwą? Czy mógłbyś dodać link lub więcej wyjaśnień? Dzięki :)
j_random_hacker
1
@j_random_hacker: Ja też jestem ciekawy tego zastosowania Godelizacji. Zwykle myśli się o Godelizacji jako pierwszej, polegającej na odwzorowaniu jakiegoś ciągu na pewną liczbę całkowitą, a po drugie, przy użyciu tej techniki do tworzenia instrukcji odwołujących się do siebie w językach formalnych. Nie znam tego terminu w kontekście wirtualnej wysyłki i chciałbym dowiedzieć się więcej.
Eric Lippert
1
W rzeczywistości nadużywam terminu: według Goedle'a, ponieważ każda liczba całkowita odpowiada liczbie całkowitej n-ple (potęgi jej czynników pierwszych), a każde n-ple odpowiada liczbie całkowitej, każdy dyskretny n-wymiarowy problem indeksowania może być zredukowana do jednowymiarowej . Nie oznacza to, że jest to jedyny sposób na zrobienie tego: jest to po prostu sposób na powiedzenie „to jest możliwe”. Wszystko, czego potrzebujesz, to mechanizm „dziel i rządź”. funkcje wirtualne to „dziel”, a dziedziczenie wielokrotne to „podbijanie”.
Emilio Garavaglia
... Kiedy wszystko, co dzieje się w polu skończonym (przedziale), kombinacje liniowe są bardziej efektywne (klasyczne i = r * C + c uzyskuje indeks w tablicy komórki macierzy). W tym przypadku dzielnikiem jest „gość”, a zwycięzca jest „złożeniem”. Ponieważ w grę wchodzi algebra liniowa, technika w tym przypadku odpowiada „diagonalizacji”
Emilio Garavaglia
Nie myślcie o tym wszystkim jako o technikach. To tylko analogie
Emilio Garavaglia
23

Na małym przykładzie wygląda to całkiem nieźle, ale w prawdziwym życiu wkrótce otrzymasz długi zestaw typów, które mogą się wzajemnie szturchać, niektóre z nich być może tylko w jednym kierunku.

A co dark_orange_node, czy black_and_orange_striped_node, czy dotted_node? Czy może mieć kropki w różnych kolorach? A jeśli większość kropek jest pomarańczowa, czy można je wtedy szturchnąć?

Za każdym razem, gdy będziesz musiał dodać nową regułę, będziesz musiał wrócić do wszystkich poke_adjacentfunkcji i dodać więcej instrukcji if.


Jak zwykle trudno jest tworzyć ogólne przykłady, podam ci to.

Ale gdybym miał zrobić ten konkretny przykład, dodałbym poke()członka do wszystkich klas i pozwolił niektórym z nich zignorować wywołanie call ( void poke() {}), jeśli nie są zainteresowani.

Z pewnością byłoby to nawet tańsze niż porównanie typeids.

Bo Persson
źródło
3
Mówisz „na pewno”, ale co daje ci taką pewność? Naprawdę właśnie to próbuję zrozumieć. Powiedzmy, że zmieniam nazwę orange_node na pokable_node i tylko na nich mogę wywołać poke (). Oznacza to, że mój interfejs będzie musiał zaimplementować metodę poke (), która, powiedzmy, zgłasza wyjątek („ten węzeł nie jest możliwy do umieszczenia”). To wydaje się droższe.
mbr0wn
2
Dlaczego miałby rzucić wyjątek? Jeśli dbasz o to, czy interfejs jest „podatny na podglądanie”, po prostu dodaj funkcję „isPokeable” i wywołaj ją najpierw przed wywołaniem funkcji poke. Lub po prostu rób to, co mówi i „nie rób nic, na zajęciach, których nie można wbijać”.
Brandon
1
@ mbr0wn: Lepszym pytaniem jest, dlaczego chcesz, aby węzły do ​​pokrywania i nieprzekraczalne miały tę samą klasę bazową.
Nicol Bolas
2
@NicolBolas Dlaczego miałbyś chcieć, aby przyjazne i wrogie potwory miały tę samą klasę bazową lub elementy interfejsu użytkownika, na które można skupić się i nie, albo klawiatury z klawiaturą numeryczną i klawiaturą bez klawiatury numerycznej?
user253751
1
@ mbr0wn To brzmi jak wzorzec zachowania. Interfejs podstawowy ma dwie metody supportsBehaviouri invokeBehaviourkażda klasa może mieć listę zachowań. Jednym z zachowań byłoby Poke i mogłoby zostać dodane do listy obsługiwanych zachowań przez wszystkie klasy, które chcą być zaczepiane.
Falco
20

Niektóre kompilatory tego nie używają / RTTI nie zawsze jest włączone

Myślę, że źle zrozumiałeś takie argumenty.

Istnieje wiele miejsc kodowania C ++, w których RTTI nie jest używane. Gdzie przełączniki kompilatora są używane do wymuszonego wyłączania RTTI. Jeśli kodujesz w ramach takiego paradygmatu ... to prawie na pewno zostałeś już poinformowany o tym ograniczeniu.

Dlatego problem dotyczy bibliotek . Oznacza to, że jeśli piszesz bibliotekę zależną od RTTI, wtedy Twoja biblioteka nie może być używana przez użytkowników, którzy wyłączyli RTTI. Jeśli chcesz, aby Twoja biblioteka była używana przez te osoby, nie może używać RTTI, nawet jeśli z Twojej biblioteki korzystają również osoby, które mogą korzystać z RTTI. Co równie ważne, jeśli nie możesz korzystać z RTTI, musisz trochę bardziej rozejrzeć się po bibliotekach, ponieważ korzystanie z RTTI jest dla Ciebie przełomem.

To kosztuje dodatkową pamięć / może działać wolno

Jest wielu rzeczy, których nie robisz w gorących pętlach. Nie przydzielasz pamięci. Nie przechodzisz przez powiązane listy. I tak dalej. RTTI z pewnością może być kolejną z tych rzeczy „nie rób tego tutaj”.

Rozważ jednak wszystkie przykłady RTTI. We wszystkich przypadkach masz jeden lub więcej obiektów nieokreślonego typu i chcesz wykonać na nich jakąś operację, która może nie być możliwa dla niektórych z nich.

To jest coś, nad czym musisz pracować na poziomie projektowania . Możesz pisać kontenery, które nie alokują pamięci, które pasują do paradygmatu „STL”. Możesz uniknąć połączonych struktur danych list lub ograniczyć ich użycie. Możesz zreorganizować tablice struktur w struktury tablic lub cokolwiek innego. To zmienia pewne rzeczy, ale możesz zachować to w podziale.

Zmieniasz złożoną operację RTTI w zwykłe wywołanie funkcji wirtualnej? To kwestia projektu. Jeśli musisz to zmienić, to jest to coś, co wymaga zmian w każdej klasie pochodnej. Zmienia to, jak dużo kodu współdziała z różnymi klasami. Zakres takiej zmiany wykracza daleko poza krytyczne dla wydajności sekcje kodu.

Więc ... dlaczego napisałeś to w niewłaściwy sposób?

Nie muszę definiować atrybutów ani metod, w których ich nie potrzebuję, podstawowa klasa węzłów może pozostać szczupła i wredna.

W jakim celu?

Mówisz, że klasa bazowa jest „chuda i wredna”. Ale tak naprawdę ... to nie istnieje . W rzeczywistości nic nie robi .

Wystarczy spojrzeć na przykład: node_base. Co to jest? Wydaje się, że jest to rzecz, która sąsiaduje z innymi rzeczami. To jest interfejs Java (w tym przypadku Java pre-generics): klasa, która istnieje wyłącznie po to, by być czymś, co użytkownicy mogą rzutować na rzeczywisty typ. Może dodasz jakąś podstawową funkcję, taką jak sąsiedztwo (dodaje Java ToString), ale to wszystko.

Istnieje różnica między „chudym i wrednym” a „przejrzystym”.

Jak powiedział Yakk, takie style programowania ograniczają się pod względem interoperacyjności, ponieważ jeśli cała funkcjonalność znajduje się w klasie pochodnej, wówczas użytkownicy spoza tego systemu, bez dostępu do tej klasy pochodnej, nie mogą współdziałać z systemem. Nie mogą zastępować funkcji wirtualnych i dodawać nowych zachowań. Nie mogą nawet wywołać tych funkcji.

Ale to, co robią, sprawia, że ​​robienie nowych rzeczy, nawet w ramach systemu, jest poważnym problemem. Rozważ swoją poke_adjacent_orangesfunkcję. Co się stanie, jeśli ktoś będzie chciał lime_nodetypu, który można szturchać tak jak orange_nodes? Cóż, nie możemy wywodzić się lime_nodez orange_node; to nie ma sensu.

Zamiast tego musimy dodać nowy lime_nodepochodzący z node_base. Następnie zmień nazwę poke_adjacent_orangesna poke_adjacent_pokables. A potem spróbuj przesyłać do orange_nodei lime_node; którakolwiek obsada jest tą, którą szturchamy.

Jednak lime_nodepotrzebuje własnego poke_adjacent_pokables . Ta funkcja musi wykonywać te same kontrole rzutowania.

A jeśli dodamy trzeci typ, musimy nie tylko dodać jego własną funkcję, ale musimy zmienić funkcje w pozostałych dwóch klasach.

Oczywiście teraz tworzysz poke_adjacent_pokablesdarmową funkcję, aby działała dla nich wszystkich. Ale jak myślisz, co się stanie, jeśli ktoś doda czwarty typ i zapomni dodać go do tej funkcji?

Witam, cicha przerwa . Program wydaje się działać mniej więcej dobrze, ale tak nie jest. Gdyby pokebyła rzeczywistą funkcją wirtualną, kompilator zawiódłby, gdybyś nie nadpisał czystej funkcji wirtualnej z node_base.

Na swój sposób nie masz takich kontroli kompilatora. Oczywiście, kompilator nie sprawdzi, czy wirtualne nie są czyste, ale przynajmniej masz ochronę w przypadkach, w których ochrona jest możliwa (tj. Nie ma domyślnej operacji).

Stosowanie przezroczystych klas podstawowych z RTTI prowadzi do koszmaru konserwacji. Rzeczywiście, większość zastosowań RTTI prowadzi do bólu głowy związanego z utrzymaniem. Nie oznacza to, że RTTI nie jest przydatne (jest na przykład niezbędne do boost::anypracy). Ale jest to bardzo wyspecjalizowane narzędzie do bardzo specjalistycznych potrzeb.

W ten sposób jest „szkodliwy” w taki sam sposób jak goto. To przydatne narzędzie, którego nie należy pozbywać się. Ale jego użycie powinno być rzadkie w twoim kodzie.


Tak więc, jeśli nie możesz użyć przezroczystych klas bazowych i dynamicznego rzutowania, jak uniknąć grubych interfejsów? Jak uniknąć bulgotania każdej funkcji, którą możesz chcieć wywołać dla typu, z propagacji do klasy bazowej?

Odpowiedź zależy od tego, do czego służy klasa bazowa.

Przezroczyste klasy bazowe, takie jak node_basepo prostu używają niewłaściwego narzędzia do rozwiązania problemu. Połączone listy najlepiej obsługiwać za pomocą szablonów. Typ węzła i sąsiedztwo będą dostarczane przez typ szablonu. Jeśli chcesz umieścić na liście typ polimorficzny, możesz. Po prostu użyj BaseClass*jak Tw argumencie szablonu. Lub preferowany inteligentny wskaźnik.

Ale są inne scenariusze. Jeden to typ, który robi wiele rzeczy, ale ma kilka opcjonalnych części. Konkretna instancja może implementować pewne funkcje, a inna nie. Jednak konstrukcja tego typu zwykle daje właściwą odpowiedź.

Klasa „entity” jest tego doskonałym przykładem. Ta klasa od dawna nęka twórców gier. Koncepcyjnie ma gigantyczny interfejs, żyjący na przecięciu prawie tuzina całkowicie odmiennych systemów. A różne byty mają różne właściwości. Niektóre elementy nie mają żadnej reprezentacji wizualnej, więc ich funkcje renderujące nic nie robią. Wszystko to jest ustalane w czasie wykonywania.

Nowoczesnym rozwiązaniem jest system komponentowy. Entityto po prostu pojemnik z zestawem komponentów, pomiędzy którymi znajduje się trochę kleju. Niektóre składniki są opcjonalne; byt, który nie ma wizualnej reprezentacji, nie ma komponentu „grafiki”. Podmiot bez AI nie ma komponentu „kontrolera”. I tak dalej.

Jednostki w takim systemie są po prostu wskaźnikami do komponentów, a większość ich interfejsu jest udostępniana poprzez bezpośredni dostęp do komponentów.

Opracowanie takiego systemu składowego wymaga uznania na etapie projektowania, że ​​pewne funkcje są koncepcyjnie zgrupowane tak, że wszystkie typy wdrażające je wszystkie zaimplementują. Pozwala to wyodrębnić klasę z przyszłej klasy bazowej i uczynić z niej osobny składnik.

Pomaga to również w przestrzeganiu zasady pojedynczej odpowiedzialności. Taka skomponowana klasa ma jedynie obowiązek bycia posiadaczem komponentów.


Matthew Walton:

Zauważam, że wiele odpowiedzi nie uwzględnia pomysłu, że twój przykład sugeruje, że node_base jest częścią biblioteki, a użytkownicy będą tworzyć własne typy węzłów. Wtedy nie mogą zmodyfikować node_base, aby zezwolić na inne rozwiązanie, więc może wtedy RTTI stanie się ich najlepszą opcją.

OK, zbadajmy to.

Aby to miało sens, musielibyśmy mieć sytuację, w której pewna biblioteka L udostępnia kontener lub inny ustrukturyzowany magazyn danych. Użytkownik może dodawać dane do tego kontenera, iterować po jego zawartości itp. Jednak biblioteka tak naprawdę nic nie robi z tymi danymi; po prostu zarządza swoim istnieniem.

Ale nie tyle zarządza swoim istnieniem, co zniszczeniem . Powodem jest to, że jeśli oczekuje się, że będziesz używać RTTI do takich celów, to tworzysz klasy, których L nie zna. Oznacza to, że twój kod przydziela obiekt i przekazuje go L do zarządzania.

Istnieją przypadki, w których coś takiego jest legalnym projektem. Sygnalizacja zdarzeń / przekazywanie komunikatów, kolejki robocze zabezpieczone wątkami itp. Ogólny wzorzec jest następujący: ktoś wykonuje usługę między dwoma fragmentami kodu, który jest odpowiedni dla dowolnego typu, ale usługa nie musi być świadoma konkretnych typów, których to dotyczy .

W języku C ten wzór jest zapisywany w formie pisowni void*, a jego użycie wymaga dużej ostrożności, aby uniknąć złamania. W C ++ ten wzorzec jest zapisywany std::experimental::any(wkrótce zostanie przeliterowanystd::any ).

Sposób, w jaki to powinno działać, polega na tym, że L zapewnia node_baseklasę, która przyjmuje A, anyktóra reprezentuje twoje rzeczywiste dane. Gdy otrzymasz wiadomość, element roboczy kolejki wątków lub cokolwiek robisz, rzutujesz to anyna odpowiedni typ, który znają zarówno nadawca, jak i odbiorca.

Więc zamiast wynikające orange_nodez node_data, po prostu trzymać się orangewnętrze node_data„s anydziedzinie członkowskim. Użytkownik końcowy wyodrębnia go i używa any_castdo konwersji na plik orange. Jeśli obsada się nie powiedzie, to tak nie jest orange.

Jeśli w ogóle znasz implementację any, prawdopodobnie powiesz: „hej, chwileczkę: any wewnętrznie używa RTTI,any_cast pracy”. Na co odpowiadam: „… tak”.

To jest punkt abstrakcji . Głęboko w szczegółach, ktoś używa RTTI. Ale na poziomie, na którym powinieneś działać, bezpośrednie RTTI nie jest czymś, co powinieneś robić.

Powinieneś używać typów, które zapewniają żądaną funkcjonalność. W końcu tak naprawdę nie chcesz RTTI. To, czego potrzebujesz, to struktura danych, która może przechowywać wartość danego typu, ukryć ją przed wszystkimi z wyjątkiem pożądanego miejsca docelowego, a następnie przekonwertować ją z powrotem na ten typ, z weryfikacją, że przechowywana wartość faktycznie jest tego typu.

To się nazywa any. To używa RTTI, ale przy użyciu anyjest o wiele lepsze niż przy użyciu RTTI bezpośrednio, ponieważ bardziej poprawnie pasuje pożądanych semantykę.

Nicol Bolas
źródło
10

Jeśli wywołujesz funkcję, z reguły nie obchodzi Cię, jakie dokładnie kroki ona podejmie, tylko, że jakiś cel wyższego poziomu zostanie osiągnięty w ramach pewnych ograniczeń (a sposób, w jaki funkcja to robi, to tak naprawdę problem).

Kiedy używasz RTTI, aby dokonać preselekcji specjalnych obiektów, które mogą wykonać określoną pracę, podczas gdy inne w tym samym zestawie nie mogą, łamiesz ten wygodny pogląd na świat. Nagle dzwoniący powinien wiedzieć, kto może co zrobić, zamiast po prostu powiedzieć swoim sługom, żeby się tym zajął. Niektórym to przeszkadza i podejrzewam, że jest to duża część powodu, dla którego RTTI jest uważane za trochę brudne.

Czy występuje problem z wydajnością? Może, ale nigdy tego nie doświadczyłem i może to być mądrość sprzed dwudziestu lat lub od ludzi, którzy szczerze wierzą, że użycie trzech instrukcji montażu zamiast dwóch jest nie do przyjęcia.

Jak więc sobie z tym poradzić ... W zależności od sytuacji sensowne może być posiadanie właściwości specyficznych dla węzła w osobnych obiektach (tj. Całe „pomarańczowe” API może być oddzielnym obiektem). Obiekt główny mógłby wówczas mieć funkcję wirtualną zwracającą „pomarańczowy” interfejs API, zwracając domyślnie wartość nullptr dla obiektów innych niż pomarańczowy.

Chociaż może to być przesada w zależności od sytuacji, pozwoliłoby to na zapytanie na poziomie głównym, czy określony węzeł obsługuje określony interfejs API, a jeśli tak, wykonanie funkcji specyficznych dla tego interfejsu API.

H. Guijt
źródło
6
Odp .: koszt wydajności - zmierzyłem, że dynamic_cast <> kosztuje około 2 µs w naszej aplikacji na procesorze 3GHz, czyli około 1000 razy wolniej niż sprawdzanie wyliczenia. (Nasza aplikacja ma termin zakończenia pętli głównej 11,1 ms, więc bardzo zależy nam na mikrosekundach).
Crashworks
6
Wydajność różni się znacznie w zależności od implementacji. GCC używa szybkiego porównania wskaźnika typeinfo. MSVC używa porównań ciągów, które nie są szybkie. Jednak metoda MSVC będzie działać z kodem połączonym z różnymi wersjami bibliotek, statycznymi lub DLL, gdzie metoda wskaźnika GCC uważa, że ​​klasa w bibliotece statycznej różni się od klasy w bibliotece współdzielonej.
Zan Lynx
1
@ Crashworks Tylko po to, aby mieć tutaj pełny zapis: który kompilator (i która wersja) to był?
H. Guijt
@ Crashworks obsługujący żądanie informacji o tym, który kompilator wygenerował zaobserwowane wyniki; dzięki.
underscore_d
@underscore_d: MSVC.
Crashworks
9

C ++ jest zbudowany na idei statycznego sprawdzania typów.

[1] RTTI, to znaczy dynamic_casti type_idjest dynamicznym sprawdzaniem typu.

Więc zasadniczo pytasz, dlaczego statyczne sprawdzanie typów jest lepsze niż dynamiczne sprawdzanie typów. A prosta odpowiedź brzmi, czy statyczne sprawdzanie typów jest lepsze od dynamicznego sprawdzania typów, zależy . Na dużo. Ale C ++ jest jednym z języków programowania, które zostały zaprojektowane w oparciu o ideę statycznego sprawdzania typów. A to oznacza, że ​​np. Proces rozwoju, w szczególności testowanie, jest zazwyczaj dostosowywany do statycznego sprawdzania typu, a następnie najlepiej do tego pasuje.


Re

Nie znałbym czystego sposobu na zrobienie tego za pomocą szablonów lub innych metod

możesz wykonać ten proces-heterogeniczne-węzły-wykresu ze statycznym sprawdzaniem typu i bez rzutowania za pomocą wzorca gości, np. w ten sposób:

#include <iostream>
#include <set>
#include <initializer_list>

namespace graph {
    using std::set;

    class Red_thing;
    class Yellow_thing;
    class Orange_thing;

    struct Callback
    {
        virtual void handle( Red_thing& ) {}
        virtual void handle( Yellow_thing& ) {}
        virtual void handle( Orange_thing& ) {}
    };

    class Node
    {
    private:
        set<Node*> connected_;

    public:
        virtual void call( Callback& cb ) = 0;

        void connect_to( Node* p_other )
        {
            connected_.insert( p_other );
        }

        void call_on_connected( Callback& cb )
        {
            for( auto const p : connected_ ) { p->call( cb ); }
        }

        virtual ~Node(){}
    };

    class Red_thing
        : public virtual Node
    {
    public:
        void call( Callback& cb ) override { cb.handle( *this ); }

        auto redness() -> int { return 255; }
    };

    class Yellow_thing
        : public virtual Node
    {
    public:
        void call( Callback& cb ) override { cb.handle( *this ); }
    };

    class Orange_thing
        : public Red_thing
        , public Yellow_thing
    {
    public:
        void call( Callback& cb ) override { cb.handle( *this ); }

        void poke() { std::cout << "Poked!\n"; }

        void poke_connected_orange_things()
        {
            struct Poker: Callback
            {
                void handle( Orange_thing& obj ) override
                {
                    obj.poke();
                }
            } poker;

            call_on_connected( poker );
        }
    };
}  // namespace graph

auto main() -> int
{
    using namespace graph;

    Red_thing   r;
    Yellow_thing    y1, y2;
    Orange_thing    o1, o2, o3;

    for( Node* p : std::initializer_list<Node*>{ &y1, &y2, &r, &o2, &o3 } )
    {
        o1.connect_to( p );
    }
    o1.poke_connected_orange_things();
}

Zakłada się, że znany jest zestaw typów węzłów.

Jeśli tak nie jest, wzorzec odwiedzających (istnieje wiele jego odmian) można wyrazić za pomocą kilku scentralizowanych rzutów lub tylko jednej.


Aby zapoznać się z podejściem opartym na szablonach, zobacz bibliotekę Boost Graph. Smutno powiedzieć, że go nie znam, nie korzystałem z niego. Więc nie jestem pewien, co dokładnie robi i jak, i w jakim stopniu używa statycznego sprawdzania typu zamiast RTTI, ale ponieważ Boost jest generalnie oparty na szablonach, a statyczne sprawdzanie typów jest główną ideą, myślę, że przekonasz się, że jego biblioteka Graph jest również oparta na statycznym sprawdzaniu typu.


[1] Informacje o typie czasu wykonywania .

Pozdrawiam i hth. - Alf
źródło
1
Jedną z „zabawnych rzeczy” jest to, że można zmniejszyć ilość kodu (zmian podczas dodawania typów) niezbędnego dla wzorca odwiedzających, używając RTTI do „wspinania się” po hierarchii. Znam to jako „acykliczny wzorzec odwiedzających”.
Daniel Jour
3

Oczywiście jest scenariusz, w którym polimorfizm nie może pomóc: nazwy. typeidumożliwia dostęp do nazwy typu, chociaż sposób kodowania tej nazwy jest zdefiniowany w implementacji. Ale zwykle nie stanowi to problemu, ponieważ można porównać dwa typeid-s:

if ( typeid(5) == "int" )
    // may be false

if ( typeid(5) == typeid(int) )
   // always true

To samo dotyczy haszy.

[...] RTTI jest „uważane za szkodliwe”

szkodliwy jest zdecydowanie przesadzone: RTTI ma pewne wady, ale ma też zalety.

Naprawdę nie musisz używać RTTI. RTTI to narzędzie do rozwiązywania problemów OOP: jeśli użyjesz innego paradygmatu, prawdopodobnie znikną. C nie ma RTTI, ale nadal działa. Zamiast tego C ++ w pełni obsługuje OOP i zapewnia wiele narzędzi do rozwiązania problemu, który może wymagać informacji o czasie wykonywania: jednym z nich jest rzeczywiście RTTI, który ma swoją cenę. Jeśli nie możesz sobie na to pozwolić, rzecz, którą lepiej określić dopiero po bezpiecznej analizie wydajności, nadal istnieje stara szkoła void*: to nic nie kosztuje. Kosztuje mniej. Ale nie masz żadnego bezpieczeństwa typu. Więc chodzi o transakcje.


  • Niektóre kompilatory nie używają / RTTI nie zawsze jest włączone
    Naprawdę nie kupuję tego argumentu. To tak, jakby powiedzieć, że nie powinienem używać funkcji C ++ 14, ponieważ istnieją kompilatory, które go nie obsługują. A jednak nikt nie zniechęciłby mnie do korzystania z funkcji C ++ 14.

Jeśli piszesz kod (prawdopodobnie ściśle) zgodny z C ++, możesz spodziewać się takiego samego zachowania niezależnie od implementacji. Implementacje zgodne ze standardami powinny obsługiwać standardowe funkcje języka C ++.

Ale weź pod uwagę, że w niektórych środowiskach C ++ definiuje („wolnostojące”), RTTI nie musi być dostarczane, nie ma też wyjątków virtuali tak dalej. RTTI potrzebuje warstwy bazowej do prawidłowego działania, która zajmuje się szczegółami niskiego poziomu, takimi jak ABI i informacje o rzeczywistym typie.


Zgadzam się z Yakkiem co do RTTI w tym przypadku. Tak, można go użyć; ale czy jest to logicznie poprawne? Fakt, że język pozwala ominąć tę kontrolę, nie oznacza, że ​​należy to zrobić.

edmz
źródło