Jak zmienne w C ++ przechowują ich typ?

42

Jeśli zdefiniuję zmienną określonego typu (która, o ile mi wiadomo, po prostu przydziela dane do zawartości zmiennej), w jaki sposób śledzi, jaki to rodzaj zmiennej?

Finn McClusky
źródło
8
Do kogo / o czym mówisz poprzez „ to ” w „ jak to śledzi ”? Kompilator, procesor czy coś takiego jak język lub program?
Erik Eidt
8
@ErikEidt IMO OP oczywiście oznacza „samą zmienną” przez „it”. Oczywiście dwuliterowa odpowiedź na pytanie brzmi „nie”.
alephzero,
2
świetne pytanie! szczególnie aktualne dzisiaj, biorąc pod uwagę wszystkie fantazyjne języki, które przechowują ich typ.
Trevor Boyd Smith
@alephzero To było oczywiście wiodące pytanie.
Luaan,

Odpowiedzi:

105

Zmienne (lub bardziej ogólnie: „obiekty” w znaczeniu C) nie przechowują swojego typu w czasie wykonywania. Jeśli chodzi o kod maszynowy, istnieje tylko pamięć bez typu. Zamiast tego operacje na tych danych interpretują dane jako określony typ (np. Jako zmiennoprzecinkowe lub wskaźnik). Typy są używane tylko przez kompilator.

Na przykład możemy mieć strukturę lub klasę struct Foo { int x; float y; };i zmienną Foo f {}. Jak można auto result = f.y;skompilować dostęp do pola ? Kompilator wie, że fjest to obiekt typu Fooi zna układ Foo-objects. W zależności od szczegółów specyficznych dla platformy można to skompilować jako „Weź wskaźnik na początek f, dodaj 4 bajty, a następnie załaduj 4 bajty i zinterpretuj te dane jako zmiennoprzecinkowe”. W wielu zestawach instrukcji kodu maszynowego (w tym x86-64 ) istnieją różne instrukcje procesora do ładowania pływaków lub ints.

Jednym z przykładów, w którym system typów C ++ nie może śledzić tego typu dla nas, jest związek union Bar { int as_int; float as_float; }. Unia zawiera maksymalnie jeden obiekt różnych typów. Jeśli przechowujemy obiekt w unii, jest to aktywny typ unii. Musimy tylko próbować przywrócić ten typ z unii, wszystko inne byłoby zachowaniem nieokreślonym. Albo „wiemy” podczas programowania, jaki jest typ aktywny, albo możemy utworzyć oznaczony związek, w którym osobno przechowujemy znacznik typu (zwykle wyliczenie). Jest to powszechna technika w języku C, ale ponieważ musimy utrzymywać synchronizację unii i znacznika typu, jest to dość podatne na błędy. void*Wskaźnik jest podobny do Unii, ale może posiadać tylko obiekty wskaźnik, z wyjątkiem wskaźników funkcji.
C ++ oferuje dwa lepsze mechanizmy radzenia sobie z obiektami nieznanych typów: Możemy użyć technik obiektowych do przeprowadzenia usuwania typu (interakcja z obiektem tylko za pomocą metod wirtualnych, aby nie musieć znać rzeczywistego typu), lub możemy zastosowanie std::variant, rodzaj bezpiecznej unii.

Jest jeden przypadek, w którym C ++ przechowuje typ obiektu: jeśli klasa obiektu ma jakieś metody wirtualne („typ polimorficzny”, czyli interfejs). Cel wirtualnego wywołania metody jest nieznany w czasie kompilacji i jest rozwiązywany w czasie wykonywania na podstawie typu dynamicznego obiektu („dynamiczna wysyłka”). Większość kompilatorów implementuje to, przechowując wirtualną tablicę funkcji („vtable”) na początku obiektu. Tabeli vtable można także użyć do uzyskania typu obiektu w czasie wykonywania. Możemy następnie rozróżnić między znanym statycznym typem wyrażenia w czasie kompilacji a dynamicznym typem obiektu w czasie wykonywania.

C ++ pozwala nam sprawdzać dynamiczny typ obiektu za pomocą typeid()operatora, który daje nam std::type_infoobiekt. Albo kompilator zna typ obiektu w czasie kompilacji, albo kompilator zapisał niezbędne informacje o typie wewnątrz obiektu i może go pobrać w czasie wykonywania.

