Dlaczego * deklaracja * danych i funkcji jest konieczna w języku C, skoro definicja jest zapisana na końcu kodu źródłowego?

15

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:

  1. Kompilator może znaleźć wartość zwracaną przez Func_i().

    • Jeśli kompilator może znaleźć wartość zwróconą przez Func_i()wyjście z main()i przeszukanie kodu źródłowego, to dlaczego nie może znaleźć typu Func_i (), który jest wyraźnie wymieniony.
  2. Kompilator musi wiedzieć, że Func_i()jest typu float - dlatego podaje błąd typów sprzecznych.

  • Jeśli kompilator wie, że Func_ijest 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.

użytkownik106313
źródło
7
Kompilator nie „znajduje” wartości zwróconej przez Func_i. odbywa się to w czasie wykonywania.
James McLeod,
26
Nie głosowałem za tym, ale pytanie opiera się na poważnych nieporozumieniach dotyczących działania kompilatorów, a twoje odpowiedzi w komentarzach sugerują, że wciąż masz kilka przeszkód pojęciowych do pokonania.
James McLeod,
4
Zauważ, że pierwszy przykładowy kod nie był prawidłowy, zgodny ze standardowym kodem przez ostatnie piętnaście lat; C99 spowodował brak typu zwracanego w definicjach funkcji i niejawnej deklaracji Func_inieważ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.)
Jonathan Leffler
19
@ user31782: Dolna linia pytania: Dlaczego język X spełnia / wymaga Y? Ponieważ jest to wybór dokonany przez projektantów. Wygląda na to, że argumentujesz, że projektanci jednego z najbardziej udanych języków powinni byli dokonać różnych wyborów dziesięcioleci temu, zamiast próbować zrozumieć te wybory w kontekście, w którym zostały dokonane. Odpowiedź na twoje pytanie: Dlaczego potrzebujemy deklaracji terminowej? podano: Ponieważ C używa kompilatora jednoprzebiegowego. Najprostsza odpowiedź na większość twoich dalszych pytań brzmi: Ponieważ wtedy nie byłby to kompilator jednoprzebiegowy.
Mr.Mindor,
4
@ user31782 Naprawdę, naprawdę chcesz przeczytać książkę smoka, aby zrozumieć, jak naprawdę działają kompilatory i procesory - po prostu niemożliwe jest przełożenie całej wymaganej wiedzy na jedną odpowiedź SO (lub nawet na 100). Świetna książka dla każdego, kto interesuje się kompilatorami.
Voo,

Odpowiedzi:

26

Ponieważ C jest single-pass , statycznie wpisany , słabo wpisany , skompilowany języka.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

Clement Cherlin
źródło
1
Dziękujemy za odpowiedź typu „wszystko w jednym”. Odpowiedź twoja i nikie jest tym, co chciałem wiedzieć. Np . Func_()+1: tutaj w czasie kompilacji kompilator musi znać typ Func_i(), aby wygenerować odpowiedni kod maszynowy. Być albo nie jest możliwe do montażu obsługiwać Func_()+1poprzez 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.
user106313
1
Ważny szczegół niejawnie zadeklarowanych funkcji C: Zakłada się, że są typu 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 jako int putc(int)(ponieważ char przechodził przez listę argumentów variadic jest promowany int). 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).
uliwitness
37

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

int Func_i();

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ć:

  1. 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.

  2. 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ę funkcji Func_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 jest intdomyślnie. Nawet jeśli to zrobisz:

    Func_i() {
        float f = 42.3;
        return f;
    }

    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.

amon
źródło
3
@ user31782: Kolejność kodu ma znaczenie w czasie kompilacji, ale nie w czasie wykonywania. Kompilator nie wyświetla się po uruchomieniu programu. W czasie wykonywania funkcja zostanie złożona i połączona, jej adres zostanie rozwiązany i zablokowany w symbolu zastępczym adresu połączenia. (Jest to nieco bardziej skomplikowane, ale to podstawowy pomysł.) Procesor może rozgałęziać się do przodu lub do tyłu.
Blrfl,
20
@ user31782: Kompilator nie drukuje wartości. Twój kompilator nie uruchamia programu !!
Wyścigi lekkości z Monicą
1
@LightnessRacesinOrbit Wiem o tym. Błędnie napisałem kompilator w powyższym komentarzu, ponieważ zapomniałem procesora nazw .
user106313,
3
@Carcigenicate C był pod silnym wpływem języka B, który miał tylko jeden typ: całkowy typ liczbowy o szerokości słowa, który można również zastosować do wskaźników. C pierwotnie skopiował to zachowanie, ale teraz jest całkowicie zakazane od standardu C99. Unittworzy ł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.
amon
2
@ user31782: Kompilator musi znać typ zmiennej, aby wygenerować poprawny zestaw dla procesora. Gdy kompilator znajdzie niejawne 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 znajdzie Func_idefinicję, upewnia się, że podpisy są zgodne, a jeśli tak, to umieszcza zestaw Func_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.
Kaczka Mooing
10

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.

  1. Typ wyniku jest opcjonalny w C90, brak podania jednego oznacza intwynik. Dotyczy to również deklaracji zmiennych (ale musisz podać klasę pamięci staticlub extern).

  2. To, co robi kompilator, widząc Func_iwywołanie bez wcześniejszej deklaracji, zakłada założenie, że istnieje deklaracja

    extern int Func_i();

    nie patrzy dalej w kodzie, aby zobaczyć, jak skutecznie Func_izadeklarowano. Jeśli Func_inie został zadeklarowany ani zdefiniowany, kompilator nie zmieniłby swojego zachowania podczas kompilacji main. 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.

