Chociaż nie jest to obowiązkowe w standardzie C ++, wydaje się, że GCC, na przykład, implementuje klasy nadrzędne, w tym czysto abstrakcyjne, poprzez włączenie wskaźnika do tabeli v dla tej abstrakcyjnej klasy w każdej instancji danej klasy .
Oczywiście powoduje to powiększenie rozmiaru każdej instancji tej klasy o wskaźnik dla każdej klasy nadrzędnej, którą posiada.
Zauważyłem jednak, że wiele klas i struktur C # ma wiele interfejsów nadrzędnych, które są w zasadzie czystymi klasami abstrakcyjnymi. Byłbym zdziwiony, gdyby każdy przykład, powiedzmy Decimal
, był rozdęty 6 wskaźnikami do wszystkich jego różnych interfejsów.
Więc jeśli C # robi interfejsy inaczej, to jak to robi, przynajmniej w typowej implementacji (rozumiem, że sam standard może nie definiować takiej implementacji)? I czy jakieś implementacje C ++ pozwalają uniknąć rozdęcia rozmiaru obiektu podczas dodawania klas czysto wirtualnych?
źródło
IComparer
doCompare
g++-7 -fdump-class-hierarchy
wynik.Odpowiedzi:
W implementacjach C # i Java obiekty zwykle mają pojedynczy wskaźnik do swojej klasy. Jest to możliwe, ponieważ są to języki jednego dziedzictwa. Struktura klas zawiera następnie vtable dla hierarchii pojedynczego dziedziczenia. Ale wywoływanie metod interfejsu ma również wszystkie problemy związane z wielokrotnym dziedziczeniem. Zwykle rozwiązuje się to poprzez umieszczenie dodatkowych tabel vtable dla wszystkich zaimplementowanych interfejsów w strukturze klasy. Oszczędza to miejsce w porównaniu z typowymi implementacjami wirtualnego dziedziczenia w C ++, ale komplikuje wysyłanie metod interfejsu - co można częściowo skompensować przez buforowanie.
Np. W OpenJDK JVM każda klasa zawiera tablicę vtables dla wszystkich zaimplementowanych interfejsów (interfejs vtable nazywa się itable ). Gdy wywoływana jest metoda interfejsu, tablica jest przeszukiwana liniowo pod kątem itable tego interfejsu, a następnie metoda może zostać wysłana przez tę itable. Buforowanie jest używane, aby każda strona wywołująca zapamiętała wynik wysłania metody, więc to wyszukiwanie musi być powtórzone tylko wtedy, gdy zmienia się konkretny typ obiektu. Pseudokod dla wysyłki metody:
(Porównaj prawdziwy kod w interpreterie OpenJDK HotSpot lub kompilatorze x86 ).
C # (a ściślej CLR) stosuje podobne podejście. Jednak tutaj itables nie zawierają wskaźników do metod, ale są mapami gniazd: wskazują na wpisy w głównej tabeli vt klasy. Podobnie jak w przypadku Javy, konieczność znalezienia właściwej opcji jest tylko najgorszym scenariuszem i oczekuje się, że buforowanie w witrynie wywołującej może prawie zawsze uniknąć tego wyszukiwania. CLR używa techniki o nazwie Virtual Stub Dispatch w celu łatania skompilowanego kodu maszynowego JIT za pomocą różnych strategii buforowania. Pseudo kod:
Główną różnicą w stosunku do pseudokodu OpenJDK jest to, że w OpenJDK każda klasa ma tablicę wszystkich bezpośrednio lub pośrednio zaimplementowanych interfejsów, podczas gdy CLR zachowuje tylko tablicę map gniazd dla interfejsów, które zostały bezpośrednio zaimplementowane w tej klasie. Dlatego musimy podążać hierarchią dziedziczenia w górę, dopóki nie zostanie znaleziona mapa miejsca. W przypadku hierarchii głębokiego dziedziczenia powoduje to oszczędność miejsca. Są one szczególnie istotne w CLR ze względu na sposób implementacji generics: w przypadku specjalizacji ogólnej struktura klasy jest kopiowana, a metody w głównej tabeli można zastąpić specjalizacjami. Mapy gniazd nadal wskazują prawidłowe wpisy vtable i dlatego mogą być współużytkowane przez wszystkie ogólne specjalizacje klasy.
Na koniec, istnieje więcej możliwości zaimplementowania wysyłki interfejsu. Zamiast umieszczać wskaźnik vtable / itable w obiekcie lub w strukturze klasy, możemy użyć wskaźników tłuszczu do obiektu, które są w zasadzie
(Object*, VTable*)
parą. Wadą jest to, że podwaja rozmiar wskaźników i że upcasty (od konkretnego typu do typu interfejsu) nie są darmowe. Ale jest bardziej elastyczny, ma mniej pośredni, a także oznacza, że interfejsy mogą być implementowane zewnętrznie z klasy. Podobne podejścia są stosowane w interfejsach Go, cechach Rust i typach klas Haskell.Referencje i dalsza lektura:
źródło
callvirt
AKACEE_CALLVIRT
w CoreCLR to instrukcja CIL, która obsługuje wywoływanie metod interfejsu, jeśli ktoś chce przeczytać więcej o tym, jak środowisko wykonawcze obsługuje tę konfigurację.call
opcode jest używany dostatic
metod, co ciekawe,callvirt
jest używany, nawet jeśli klasa jestsealed
.Jeśli przez „klasę nadrzędną” masz na myśli „klasę podstawową”, nie jest tak w przypadku gcc (ani nie oczekuję w żadnym innym kompilatorze).
W przypadku, gdy C pochodzi od B, pochodzi od A, gdzie A jest klasą polimorficzną, instancja C będzie miała dokładnie jedną tabelę.
Kompilator ma wszystkie informacje potrzebne do scalenia danych w tabeli A w B i B w C.
Oto przykład: https://godbolt.org/g/sfdtNh
Zobaczysz, że jest tylko jedna inicjalizacja vtable.
Skopiowałem tutaj dane wyjściowe zestawu dla głównej funkcji z adnotacjami:
Pełne źródło informacji:
źródło
class Derived : public FirstBase, public SecondBase
wtedy mogą istnieć dwie tabele. Możesz uruchomić,g++ -fdump-class-hierarchy
aby zobaczyć układ zajęć (pokazany również w moim blogu). Godbolt następnie pokazuje dodatkowy przyrost wskaźnika przed połączeniem, aby wybrać 2. tabelę.