Czy jest jakiś narzut związany z deklarowaniem zmiennej w pętli? (C ++)

158

Zastanawiam się tylko, czy nastąpiłaby jakakolwiek utrata szybkości lub wydajności, gdybyś zrobił coś takiego:

int i = 0;
while(i < 100)
{
    int var = 4;
    i++;
}

który deklaruje int varsto razy. Wydaje mi się, że tak będzie, ale nie jestem pewien. czy zamiast tego byłoby to bardziej praktyczne / szybsze:

int i = 0;
int var;
while(i < 100)
{
    var = 4;
    i++;
}

czy też są takie same, pod względem szybkości i wydajności?


źródło
7
Żeby było jasne, powyższy kod nie „deklaruje” var sto razy.
jason
1
@Rabarberski: Przywoływane pytanie nie jest dokładnym duplikatem, ponieważ nie określa języka. To pytanie jest specyficzne dla C ++ . Ale zgodnie z odpowiedziami wysłanymi na Twoje pytanie, do którego się odwołujesz, odpowiedź zależy od języka i prawdopodobnie kompilatora.
DavidRR
2
@jason Jeśli pierwszy fragment kodu nie deklaruje zmiennej „var” sto razy, czy możesz wyjaśnić, co się dzieje? Czy po prostu deklaruje zmienną raz i inicjalizuje ją 100 razy? Pomyślałbym, że kod deklaruje i inicjalizuje zmienną 100 razy, ponieważ wszystko w pętli jest wykonywane 100 razy. Dzięki.
randomUser47534

Odpowiedzi:

194

Przestrzeń stosu dla zmiennych lokalnych jest zwykle przydzielana w zakresie funkcji. Wewnątrz pętli nie ma więc możliwości dostosowania wskaźnika stosu, wystarczy przypisać 4 do var. Dlatego te dwa fragmenty mają ten sam narzut.

laalto
źródło
50
Chciałbym, żeby ci faceci, którzy uczą na naszych uczelniach, przynajmniej wiedzieli o tej podstawowej rzeczy. Kiedyś śmiał się ze mnie deklarując zmienną wewnątrz pętli, a ja zastanawiałem się, co jest nie tak, dopóki nie podał wydajności jako powodu, aby tego nie robić, a ja pomyślałem „WTF !?”.
mmx
18
Czy na pewno powinieneś od razu mówić o miejscu na stosie? Taka zmienna może również znajdować się w rejestrze.
toto
3
@toto Takiej zmiennej też nie ma nigdzie - varzmienna jest zainicjowana, ale nigdy nie jest używana, więc rozsądny optymalizator może ją całkowicie usunąć (z wyjątkiem drugiego fragmentu, jeśli zmienna została użyta gdzieś po pętli).
CiaPan
@Mehrdad Afshari zmienna w pętli pobiera konstruktor wywoływany raz na iterację. EDYCJA - Widzę, że wspomniałeś o tym poniżej, ale myślę, że zasługuje na to również w zaakceptowanej odpowiedzi.
hoodaticus
106

W przypadku typów pierwotnych i POD nie ma to znaczenia. Kompilator przydzieli miejsce na stosie zmiennej na początku funkcji i zwolni je, gdy funkcja zwróci w obu przypadkach.

W przypadku typów klas innych niż POD, które mają nietrywialne konstruktory, będzie to miało znaczenie - w takim przypadku umieszczenie zmiennej poza pętlą spowoduje wywołanie konstruktora i destruktora tylko raz oraz operatora przypisania w każdej iteracji, podczas gdy umieszczenie jej wewnątrz loop wywoła konstruktor i destruktor dla każdej iteracji pętli. W zależności od tego, co robi konstruktor, destruktor i operator przypisania klasy, może to być pożądane lub nie.

