Czy w C klamry działają jak ramka stosu?

153

Jeśli utworzę zmienną w nowym zestawie nawiasów klamrowych, to czy ta zmienna wyskoczyła ze stosu w nawiasie zamykającym, czy też zawiesza się do końca funkcji? Na przykład:

void foo() {
   int c[100];
   {
       int d[200];
   }
   //code that takes a while
   return;
}

Będzie dzajmować pamięć podczas code that takes a whilesekcji?

Claudiu
źródło
8
Czy masz na myśli (1) według normy, (2) powszechną praktykę wśród wdrożeń, czy (3) powszechną praktykę wśród wdrożeń?
David Thornley

Odpowiedzi:

83

Nie, klamry nie działają jak ramka stosu. W języku C nawiasy klamrowe oznaczają tylko zakres nazewnictwa, ale nic nie zostaje zniszczone ani nic nie jest wyskakujące ze stosu, gdy kontrola przechodzi poza niego.

Jako programista piszący kod, często możesz myśleć o nim jak o ramce stosu. Identyfikatory zadeklarowane w nawiasach są dostępne tylko w nawiasach, więc z punktu widzenia programisty wygląda to tak, jakby były umieszczane na stosie, gdy są zadeklarowane, a następnie usuwane po wyjściu z zakresu. Jednak kompilatory nie muszą generować kodu, który wypycha / wyskakuje cokolwiek przy wejściu / wyjściu (i generalnie tego nie robi).

Należy również zauważyć, że zmienne lokalne mogą w ogóle nie zajmować miejsca na stosie: mogą być przechowywane w rejestrach procesora lub w innej lokalizacji pamięci dyskowej lub całkowicie zoptymalizowane.

A zatem dtablica teoretycznie może zużywać pamięć dla całej funkcji. Jednak kompilator może go zoptymalizować lub udostępnić pamięć innym zmiennym lokalnym, których okresy użytkowania nie pokrywają się.

Kristopher Johnson
źródło
9
Czy to nie jest specyficzne dla implementacji?
avakar
54
W C ++ destruktor obiektu jest wywoływany na końcu jego zasięgu. To, czy pamięć zostanie odzyskana, jest kwestią specyficzną dla implementacji.
Kristopher Johnson
8
@ pm100: Zostaną wywołane destruktory. To nic nie mówi o pamięci, jaką zajmowały te przedmioty.
Donal Fellows
9
Standard C określa, że ​​czas życia zmiennych automatycznych zadeklarowanych w bloku rozciąga się tylko do zakończenia wykonywania bloku. Więc zasadniczo te automatyczne zmienne „niszczone” na końcu bloku.
kawiarnia
3
@KristopherJohnson: Gdyby metoda miała dwa oddzielne bloki, z których każdy zadeklarował tablicę 1 KB, i trzeci blok, który wywoływał metodę zagnieżdżoną, kompilator mógłby użyć tej samej pamięci dla obu tablic i / lub umieścić tablicę w najpłytszej części stosu i przesuń nad nią wskaźnik stosu, wywołując metodę zagnieżdżoną. Takie zachowanie mogłoby zmniejszyć o 2K głębokość stosu wymaganą dla wywołania funkcji.
supercat,
39

Czas, w którym zmienna faktycznie zajmuje pamięć, jest oczywiście zależny od kompilatora (a wiele kompilatorów nie dostosowuje wskaźnika stosu, gdy wewnętrzne bloki są wprowadzane i zamykane w funkcjach).

Jednak ściśle powiązanym, ale być może bardziej interesującym pytaniem jest to, czy program może uzyskać dostęp do tego wewnętrznego obiektu poza wewnętrznym zakresem (ale w ramach funkcji zawierającej), tj .:

void foo() {
   int c[100];
   int *p;

   {
       int d[200];
       p = d;
   }

   /* Can I access p[0] here? */

   return;
}

(Innymi słowy: czy kompilator może zwolnić przydział d, nawet jeśli w praktyce większość tego nie robi?).

