1.6 — Niezainicjowane zmienne i niezdefiniowane zachowanie

Niezainicjowane zmienne

W przeciwieństwie do niektórych języków programowania, C/C++ nie inicjuje automatycznie większości zmiennych do danej wartości (takiej jak zero). Kiedy zmienna, która nie została zainicjowana, otrzymuje adres pamięci, który ma być używany do przechowywania danych, domyślną wartością tej zmiennej jest wartość (śmieciowa), która już znajduje się w tym adresie pamięci! Zmienna, której nie nadano znanej wartości (w drodze inicjalizacji lub przypisania), nazywana jest zmienną niezainicjowaną.

Nomenklatura

Wielu czytelników spodziewa się, że terminy „zainicjowany” i „niezainicjowany” będą całkowitymi przeciwieństwami, ale tak nie jest! W języku potocznym „zainicjowany” oznacza, że ​​obiektowi nadano wartość początkową w momencie definicji. „Niezainicjowany” oznacza, że ​​obiektowi nie nadano jeszcze znanej wartości (w żaden sposób, łącznie z przypisaniem). Dlatego obiekt, który nie został zainicjowany, ale następnie przypisano mu wartość, nie jest już niezainicjowany (ponieważ nadano mu znaną wartość).

Podsumowując:

  • Zainicjowany = Obiektowi nadawana jest znana wartość w momencie definicji.
  • Przypisanie = Obiektowi nadawana jest znana wartość poza punktem definicja.
  • Niezainicjowany = Obiektowi nie nadano jeszcze znanej wartości.

W związku z tym rozważ tę definicję zmiennej:

int x;

W lekcji 1.4 — Przypisywanie zmiennych i inicjalizacja, zauważyliśmy, że jeśli nie podano inicjatora, zmienna jest inicjowana domyślnie. W większości przypadków (takich jak ten) inicjalizacja domyślna nie powoduje faktycznej inicjalizacji. Zatem powiedzielibyśmy, że x jest niezainicjowany. Skupiamy się na wyniku (obiektowi nie nadano znanej wartości), a nie na procesie.

Na marginesie…

Ten brak inicjalizacji jest optymalizacją wydajności odziedziczoną z C, kiedy komputery były wolne. Wyobraź sobie przypadek, w którym zamierzasz wczytać 100 000 wartości z pliku. W takim przypadku możesz utworzyć 100 000 zmiennych, a następnie wypełnić je danymi z pliku.

Gdyby C++ zainicjował wszystkie te zmienne wartościami domyślnymi podczas tworzenia, spowodowałoby to 100 000 inicjalizacji (co byłoby powolne) i przyniosłoby niewielkie korzyści (ponieważ i tak nadpiszesz te wartości).

Na razie powinieneś zawsze inicjować swoje zmienne, ponieważ koszt tego wynosi niewielka w porównaniu z korzyścią. Kiedy już oswoisz się z językiem, mogą zaistnieć pewne przypadki, w których pominiesz inicjalizację w celach optymalizacji. Należy to jednak zawsze robić selektywnie i celowo.

Używanie wartości niezainicjowanych zmiennych może prowadzić do nieoczekiwanych rezultatów. Rozważmy następujący krótki program:

#include <iostream>

int main()
{
    // define an integer variable named x
    int x; // this variable is uninitialized because we haven't given it a value
    
    // print the value of x to the screen
    std::cout << x << '\n'; // who knows what we'll get, because x is uninitialized

    return 0;
}

W tym przypadku komputer przydzieli część nieużywanej pamięci do x. Następnie wyśle ​​wartość znajdującą się w tej lokalizacji pamięci do std::cout, która wydrukuje wartość (interpretowaną jako liczba całkowita). Ale jaką wartość wydrukuje? Odpowiedź brzmi „kto wie!”, a odpowiedź może (ale nie musi) zmieniać się przy każdym uruchomieniu programu. Gdy autor uruchomił ten program w Visual Studio, std::cout wypisał wartość 7177728 raz i 5277592 za drugim. Możesz samodzielnie skompilować i uruchomić program (twój komputer nie eksploduje).

Ostrzeżenie