Adam Rosenfield
źródło
42
Poprawny pomysł, zły powód. Zmienna poza pętlą. Skonstruowany raz, zniszczony raz, ale operator przypisania stosował każdą iterację. Zmienna wewnątrz pętli. Constructe / Desatructor stosował każdą iterację, ale zero operacji przypisania.
Martin York
8
To najlepsza odpowiedź, ale te komentarze są mylące. Istnieje duża różnica między wywołaniem konstruktora a operatorem przypisania.
Andrew Grant
1
Jest to prawdą, jeśli treść pętli i tak wykonuje przypisanie, a nie tylko w celu zainicjowania. A jeśli jest tylko niezależna od ciała / stała inicjalizacja, optymalizator może ją podnieść.
peterchen
7
@ Andrew Grant: Dlaczego. Operator przypisania jest zwykle definiowany jako konstrukcja kopiująca do tmp, po której następuje zamiana (aby być bezpiecznym dla wyjątków), po której następuje zniszczenie tmp. Zatem operator przypisania nie różni się zbytnio od powyższego cyklu konstrukcja / zniszczenie. Zobacz stackoverflow.com/questions/255612/… jako przykład typowego operatora przypisania.
Martin York
1
Jeśli konstrukcja / zniszczenie są drogie, ich całkowity koszt jest rozsądną górną granicą kosztu operatora =. Ale zadanie rzeczywiście mogłoby być tańsze. Ponadto, gdy rozszerzymy tę dyskusję z typów ints na typy C ++, można uogólnić „var = 4” jako inną operację niż „przypisanie zmiennej z wartości tego samego typu”.
greggo
69

Oba są takie same, a oto jak możesz się tego dowiedzieć, patrząc na to, co robi kompilator (nawet bez ustawienia optymalizacji):

Spójrz, co kompilator (gcc 4.0) robi z Twoimi prostymi przykładami:

1.c:

main(){ int var; while(int i < 100) { var = 4; } }

gcc -S 1.c

1.s:

_main:
    pushl   %ebp
    movl    %esp, %ebp
    subl    $24, %esp
    movl    $0, -16(%ebp)
    jmp L2
L3:
    movl    $4, -12(%ebp)
L2:
    cmpl    $99, -16(%ebp)
    jle L3
    leave
    ret

2.c

main() { while(int i < 100) { int var = 4; } }

gcc -S 2.c

2.s:

_main:
        pushl   %ebp
        movl    %esp, %ebp
        subl    $24, %esp
        movl    $0, -16(%ebp)
        jmp     L2
L3:
        movl    $4, -12(%ebp)
L2:
        cmpl    $99, -16(%ebp)
        jle     L3
        leave
        ret

Z tego widać dwie rzeczy: po pierwsze, kod jest taki sam w obu.

Po drugie, pamięć dla var jest przydzielana poza pętlą:

         subl    $24, %esp

I wreszcie jedyną rzeczą w pętli jest przypisanie i sprawdzenie stanu:

L3:
        movl    $4, -12(%ebp)
L2:
        cmpl    $99, -16(%ebp)
        jle     L3

Co jest tak wydajne, jak tylko możesz, bez całkowitego usuwania pętli.

Alex Brown
źródło
2
„Co jest mniej więcej tak wydajne, jak tylko możesz, bez całkowitego usuwania pętli”. Niezupełnie. Częściowe rozwinięcie pętli (powiedzmy 4 razy na przebieg) przyspieszyłoby to dramatycznie. Prawdopodobnie istnieje wiele innych sposobów optymalizacji ... chociaż większość współczesnych kompilatorów prawdopodobnie zdałaby sobie sprawę, że nie ma sensu w ogóle pętli. Jeśli „i” zostanie użyte później, po prostu ustawi „i” = 100.
darron
to przy założeniu, że kod został w ogóle zmieniony na przyrostowe „i” ... ponieważ jest to po prostu wieczna pętla.
darron
Tak jak oryginalny post!
Alex Brown
2
Lubię odpowiedzi, które wspierają teorię z dowodem! Miło widzieć zrzut ASM, który potwierdza teorię równych kodów. +1
Xavi Montero
1
W rzeczywistości otrzymałem wyniki, generując kod maszynowy dla każdej wersji. Nie ma potrzeby go uruchamiać.
Alex Brown
14

Obecnie lepiej jest zadeklarować to wewnątrz pętli, chyba że jest to stała, ponieważ kompilator będzie mógł lepiej zoptymalizować kod (zmniejszając zakres zmiennych).

EDYCJA: Ta odpowiedź jest teraz w większości nieaktualna. Wraz z pojawieniem się postklasycznych kompilatorów przypadki, w których kompilator nie może tego zrozumieć, stają się rzadkie. Nadal mogę je skonstruować, ale większość ludzi zaklasyfikowałaby konstrukcję jako zły kod.