Odpowiedź jest, że kompilator jest dozwolone na zwalnianie di dostępu p[0]gdzie wskazuje komentarz jest niezdefiniowane zachowanie (program nie może uzyskać dostęp do wewnętrznej zewnątrz obiektu zakresu wewnętrznego). Odpowiednia część normy C to 6.2.4p5:

W przypadku takiego obiektu [z automatycznym czasem przechowywania], który nie ma typu tablicy o zmiennej długości, jego żywotność rozciąga się od wejścia do bloku, z którym jest skojarzony, aż do zakończenia wykonania tego bloku w jakikolwiek sposób . (Wprowadzenie bloku zamkniętego lub wywołanie funkcji wstrzymuje wykonywanie bieżącego bloku, ale go nie kończy). Jeśli blok jest wprowadzany rekurencyjnie, za każdym razem tworzona jest nowa instancja obiektu. Początkowa wartość obiektu jest nieokreślona. Jeśli dla obiektu określono inicjalizację, jest ona wykonywana za każdym razem, gdy deklaracja zostanie osiągnięta podczas wykonywania bloku; w przeciwnym razie wartość staje się nieokreślona za każdym razem, gdy deklaracja zostanie osiągnięta.

kawiarnia
źródło
Jako osoba ucząca się, jak zakres i pamięć działa w C i C ++ po latach używania języków wyższego poziomu, uważam tę odpowiedź za bardziej precyzyjną i użyteczną niż przyjęta.
Chris,
20

Twoje pytanie nie jest na tyle jasne, aby uzyskać jednoznaczną odpowiedź.

Z jednej strony kompilatory normalnie nie wykonują żadnej lokalnej alokacji / zwalniania alokacji pamięci dla zagnieżdżonych zakresów bloków. Pamięć lokalna jest zwykle przydzielana tylko raz przy wejściu do funkcji i zwalniana przy jej wyjściu.

Z drugiej strony, gdy kończy się okres istnienia obiektu lokalnego, pamięć zajmowaną przez ten obiekt można później ponownie wykorzystać dla innego obiektu lokalnego. Na przykład w tym kodzie

void foo()
{
  {
    int d[100];
  }
  {
    double e[20];
  }
}

obie tablice zwykle zajmują ten sam obszar pamięci, co oznacza, że ​​całkowita ilość lokalnej pamięci potrzebnej do funkcji foojest taka, jaka jest potrzebna dla największej z dwóch tablic, a nie dla obu jednocześnie.

To, czy to ostatnie kwalifikuje się jako ddalsze zajmowanie pamięci do końca funkcji w kontekście twojego pytania, zależy od ciebie.

Mrówka
źródło
6

To zależy od implementacji. Napisałem krótki program, aby sprawdzić, co robi gcc 4.3.4, i przydziela on całą przestrzeń stosu naraz na początku funkcji. Możesz zbadać zestaw, który tworzy gcc, używając opcji -S.

Daniel Stutzbach
źródło
3

Nie, d [] nie będzie na stosie do końca procedury. Ale throwa () jest inaczej.

Edycja: Kristopher Johnson (oraz simon i Daniel) mają rację , a moja początkowa odpowiedź była błędna . W gcc 4.3.4. Na CYGWIN, kod:

void foo(int[]);
void bar(void);
void foobar(int); 

void foobar(int flag) {
    if (flag) {
        int big[100000000];
        foo(big);
    }
    bar();
}

daje:

_foobar:
    pushl   %ebp
    movl    %esp, %ebp
    movl    $400000008, %eax
    call    __alloca
    cmpl    $0, 8(%ebp)
    je      L2
    leal    -400000000(%ebp), %eax
    movl    %eax, (%esp)
    call    _foo
L2:
    call    _bar
    leave
    ret

Żyj i ucz się! Szybki test wydaje się wykazywać, że AndreyT ma również rację co do wielu alokacji.