AProgrammer
źródło
Jeśli kompilator może znaleźć wartość zwróconą przez Func_i (), wychodząc z main () i wyszukując kod źródłowy, to dlaczego nie może znaleźć typu Func_i (), który jest wyraźnie wymieniony.
user106313,
1
@ user31782 Jeśli nie było wcześniejszej deklaracji Func_i, widząc, że Func_i jest używany w wyrażeniu wywołania, zachowuje się tak, jakby taki był extern int Func_i(). Nigdzie nie wygląda.
AProgrammer
1
@ user31782, kompilator nigdzie nie skacze. Wyśle kod wywołujący tę funkcję; zwracana wartość zostanie ustalona w czasie wykonywania. Cóż, w przypadku tak prostej funkcji, która jest obecna w tej samej jednostce kompilacji, faza optymalizacji może obejmować funkcję, ale nie jest to coś, o czym powinieneś pomyśleć, biorąc pod uwagę reguły języka, jest to optymalizacja.
AProgrammer
10
@ user31782, masz poważne nieporozumienia na temat działania programów. Tak poważne, że nie uważam, że p.se jest dobrym miejscem na ich poprawienie (być może czat, ale nie będę tego próbował).
AProgrammer
1
@ user31782: Napisanie małego fragmentu kodu i kompilowanie go -S(jeśli używasz gcc) 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).
Giorgio
7

Napisałeś w komentarzu:

Wykonanie odbywa się linia po linii. Jedynym sposobem na znalezienie wartości zwracanej przez Func_i () jest wyskoczenie z głównego

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:

  printf("func:%d",Func_i());

emituje kod odpowiadający:

  1. call "function #2" and put the return value on the stack
  2. put the constant string "func:%d" on the stack
  3. call "function #1"

Kompilator zapisuje również notatkę w wewnętrznej tabeli, która function #2nie jest jeszcze zadeklarowaną funkcją o nazwie Func_i, która pobiera nieokreśloną liczbę argumentów i zwraca wartość int (domyślną).

Później, gdy analizuje to:

 int Func_i() { ...

kompilator wyszukuje Func_iw 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_ipodczas 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:

  call 0x0001215 and put the result on the stack
  put constant ... on the stack
  call ...
...
[at offset 0x0001215 in the file, compiled result of Func_i]:
  put 3 on the stack
  return top of the stack

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.

nikie
źródło
Dziękuję za Twoją odpowiedź. Czy kompilator nie może 1. call "function #2", put the return-type onto the stack and put the return value on the stack?
wysłać
1
(Cd.) Także: Co jeśli napisałeś printf(..., Func_i()+1);- kompilator musi znać typ Func_i, aby mógł zdecydować, czy powinien wydać instrukcję add integerczy add floatinstrukcję. 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.
nikie
4
@ user31782: Instrukcje maszynowe z reguły są bardzo proste: dodaj dwa 32-bitowe rejestry całkowite. Załaduj adres pamięci do 16-bitowego rejestru całkowitego. Przejdź do adresu. Ponadto nie ma żadnych typów : Możesz z przyjemnością załadować lokalizację pamięci reprezentującą 32-bitową liczbę zmiennoprzecinkową do 32-bitowego rejestru liczb całkowitych i wykonać z nią trochę arytmetyki. (To po prostu rzadko ma sens.) Więc nie, nie możesz emitować takiego kodu maszynowego bezpośrednio. Możesz napisać kompilator, który wykonuje te wszystkie czynności, sprawdzając w czasie wykonywania i dodatkowe dane typu na stosie. Ale to nie byłby kompilator C.
nikie
1
@ user31782: Zależy, IIRC. floatwartoś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.
nikie
1
Z całej dyskusji doszedłem do tego zrozumienia: aby kompilator stworzył wiarygodny kod asemblera dla dowolnej instrukcji, w tym Func_i()lub / i Data_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ć.
user106313,
5

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, gdy Func_i()jest zadeklarowany, intponieważ 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ć, doublerzeczywiście ma intw sobie. Robi to, o co prosisz, a jeśli go złamiesz, posiadasz oba elementy.

Rozważ te fragmenty kodu:

double foo();         double foo();
double x;             int x;
x = foo();            x = foo();

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 w xpamię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ć, intponieważ 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 zwrotu foo()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.

Blrfl
źródło
Dziękujemy za odpowiedź (+1). Nie znam szczegółów dotyczących wewnętrznego działania kompilatora, asemblera, procesora itp. Podstawową ideą mojego pytania jest to, że jeśli powiem (zapisz) wartość zwrotną funkcji w kodzie źródłowym w końcu, po użyciu tej funkcji, wówczas język 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 Func_i()wartość zwracaną.
user106313,
Nadal nie jestem zadowolony. 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?
user106313,
1
@ user31782: Nie powinno. Istnieje prototyp foo(), więc kompilator wie, co z nim zrobić.
Blrfl,
2
@ user31782: Procesory nie mają pojęcia typu zwrotu.
Blrfl,
1
@ user31782 Pytanie dotyczące czasu kompilacji: Można napisać język, w którym wszystkie analizy tego typu można wykonać w czasie kompilacji. C nie jest takim językiem. Kompilator C nie może tego zrobić, ponieważ nie jest do tego przeznaczony. Czy mógł być zaprojektowany inaczej? Jasne, ale zajęłoby to znacznie więcej mocy obliczeniowej i pamięci. Najważniejsze jest to, że nie było. Został zaprojektowany w sposób, który najlepiej poradziły sobie komputery dnia.
Mr.Mindor,