Zgłaszanie zmiennych w pętlach, dobrych praktyk czy złych praktyk?

265

Pytanie nr 1: Czy zadeklarowanie zmiennej wewnątrz pętli jest dobrą lub złą praktyką?

Przeczytałem inne wątki na temat tego, czy występuje problem z wydajnością (większość mówiła „nie”) i że zawsze powinieneś deklarować zmienne tak blisko miejsca, w którym będą używane. Zastanawiam się, czy należy tego unikać, czy też rzeczywiście jest to preferowane.

Przykład:

for(int counter = 0; counter <= 10; counter++)
{
   string someString = "testing";

   cout << someString;
}

Pytanie nr 2: Czy większość kompilatorów zdaje sobie sprawę, że zmienna została już zadeklarowana i po prostu pomija tę część, czy też faktycznie tworzy dla niej miejsce w pamięci za każdym razem?

JeramyRR
źródło
29
Umieść je blisko ich użycia, chyba że profilowanie mówi inaczej.
Mooing Duck
3
@drnewman Przeczytałem te wątki, ale nie odpowiedziały na moje pytanie. Rozumiem, że deklarowanie zmiennych wewnątrz pętli działa. Zastanawiam się, czy jest to dobra praktyka, czy też należy tego unikać.
JeramyRR

Odpowiedzi:

348

To doskonała praktyka.

Tworząc zmienne wewnątrz pętli, upewniasz się, że ich zakres jest ograniczony do wewnątrz pętli. Nie można się do niego odwoływać ani wywoływać poza pętlą.

Tą drogą:

  • Jeśli nazwa zmiennej jest nieco „ogólna” (jak „i”), nie ma ryzyka pomieszania jej z inną zmienną o tej samej nazwie gdzieś później w kodzie (można ją również złagodzić za pomocą -Wshadowinstrukcji ostrzegawczej na GCC)

  • Kompilator wie, że zakres zmiennej jest ograniczony do wewnątrz pętli i dlatego wyda odpowiedni komunikat o błędzie, jeśli zmienna zostanie omyłkowo przywołana w innym miejscu.

  • Na koniec, niektóre dedykowane optymalizacje mogą być przeprowadzane bardziej efektywnie przez kompilator (co najważniejsze alokacja rejestru), ponieważ wie, że zmiennej nie można używać poza pętlą. Na przykład nie trzeba przechowywać wyniku do późniejszego ponownego wykorzystania.

Krótko mówiąc, masz rację.

Zauważ jednak, że zmienna nie powinna zachowywać swojej wartości między każdą pętlą. W takim przypadku konieczne może być zainicjowanie go za każdym razem. Możesz również utworzyć większy blok obejmujący pętlę, której jedynym celem jest deklarowanie zmiennych, które muszą zachować swoją wartość z jednej pętli do drugiej. Zwykle obejmuje to sam licznik pętli.

{
    int i, retainValue;
    for (i=0; i<N; i++)
    {
       int tmpValue;
       /* tmpValue is uninitialized */
       /* retainValue still has its previous value from previous loop */

       /* Do some stuff here */
    }
    /* Here, retainValue is still valid; tmpValue no longer */
}

W przypadku pytania nr 2: Zmienna jest przydzielana raz, gdy wywoływana jest funkcja. W rzeczywistości z punktu widzenia alokacji jest (prawie) tym samym, co deklaracja zmiennej na początku funkcji. Jedyną różnicą jest zakres: zmiennej nie można używać poza pętlą. Możliwe jest nawet, że zmienna nie jest przydzielona, ​​wystarczy ponownie użyć wolnego miejsca (z innej zmiennej, której zakres się zakończył).

Z ograniczonym i bardziej precyzyjnym zakresem pochodzą dokładniejsze optymalizacje. Ale co ważniejsze, sprawia, że ​​kod jest bezpieczniejszy, ponieważ mniej stanów (tj. Zmiennych) martwi się podczas czytania innych części kodu.

Jest to prawdą nawet poza if(){...}blokiem. Zazwyczaj zamiast:

    int result;
    (...)
    result = f1();
    if (result) then { (...) }
    (...)
    result = f2();
    if (result) then { (...) }

bezpieczniej jest pisać:

    (...)
    {
        int const result = f1();
        if (result) then { (...) }
    }
    (...)
    {
        int const result = f2();
        if (result) then { (...) }
    }

Różnica może wydawać się niewielka, szczególnie na tak małym przykładzie. Ale na większej podstawie kodu pomoże: teraz nie ma ryzyka przeniesienia pewnej resultwartości z f1()do f2()bloku. Każdy z nich resultjest ściśle ograniczony do własnego zakresu, dzięki czemu jego rola jest dokładniejsza. Z punktu widzenia recenzenta jest o wiele ładniejszy, ponieważ ma on mniej zmiennych stanu o dalekim zasięgu , którymi należy się martwić i śledzić.