Dodano dużo później : Powyższy test pokazuje, że dokumentacja gcc nie jest całkiem poprawna. Od lat mówi (podkreślenie dodane):

„Miejsce na tablicę o zmiennej długości jest zwalniane, gdy tylko kończy się zakres nazwy tablicy ”.

Joseph Quinsey
źródło
Kompilowanie z wyłączoną optymalizacją niekoniecznie pokazuje, co otrzymasz w zoptymalizowanym kodzie. W tym przypadku zachowanie jest takie samo ( alokuj na początku funkcji i wolne tylko wtedy, gdy opuszczasz funkcję): godbolt.org/g/M112AQ . Ale gcc inne niż Cygwin nie wywołuje allocafunkcji. Jestem naprawdę zaskoczony, że cygwin gcc to zrobił. To nawet nie jest tablica o zmiennej długości, więc IDK, dlaczego o tym wspominasz.
Peter Cordes
2

Mogą. Może nie. Odpowiedź, której myślę, że naprawdę potrzebujesz, brzmi: nigdy niczego nie zakładaj. Nowoczesne kompilatory wykonują wszystkie rodzaje architektury i magii specyficznej dla implementacji. Napisz swój kod prosto i czytelnie dla ludzi i pozwól kompilatorowi zrobić dobre rzeczy. Jeśli próbujesz kodować wokół kompilatora, prosisz o kłopoty - a kłopoty, które zwykle pojawiają się w takich sytuacjach, są zwykle strasznie subtelne i trudne do zdiagnozowania.

user19666
źródło
1

Twoja zmienna dzazwyczaj nie jest usuwana ze stosu. Nawiasy klamrowe nie oznaczają ramki stosu. W przeciwnym razie nie byłbyś w stanie zrobić czegoś takiego:

char var = getch();
    {
        char next_var = var + 1;
        use_variable(next_char);
    }

Jeśli nawiasy klamrowe spowodowałyby prawdziwy stos push / pop (jak wywołanie funkcji), powyższy kod nie zostałby skompilowany, ponieważ kod wewnątrz nawiasów nie byłby w stanie uzyskać dostępu do zmiennej, varktóra znajduje się poza nawiasami klamrowymi (tak jak pod- funkcja nie ma bezpośredniego dostępu do zmiennych w funkcji wywołującej). Wiemy, że tak nie jest.

Szelki kręcone są po prostu używane do określania zakresu. Kompilator potraktuje każdy dostęp do zmiennej "wewnętrznej" spoza otaczających nawiasów jako nieprawidłowy i może ponownie wykorzystać tę pamięć do czegoś innego (jest to zależne od implementacji). Jednak nie można go zdjąć ze stosu, dopóki funkcja otaczająca nie powróci.

Aktualizacja: Oto, co ma do powiedzenia specyfikacja C. Odnośnie obiektów z automatycznym czasem przechowywania (sekcja 6.4.2):

W przypadku obiektu, który nie ma typu tablicy o zmiennej długości, jego okres istnienia rozciąga się od wejścia do bloku, z którym jest skojarzony, do momentu, gdy wykonanie tego bloku i tak zakończy się.

Ta sama sekcja definiuje termin „żywotność” jako (wyróżnienie moje):

Czas życia obiektu to część wykonywania programu, podczas której gwarantowane jest zarezerwowanie dla niego miejsca. Obiekt istnieje, ma stały adres i zachowuje swoją ostatnio zapisaną wartość przez cały okres jego istnienia. Jeśli odniesienie do obiektu jest poza jego okresem istnienia, zachowanie jest niezdefiniowane.

Kluczowym słowem jest tutaj oczywiście „gwarantowana”. Po wyjściu z zakresu wewnętrznego zestawu nawiasów klamrowych okres istnienia tablicy dobiega końca. Pamięć może być dla niego przydzielona lub nie (Twój kompilator może ponownie wykorzystać to miejsce na coś innego), ale wszelkie próby uzyskania dostępu do tablicy wywołują niezdefiniowane zachowanie i powodują nieprzewidywalne rezultaty.