amon
źródło
3
Bardzo obszerny.
Deduplicator,
9
Zauważ, że aby uzyskać dostęp do typu obiektu polimorficznego, kompilator nadal musi wiedzieć, że obiekt należy do określonej rodziny dziedziczenia (tzn. Mieć wpisane odwołanie / wskaźnik do obiektu, a nie void*).
Ruslan
5
+0, ponieważ pierwsze zdanie jest nieprawdziwe, dwa ostatnie akapity go poprawiają.
Marcin
3
Ogólnie rzecz biorąc, to, co jest przechowywane na początku obiektu polimorficznego, to wskaźnik do wirtualnej tablicy metod, a nie sama tabela.
Peter Green
3
@ v.oddou W moim akapicie zignorowałem niektóre szczegóły. typeid(e)introspekuje statyczny typ wyrażenia e. Jeśli typ statyczny jest typem polimorficznym, wyrażenie zostanie ocenione i zostanie pobrany typ dynamiczny tego obiektu. Nie można wskazać typeid w pamięci nieznanego typu i uzyskać użytecznych informacji. Np. Typid unii opisuje unię, a nie przedmiot w unii. Typ a void*jest tylko pustym wskaźnikiem. I nie można oderwać się void*od treści. W C ++ nie ma boksu, chyba że jest to wyraźnie zaprogramowane w ten sposób.
amon
51

Druga odpowiedź dobrze wyjaśnia aspekt techniczny, ale chciałbym dodać ogólne „jak myśleć o kodzie maszynowym”.

Kod maszynowy po kompilacji jest dość głupi i tak naprawdę zakłada, że ​​wszystko działa zgodnie z przeznaczeniem. Załóżmy, że masz prostą funkcję

bool isEven(int i) { return i % 2 == 0; }

To zajmuje int i wypluwa bool.

Po skompilowaniu możesz pomyśleć o czymś takim jak ten automatyczny sokowirówka pomarańczowy:

automatyczna sokowirówka pomarańczowa

Przyjmuje pomarańcze i zwraca sok. Czy rozpoznaje rodzaj obiektów, w które wchodzi? Nie, to powinny być tylko pomarańcze. Co się stanie, jeśli dostanie jabłko zamiast pomarańczy? Być może się zepsuje. To nie ma znaczenia, ponieważ odpowiedzialny właściciel nie będzie próbował używać go w ten sposób.

Powyższa funkcja jest podobna: jest przeznaczona do przyjmowania ints i może zepsuć się lub zrobić coś nieistotnego, gdy zostanie nakarmiona coś innego. To (zwykle) nie ma znaczenia, ponieważ kompilator (ogólnie) sprawdza, czy to się nigdy nie zdarza - i tak naprawdę nigdy nie dzieje się w dobrze sformatowanym kodzie. Jeśli kompilator wykryje możliwość, że funkcja otrzyma niepoprawną wartość, odmawia skompilowania kodu i zamiast tego zwraca błędy typu.

Zastrzeżenie polega na tym, że niektóre przypadki źle sformułowanego kodu zostaną przekazane przez kompilator. Przykłady to:

  • nieprawidłowy typ odlewania: wyraźne odlewy są uważane za prawidłowe, i to na programatorze aby upewnić się, że nie rzuca void*się orange*, gdy nie jest jabłko na drugim końcu wskaźnika,
  • problemy z zarządzaniem pamięcią, takie jak wskaźniki zerowe, wiszące wskaźniki lub użycie po zasięgu; kompilator nie jest w stanie znaleźć większości z nich,
  • Jestem pewien, że brakuje mi czegoś jeszcze.

Jak już powiedziano, skompilowany kod przypomina maszynę do wyciskania soków - nie wie, co przetwarza, po prostu wykonuje instrukcje. A jeśli instrukcje są błędne, psuje się. Dlatego powyższe problemy w C ++ powodują niekontrolowane awarie.

Frax
źródło
4
Kompilator próbuje sprawdzić, czy funkcja przekazała obiekt poprawnego typu, ale zarówno C, jak i C ++ są zbyt skomplikowane, aby kompilator mógł to udowodnić w każdym przypadku. Porównanie jabłek i pomarańczy z sokowirówką jest więc bardzo pouczające.
Calchas,
@ Calchas Dzięki za komentarz! To zdanie było rzeczywiście nadmiernym uproszczeniem. Opracowałem trochę na temat możliwych problemów, są one właściwie związane z pytaniem.
Frax,
5
wow, wspaniała metafora kodu maszynowego! twoja metafora jest także 10 razy lepsza na zdjęciu!
Trevor Boyd Smith
2
„Jestem pewien, że brakuje mi czegoś jeszcze”. - Oczywiście! C void*wymusza foo*, zwykłe promocje arytmetyczne, unionpisanie na klawiaturze, NULLvs. nullptr, nawet posiadanie złego wskaźnika to UB itp. Ale nie sądzę, że umieszczenie wszystkich tych rzeczy w znacznym stopniu poprawiłoby twoją odpowiedź, więc prawdopodobnie najlepiej jest odejść tak jak jest.
Kevin
@Kevin Nie sądzę, aby dodawać C tutaj, ponieważ pytanie jest oznaczone tylko jako C ++. W C ++ void*nie jest domyślnie konwertowany na foo*, a unionpisanie na klawiaturze nie jest obsługiwane (ma UB).
Ruslan
3