Nawet kompilator pomoże lepiej: zakładając, że w przyszłości, po pewnej błędnej zmianie kodu, resultnie zostanie poprawnie zainicjowany f2(). Druga wersja po prostu odmówi działania, podając wyraźny komunikat o błędzie w czasie kompilacji (znacznie lepiej niż w czasie wykonywania). Pierwsza wersja niczego nie wykryje, wynik f1()zostanie po prostu przetestowany po raz drugi, mylony z wynikiem f2().

Informacje uzupełniające

Narzędzie open source CppCheck (narzędzie do analizy statycznej kodu C / C ++) zapewnia doskonałe wskazówki dotyczące optymalnego zakresu zmiennych.

W odpowiedzi na komentarz dotyczący alokacji: powyższa reguła jest prawdą w C, ale może nie dotyczyć niektórych klas C ++.

W przypadku standardowych typów i struktur rozmiar zmiennej jest znany w czasie kompilacji. W C nie ma czegoś takiego jak „konstrukcja”, więc miejsce na zmienną zostanie po prostu przydzielone do stosu (bez jakiejkolwiek inicjalizacji), gdy funkcja zostanie wywołana. Dlatego deklarowanie zmiennej w pętli wiąże się z „zerowym” kosztem.

Jednak w przypadku klas C ++ jest coś o konstruktorze, o którym wiem znacznie mniej. Myślę, że alokacja prawdopodobnie nie będzie problemem, ponieważ kompilator będzie wystarczająco sprytny, aby ponownie wykorzystać tę samą przestrzeń, ale inicjalizacja prawdopodobnie nastąpi przy każdej iteracji pętli.

Cyjan
źródło
4
Świetna odpowiedź. Właśnie tego szukałem, a nawet dał mi wgląd w coś, czego nie zdawałem sobie sprawy. Nie zdawałem sobie sprawy, że luneta pozostaje tylko w pętli. Dziękuję za odpowiedź!
JeramyRR
22
„Ale nigdy nie będzie wolniejsze niż przydzielanie na początku funkcji”. To nie zawsze jest prawdą. Zmienna zostanie przydzielona raz, ale nadal będzie konstruowana i niszczona tyle razy, ile będzie to konieczne. Który w przypadku przykładowego kodu jest 11 razy. Cytując komentarz Mooinga: „Zbliż ich do użycia, chyba że profilowanie mówi inaczej”.
IronMensan
4
@JeramyRR: Absolutnie nie - kompilator nie ma możliwości dowiedzenia się, czy obiekt ma znaczące skutki uboczne w konstruktorze lub destruktorze.
ildjarn
2
@Iron: Z drugiej strony, kiedy zadeklarujesz element jako pierwszy, po prostu otrzymujesz wiele połączeń z operatorem przypisania; co zwykle kosztuje mniej więcej tyle samo, co skonstruowanie i zniszczenie obiektu.
Billy ONeal
4
@BillyONeal: Do stringa vectorkonkretnie operatorowi przypisanie może ponownie przydzielony bufor każdej z pętli, które (w zależności od pętli) może być ogromne oszczędności czasu.
Mooing Duck
22

Ogólnie rzecz biorąc, bardzo dobrą praktyką jest trzymanie go bardzo blisko.

W niektórych przypadkach będą brane pod uwagę takie czynniki, jak wydajność, która uzasadnia wyciągnięcie zmiennej z pętli.

W twoim przykładzie program tworzy i niszczy ciąg za każdym razem. Niektóre biblioteki używają optymalizacji małych ciągów (SSO), więc w niektórych przypadkach można uniknąć dynamicznej alokacji.

Załóżmy, że chcesz uniknąć zbędnych kreacji / alokacji, zapisałbyś to jako:

for (int counter = 0; counter <= 10; counter++) {
   // compiler can pull this out
   const char testing[] = "testing";
   cout << testing;
}

lub możesz wyciągnąć stałą:

const std::string testing = "testing";
for (int counter = 0; counter <= 10; counter++) {
   cout << testing;
}

Czy większość kompilatorów zdaje sobie sprawę, że zmienna została już zadeklarowana i po prostu pomija tę część, czy też faktycznie tworzy dla niej miejsce w pamięci za każdym razem?