Niektóre kompilatory, takie jak Visual Studio, będziesz inicjują zawartość pamięci do określonej wartości, gdy używasz konfiguracji kompilacji debugowania. Nie stanie się to w przypadku korzystania z konfiguracji kompilacji wydania. Dlatego jeśli chcesz samodzielnie uruchomić powyższy program, upewnij się, że używasz konfiguracji kompilacji wydania (przypomnienie, jak to zrobić, zobacz lekcja 0.9 — Konfigurowanie kompilatora: Konfiguracje kompilacji ).

Na przykład, jeśli uruchomisz powyższy program w konfiguracji debugowania programu Visual Studio, będzie on konsekwentnie wyświetlał -858993460, ponieważ jest to wartość (interpretowana jako liczba całkowita), za pomocą której Visual Studio inicjuje pamięć w konfiguracjach debugowania.

Większość współczesnych kompilatorów będzie próbowała wykryć, czy zmienna jest używana bez podania wartości. Jeśli uda im się to wykryć, zazwyczaj wygenerują ostrzeżenie lub błąd w czasie kompilacji. Na przykład kompilacja powyższego programu w Visual Studio spowodowała wyświetlenie następującego ostrzeżenia:

c:\VCprojects\test\test.cpp(11) : warning C4700: uninitialized local variable 'x' used

Jeśli Twój kompilator nie pozwala na skompilowanie i uruchomienie powyższego programu (np. ponieważ traktuje problem jako błąd), oto możliwe rozwiązanie tego problemu:

#include <iostream>

void doNothing(int&) // Don't worry about what & is for now, we're just using it to trick the compiler into thinking variable x is used
{
}

int main()
{
    // define an integer variable named x
    int x; // this variable is uninitialized

    doNothing(x); // make the compiler think we're assigning a value to this variable

    // print the value of x to the screen (who knows what we'll get, because x is uninitialized)
    std::cout << x << '\n';

    return 0;
}

Używanie niezainicjowanych zmiennych jest jednym z najczęstszych błędów popełnianych przez początkujących programistów i niestety może być również jednym z najtrudniejszych do debugowania (ponieważ program może i tak działa poprawnie, jeśli zdarzy się, że niezainicjowana zmienna zostanie przypisana do miejsca w pamięci, które ma rozsądną wartość, np. 0).

To jest główny powód najlepszej praktyki „zawsze inicjuj swoje zmienne”.

Niezdefiniowane zachowanie

Użycie wartości z niezainicjowanej zmiennej jest naszym pierwszym przykładem niezdefiniowanego zachowania. Niezdefiniowane zachowanie (często w skrócie UB) jest wynikiem wykonania kodu, którego zachowanie nie jest zgodne z dobrze zdefiniowany przez język C++. W tym przypadku w języku C++ nie ma żadnych reguł określających, co się stanie, jeśli użyjesz wartości zmiennej, której nie nadano znanej wartości. W rezultacie, jeśli faktycznie to zrobisz, spowoduje to niezdefiniowane zachowanie.

Kod implementujący niezdefiniowane zachowanie może wykazywać dowolnego następujące symptomy:

  • Twój program generuje inne wyniki przy każdym uruchomieniu.
  • Twój program konsekwentnie generuje ten sam nieprawidłowy wynik.
  • Twój program zachowuje się niespójnie (czasami generuje poprawny wynik, czasem nie).
  • Twój program wydaje się działać, ale w dalszej części programu generuje nieprawidłowe wyniki.
  • Twój program ulega awarii natychmiast lub później.
  • Twój program działa na niektórych kompilatorach, ale nie na innych.
  • Twój program działa, dopóki nie zmienisz innego pozornie niezwiązanego kodu.

Lub Twój kod i tak może generować prawidłowe zachowanie.

Nota autora

Niezdefiniowane zachowanie jest jak pudełko czekoladek. Nigdy nie wiesz, co otrzymasz!

C++ zawiera wiele przypadków, które mogą skutkować niezdefiniowanym zachowaniem, jeśli nie będziesz ostrożny. Będziemy je podkreślać na przyszłych lekcjach, ilekroć je napotkamy. Zwróć uwagę, gdzie występują takie przypadki i pamiętaj, aby ich unikać.

Reguła

Uważaj, aby unikać wszelkich sytuacji, które skutkują niezdefiniowanym zachowaniem, takich jak użycie niezainicjowanych zmiennych.

Nota autora