Zmienna ma wiele podstawowych właściwości w języku takim jak C:

  1. Imię
  2. Typ
  3. Zakres
  4. Czas życia
  5. Lokacja
  6. Wartość

W twoim kodzie źródłowym lokalizacja (5) jest konceptualna, a do tej lokalizacji odwołuje się jej nazwa (1). Tak więc deklaracja zmiennej służy do utworzenia położenia i miejsca dla wartości (6), aw innych wierszach źródła odwołujemy się do tego położenia i wartości, jaką posiada, nazywając zmienną w pewnym wyrażeniu.

Upraszczając tylko nieco, po przetłumaczeniu programu na kod maszynowy przez kompilator, lokalizacja (5) to pewna lokalizacja pamięci lub rejestru procesora, a wszelkie wyrażenia kodu źródłowego odnoszące się do zmiennej są tłumaczone na sekwencje kodu maszynowego odwołujące się do tej pamięci lub lokalizacja rejestru procesora.

Zatem po zakończeniu tłumaczenia i uruchomieniu programu na procesorze nazwy zmiennych są skutecznie zapominane w kodzie maszynowym, a instrukcje generowane przez kompilator odnoszą się tylko do przypisanych lokalizacji zmiennych (a nie do ich lokalizacji nazwy). Jeśli debugujesz i żądasz debugowania, lokalizacja zmiennej powiązanej z nazwą jest dodawana do metadanych programu, chociaż procesor nadal widzi instrukcje kodu maszynowego przy użyciu lokalizacji (nie tych metadanych). (Jest to nadmierne uproszczenie, ponieważ niektóre nazwy znajdują się w metadanych programu do celów łączenia, ładowania i wyszukiwania dynamicznego - procesor po prostu wykonuje instrukcje kodu maszynowego, o które jest proszony dla programu, aw tym kodzie maszynowym nazwy mają zostały przekonwertowane na lokalizacje).

To samo dotyczy rodzaju, zakresu i czasu życia. Generowane przez kompilator instrukcje kodu maszynowego znają wersję komputerową lokalizacji, która przechowuje wartość. Inne właściwości, takie jak typ, są kompilowane w przetłumaczonym kodzie źródłowym jako konkretne instrukcje, które uzyskują dostęp do lokalizacji zmiennej. Na przykład, jeśli dana zmienna jest bajtem 8-bitowym ze znakiem, a bajtem 8-bitowym bez znaku, wyrażenia w kodzie źródłowym, które odwołują się do zmiennej, zostaną przetłumaczone na, powiedzmy, ładunki bajtów ze znakiem a ładunki bajtów bez znaku, w razie potrzeby w celu spełnienia reguł języka (C). Typ zmiennej jest więc zakodowany w tłumaczeniu kodu źródłowego na instrukcje maszynowe, które nakazują CPU, jak interpretować lokalizację pamięci lub rejestru rejestru procesora za każdym razem, gdy korzysta z lokalizacji zmiennej.

Istotą jest to, że musimy powiedzieć CPU, co ma robić, poprzez instrukcje (i więcej instrukcji) w zestawie instrukcji kodu maszynowego procesora. Procesor bardzo mało pamięta o tym, co właśnie zrobił lub powiedziano - wykonuje tylko podane instrukcje, a zadaniem kompilatora lub programisty w asemblerze jest dostarczenie pełnego zestawu sekwencji instrukcji w celu właściwego manipulowania zmiennymi.

Procesor bezpośrednio obsługuje niektóre podstawowe typy danych, takie jak bajt / słowo / int / długi podpisany / niepodpisany, zmiennoprzecinkowy, podwójny itp. Procesor ogólnie nie będzie narzekał ani nie sprzeciwiał się, jeśli na przemian traktujesz tę samą lokalizację pamięci jako podpisaną lub niepodpisaną, dla przykład, chociaż zwykle byłby to błąd logiczny w programie. Zadaniem programowania jest instruowanie procesora przy każdej interakcji ze zmienną.

Oprócz tych podstawowych typów prymitywnych musimy zakodować rzeczy w strukturach danych i użyć algorytmów do manipulowania nimi w kategoriach tych prymitywów.

W C ++ obiekty zaangażowane w hierarchię klas polimorfizmu mają wskaźnik, zwykle na początku obiektu, który odnosi się do specyficznej dla klasy struktury danych, która pomaga w wirtualnym wysyłaniu, rzutowaniu itp.

Podsumowując, procesor inaczej nie zna lub nie pamięta zamierzonego wykorzystania lokalizacji pamięci - wykonuje instrukcje kodu maszynowego programu, które mówią mu, jak manipulować pamięcią w rejestrach procesora i pamięci głównej. Programowanie jest zatem zadaniem oprogramowania (i programistów) do znaczącego wykorzystania pamięci i przedstawienia spójnego zestawu instrukcji kodu maszynowego procesorowi, który wiernie wykonuje program jako całość.

Erik Eidt
źródło
1
Ostrożnie z „gdy tłumaczenie jest zakończone, nazwa jest zapomniana” ... łączenie odbywa się poprzez nazwy („niezdefiniowany symbol xy”) i może się zdarzyć w czasie wykonywania z dynamicznym łączeniem. Zobacz blog.fesnel.com/blog/2009/08/19/… . Brak symboli debugowania, nawet pozbawionych: Potrzebujesz funkcji (i, jak zakładam, zmiennej globalnej) do dynamicznego łączenia. Można więc zapomnieć tylko nazwy wewnętrznych obiektów. Nawiasem mówiąc, dobra lista właściwości zmiennych.
Peter - Przywróć Monikę
@ PeterA.Schneider, masz absolutną rację, na dużym obrazie rzeczy, że linkery i programy ładujące również uczestniczą i używają nazw (globalnych) funkcji i zmiennych, które pochodziły z kodu źródłowego.
Erik Eidt
Dodatkową komplikacją jest to, że niektóre kompilatory interpretują reguły, które zgodnie ze standardem pozwalają kompilatorom zakładać, że pewne rzeczy nie będą aliasami, ponieważ pozwalają im traktować operacje obejmujące różne typy jako niepowiązane, nawet w przypadkach, które nie wymagają aliasingu jak napisano . Biorąc pod uwagę coś takiego useT1(&unionArray[i].member1); useT2(&unionArray[j].member2); useT1(&unionArray[i].member1);, clang i gcc mają skłonność do zakładania, że ​​wskaźnik unionArray[j].member2nie ma dostępu, unionArray[i].member1mimo że oba pochodzą z tego samego unionArray[].
supercat
Niezależnie od tego, czy kompilator poprawnie interpretuje specyfikację języka, jego zadaniem jest generowanie sekwencji instrukcji kodu maszynowego realizujących program. Oznacza to, że (optymalizacja modulo i wiele innych czynników) dla każdego dostępu do zmiennej w kodzie źródłowym musi wygenerować pewne instrukcje kodu maszynowego, które podadzą procesorowi, jaki rozmiar i interpretację danych należy zastosować dla lokalizacji przechowywania. Procesor nic nie pamięta o zmiennej, więc za każdym razem, gdy ma ona uzyskać dostęp do zmiennej, należy ją dokładnie pouczyć, jak to zrobić.
Erik Eidt,
2

jeśli zdefiniuję zmienną określonego typu, to w jaki sposób śledzi ona rodzaj zmiennej.

Istnieją tutaj dwie istotne fazy:

  • Czas kompilacji

Kompilator C kompiluje kod C do języka maszynowego. Kompilator ma wszystkie informacje, które może uzyskać z pliku źródłowego (i bibliotek oraz wszelkich innych rzeczy potrzebnych do wykonania swojej pracy). Kompilator C śledzi, co znaczy co. Kompilator C wie, że jeśli zadeklarujesz zmienną char, będzie to char.

Robi to za pomocą tak zwanej „tablicy symboli”, która zawiera nazwy zmiennych, ich typ i inne informacje. Jest to dość złożona struktura danych, ale można ją traktować jako śledzenie znaczenia nazw czytelnych dla człowieka. W wynikach binarnych kompilatora nie pojawiają się już takie nazwy zmiennych (jeśli zignorujemy opcjonalne informacje debugowania, które mogą być wymagane przez programistę).

  • Środowisko wykonawcze

Dane wyjściowe kompilatora - skompilowanego pliku wykonywalnego - jest językiem maszynowym, który jest ładowany do pamięci RAM przez system operacyjny i wykonywany bezpośrednio przez procesor. W języku maszynowym w ogóle nie ma pojęcia „typ” - ma tylko polecenia, które działają w niektórych miejscach w pamięci RAM. Te polecenia rzeczywiście mają stałą typ one działać z (czyli nie może być komenda język maszynowy „dodać te dwie liczby 16-bitowe przechowywane w pamięci RAM 0x100 i 0x521”), ale nie ma informacji w dowolnym miejscu w systemie, że bajty w tych lokalizacjach faktycznie reprezentują liczby całkowite. Nie ma ochrony przed błędami typu w ogóle tutaj.

AnoE
źródło
Jeśli przypadkiem odwołujesz się do C # lub Java z „bajtowymi językami zorientowanymi na kod”, wskaźniki w żadnym wypadku nie zostały pominięte; wręcz przeciwnie: wskaźniki są znacznie częstsze w języku C # i Javie (w związku z tym jednym z najczęstszych błędów w Javie jest „NullPointerException”). Nazwanie ich „referencjami” to tylko kwestia terminologii.
Peter - Przywróć Monikę
@ PeterA.Schneider, oczywiście, istnieje wyjątek NullPOINTERException, ale istnieje bardzo wyraźne rozróżnienie między referencją a wskaźnikiem we wspomnianych językach (takich jak Java, Ruby, prawdopodobnie C #, a nawet w pewnym stopniu Perl) - referencje idą w parze z ich systemem typów, wyrzucaniem elementów bezużytecznych, automatycznym zarządzaniem pamięcią itp .; zwykle nie jest nawet możliwe bezpośrednie podanie lokalizacji pamięci (jak char *ptr = 0x123w C). Uważam, że moje użycie słowa „wskaźnik” powinno być dość jasne w tym kontekście. Jeśli nie, daj mi znać, a dodam zdanie do odpowiedzi.
AnoE
wskaźniki „idą w parze z systemem typów” również w C ++ ;-). (W rzeczywistości klasyczne generiki Javy są mniej typowe niż C ++.) Odśmiecanie jest funkcją, której C ++ zdecydowało się nie wymagać, ale implementacja może ją zapewnić i nie ma ona nic wspólnego z tym, jakiego słowa używamy dla wskaźników.
Peter - Przywróć Monikę
OK, @ PeterA.Schneider, nie sądzę, żebyśmy osiągnęli poziom. Usunąłem akapit, w którym wspomniałem o wskaźnikach, i tak nie zrobiło to nic z odpowiedzią.
AnoE
1

Istnieje kilka ważnych specjalnych przypadków, w których C ++ przechowuje typ w czasie wykonywania.

Klasycznym rozwiązaniem jest dyskryminacja jedności: struktura danych zawierająca jeden z kilku typów obiektów oraz pole określające, jaki typ aktualnie zawiera. Wersja szablonowa znajduje się w standardowej bibliotece C ++ as std::variant. Zwykle tag byłby enum, ale jeśli nie potrzebujesz wszystkich bitów do przechowywania danych, może to być pole bitowe.

Innym częstym tego przykładem jest pisanie dynamiczne. Gdy twoja funkcja classma virtualfunkcję, program zapisze wskaźnik do tej funkcji w wirtualnej tabeli funkcji , którą zainicjuje dla każdej instancji, classkiedy zostanie zbudowana. Zwykle będzie to oznaczać jedną wirtualną tabelę funkcji dla wszystkich instancji klas i każdą instancję zawierającą wskaźnik do odpowiedniej tabeli. (Oszczędza to czas i pamięć, ponieważ tabela będzie znacznie większa niż pojedynczy wskaźnik.) Gdy wywołasz tę virtualfunkcję za pomocą wskaźnika lub odwołania, program wyszuka wskaźnik funkcji w wirtualnej tabeli. (Jeśli zna dokładny typ w czasie kompilacji, może pominąć ten krok.) Pozwala to kodowi wywołać implementację typu pochodnego zamiast klasy podstawowej.

Istotne jest to, że tutaj: każdy ofstreamzawiera wskaźnik do ofstreamwirtualnego stołu, każdy ifstreamdo ifstreamwirtualnego stołu i tak dalej. W przypadku hierarchii klas wirtualny wskaźnik tabeli może służyć jako znacznik informujący program, jaki typ ma obiekt klasy!

Chociaż standard językowy nie mówi ludziom, którzy projektują kompilatory, w jaki sposób muszą wdrożyć środowisko uruchomieniowe pod maską, tak można się spodziewać dynamic_casti typeofpracować.

Davislor
źródło
„standard językowy nie mówi programistom”, powinieneś chyba podkreślić, że omawianymi „programistami” są ludzie piszący gcc, clang, msvc itp., a nie ludzie używający ich do kompilacji swojego C ++.
Caleth
@Caleth Dobra sugestia!
Davislor,