Może ponownie wykorzystać przestrzeń zajmowaną przez zmienną i wyciągnąć niezmienniki z pętli. W przypadku tablicy const char (powyżej) tablicę tę można wyciągnąć. Jednak konstruktor i destruktor muszą być wykonywane przy każdej iteracji w przypadku obiektu (takiego jak std::string). W przypadku std::stringtej „spacja” zawiera wskaźnik zawierający dynamiczny przydział reprezentujący znaki. Więc to:

for (int counter = 0; counter <= 10; counter++) {
   string testing = "testing";
   cout << testing;
}

wymagałoby nadmiarowego kopiowania w każdym przypadku oraz dynamicznej alokacji i bezpłatnej, jeśli zmienna znajduje się powyżej progu liczby znaków SSO (a SSO jest implementowane przez bibliotekę std).

Robiąc to:

string testing;
for (int counter = 0; counter <= 10; counter++) {
   testing = "testing";
   cout << testing;
}

nadal wymagałby fizycznej kopii znaków przy każdej iteracji, ale formularz może skutkować jedną dynamiczną alokacją, ponieważ przypisujesz ciąg, a implementacja powinna zauważyć, że nie ma potrzeby zmiany rozmiaru alokacji kopii zapasowej ciągu. Oczywiście nie zrobiłbyś tego w tym przykładzie (ponieważ pokazano już wiele lepszych alternatyw), ale możesz wziąć to pod uwagę, gdy zawartość łańcucha lub wektora jest różna.

Co robisz ze wszystkimi tymi opcjami (i nie tylko)? Domyślnie trzymaj go bardzo blisko - dopóki nie zrozumiesz dobrze kosztów i nie wiesz, kiedy powinieneś odstąpić.

justin
źródło
1
Jeśli chodzi o podstawowe typy danych, takie jak zmiennoprzecinkowe lub int, czy zadeklarowanie zmiennej w pętli będzie wolniejsze niż zadeklarowanie tej zmiennej poza pętlą, ponieważ będzie ona musiała przydzielić miejsce dla zmiennej w każdej iteracji?
Kasparov92
2
@ Kasparov92 Krótka odpowiedź brzmi: „Nie. Zignoruj ​​tę optymalizację i umieść ją w pętli, jeśli to możliwe, aby poprawić czytelność / lokalizację. Kompilator może wykonać tę mikrooptymalizację za Ciebie”. Bardziej szczegółowo, to ostatecznie decyduje kompilator na podstawie tego, co jest najlepsze dla platformy, poziomów optymalizacji itp. Zwykły int / float wewnątrz pętli zwykle umieszczany jest na stosie. Kompilator z pewnością może przenieść to poza pętlę i ponownie użyć pamięci, jeśli jest to optymalizacja. Dla celów praktycznych, to byłoby bardzo bardzo bardzo mały optymalizacji ...
justin
1
@ Kasparov92… (ciąg dalszy), które można rozważyć tylko w środowiskach / aplikacjach, w których liczy się każdy cykl. W takim przypadku możesz rozważyć użycie zestawu.
justin
14

W przypadku C ++ zależy to od tego, co robisz. OK, to jest głupi kod, ale wyobraź sobie

class myTimeEatingClass
{
 public:
 //constructor
      myTimeEatingClass()
      {
          sleep(2000);
          ms_usedTime+=2;
      }
      ~myTimeEatingClass()
      {
          sleep(3000);
          ms_usedTime+=3;
      }
      const unsigned int getTime() const
      {
          return  ms_usedTime;
      }
      static unsigned int ms_usedTime;
};
myTimeEatingClass::ms_CreationTime=0; 
myFunc()
{
    for (int counter = 0; counter <= 10; counter++) {

        myTimeEatingClass timeEater();
        //do something
    }
    cout << "Creating class took "<< timeEater.getTime() <<"seconds at all<<endl;

}
myOtherFunc()
{
    myTimeEatingClass timeEater();
    for (int counter = 0; counter <= 10; counter++) {
        //do something
    }
    cout << "Creating class took "<< timeEater.getTime() <<"seconds at all<<endl;

}

Poczekaj 55 sekund, aż otrzymasz wynik myFunc. Tylko dlatego, że każdy konstruktor pętli i destruktor razem potrzebują 5 sekund na zakończenie.

Będziesz potrzebował 5 sekund, aż otrzymasz wynik myOtherFunc.

Oczywiście to szalony przykład.

Ale ilustruje to, że może to stać się problemem wydajnościowym, gdy każda pętla wykonuje tę samą konstrukcję, gdy konstruktor i / lub destruktor potrzebują trochę czasu.

Nobby
źródło
2
Cóż, technicznie rzecz biorąc w drugiej wersji otrzymasz wynik w zaledwie 2 sekundy, ponieważ nie zniszczyłeś jeszcze obiektu .....
Chrys
12