Joshua
źródło
4
Wątpię, czy wpłynie to na optymalizację - jeśli kompilator przeprowadzi jakąkolwiek analizę przepływu danych, może zorientować się, że nie jest on modyfikowany poza pętlą, więc powinien wygenerować ten sam zoptymalizowany kod w obu przypadkach.
Adam Rosenfield
3
Nie zrozumie tego, jeśli masz dwie różne pętle używające tej samej nazwy zmiennej tymczasowej.
Joshua
11

Większość nowoczesnych kompilatorów zoptymalizuje to dla Ciebie. Mając to na uwadze, użyłbym twojego pierwszego przykładu, ponieważ uważam go za bardziej czytelny.

Andrew Hare
źródło
3
Nie uważam tego za optymalizację. Ponieważ są to zmienne lokalne, miejsce na stosie jest przydzielane na początku funkcji. Nie ma prawdziwego „tworzenia”, które mogłoby zaszkodzić wydajności (chyba że wzywamy konstruktora, co jest zupełnie inną historią).
mmx
Masz rację, „optymalizacja” to niewłaściwe słowo, ale brakuje mi lepszego.
Andrew Hare
Problem polega na tym, że taki optymalizator użyje analizy zakresu na żywo, a obie zmienne są raczej martwe.
MSalters
A co powiesz na „kompilator nie zauważy żadnej różnicy między nimi po wykonaniu analizy przepływu danych”. Osobiście wolę, aby zakres zmiennej był ograniczony do miejsca, w którym jest używana, nie ze względu na efektywność, ale ze względu na przejrzystość.
greggo
9

W przypadku typu wbudowanego prawdopodobnie nie będzie różnicy między dwoma stylami (prawdopodobnie aż do wygenerowanego kodu).

Jeśli jednak zmienna jest klasą z nietrywialnym konstruktorem / destruktorem, może wystąpić duża różnica w kosztach czasu wykonania. Generalnie ograniczałbym zmienną do wewnątrz pętli (aby zakres był jak najmniejszy), ale jeśli okaże się, że ma to wpływ na wydajność, chciałbym przenieść zmienną klasy poza zakres pętli. Jednak zrobienie tego wymaga dodatkowej analizy, ponieważ semantyka ścieżki ody może się zmienić, więc można to zrobić tylko wtedy, gdy pozwala na to sematyka.

Klasa RAII może potrzebować takiego zachowania. Na przykład klasa zarządzająca okresem istnienia dostępu do plików może wymagać utworzenia i zniszczenia przy każdej iteracji pętli, aby prawidłowo zarządzać dostępem do plików.

Załóżmy, że masz LockMgrklasę, która uzyskuje sekcję krytyczną podczas tworzenia i zwalnia ją po zniszczeniu:

while (i< 100) {
    LockMgr lock( myCriticalSection); // acquires a critical section at start of
                                      //    each loop iteration

    // do stuff...

}   // critical section is released at end of each loop iteration

różni się od:

LockMgr lock( myCriticalSection);
while (i< 100) {

    // do stuff...

}
Michael Burr
źródło
6

Obie pętle mają taką samą wydajność. Oba zajmie nieskończoną ilość czasu :) Dobrym pomysłem może być inkrementacja i wewnątrz pętli.

Larry Watanabe
źródło
Ach tak, zapomniałem zająć się wydajnością przestrzeni - w porządku - 2 int dla obu. Po prostu wydaje mi się dziwne, że programiści brakuje lasu dla drzewa - wszystkie te sugestie dotyczące kodu, który się nie kończy.
Larry Watanabe
W porządku, jeśli się nie zakończą. Żaden z nich nie jest nazywany. :-)
Nosredna
2

Kiedyś przeprowadziłem kilka testów wydajności i ku mojemu zdziwieniu stwierdziłem, że przypadek 1 był faktycznie szybszy! Przypuszczam, że może to być spowodowane tym, że zadeklarowanie zmiennej wewnątrz pętli zmniejsza jej zakres, więc wcześniej zostanie zwolniona. Jednak to było dawno temu na bardzo starym kompilatorze. Jestem pewien, że nowoczesne kompilatory lepiej radzą sobie z optymalizacją różnic, ale nadal nie zaszkodzi utrzymywać jak najkrótszy zakres zmiennych.

