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?
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?
Odpowiedzi:
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 f
jest to obiekt typu Foo
i 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_info
obiekt. 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.
void*
).
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.
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:
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:
void*
się orange*
, gdy nie jest jabłko na drugim końcu wskaźnika,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.
void*
wymusza foo*
, zwykłe promocje arytmetyczne, union
pisanie na klawiaturze, NULL
vs. 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.
void*
nie jest domyślnie konwertowany na foo*
, a union
pisanie na klawiaturze nie jest obsługiwane (ma UB).
Zmienna ma wiele podstawowych właściwości w języku takim jak C:
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ść.
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].member2
nie ma dostępu, unionArray[i].member1
mimo że oba pochodzą z tego samego unionArray[]
.
jeśli zdefiniuję zmienną określonego typu, to w jaki sposób śledzi ona rodzaj zmiennej.
Istnieją tutaj dwie istotne fazy:
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ę).
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.
char *ptr = 0x123
w 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.
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 class
ma virtual
funkcję, program zapisze wskaźnik do tej funkcji w wirtualnej tabeli funkcji , którą zainicjuje dla każdej instancji, class
kiedy 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ę virtual
funkcję 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 ofstream
zawiera wskaźnik do ofstream
wirtualnego stołu, każdy ifstream
do ifstream
wirtualnego 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_cast
i typeof
pracować.