Nie opublikowałem, aby odpowiedzieć na pytania JeremyRR (ponieważ już na nie odpowiedzieli); zamiast tego napisałem jedynie, aby dać sugestię.

Do JeremyRR możesz to zrobić:

{
  string someString = "testing";   

  for(int counter = 0; counter <= 10; counter++)
  {
    cout << someString;
  }

  // The variable is in scope.
}

// The variable is no longer in scope.

Nie wiem, czy zdajesz sobie sprawę (nie wiedziałem, kiedy zaczynałem programować), że nawiasy (o ile są w parach) można umieścić w dowolnym miejscu kodu, nie tylko po słowach „if”, „for”, „ podczas ”itp.

Mój kod skompilowany w Microsoft Visual C ++ 2010 Express, więc wiem, że działa; również próbowałem użyć zmiennej poza nawiasami, w których została zdefiniowana, i otrzymałem błąd, więc wiem, że zmienna została „zniszczona”.

Nie wiem, czy stosowanie tej metody jest złą praktyką, ponieważ wiele nieoznaczonych nawiasów może szybko sprawić, że kod będzie nieczytelny, ale może niektóre komentarze mogą wyjaśnić sytuację.

Fearnbuster
źródło
4
Dla mnie jest to bardzo uzasadniona odpowiedź, która zawiera sugestię bezpośrednio związaną z pytaniem. Masz mój głos!
Alexis Leclerc
0

Jest to bardzo dobra praktyka, ponieważ wszystkie powyższe odpowiedzi zapewniają bardzo dobry teoretyczny aspekt pytania, pozwalam rzucić okiem na kod, próbowałem rozwiązać DFS ponad GEEKSFORGEEKS, napotkałem problem optymalizacji ...... Jeśli spróbujesz rozwiązać kod deklarujący liczbę całkowitą poza pętlą da błąd optymalizacji.

stack<int> st;
st.push(s);
cout<<s<<" ";
vis[s]=1;
int flag=0;
int top=0;
while(!st.empty()){
    top = st.top();
    for(int i=0;i<g[top].size();i++){
        if(vis[g[top][i]] != 1){
            st.push(g[top][i]);
            cout<<g[top][i]<<" ";
            vis[g[top][i]]=1;
            flag=1;
            break;
        }
    }
    if(!flag){
        st.pop();
    }
}

Teraz umieść liczby całkowite w pętli, to da ci poprawną odpowiedź ...

stack<int> st;
st.push(s);
cout<<s<<" ";
vis[s]=1;
// int flag=0;
// int top=0;
while(!st.empty()){
    int top = st.top();
    int flag = 0;
    for(int i=0;i<g[top].size();i++){
        if(vis[g[top][i]] != 1){
            st.push(g[top][i]);
            cout<<g[top][i]<<" ";
            vis[g[top][i]]=1;
            flag=1;
            break;
        }
    }
    if(!flag){
        st.pop();
    }
}

całkowicie odzwierciedla to, co powiedział pan @ justin w drugim komentarzu .... spróbuj tutaj https://practice.geeksforgeeks.org/problems/depth-first-traversal-for-a-graph/1 . po prostu spróbuj ... dostaniesz. Mam nadzieję, że ta pomoc.

KhanJr
źródło
Nie sądzę, że dotyczy to pytania. Oczywiście w powyższym przypadku ma to znaczenie. Pytanie dotyczyło przypadku, w którym definicję zmiennej można zdefiniować w innym miejscu bez zmiany zachowania kodu.
pcarter
W opublikowanym kodzie problemem nie jest definicja, ale część inicjalizacji. flagnależy ponownie zainicjować przy 0 każdej whileiteracji. To problem logiczny, a nie problem definicji.
Martin Véronneau
0

Rozdział 4.8 Struktura bloku w języku programowania K&R The C Programming Language 2.Ed. :

Automatyczna zmienna zadeklarowana i zainicjowana w bloku jest inicjowana za każdym razem, gdy blok jest wprowadzany.

Mogłem nie zobaczyć odpowiedniego opisu w książce, takiego jak:

Zmienna automatyczna zadeklarowana i zainicjowana w bloku jest przydzielana tylko jeden raz przed wprowadzeniem bloku.

Ale prosty test może udowodnić przyjęte założenie:

 #include <stdio.h>                                                                                                    

 int main(int argc, char *argv[]) {                                                                                    
     for (int i = 0; i < 2; i++) {                                                                                     
         for (int j = 0; j < 2; j++) {                                                                                 
             int k;                                                                                                    
             printf("%p\n", &k);                                                                                       
         }                                                                                                             
     }                                                                                                                 
     return 0;                                                                                                         
 }                                                                                                                     
sof
źródło