Jeden z najczęstszych typów komentarzy, jakie otrzymujemy od czytelników, brzmi: „Powiedziałeś, że nie mogę wykonać X, ale i tak to zrobiłem, a mój program działa! Dlaczego?”.

Istnieją dwie typowe odpowiedzi. Najczęstszą odpowiedzią jest to, że Twój program w rzeczywistości wykazuje niezdefiniowane zachowanie, ale tak się składa, że ​​to niezdefiniowane zachowanie i tak daje oczekiwany rezultat… na razie. Jutro (lub na innym kompilatorze lub maszynie) może tego nie być.

Alternatywnie, czasami autorzy kompilatorów pozwalają sobie na swobodę w zakresie wymagań językowych, gdy wymagania te mogą być bardziej restrykcyjne niż to konieczne. Na przykład standard może mówić: „musisz wykonać X przed Y”, ale autor kompilatora może uznać, że jest to niepotrzebne i sprawić, że Y będzie działać, nawet jeśli najpierw nie wykonasz X. Nie powinno to mieć wpływu na działanie poprawnie napisanych programów, ale może spowodować, że niepoprawnie napisane programy i tak będą działać. Zatem alternatywną odpowiedzią na powyższe pytanie jest to, że Twój kompilator może po prostu nie przestrzegać standardu! To się zdarza. Wiele z tych sytuacji można uniknąć, wyłączając rozszerzenia kompilatora, jak opisano w lekcji 0.10 -- Konfigurowanie kompilatora: Rozszerzenia kompilatora.

Zachowanie zdefiniowane w implementacji i zachowanie nieokreślone

Określony kompilator i powiązana z nim biblioteka standardowa nazywane są implementacją (ponieważ to właśnie one faktycznie implementują język C++). W niektórych przypadkach standard języka C++ pozwala implementacji określić, jak zachowa się jakiś aspekt języka, dzięki czemu kompilator może wybrać zachowanie, które jest efektywne dla danej platformy. Zachowanie zdefiniowane przez implementację nazywa się zachowaniem zdefiniowanym przez implementację. Zachowanie zdefiniowane w implementacji musi być udokumentowane i spójne dla danej implementacji.

Spójrzmy na prosty przykład zachowania zdefiniowanego w implementacji:

#include <iostream>

int main()
{
	std::cout << sizeof(int) << '\n'; // print how many bytes of memory an int value takes

	return 0;
}

Na większości platform spowoduje to 4, ale na innych może spowodować 2.

Powiązana treść

Omawiamy sizeof() w lekcji 4.3 -- Rozmiary obiektów i operator sizeof.

Nieokreślone zachowanie jest prawie identyczne z zachowaniem zdefiniowanym w implementacji w tym sensie, że zachowanie zależy od implementacji do zdefiniowania, ale implementacja nie jest wymagana dokumentuj zachowanie.

Zasadniczo chcemy unikać zachowań zdefiniowanych w implementacji i nieokreślonych, ponieważ oznacza to, że nasz program może nie działać zgodnie z oczekiwaniami, jeśli zostanie skompilowany na innym kompilatorze (lub nawet na tym samym kompilatorze, jeśli zmienimy ustawienia projektu, które wpływają na zachowanie implementacji!)

Najlepsza praktyka

Unikaj, jeśli to możliwe, zachowań zdefiniowanych i nieokreślonych w implementacji, ponieważ może to spowodować nieprawidłowe działanie programu w innych implementacjach.

Powiązana treść

Pokazujemy przykłady nieokreślonych zachowań na lekcji 6.1 — Pierwszeństwo operatora i łączność.

Czas quizu

Pytanie nr 1

Co to jest niezainicjowana zmienna? Dlaczego należy ich unikać?

Pokaż rozwiązanie

Pytanie nr 2

Co to jest niezdefiniowane zachowanie i co może się stać, jeśli zrobisz coś, co wykazuje niezdefiniowane zachowanie?

Pokaż rozwiązanie

guest
Twój adres e-mail nie zostanie wyświetlony
Znalazłeś błąd? Zostaw komentarz powyżej!
Komentarze związane z poprawkami zostaną usunięte po przetworzeniu, aby pomóc zmniejszyć bałagan. Dziękujemy za pomoc w ulepszaniu witryny dla wszystkich!
Awatary z https://gravatar.com/ są połączone z podanym adresem e-mail.
Powiadamiaj mnie o odpowiedziach:  
400 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze