Rozważ następujący kod „C”:
#include<stdio.h>
main()
{
printf("func:%d",Func_i());
}
Func_i()
{
int i=3;
return i;
}
Func_i()
jest zdefiniowany na końcu kodu źródłowego i nie jest dostarczana żadna deklaracja przed użyciem w main()
. Na samym czasie, gdy kompilator widzi Func_i()
się main()
, że wychodzi z main()
i dowie Func_i()
. Kompilator w jakiś sposób znajduje wartość zwróconą przez Func_i()
i podaje ją printf()
. Wiem też, że kompilator nie może znaleźć typ zwracany z Func_i()
. To, domyślnie przyjmuje (domysły?) Ten typ zwracany z Func_i()
być int
. To znaczy, jeśli kod miałby float Func_i()
to kompilator dałby błąd: Konflikt typów dlaFunc_i()
.
Z powyższej dyskusji wynika, że:
Kompilator może znaleźć wartość zwracaną przez
Func_i()
.- Jeśli kompilator może znaleźć wartość zwróconą przez
Func_i()
wyjście zmain()
i przeszukanie kodu źródłowego, to dlaczego nie może znaleźć typu Func_i (), który jest wyraźnie wymieniony.
- Jeśli kompilator może znaleźć wartość zwróconą przez
Kompilator musi wiedzieć, że
Func_i()
jest typu float - dlatego podaje błąd typów sprzecznych.
- Jeśli kompilator wie, że
Func_i
jest typu float, to dlaczego nadal zakłada,Func_i()
że jest typu int, i podaje błąd typów sprzecznych? Dlaczego nie zmusza sięFunc_i()
do tego, aby być typu float.
Mam te same wątpliwości co do deklaracji zmiennej . Rozważ następujący kod „C”:
#include<stdio.h>
main()
{
/* [extern int Data_i;]--omitted the declaration */
printf("func:%d and Var:%d",Func_i(),Data_i);
}
Func_i()
{
int i=3;
return i;
}
int Data_i=4;
Kompilator podaje błąd: „Data_i” nie zadeklarowany (pierwsze użycie w tej funkcji).
- Gdy kompilator zobaczy
Func_i()
, przechodzi do kodu źródłowego, aby znaleźć wartość zwróconą przez Func_ (). Dlaczego kompilator nie może zrobić tego samego dla zmiennej Data_i?
Edytować:
Nie znam szczegółów wewnętrznego działania kompilatora, asemblera, procesora itp. Podstawową ideą mojego pytania jest to, że jeśli powiem (napiszę) wartość zwrotną funkcji w kodzie źródłowym w końcu, po użyciu tej funkcji, wówczas język „C” pozwala komputerowi znaleźć tę wartość bez żadnego błędu. Dlaczego komputer nie może znaleźć tego typu w podobny sposób. Dlaczego nie można znaleźć typu Data_i, ponieważ znaleziono wartość zwracaną przez Func_i (). Nawet jeśli używam extern data-type identifier;
instrukcji, nie mówię, że wartość ma być zwracana przez ten identyfikator (funkcja / zmienna). Jeśli komputer może znaleźć tę wartość, to dlaczego nie może znaleźć tego typu. Dlaczego w ogóle potrzebujemy deklaracji terminowej?
Dziękuję Ci.
źródło
Func_i
nieważności. Nigdy nie istniała reguła, aby pośrednio deklarować niezdefiniowane zmienne, więc drugi fragment zawsze był zniekształcony. (Tak, kompilatory nadal akceptują pierwszą próbkę, ponieważ była poprawna, jeśli była niechlujna, pod C89 / C90.)Odpowiedzi:
Ponieważ C jest single-pass , statycznie wpisany , słabo wpisany , skompilowany języka.
Jednoprzebiegowy oznacza, że kompilator nie patrzy w przyszłość, aby zobaczyć definicję funkcji lub zmiennej. Ponieważ kompilator nie patrzy w przyszłość, deklaracja funkcji musi pojawić się przed użyciem funkcji, w przeciwnym razie kompilator nie będzie wiedział, jaki jest jego typ. Jednak definicja funkcji może być później zapisana w tym samym pliku lub nawet w innym pliku. Punkt 4.
Jedynym wyjątkiem jest artefakt historyczny, w którym zakłada się, że niezadeklarowane funkcje i zmienne są typu „int”. Współczesna praktyka polega na unikaniu niejawnego pisania poprzez zawsze jawne deklarowanie funkcji i zmiennych.
Wpisanie statyczne oznacza, że wszystkie informacje o typie są obliczane w czasie kompilacji. Informacje te są następnie wykorzystywane do generowania kodu maszynowego, który jest wykonywany w czasie wykonywania. W C nie ma pojęcia o pisaniu w czasie wykonywania. Raz int, zawsze int, raz float, zawsze float. Jednak fakt ten jest nieco niejasny w następnym punkcie.
Słabo wpisane oznacza, że kompilator C automatycznie generuje kod do konwersji między typami liczbowymi bez wymagania od programisty jawnego określenia operacji konwersji. Z powodu pisania statycznego ta sama konwersja będzie zawsze przeprowadzana w ten sam sposób za każdym razem przez program. Jeśli wartość zmiennoprzecinkowa jest konwertowana na wartość całkowitą w danym miejscu w kodzie, wartość zmiennoprzecinkowa zawsze będzie konwertowana na wartość całkowitą w tym miejscu w kodzie. Nie można tego zmienić w czasie wykonywania. Sama wartość może oczywiście zmieniać się z jednego wykonania programu do następnego, a instrukcje warunkowe mogą zmieniać, które sekcje kodu są uruchamiane w jakiej kolejności, ale dana pojedyncza sekcja kodu bez wywołań funkcji lub warunków będzie zawsze wykonywać dokładnie te same operacje przy każdym uruchomieniu.
Kompilacja oznacza, że proces analizy kodu źródłowego czytelnego dla człowieka i przekształcenia go w instrukcje do odczytu maszynowego jest w pełni przeprowadzany przed uruchomieniem programu. Kiedy kompilator kompiluje funkcję, nie ma wiedzy o tym, co napotka dalej w danym pliku źródłowym. Jednak po zakończeniu kompilacji (i montażu, łączenia itp.) Każda funkcja w gotowym pliku wykonywalnym zawiera wskaźniki numeryczne do funkcji, które wywoła po uruchomieniu. Dlatego main () może wywoływać funkcję w dalszej części pliku źródłowego. Do czasu uruchomienia main () będzie zawierać wskaźnik do adresu Func_i ().
Kod maszynowy jest bardzo, bardzo konkretny. Kod dodawania dwóch liczb całkowitych (3 + 2) różni się od kodu dodawania dwóch liczb zmiennoprzecinkowych (3.0 + 2.0). Oba różnią się od dodania int do float (3 + 2.0) i tak dalej. Kompilator określa dla każdego punktu funkcji, jaka dokładna operacja musi zostać wykonana w tym punkcie, i generuje kod, który wykonuje tę dokładną operację. Po wykonaniu tej czynności nie można jej zmienić bez ponownej kompilacji funkcji.
Zestawiając wszystkie te koncepcje, powodem, dla którego main () nie może „zobaczyć” dalej, aby określić typ Func_i (), jest to, że analiza typu ma miejsce na samym początku procesu kompilacji. W tym momencie tylko część pliku źródłowego do definicji main () została odczytana i przeanalizowana, a kompilator nie zna jeszcze definicji Func_i ().
Dlatego, że main () „widzi”, gdzie Func_i () jest nazywają go, że powołanie się dzieje w czasie wykonywania, po kompilacji już rozwiązane wszystkie nazwy i typy wszystkich identyfikatorów, zespół został już przekształcony wszystkie z funkcje do kodu maszynowego, a linkowanie już wstawiło poprawny adres każdej funkcji w każdym miejscu, w którym jest wywoływana.
Oczywiście pominąłem większość krwawych szczegółów. Rzeczywisty proces jest o wiele bardziej skomplikowany. Mam nadzieję, że przedstawiłem wystarczający przegląd wysokiego poziomu, aby odpowiedzieć na pytania.
Ponadto pamiętaj, że to, co napisałem powyżej, dotyczy konkretnie C.
W innych językach kompilator może dokonywać wielu przejść przez kod źródłowy, a zatem kompilator może pobrać definicję Func_i () bez uprzedniej deklaracji.
W innych językach funkcje i / lub zmienne mogą być dynamicznie wpisywane, więc pojedyncza zmienna może pomieścić lub pojedyncza funkcja może zostać przekazana lub zwrócona, liczba całkowita, liczba zmiennoprzecinkowa, łańcuch, tablica lub obiekt w różnych momentach.
W innych językach pisanie może być silniejsze, co wymaga jawnej konwersji z liczby zmiennoprzecinkowej na liczbę całkowitą. W jeszcze innych językach pisanie może być słabsze, co pozwala na automatyczną konwersję ciągu „3.0” na liczbę zmiennoprzecinkową 3.0 na liczbę całkowitą 3.
W innych językach kod może być interpretowany po jednym wierszu na raz lub kompilowany do kodu bajtowego, a następnie interpretowany lub kompilowany na czas lub poddawany wielu innym schematom wykonywania.
źródło
Func_()+1
: tutaj w czasie kompilacji kompilator musi znać typFunc_i()
, aby wygenerować odpowiedni kod maszynowy. Być albo nie jest możliwe do montażu obsługiwaćFunc_()+1
poprzez wywołanie typu w czasie wykonywania, czy jest to możliwe, ale robi tak uczyni program powolny w czasie wykonywania. Myślę, że na razie mi wystarczy.int func(...)
... tzn. Biorą listę argumentów variadic. Oznacza to, że jeśli zdefiniujesz funkcję jako,int putc(char)
ale zapomnisz ją zadeklarować, zamiast tego zostanie wywołana jakoint putc(int)
(ponieważ char przechodził przez listę argumentów variadic jest promowanyint
). Tak więc chociaż przykład PO zadziałał, ponieważ jego podpis pasował do niejawnej deklaracji, zrozumiałe jest, dlaczego takie zachowanie było zniechęcone (i dodano odpowiednie ostrzeżenia).Ograniczeniem projektowym języka C było to, że miał on zostać skompilowany przez kompilator jednoprzebiegowy, co czyni go odpowiednim dla systemów o bardzo ograniczonej pamięci. Dlatego kompilator wie w dowolnym momencie tylko o rzeczach wspomnianych wcześniej. Kompilator nie może przejść do przodu w źródle, aby znaleźć deklarację funkcji, a następnie wrócić do kompilacji wywołania tej funkcji. Dlatego wszystkie symbole należy zadeklarować przed ich użyciem. Możesz wstępnie zadeklarować funkcję podobną do
u góry lub w pliku nagłówkowym, aby pomóc kompilatorowi.
W swoich przykładach używasz dwóch wątpliwych funkcji języka C, których należy unikać:
Jeśli funkcja jest używana przed jej prawidłowym zadeklarowaniem, jest ona używana jako „deklaracja niejawna”. Kompilator korzysta z bezpośredniego kontekstu, aby ustalić sygnaturę funkcji. Kompilator nie skanuje reszty kodu, aby dowiedzieć się, jaka jest prawdziwa deklaracja.
Jeśli coś jest zadeklarowane bez typu, przyjmuje się, że jest to typ
int
. Dotyczy to np. Zmiennych statycznych lub typów zwracanych funkcji.Więc
printf("func:%d",Func_i())
mamy niejawny deklaracjęint Func_i()
. Gdy kompilator osiągnie definicję funkcjiFunc_i() { ... }
, jest to zgodne z typem. Ale jeśli napisałeśfloat Func_i() { ... }
w tym momencie, masz zadeklarowaną implikacjęint Func_i()
i jawnie zadeklarowanąfloat Func_i()
. Ponieważ dwie deklaracje się nie zgadzają, kompilator wyświetla błąd.Usunięcie niektórych nieporozumień
Kompilator nie znajduje wartości zwróconej przez
Func_i
. Brak typu jawnego oznacza, że typem zwracanym jestint
domyślnie. Nawet jeśli to zrobisz:wtedy typ będzie
int Func_i()
, a zwracana wartość zostanie po cichu obcięta!Kompilator w końcu poznaje prawdziwy typ
Func_i
, ale nie zna prawdziwego typu podczas niejawnej deklaracji. Dopiero gdy później osiągnie rzeczywistą deklarację, może dowiedzieć się, czy niejawnie zadeklarowany typ był poprawny. Ale w tym momencie zestaw wywołania funkcji mógł już zostać napisany i nie można go zmienić w modelu kompilacji C.źródło
Unit
tworzy ładny domyślny typ z punktu widzenia teorii typów, ale zawodzi w praktyczności programowania bliskiego dla systemów metalowych, dla którego zaprojektowano B, a następnie C.Func_i()
, natychmiast generuje i zapisuje kod, aby procesor przeskoczył w inne miejsce, a następnie otrzymał liczbę całkowitą, a następnie kontynuował. Kiedy kompilator później znajdzieFunc_i
definicję, upewnia się, że podpisy są zgodne, a jeśli tak, to umieszcza zestawFunc_i()
pod tym adresem i mówi, aby zwrócił jakąś liczbę całkowitą. Po uruchomieniu programu procesor postępuje zgodnie z instrukcjami z wartością3
.Po pierwsze, wasze programy są ważne dla standardu C90, ale nie dla następujących. niejawna int (pozwalająca na zadeklarowanie funkcji bez podania jej typu zwracanego) oraz niejawna deklaracja funkcji (pozwalająca na użycie funkcji bez deklarowania jej) nie jest już ważna.
Po drugie, to nie działa tak, jak myślisz.
Typ wyniku jest opcjonalny w C90, brak podania jednego oznacza
int
wynik. Dotyczy to również deklaracji zmiennych (ale musisz podać klasę pamięcistatic
lubextern
).To, co robi kompilator, widząc
Func_i
wywołanie bez wcześniejszej deklaracji, zakłada założenie, że istnieje deklaracjanie patrzy dalej w kodzie, aby zobaczyć, jak skutecznie
Func_i
zadeklarowano. JeśliFunc_i
nie został zadeklarowany ani zdefiniowany, kompilator nie zmieniłby swojego zachowania podczas kompilacjimain
. Deklaracja niejawna dotyczy tylko funkcji, nie ma żadnej zmiennej.Zauważ, że pusta lista parametrów w deklaracji nie oznacza, że funkcja nie przyjmuje parametrów (musisz to określić
(void)
), oznacza to, że kompilator nie musi sprawdzać typów parametrów i będzie to samo niejawne konwersje stosowane do argumentów przekazywanych do funkcji variadic.źródło
extern int Func_i()
. Nigdzie nie wygląda.-S
(jeśli używaszgcc
) pozwoli ci spojrzeć na kod asemblera wygenerowany przez kompilator. Następnie możesz mieć pojęcie o tym, jak wartości zwracane są obsługiwane w czasie wykonywania (zwykle przy użyciu rejestru procesora lub pewnej przestrzeni na stosie programu).Napisałeś w komentarzu:
To nieporozumienie: egzekucja nie jest wykonywana wiersz po wierszu. Kompilacja jest wykonywana wiersz po wierszu, a rozpoznawanie nazw odbywa się podczas kompilacji i rozwiązuje tylko nazwy, a nie zwraca wartości.
Pomocny model koncepcyjny jest następujący: Gdy kompilator odczytuje wiersz:
emituje kod odpowiadający:
Kompilator zapisuje również notatkę w wewnętrznej tabeli, która
function #2
nie jest jeszcze zadeklarowaną funkcją o nazwieFunc_i
, która pobiera nieokreśloną liczbę argumentów i zwraca wartość int (domyślną).Później, gdy analizuje to:
kompilator wyszukuje
Func_i
w powyższej tabeli i sprawdza, czy parametry i typ zwracanego komunikatu są zgodne. Jeśli nie, zatrzymuje się na komunikacie o błędzie. Jeśli tak, dodaje bieżący adres do wewnętrznej tabeli funkcji i przechodzi do następnego wiersza.Tak więc kompilator nie „szukał”
Func_i
podczas analizy pierwszego odwołania. Po prostu zanotował w jakiejś tabeli, po czym parsował następną linię. Na końcu pliku znajduje się plik obiektowy i lista adresów skoku.Później linker bierze to wszystko i zastępuje wszystkie wskaźniki do „funkcji nr 2” rzeczywistym adresem skoku, więc emituje coś takiego:
Znacznie później, gdy plik wykonywalny jest uruchamiany, adres skoku jest już rozwiązany, a komputer może po prostu przejść do adresu 0x1215. Wyszukiwanie nazw nie jest wymagane.
Zastrzeżenie : Jak powiedziałem, jest to model konceptualny, a świat rzeczywisty jest bardziej skomplikowany. Kompilatory i konsolidatory dokonują dziś wszelkiego rodzaju szalonych optymalizacji. Nawet może „podskoczyć się”, aby szukać
Func_i
, chociaż wątpię. Ale języki C są zdefiniowane w taki sposób, że można napisać taki bardzo prosty kompilator. Przez większość czasu jest to bardzo przydatny model.źródło
1. call "function #2", put the return-type onto the stack and put the return value on the stack?
printf(..., Func_i()+1);
- kompilator musi znać typFunc_i
, aby mógł zdecydować, czy powinien wydać instrukcjęadd integer
czyadd float
instrukcję. Można znaleźć pewne szczególne przypadki, w których kompilator może trwać bez informacji o typie, ale kompilator musi pracować dla wszystkich przypadków.float
wartości mogą znajdować się w rejestrze FPU - wtedy nie byłoby instrukcji. Kompilator po prostu śledzi, która wartość jest przechowywana w którym rejestrze podczas kompilacji, i wysyła takie rzeczy jak „dodaj stałą 1 do rejestru FP X”. Lub może żyć na stosie, jeśli nie ma wolnych rejestrów. Wtedy pojawiłaby się instrukcja „zwiększ wskaźnik stosu o 4”, a wartość byłaby „odniesiona” jako coś w rodzaju „wskaźnik stosu - 4”. Ale wszystkie te rzeczy działają tylko wtedy, gdy rozmiary wszystkich zmiennych (przed i po) na stosie są znane w czasie kompilacji.Func_i()
lub / iData_i
, musi określić ich typy; w języku asemblera nie jest możliwe wywołanie typu danych. Muszę się dokładnie przestudiować, aby się upewnić.C i wiele innych języków wymagających deklaracji zostało zaprojektowanych w czasach, gdy czas i pamięć procesora były drogie. Rozwój C i Unix szedł ramię w ramię przez długi czas, a ta ostatnia nie miała pamięci wirtualnej, dopóki nie pojawił się 3BSD w 1979 roku. Bez dodatkowego miejsca do pracy, kompilatory były zwykle sprawami jednoprzebiegowymi , ponieważ nie miały wymagają możliwości zachowania pewnej reprezentacji całego pliku w pamięci jednocześnie.
Kompilatory jednoprzebiegowe są, podobnie jak my, obarczone niemożnością patrzenia w przyszłość. Oznacza to, że jedyne, co mogą wiedzieć na pewno, to to, co zostało im powiedziane przed skompilowaniem wiersza kodu. Dla każdego z nas
Func_i()
jest to oczywiste, zadeklarowane później w pliku źródłowym, ale kompilator, który działa na niewielkiej części kodu naraz, nie ma pojęcia, że nadejdzie.Na początku C (AT&T, K&R, C89) użycie funkcji
foo()
przed deklaracją spowodowało de facto lub dorozumianą deklaracjęint foo()
. Twój przykład działa, gdyFunc_i()
jest zadeklarowany,int
ponieważ pasuje do tego, co kompilator zadeklarował w twoim imieniu. Zmiana na inny typ spowoduje konflikt, ponieważ nie będzie już zgodny z wyborem kompilatora przy braku wyraźnej deklaracji. To zachowanie zostało usunięte w C99, gdzie użycie niezadeklarowanej funkcji stało się błędem.A co z typami zwrotów?
Konwencja wywoływania kodu obiektowego w większości środowisk wymaga znajomości tylko adresu wywoływanej funkcji, co jest stosunkowo łatwe dla kompilatorów i konsolidatorów. Wykonanie przeskakuje na początek funkcji i wraca, gdy powraca. Wszystko inne, w szczególności układy przekazywania argumentów i wartość zwracana, są całkowicie określane przez dzwoniącego i odbierającego w układzie zwanym konwencją wywoływania . Tak długo, jak oba mają ten sam zestaw konwencji, program może wywoływać funkcje w innych plikach obiektowych, niezależnie od tego, czy zostały one skompilowane w dowolnym języku, który dzieli te konwencje. (W informatyce naukowej napotykasz dużo C wywołujących FORTRAN i odwrotnie, a zdolność do tego wynika z posiadania konwencji wywoływania.)
Inną cechą wczesnego C było to, że prototypy, jakie znamy teraz, nie istniały. Możesz zadeklarować typ zwracany przez funkcję (np.
int foo()
), Ale nie jej argumenty (tj.int foo(int bar)
Nie było opcją). Istniało to, ponieważ, jak wspomniano powyżej, program zawsze trzymał się konwencji wywoływania, która może być określona przez argumenty. Jeśli wywołano funkcję z niepoprawnym typem argumentów, była to sytuacja „śmieci”, „śmieci”.Ponieważ kod obiektowy ma pojęcie zwracane, ale nie typ zwracany, kompilator musi znać typ zwracany, aby radzić sobie z zwracaną wartością. Kiedy uruchamiasz instrukcje maszynowe, to tylko bity, a procesor nie dba o to, czy pamięć, w której próbujesz porównać,
double
rzeczywiście maint
w sobie. Robi to, o co prosisz, a jeśli go złamiesz, posiadasz oba elementy.Rozważ te fragmenty kodu:
Kod po lewej kompiluje się do wywołania,
foo()
po którym następuje skopiowanie wyniku dostarczonego za pośrednictwem konwencji wywołania / powrotu do dowolnego miejsca wx
pamięci. To łatwy przypadek.Kod po prawej pokazuje konwersję typu i dlatego kompilatory muszą znać typ zwracany przez funkcję. Liczb zmiennoprzecinkowych nie można wrzucić do pamięci, w której inny kod będzie oczekiwać,
int
ponieważ nie nastąpi magiczna konwersja. Jeśli wynikiem końcowym musi być liczba całkowita, muszą istnieć instrukcje, które poprowadzą procesor do wykonania konwersji przed zapisaniem. Bez znajomości wcześniejszego typu zwrotufoo()
kompilator nie miałby pojęcia, że kod konwersji jest konieczny.Kompilatory wieloprzebiegowe umożliwiają wszelkiego rodzaju rzeczy, z których jedną jest możliwość deklarowania zmiennych, funkcji i metod po ich pierwszym użyciu. Oznacza to, że gdy kompilator zacznie kompilować kod, zobaczył już przyszłość i wie, co robić. Na przykład Java nakazuje wieloprzebiegowość, ponieważ jej składnia umożliwia deklarację po użyciu.
źródło
Func_i()
wartość zwracaną.double foo(); int x; x = foo();
po prostu podaje błąd. Wiem, że nie możemy tego zrobić. Moje pytanie brzmi: w wywołaniu funkcji procesor znajduje tylko wartość zwracaną; dlaczego nie może również znaleźć typu zwrotu?foo()
, więc kompilator wie, co z nim zrobić.