Specyfikacja C nie zawiera pojęcia ramek stosu. Mówi tylko o tym, jak zachowa się wynikowy program i pozostawia szczegóły implementacji kompilatorowi (w końcu implementacja wyglądałaby zupełnie inaczej na procesorze bez stosu niż na procesorze ze stosem sprzętowym). W specyfikacji C nie ma nic, co określa, gdzie ramka stosu będzie się kończyć lub nie. Jedyny prawdziwy sposobem, aby to wiedzieć, jest skompilowanie kodu na konkretnym kompilatorze / platformie i zbadanie wynikowego zestawu. Obecny zestaw opcji optymalizacyjnych Twojego kompilatora prawdopodobnie również odegra w tym rolę.

Jeśli chcesz mieć pewność, że tablica dnie zajmuje już pamięci podczas działania kodu, możesz albo przekonwertować kod w nawiasach klamrowych na osobną funkcję, albo jawnie malloci freepamięć, zamiast korzystać z automatycznego przechowywania.

bta
źródło
1
„Gdyby nawiasy klamrowe powodowały wypychanie / popychanie stosu, powyższy kod nie byłby kompilowany, ponieważ kod wewnątrz nawiasów nie byłby w stanie uzyskać dostępu do zmiennej var, która znajduje się poza nawiasami” - to po prostu nieprawda. Kompilator zawsze może zapamiętać odległość od wskaźnika stosu / ramki i użyć go do odniesienia się do zmiennych zewnętrznych. Zobacz również odpowiedź Josepha na przykład nawiasów klamrowych, które powodują push / pop stosu.
george
@ george - zachowanie, które opisujesz, jak również przykład Josepha, zależy od używanego kompilatora i platformy. Na przykład kompilacja tego samego kodu dla celu MIPS daje zupełnie inne wyniki. Mówiłem wyłącznie z punktu widzenia specyfikacji C (ponieważ OP nie określał kompilatora ani celu). Zmienię odpowiedź i dodam więcej szczegółów.
bta
0

Uważam, że wykracza poza zakres, ale nie jest wyskakiwany ze stosu, dopóki funkcja nie powróci. Tak więc nadal będzie zajmować pamięć na stosie, dopóki funkcja nie zostanie zakończona, ale nie będzie dostępna za pierwszym zamykającym nawiasem klamrowym.

Szymon
źródło
3
Żadnych gwarancji. Po zamknięciu zakresu kompilator nie śledzi już tej pamięci (a przynajmniej nie musi ...) i może ją ponownie wykorzystać. Dlatego dotknięcie pamięci zajmowanej wcześniej przez zmienną spoza zakresu jest niezdefiniowanym zachowaniem. Uważaj na nosowe demony i podobne ostrzeżenia.
dmckee --- kociak ex-moderator
0

Podano już wiele informacji na temat normy, wskazujących, że jest ona rzeczywiście specyficzna dla implementacji.

Tak więc jeden eksperyment może być interesujący. Jeśli spróbujemy poniższego kodu:

#include <stdio.h>
int main() {
    int* x;
    int* y;
    {
        int a;
        x = &a;
        printf("%p\n", (void*) x);
    }
    {
        int b;
        y = &b;
        printf("%p\n", (void*) y);
    }
}

Używając gcc otrzymujemy tutaj dwa razy ten sam adres: Coliro

Ale jeśli spróbujemy następującego kodu:

#include <stdio.h>
int main() {
    int* x;
    int* y;
    {
        int a;
        x = &a;
    }
    {
        int b;
        y = &b;
    }
    printf("%p\n", (void*) x);
    printf("%p\n", (void*) y);
}

Używając gcc otrzymujemy tutaj dwa różne adresy: Coliro

Więc nie możesz być naprawdę pewien, co się dzieje.

Mi-He
źródło