user3864776
źródło
Różnica prawdopodobnie wynika z różnicy zakresu. Im mniejszy zakres, tym większe prawdopodobieństwo, że kompilator będzie w stanie wyeliminować serializację zmiennej. W zakresie małej pętli zmienna prawdopodobnie została umieszczona w rejestrze i nie została zapisana w ramce stosu. Jeśli wywołasz funkcję w pętli lub wyłuskujesz wskaźnik, którego kompilator tak naprawdę nie wie, dokąd wskazuje, rozleje zmienną pętli, jeśli jest w zakresie funkcji (wskaźnik może zawierać &i).
Patrick Schlüter
Opublikuj swoją konfigurację i wyniki.
jxramos
2
#include <stdio.h>
int main()
{
    for(int i = 0; i < 10; i++)
    {
        int test;
        if(i == 0)
            test = 100;
        printf("%d\n", test);
    }
}

Powyższy kod zawsze wypisuje 100 10 razy, co oznacza, że ​​zmienna lokalna wewnątrz pętli jest przydzielana tylko raz na każde wywołanie funkcji.

Byeonggon Lee
źródło
0

Jedynym sposobem, aby mieć pewność, jest zmierzenie czasu. Ale różnica, jeśli taka istnieje, będzie mikroskopijna, więc będziesz potrzebować potężnej pętli czasowej.

Co więcej, pierwsza jest lepszym stylem, ponieważ inicjalizuje zmienną var, podczas gdy druga pozostawia ją niezainicjowaną. To oraz wskazówka, że ​​należy definiować zmienne jak najbliżej miejsca ich użycia, oznacza, że ​​zwykle preferowana jest pierwsza forma.


źródło
„Jedynym sposobem, aby mieć pewność, jest zmierzenie czasu”. -1 nieprawda. Przepraszamy, ale inny post udowodnił, że to błąd, porównując wygenerowany język maszynowy i stwierdzając, że jest zasadniczo identyczny. Ogólnie nie mam żadnego problemu z twoją odpowiedzią, ale czy nie jest źle, do czego służy -1?
Bill K
Badanie emitowanego kodu jest z pewnością przydatne, aw takim prostym przypadku może być wystarczające. Jednak w bardziej złożonych przypadkach kwestie, takie jak lokalizacja odniesienia, podnoszą głowę, a te można sprawdzić tylko poprzez określenie czasu wykonania.
-1

Mając tylko dwie zmienne, kompilator prawdopodobnie przypisze rejestr dla obu. Te rejestry i tak tam są, więc to nie zajmuje czasu. W obu przypadkach są 2 instrukcje zapisu rejestru i jedna instrukcja odczytu rejestru.

MSalters
źródło
-2

Myślę, że w większości odpowiedzi brakuje ważnej kwestii do rozważenia, która brzmi: „Czy to jasne” i oczywiście w całej dyskusji jest taki fakt; nie, nie jest. Sugerowałbym, że w większości kodu pętli wydajność praktycznie nie stanowi problemu (chyba że obliczasz dla lądownika marsjańskiego), więc tak naprawdę jedynym pytaniem jest, co wygląda na bardziej rozsądne, czytelne i możliwe do utrzymania - w tym przypadku zalecałbym zadeklarowanie zmienna z przodu i poza pętlą - to po prostu sprawia, że ​​jest jaśniejszy. Wtedy ludzie tacy jak ty i ja nie zawracalibyśmy sobie głowy marnowaniem czasu na sprawdzanie online, czy jest ważny, czy nie.

Obrabować
źródło
-6

to nieprawda, istnieje narzut, ale jego zaniedbany narzut.

Mimo że prawdopodobnie skończą w tym samym miejscu na stosie. Nadal je przypisuje. Przypisze miejsce w pamięci na stosie dla tego int, a następnie zwolni je na końcu}. Nie w sensie bez sterty, przesunie sp (wskaźnik stosu) o 1. A w twoim przypadku, biorąc pod uwagę tylko jedną zmienną lokalną, po prostu zrówna fp (wskaźnik ramki) i sp

Krótka odpowiedź brzmiałaby: NIE DBAJ O INNY SPOSÓB DZIAŁA PRAWIE TAKIE SAME.

Ale spróbuj przeczytać więcej na temat organizacji stosu. Moja szkoła miała całkiem niezłe wykłady na ten temat. Jeśli chcesz przeczytać więcej, zajrzyj tutaj http://www.cs.utk.edu/~plank/plank/classes/cs360/360/notes/Assembler1/lecture.html

grobartn
źródło
Ponownie, -1 nieprawdziwe. Przeczytaj post, który obejrzał montaż.
Bill K,
nie, mylisz się. spójrz na kod asemblera wygenerowany za pomocą tego kodu
grobartn